Updated to latest SPFx version and graph call replaced with azure function
|
@ -0,0 +1,264 @@
|
|||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
|
||||
# Azure Functions localsettings file
|
||||
local.settings.json
|
||||
|
||||
# User-specific files
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Ll]og/
|
||||
|
||||
# Visual Studio 2015 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUNIT
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# DNX
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_i.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*.log
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# JustCode is a .NET coding add-in
|
||||
.JustCode
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/*.hhc
|
||||
DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# TODO: Comment the next line if you want to checkin your web deploy settings
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
#*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these scripts will be unencrypted
|
||||
PublishScripts/
|
||||
|
||||
# NuGet Packages
|
||||
*.nupkg
|
||||
# The packages folder can be ignored because of Package Restore
|
||||
**/packages/*
|
||||
# except build/, which is used as an MSBuild target.
|
||||
!**/packages/build/
|
||||
# Uncomment if necessary however generally it will be regenerated when needed
|
||||
#!**/packages/repositories.config
|
||||
# NuGet v3's project.json files produces more ignoreable files
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
node_modules/
|
||||
orleans.codegen.cs
|
||||
|
||||
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# JetBrains Rider
|
||||
.idea/
|
||||
*.sln.iml
|
||||
|
||||
# CodeRush
|
||||
.cr/
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.pyc
|
|
@ -0,0 +1,3 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<configuration>
|
||||
</configuration>
|
|
@ -0,0 +1,134 @@
|
|||
|
||||
using Azure.Identity;
|
||||
using M365ServiceHealth.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Graph;
|
||||
using Microsoft.Graph.Models;
|
||||
using Azure.Core.Serialization;
|
||||
|
||||
|
||||
namespace M365ServiceHealth.Function
|
||||
{
|
||||
|
||||
public class M365ServiceHealth
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly IGraphClientService _graphClientService;
|
||||
private readonly ITokenValidationService _tokenAcquisition;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly JsonObjectSerializer _jsonSerializer;
|
||||
public M365ServiceHealth(ILoggerFactory loggerFactory, IConfiguration configuration,ITokenValidationService tokenValidationService,IGraphClientService graphClientService, JsonObjectSerializer jsonSerializer)
|
||||
{
|
||||
_logger = loggerFactory.CreateLogger<M365ServiceHealth>();
|
||||
_graphClientService = graphClientService;
|
||||
_tokenAcquisition = tokenValidationService;
|
||||
_configuration = configuration;
|
||||
_jsonSerializer = jsonSerializer;
|
||||
}
|
||||
[Authorize]
|
||||
|
||||
[Function("M365ServiceHealth")]
|
||||
public async Task<HttpResponseData> Run([HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequestData req)
|
||||
{
|
||||
_logger.LogInformation("M365ServiceHealth azure function processing started");
|
||||
HttpResponseData response = req.CreateResponse();
|
||||
|
||||
bool runAsApplicationPermission = _configuration.GetValue<bool>("RunAsApplicationPermission");
|
||||
string bearerToken = String.Empty;
|
||||
try
|
||||
{
|
||||
bearerToken = await _tokenAcquisition
|
||||
.ValidateAuthorizationHeaderAsync(req);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
response.StatusCode = System.Net.HttpStatusCode.BadRequest;
|
||||
response.WriteString(ex.Message + " Required settings missing or not valid: 'tenantId', 'clientId', and 'clientSecret'.");
|
||||
return response;
|
||||
}
|
||||
|
||||
// Use the options for serialization within this function
|
||||
if(bearerToken == null)
|
||||
{
|
||||
response.StatusCode = System.Net.HttpStatusCode.BadRequest;
|
||||
response.WriteString("Required settings missing or not valid: 'tenantId', 'clientId', and 'clientSecret'.");
|
||||
return response;
|
||||
}
|
||||
if (runAsApplicationPermission == true)
|
||||
{
|
||||
var graphClientAppPermission = _graphClientService.GetAppGraphClient();
|
||||
|
||||
if (graphClientAppPermission != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
ServiceHealthCollectionResponse result = await graphClientAppPermission.Admin.ServiceAnnouncement.HealthOverviews.GetAsync((requestConfiguration) =>
|
||||
{
|
||||
requestConfiguration.QueryParameters.Expand = new string[] { "issues" };
|
||||
});
|
||||
|
||||
|
||||
await response.WriteAsJsonAsync(result.Value, _jsonSerializer);
|
||||
return response;
|
||||
}
|
||||
// Catch CAE exception from Graph SDK
|
||||
catch (ServiceException svcex) when (svcex.Message.Contains("Continuous access evaluation resulted in claims challenge"))
|
||||
{
|
||||
|
||||
Console.WriteLine($"{svcex}");
|
||||
|
||||
response.StatusCode = System.Net.HttpStatusCode.BadRequest;
|
||||
response.WriteString(svcex.Message);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
response.StatusCode = System.Net.HttpStatusCode.BadRequest;
|
||||
response.WriteString("Required settings missing or not valid: 'tenantId', 'clientId', and 'clientSecret'.");
|
||||
return response;
|
||||
} else
|
||||
{
|
||||
|
||||
|
||||
var options = new OnBehalfOfCredentialOptions
|
||||
{
|
||||
AuthorityHost = AzureAuthorityHosts.AzurePublicCloud,
|
||||
};
|
||||
string incomingToken = bearerToken.Replace("Bearer ", "");
|
||||
var graphClient = _graphClientService.GetAppGraphClient();
|
||||
if (graphClient != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
ServiceHealthCollectionResponse result = await graphClient.Admin.ServiceAnnouncement.HealthOverviews.GetAsync((requestConfiguration) =>
|
||||
{
|
||||
requestConfiguration.QueryParameters.Expand = new string[] { "issues" };
|
||||
});
|
||||
response.StatusCode = System.Net.HttpStatusCode.OK;
|
||||
await response.WriteAsJsonAsync(result.Value, _jsonSerializer);
|
||||
return response;
|
||||
//return new OkObjectResult(_jsonSerializer.Serialize(result));
|
||||
}
|
||||
// Catch CAE exception from Graph SDK
|
||||
catch (ServiceException svcex) when (svcex.Message.Contains("Continuous access evaluation resulted in claims challenge"))
|
||||
{
|
||||
|
||||
Console.WriteLine($"{svcex}");
|
||||
response.StatusCode=System.Net.HttpStatusCode.BadRequest;
|
||||
response.WriteString($"{svcex.Message}");
|
||||
return response;
|
||||
|
||||
}
|
||||
}
|
||||
response.StatusCode = System.Net.HttpStatusCode.BadRequest;
|
||||
response.WriteString("Required settings missing: 'tenantId', 'clientId', and 'clientSecret'.");
|
||||
return response;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Arcus.WebApi.Hosting.AzureFunctions" Version="1.7.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Extensions" Version="1.1.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="1.20.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.1.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="1.16.2" />
|
||||
<PackageReference Include="Microsoft.ApplicationInsights.WorkerService" Version="2.21.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="1.0.0" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.10.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="8.0.1" />
|
||||
|
||||
<PackageReference Include="Microsoft.Identity.Web" Version="2.16.1" />
|
||||
|
||||
|
||||
<PackageReference Include="Microsoft.Identity.Web.UI" Version="2.16.1" />
|
||||
<PackageReference Include="Microsoft.Identity.Web.GraphServiceClient" Version="2.16.1" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="host.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="local.settings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="System.Threading.ExecutionContext" Alias="ExecutionContext" />
|
||||
</ItemGroup>
|
||||
</Project>
|
|
@ -0,0 +1,25 @@
|
|||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.5.002.0
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "M365ServiceHealth", "M365ServiceHealth.csproj", "{7A9489C2-EDAD-4619-9BAE-EA3256056D40}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{7A9489C2-EDAD-4619-9BAE-EA3256056D40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7A9489C2-EDAD-4619-9BAE-EA3256056D40}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7A9489C2-EDAD-4619-9BAE-EA3256056D40}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7A9489C2-EDAD-4619-9BAE-EA3256056D40}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {280A3DA2-CAD0-4731-85DE-75527154160A}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
|
@ -0,0 +1,18 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace M365ServiceHealth.Models
|
||||
{
|
||||
public class AzureEntraSettings
|
||||
{
|
||||
public string? Instance { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public string? ClientId { get; set; }
|
||||
public string? ClientSecret { get; set;}
|
||||
public string? Audience { get; set; }
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
using Microsoft.Graph.Admin.Edge.InternetExplorerMode.SiteLists.Item.Publish;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace M365ServiceHealth.Models
|
||||
{
|
||||
public class GraphSettings
|
||||
{
|
||||
public string? BaseUrl { get; set; }
|
||||
public string? Scopes { get;set; }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
using Microsoft.Graph;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace M365ServiceHealth.Models
|
||||
{
|
||||
public interface IGraphClientService
|
||||
{
|
||||
public GraphServiceClient? GetUserGraphClient(string userAssertion);
|
||||
public GraphServiceClient? GetAppGraphClient();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
|
||||
namespace M365ServiceHealth.Models
|
||||
{
|
||||
public interface ITokenValidationService
|
||||
{
|
||||
public Task<string?> ValidateAuthorizationHeaderAsync(
|
||||
HttpRequestData request);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using M365ServiceHealth.Services;
|
||||
using M365ServiceHealth.Models;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
|
||||
var builder = Host.CreateDefaultBuilder(args);
|
||||
var host = Host.CreateDefaultBuilder(args)
|
||||
.ConfigureFunctionsWorkerDefaults(builder =>
|
||||
{
|
||||
builder.ConfigureJsonFormatting(options =>
|
||||
{
|
||||
options.Converters.Add(new JsonStringEnumConverter());
|
||||
options.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
|
||||
});
|
||||
}).ConfigureAppConfiguration((ctx,configBuilder)=> {
|
||||
configBuilder.AddJsonFile("local.settings.json", optional: true, reloadOnChange: true).AddJsonFile($"appsettings.json", optional: true, reloadOnChange: true).AddEnvironmentVariables();
|
||||
})
|
||||
.ConfigureServices((ctx,services) => {
|
||||
services.AddSingleton<ITokenValidationService, TokenValidationService>();
|
||||
services.AddSingleton<IGraphClientService, GraphClientService>();
|
||||
})
|
||||
.Build();
|
||||
host.Run();
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"profiles": {
|
||||
"M365ServiceHealth": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "--port 7097",
|
||||
"launchBrowser": false
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Identity;
|
||||
using M365ServiceHealth.Models;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Graph;
|
||||
namespace M365ServiceHealth.Services
|
||||
{
|
||||
public class GraphClientService : IGraphClientService
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger _logger;
|
||||
private GraphServiceClient? _appGraphClient;
|
||||
|
||||
public GraphClientService(IConfiguration config, ILoggerFactory loggerFactory)
|
||||
{
|
||||
_config = config;
|
||||
_logger = loggerFactory.CreateLogger<GraphClientService>();
|
||||
}
|
||||
|
||||
public GraphServiceClient? GetUserGraphClient(string userAssertion)
|
||||
{
|
||||
|
||||
AzureEntraSettings azureSettings = _config.GetSection("AzureAd").Get<AzureEntraSettings>();
|
||||
var tenantId = azureSettings?.TenantId;
|
||||
var clientId = azureSettings?.ClientId;
|
||||
var clientSecret = azureSettings?.ClientSecret;
|
||||
|
||||
if (string.IsNullOrEmpty(tenantId) ||
|
||||
string.IsNullOrEmpty(clientId) ||
|
||||
string.IsNullOrEmpty(clientSecret))
|
||||
{
|
||||
string message= "Required settings missing: 'tenantId', 'clientId', and 'clientSecret'.";
|
||||
_logger.LogError(message);
|
||||
return null;
|
||||
}
|
||||
|
||||
var onBehalfOfCredential = new OnBehalfOfCredential(
|
||||
tenantId, clientId, clientSecret, userAssertion);
|
||||
|
||||
return new GraphServiceClient(onBehalfOfCredential);
|
||||
}
|
||||
|
||||
public GraphServiceClient? GetAppGraphClient()
|
||||
{
|
||||
if (_appGraphClient == null)
|
||||
{
|
||||
|
||||
AzureEntraSettings azureSettings = _config.GetSection("AzureAd").Get<AzureEntraSettings>();
|
||||
var tenantId = azureSettings?.TenantId;
|
||||
var clientId = azureSettings?.ClientId;
|
||||
var clientSecret = azureSettings?.ClientSecret;
|
||||
|
||||
if (string.IsNullOrEmpty(tenantId) ||
|
||||
string.IsNullOrEmpty(clientId) ||
|
||||
string.IsNullOrEmpty(clientSecret))
|
||||
{
|
||||
_logger.LogError("Required settings missing: 'tenantId', 'clientId', and 'clientSecret'.");
|
||||
return null;
|
||||
}
|
||||
var options = new TokenCredentialOptions
|
||||
{
|
||||
AuthorityHost = AzureAuthorityHosts.AzurePublicCloud
|
||||
};
|
||||
var clientSecretCredential = new ClientSecretCredential(
|
||||
tenantId, clientId, clientSecret,options);
|
||||
|
||||
_appGraphClient = new GraphServiceClient(clientSecretCredential);
|
||||
}
|
||||
|
||||
return _appGraphClient;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,107 @@
|
|||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Protocols;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.Globalization;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Net.Http.Headers;
|
||||
using M365ServiceHealth.Models;
|
||||
|
||||
|
||||
namespace M365ServiceHealth.Services
|
||||
{
|
||||
public class TokenValidationService : ITokenValidationService
|
||||
{
|
||||
private TokenValidationParameters? _validationParameters;
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public TokenValidationService(IConfiguration config, ILoggerFactory loggerFactory)
|
||||
{
|
||||
_config = config;
|
||||
_logger = loggerFactory.CreateLogger<TokenValidationService>();
|
||||
}
|
||||
|
||||
public async Task<string?> ValidateAuthorizationHeaderAsync(
|
||||
Microsoft.Azure.Functions.Worker.Http.HttpRequestData request)
|
||||
{
|
||||
// The incoming request should have an Authorization header
|
||||
if (request.Headers.TryGetValues("authorization", out IEnumerable<string>? authValues))
|
||||
{
|
||||
var authHeader = AuthenticationHeaderValue.Parse(authValues.ToArray().First());
|
||||
|
||||
// Make sure that the value is "Bearer token-value"
|
||||
if (authHeader != null &&
|
||||
string.Compare(authHeader.Scheme, "bearer", true, CultureInfo.InvariantCulture) == 0 &&
|
||||
!string.IsNullOrEmpty(authHeader.Parameter))
|
||||
{
|
||||
var validationParameters = await GetTokenValidationParametersAsync();
|
||||
if (validationParameters == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
|
||||
try
|
||||
{
|
||||
//Validate the token
|
||||
var result = tokenHandler.ValidateToken(authHeader.Parameter,
|
||||
_validationParameters, out SecurityToken jwtToken);
|
||||
|
||||
|
||||
// If ValidateToken did not throw an exception, token is valid.
|
||||
return authHeader.Parameter;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "Error validating bearer token");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<TokenValidationParameters?> GetTokenValidationParametersAsync()
|
||||
{
|
||||
if (_validationParameters == null)
|
||||
{
|
||||
// Get tenant ID and client ID
|
||||
AzureEntraSettings azureSettings = _config.GetSection("AzureAd").Get<AzureEntraSettings>();
|
||||
var tenantId = azureSettings?.TenantId;
|
||||
var apiClientId = azureSettings?.ClientId;
|
||||
if (string.IsNullOrEmpty(tenantId) ||
|
||||
string.IsNullOrEmpty(apiClientId))
|
||||
{
|
||||
_logger.LogError("Required settings missing: 'tenantId' and 'clientId'.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load the tenant-specific OpenID config from Azure
|
||||
var configManager = new ConfigurationManager<OpenIdConnectConfiguration>(
|
||||
$"https://login.microsoftonline.com/{tenantId}/.well-known/openid-configuration",
|
||||
new OpenIdConnectConfigurationRetriever());
|
||||
|
||||
var config = await configManager.GetConfigurationAsync();
|
||||
|
||||
_validationParameters = new TokenValidationParameters
|
||||
{
|
||||
// Use signing keys retrieved from Azure
|
||||
IssuerSigningKeys = config.SigningKeys,
|
||||
ValidateAudience = false,
|
||||
ValidAudience = azureSettings?.Audience,
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = config.Issuer,
|
||||
ValidateLifetime = true,
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
return _validationParameters;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"version": "2.0",
|
||||
"logging": {
|
||||
"applicationInsights": {
|
||||
"samplingSettings": {
|
||||
"isEnabled": true,
|
||||
"excludedTypes": "Request"
|
||||
},
|
||||
"enableLiveMetricsFilters": true
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
# Build the function app using dotnet publish
|
||||
$AzureFunctionAppPath ="<AzureFunctionAppPath>" #Path of azure function
|
||||
$AzureFunctionAppZipPath="<AzureFunctionAppZipPath>" #Path where zip file will be created
|
||||
$resourceGroupName = "<AzureFunctionAppResourceGroupName>" #Resource group name of azure function
|
||||
$functionAppName = "<AzureFunctionAppName>" #Name of azure function created in azure portal
|
||||
|
||||
dotnet publish $AzureFunctionAppPath -c Release -o "$AzureFunctionAppPath\bin\Release"
|
||||
# Include the published output folder in the zip
|
||||
Compress-Archive -Path "$AzureFunctionAppPath\bin\Release\*" -DestinationPath "$AzureFunctionAppZipPath\functionApp.zip" -CompressionLevel Optimal
|
||||
|
||||
Connect-AzAccount
|
||||
|
||||
|
||||
Publish-AzWebApp -ResourceGroupName $resourceGroupName -Name $functionAppName -ArchivePath "$AzureFunctionAppZipPath\functionApp.zip"
|
||||
Update-AzFunctionAppSetting -Name $functionAppName -ResourceGroupName $resourceGroupName -AppSetting @{"AzureAd:Audience" = "<Entra ID Application ID URI>"} #Application ID URI created during `Expose an API' step
|
||||
Update-AzFunctionAppSetting -Name $functionAppName -ResourceGroupName $resourceGroupName -AppSetting @{"AzureAd:ClientId" = "<Entra ID Application Client Id>"}
|
||||
Update-AzFunctionAppSetting -Name $functionAppName -ResourceGroupName $resourceGroupName -AppSetting @{"AzureAd:ClientSecret" = "<Entra ID Application Client Secret>"}
|
||||
Update-AzFunctionAppSetting -Name $functionAppName -ResourceGroupName $resourceGroupName -AppSetting @{"AzureAd:Instance" = "https://login.microsoftonline.com/"}
|
||||
Update-AzFunctionAppSetting -Name $functionAppName -ResourceGroupName $resourceGroupName -AppSetting @{"AzureAd:TenantId" = "<Tenant ID where Entra ID APP is created>"}
|
||||
Update-AzFunctionAppSetting -Name $functionAppName -ResourceGroupName $resourceGroupName -AppSetting @{"RunAsApplicationPermission" = "<true | false>"} #True will use application permissions and false will use delegated permissions
|
|
@ -2,10 +2,12 @@
|
|||
|
||||
## Summary
|
||||
|
||||
Contains SPFx web part with below functionalities
|
||||
Contains SPFx web part & Azure Function with below functionalities
|
||||
|
||||
1. Azure Function to get the health status of all the M365 services using delegate or application permission.
|
||||
2. SPFx web part shows the health status for all the M365 services.
|
||||
3. SPFx web part shows the complete details including all the updates for all the impacted services.
|
||||
|
||||
1. Show the health status for all the M365 services
|
||||
2. Complete details including all the updates for all the impacted services
|
||||
![M365 Services Health List](./assets/M365ServiceHealthList.png)
|
||||
![Service Health Detail](./assets/M365ServiceHealthDetail.png)
|
||||
|
||||
|
@ -18,8 +20,8 @@ Contains SPFx web part with below functionalities
|
|||
|
||||
This sample is optimally compatible with the following environment configuration:
|
||||
|
||||
![SPFx 1.16.1](https://img.shields.io/badge/SPFx-1.16.1-green.svg)
|
||||
![Node.js v16 | v14 | v12](https://img.shields.io/badge/Node.js-v16%20%7C%20v14%20%7C%20v12-green.svg)
|
||||
![SPFx 1.18.2](https://img.shields.io/badge/SPFx-1.18.2-green.svg)
|
||||
![Node.js v18 | v16](https://img.shields.io/badge/Node.js-v18%20%7C%20v16-green.svg)
|
||||
![Compatible with SharePoint Online](https://img.shields.io/badge/SharePoint%20Online-Compatible-green.svg)
|
||||
![Does not work with SharePoint 2019](https://img.shields.io/badge/SharePoint%20Server%202019-Incompatible-red.svg "SharePoint Server 2019 requires SPFx 1.4.1 or lower")
|
||||
![Does not work with SharePoint 2016 (Feature Pack 2)](<https://img.shields.io/badge/SharePoint%20Server%202016%20(Feature%20Pack%202)-Incompatible-red.svg> "SharePoint Server 2016 Feature Pack 2 requires SPFx 1.1")
|
||||
|
@ -34,23 +36,68 @@ For more information about SPFx compatibility, please refer to <https://aka.ms/s
|
|||
- [SharePoint Framework](https://learn.microsoft.com/sharepoint/dev/spfx/sharepoint-framework-overview)
|
||||
- [Microsoft 365 tenant](https://learn.microsoft.com/sharepoint/dev/spfx/set-up-your-development-environment)
|
||||
|
||||
> Get your own free development tenant by subscribing to [Microsoft 365 developer program](https://aka.ms/m365/devprogram)
|
||||
> Get your own free development tenant by subscribing to [Microsoft 365 developer program](http://aka.ms/m365devprogram)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- SharePoint Online tenant
|
||||
- You have to provide permission in SharePoint admin for accessing Graph API on behalf of your solution. You can do it before deployment as proactive steps, or after deployment. You can refer to [steps about how to do this post-deployment](https://learn.microsoft.com/sharepoint/dev/spfx/use-aad-tutorial#deploy-the-solution-and-grant-permissions). You have to use API Access Page of SharePoint admin and add below permission for our use case.
|
||||
- Valid Azure subscription
|
||||
|
||||
Steps to follow:
|
||||
|
||||
- Entra ID App Registration:
|
||||
|
||||
1. Register new Entra ID App in [Azure portal](https://portal.azure.com/).
|
||||
2. Select App registrations.
|
||||
3. Select New registration.
|
||||
4. For Supported account types, select Accounts in this organization directory only. Leave the other options as is.
|
||||
5. Select Register.
|
||||
|
||||
![Azure Entra ID app registration](./assets/AppRegistration.png)
|
||||
|
||||
6. After registering the application, you can find the application (client) ID and Directory (tenant) ID from the overview menu option of Entra ID App. Make a note of the values for use later.
|
||||
7. Select Certificates & Secrets in the manage section of Entra ID app created and select New Client Secret. Select Recommended 6 months in the Expires field. This new secret will be valid for six months. You can also choose different values such as:
|
||||
|
||||
- 03 months
|
||||
- 12 months
|
||||
- 24 months
|
||||
- Custom start date and end date.
|
||||
|
||||
8. Select API Permissions option of Entra ID app created.
|
||||
9. Select Add a permission.
|
||||
10. Select Microsoft Graph and add permissions as per below:
|
||||
|
||||
- Select Delegate permissions and then select ServiceHealth.Read.All, if you want to run the Service health web part based on user permssions. User must have 'Message center Reader' role to access the service health status.
|
||||
- Select Application permissions and then select ServiceHealth.Read.All, if you want to run the Service health web part in elevated permissions mode.
|
||||
|
||||
11. Select Expose an API and Select Add next to Application ID URI. You can use the default value of api://<application-client-id> or another supported [App ID URI pattern](https://learn.microsoft.com/en-us/entra/identity-platform/reference-app-manifest#identifieruris-attribute).Make a note of the applicaiton ID URI for use later.
|
||||
|
||||
- Azure Function App Deployment:
|
||||
Azure Function can be deployed using Visual Studio or Visual Studio code. Alternatively PowerShell can be used to deploy the Azure Function and to configure the required configuration settings. Below are the steps required to deploy Azure function:
|
||||
|
||||
1. Open [AzureFunctionDeployment.ps1](./PowerShell/AzureFunctionDeployment.ps1).
|
||||
2. Update the required variables and execute the PowerShell script. Please note that provided PowerShell use [Azure-CLI](https://learn.microsoft.com/en-us/cli/azure/what-is-azure-cli) commands.
|
||||
|
||||
- SPFx configuration
|
||||
|
||||
1. You have to provide permission in SharePoint admin for accessing azure function on behalf of your solution. You can do it before deployment as proactive steps, or after deployment. You can refer to [steps about how to do this post-deployment](https://learn.microsoft.com/sharepoint/dev/spfx/use-aad-tutorial#deploy-the-solution-and-grant-permissions). You have to use API Access Page of SharePoint admin and add below permission for our use case.
|
||||
|
||||
```
|
||||
"webApiPermissionRequests": [
|
||||
{
|
||||
"resource": "Microsoft Graph",
|
||||
"resource": <Replace with Application ID URI created during `Expose an API' step>,
|
||||
"scope": "ServiceHealth.Read.All"
|
||||
}
|
||||
]
|
||||
|
||||
```
|
||||
|
||||
2. Provide the values in web part properties as per below
|
||||
- Provide the Azure function app URL(without /api/m365servicehealth) in API Base URL property
|
||||
- Provide Application ID URI(created during 'Expose an API' step) in Audience property
|
||||
|
||||
![Web Part Properties](./assets/WebPartProperties.png)
|
||||
|
||||
## Contributors
|
||||
|
||||
[Harminder Singh](https://github.com/HarminderSethi)
|
||||
|
@ -58,8 +105,9 @@ For more information about SPFx compatibility, please refer to <https://aka.ms/s
|
|||
## Version history
|
||||
|
||||
| Version | Date | Comments |
|
||||
| ------- | ----------------- | --------------- |
|
||||
| ------- | ----------------- | ----------------------------------- |
|
||||
| 1.0 | February 15, 2023 | Initial release |
|
||||
| 2.0 | February 10, 2024 | Implementation using Azure Function |
|
||||
|
||||
## Minimal Path to Awesome
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
v16.16.0
|
|
@ -5,9 +5,9 @@
|
|||
"nodeVersion": "16.16.0",
|
||||
"sdksVersions": {
|
||||
"@microsoft/microsoft-graph-client": "3.0.2",
|
||||
"@microsoft/teams-js": "2.4.1"
|
||||
"@microsoft/teams-js": "2.12.0"
|
||||
},
|
||||
"version": "1.16.1",
|
||||
"version": "1.18.2",
|
||||
"libraryName": "react-m-365-services-health",
|
||||
"libraryId": "f4d4b329-f1c6-49c9-a2fa-12a9816ac717",
|
||||
"environment": "spo",
|
|
@ -18,8 +18,13 @@
|
|||
{
|
||||
"resource": "Microsoft Graph",
|
||||
"scope": "ServiceHealth.Read.All"
|
||||
},
|
||||
{
|
||||
"resource": "M365ServiceHealth",
|
||||
"scope": "ServiceHealth.Read.All"
|
||||
}
|
||||
],
|
||||
|
||||
"metadata": {
|
||||
"shortDescription": {
|
||||
"default": "Service Health for Microsoft 365"
|
||||
|
@ -30,7 +35,6 @@
|
|||
"screenshotPaths": [],
|
||||
"videoUrl": "",
|
||||
"categories": []
|
||||
|
||||
},
|
||||
"features": [
|
||||
{
|
||||
|
@ -38,9 +42,7 @@
|
|||
"description": "The feature that activates elements of the react-m-365-services-health solution.",
|
||||
"id": "9d9e7584-45be-4df4-8023-5c271402cf87",
|
||||
"version": "1.0.0.0"
|
||||
|
||||
}
|
||||
|
||||
]
|
||||
},
|
||||
"paths": {
|
|
@ -2,5 +2,5 @@
|
|||
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/spfx-serve.schema.json",
|
||||
"port": 4321,
|
||||
"https": true,
|
||||
"initialPage": "https://enter-your-SharePoint-site/_layouts/workbench.aspx"
|
||||
"initialPage": "https://{tenantDomain}/_layouts/workbench.aspx"
|
||||
}
|
|
@ -3,7 +3,7 @@
|
|||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=16.13.0 <17.0.0"
|
||||
"node": ">=16.13.0 <17.0.0 || >=18.17.1 <19.0.0"
|
||||
},
|
||||
"main": "lib/index.js",
|
||||
"scripts": {
|
||||
|
@ -13,33 +13,35 @@
|
|||
"serve": "gulp bundle --custom-serve --max_old_space_size=4096 && fast-serve"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluentui/react": "^8.104.8",
|
||||
"@fluentui/react": "8.106.4",
|
||||
"@microsoft/microsoft-graph-types": "^2.25.0",
|
||||
"@microsoft/sp-core-library": "1.16.1",
|
||||
"@microsoft/sp-lodash-subset": "1.16.1",
|
||||
"@microsoft/sp-office-ui-fabric-core": "1.16.1",
|
||||
"@microsoft/sp-property-pane": "1.16.1",
|
||||
"@microsoft/sp-webpart-base": "1.16.1",
|
||||
"@microsoft/sp-adaptive-card-extension-base": "1.18.2",
|
||||
"@microsoft/sp-core-library": "1.18.2",
|
||||
"@microsoft/sp-lodash-subset": "1.18.2",
|
||||
"@microsoft/sp-office-ui-fabric-core": "1.18.2",
|
||||
"@microsoft/sp-property-pane": "1.18.2",
|
||||
"@microsoft/sp-webpart-base": "1.18.2",
|
||||
"moment": "^2.29.4",
|
||||
"office-ui-fabric-react": "^7.199.1",
|
||||
"react": "17.0.1",
|
||||
"react-dom": "17.0.1",
|
||||
"tslib": "2.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@microsoft/eslint-config-spfx": "1.16.1",
|
||||
"@microsoft/eslint-plugin-spfx": "1.16.1",
|
||||
"@microsoft/eslint-config-spfx": "1.18.2",
|
||||
"@microsoft/eslint-plugin-spfx": "1.18.2",
|
||||
"@microsoft/rush-stack-compiler-4.5": "0.2.2",
|
||||
"@microsoft/sp-build-web": "1.16.1",
|
||||
"@microsoft/sp-module-interfaces": "1.16.1",
|
||||
"@microsoft/rush-stack-compiler-4.7": "0.1.0",
|
||||
"@microsoft/sp-build-web": "1.18.2",
|
||||
"@microsoft/sp-module-interfaces": "1.18.2",
|
||||
"@rushstack/eslint-config": "2.5.1",
|
||||
"@types/react": "17.0.45",
|
||||
"@types/react-dom": "17.0.17",
|
||||
"@types/webpack-env": "~1.15.2",
|
||||
"ajv": "^6.12.5",
|
||||
"eslint": "8.7.0",
|
||||
"eslint-plugin-react-hooks": "4.3.0",
|
||||
"gulp": "4.0.2",
|
||||
"spfx-fast-serve-helpers": "~1.16.0",
|
||||
"typescript": "4.5.5"
|
||||
"spfx-fast-serve-helpers": "~1.18.9",
|
||||
"typescript": "4.7.4"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { ServiceHealth } from "@microsoft/microsoft-graph-types";
|
||||
import { MSGraphClientV3 } from "@microsoft/sp-http-msgraph";
|
||||
import { WebPartContext } from "@microsoft/sp-webpart-base";
|
||||
|
||||
export class GraphService {
|
||||
private context: WebPartContext;
|
||||
|
||||
//private token: string;
|
||||
constructor(context: WebPartContext) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
private getClient = async (): Promise<MSGraphClientV3> => {
|
||||
return await this.context.msGraphClientFactory.getClient("3");
|
||||
};
|
||||
|
||||
public getHealthOverviews = async (): Promise<ServiceHealth[]> => {
|
||||
const client = await this.getClient();
|
||||
const request = client.api("/admin/serviceAnnouncement/healthOverviews");
|
||||
const response = await request.expand("issues").get();
|
||||
return Promise.resolve(response.value);
|
||||
};
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { ServiceHealth } from "@microsoft/microsoft-graph-types";
|
||||
|
||||
import { WebPartContext } from "@microsoft/sp-webpart-base";
|
||||
import { AadHttpClient, IHttpClientOptions, HttpClientResponse } from "@microsoft/sp-http";
|
||||
export class M365HealthService {
|
||||
private context: WebPartContext;
|
||||
private apiBaseUrl: string;
|
||||
public aadHttpClient: AadHttpClient | undefined;
|
||||
private audience: string;
|
||||
//private token: string;
|
||||
constructor(context: WebPartContext, apiBaseUrl: string, audience: string) {
|
||||
this.context = context;
|
||||
this.apiBaseUrl = apiBaseUrl;
|
||||
this.audience = audience;
|
||||
}
|
||||
|
||||
public initClint = async (): Promise<void> => {
|
||||
this.context.aadHttpClientFactory
|
||||
.getClient(this.audience)
|
||||
.then((client: AadHttpClient) => {
|
||||
return (this.aadHttpClient = client);
|
||||
})
|
||||
.catch((ex) => {
|
||||
console.log(ex);
|
||||
return (this.aadHttpClient = undefined);
|
||||
});
|
||||
};
|
||||
|
||||
public getServiceHealth = async (): Promise<ServiceHealth[]> => {
|
||||
return this.get(`${this.apiBaseUrl}/api/m365servicehealth`);
|
||||
};
|
||||
private async get<T>(url: string, headers?: Headers): Promise<T> {
|
||||
headers = this.mergeHeaders(headers);
|
||||
|
||||
const options: IHttpClientOptions = {
|
||||
headers: headers,
|
||||
};
|
||||
|
||||
const resp: HttpClientResponse | undefined = await this.aadHttpClient?.get(url, AadHttpClient.configurations.v1, options);
|
||||
return this.handleResponse(resp);
|
||||
}
|
||||
|
||||
private async handleResponse(response: HttpClientResponse | undefined): Promise<any> {
|
||||
if (response?.ok) {
|
||||
try {
|
||||
return await response.clone().json();
|
||||
} catch (error) {
|
||||
return await response.text();
|
||||
}
|
||||
} else {
|
||||
// eslint-disable-next-line no-throw-literal
|
||||
throw {
|
||||
errorCode: response?.status,
|
||||
error: response.statusText,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private mergeHeaders(headers: Headers | undefined): Headers {
|
||||
const commonHeaders = this.getCommonRequestHeaders();
|
||||
|
||||
if (headers) {
|
||||
headers?.forEach((value, key) => {
|
||||
if (commonHeaders.has(key)) {
|
||||
commonHeaders.set(key, value);
|
||||
} else {
|
||||
commonHeaders.append(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return commonHeaders;
|
||||
}
|
||||
|
||||
private getCommonRequestHeaders(): Headers {
|
||||
const headers = new Headers();
|
||||
headers.append("Accept", "application/json");
|
||||
headers.append("Content-Type", "application/json");
|
||||
headers.append("Cache-Control", "no-cache");
|
||||
return headers;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import * as React from "react";
|
||||
import * as ReactDom from "react-dom";
|
||||
import { Version } from "@microsoft/sp-core-library";
|
||||
import { IPropertyPaneConfiguration, PropertyPaneTextField } from "@microsoft/sp-property-pane";
|
||||
import { BaseClientSideWebPart } from "@microsoft/sp-webpart-base";
|
||||
|
||||
import * as strings from "M365ServicesHealthWebPartStrings";
|
||||
import M365ServicesHealth from "./components/M365ServicesHealth";
|
||||
import { IM365ServicesHealthProps } from "./components/IM365ServicesHealthProps";
|
||||
|
||||
import { initializeIcons } from "@fluentui/font-icons-mdl2";
|
||||
initializeIcons();
|
||||
export interface IM365ServicesHealthWebPartProps {
|
||||
title: string;
|
||||
audience: string;
|
||||
apiBaseUrl: string;
|
||||
}
|
||||
|
||||
export default class M365ServicesHealthWebPart extends BaseClientSideWebPart<IM365ServicesHealthWebPartProps> {
|
||||
public render(): void {
|
||||
const element: React.ReactElement<IM365ServicesHealthProps> = React.createElement(M365ServicesHealth, {
|
||||
title: this.properties.title,
|
||||
context: this.context,
|
||||
|
||||
apiBaseUrl: this.properties.apiBaseUrl,
|
||||
audience: this.properties.audience,
|
||||
});
|
||||
|
||||
ReactDom.render(element, this.domElement);
|
||||
}
|
||||
|
||||
protected onInit(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
protected onDispose(): void {
|
||||
ReactDom.unmountComponentAtNode(this.domElement);
|
||||
}
|
||||
|
||||
protected get dataVersion(): Version {
|
||||
return Version.parse("1.0");
|
||||
}
|
||||
|
||||
protected onPropertyPaneFieldChanged(propertyPath: string, oldValue: any, newValue: any): void {
|
||||
if (propertyPath === "title" && oldValue !== newValue) {
|
||||
this.properties.title = newValue;
|
||||
} else if (propertyPath === "apiBaseUrl" && oldValue !== newValue) {
|
||||
this.properties.apiBaseUrl = newValue;
|
||||
} else if (propertyPath === "audience" && oldValue !== newValue) {
|
||||
this.properties.audience = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
|
||||
return {
|
||||
pages: [
|
||||
{
|
||||
header: {
|
||||
description: strings.PropertyPaneDescription,
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
groupName: strings.BasicGroupName,
|
||||
groupFields: [
|
||||
PropertyPaneTextField("title", {
|
||||
label: strings.TitleFieldLabel,
|
||||
}),
|
||||
PropertyPaneTextField("apiBaseUrl", {
|
||||
label: "API Base URL",
|
||||
}),
|
||||
PropertyPaneTextField("audience", {
|
||||
label: "Audience",
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 132 KiB |
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
@ -0,0 +1,8 @@
|
|||
import { WebPartContext } from "@microsoft/sp-webpart-base";
|
||||
|
||||
export interface IM365ServicesHealthProps {
|
||||
title: string;
|
||||
context: WebPartContext;
|
||||
apiBaseUrl: string;
|
||||
audience: string;
|
||||
}
|
|
@ -7,7 +7,7 @@ const M365ServicesHealth = (props: IM365ServicesHealthProps): JSX.Element => {
|
|||
return (
|
||||
<>
|
||||
<ServiceHealthHeader title={props.title} />
|
||||
<ServiceHealthOverview graphService={props.graphService} />
|
||||
<ServiceHealthOverview {...props} />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,100 @@
|
|||
import { ServiceHealthIssuePost } from "@microsoft/microsoft-graph-types";
|
||||
import Style from "./IssueDetail.module.scss";
|
||||
import * as React from "react";
|
||||
import * as HelperService from "../../../../../common/services/HelperService";
|
||||
import { Icon, Label } from "@fluentui/react";
|
||||
import { IIssueDetailProps } from "../../../interfaces/ServiceHealthModels";
|
||||
|
||||
export const IssueDetail = (props: IIssueDetailProps): JSX.Element => {
|
||||
return (
|
||||
<>
|
||||
<div className={Style.issueDetailRoot}>
|
||||
<div className={Style.issueDetailWrapper}>
|
||||
<h1 className={Style.issueDetailTitle}>{props.details?.title}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className={Style.subHeaderContainer}>
|
||||
<div className={Style.subHeaderText}>
|
||||
{props.details?.id}, Last updated: {HelperService.getFormattedDateTime(props.details?.lastModifiedDateTime)}
|
||||
</div>
|
||||
<div className={Style.subHeaderText}>Estimated Start time: {HelperService.getFormattedDateTime(props.details?.startDateTime)}</div>
|
||||
</div>
|
||||
<Label>Affected Services</Label>
|
||||
<div className={Style.flyoutTextStyle}>
|
||||
<Icon iconName={HelperService.getProductIcon(props.details?.service)} />
|
||||
<span> {props.details?.service}</span>
|
||||
</div>
|
||||
<div className={Style.flyoutTextStyle} style={{ paddingBottom: 10 }}>
|
||||
<div className={Style.itemsContainer}>
|
||||
<div className={Style.itemContainer}>
|
||||
<Label>Issue Type</Label>
|
||||
<div className={Style.flyoutTextStyle}>
|
||||
<span className={Style.rowItem}>
|
||||
<div className={Style.serviceStatusIcon}>
|
||||
<Icon
|
||||
iconName={props.details?.classification?.toLowerCase() === "advisory" ? "InfoSolid" : "WarningSolid"}
|
||||
styles={{ root: { color: props.details?.classification?.toLowerCase() === "advisory" ? "rgb(0, 120, 212)" : "rgb(197, 54, 1)" } }}
|
||||
/>
|
||||
<div style={{ fontSize: 14, fontWeight: 400, marginLeft: 6, verticalAlign: "middle" }}>
|
||||
<span style={{ color: props.details?.classification?.toLowerCase() === "advisory" ? "rgb(0, 120, 212)" : "rgb(197, 54, 1)" }}>
|
||||
{props.details?.classification.charAt(0).toUpperCase() + props.details?.classification.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={Style.itemContainer}>
|
||||
<Label>Issue origin</Label>
|
||||
<div className={Style.flyoutTextStyle}>
|
||||
<span className={Style.rowItem}>
|
||||
<div className={Style.serviceStatusIcon}>
|
||||
<div style={{ fontSize: 14, fontWeight: 400, verticalAlign: "middle" }}>
|
||||
<span>{props.details?.origin.charAt(0).toUpperCase() + props.details?.origin.slice(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={Style.flyoutTextStyle} style={{ paddingBottom: 10 }}>
|
||||
<div className={Style.itemsContainer}>
|
||||
<div className={Style.itemContainer}>
|
||||
<Label>Status</Label>
|
||||
<div className={Style.flyoutTextStyle}>
|
||||
<span className={Style.rowItem}>
|
||||
<div className={Style.serviceStatusIcon}>
|
||||
<div style={{ fontSize: 14, fontWeight: 400, verticalAlign: "middle" }}>
|
||||
<span>{props.details?.status.charAt(0).toUpperCase() + props.details?.status.slice(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label> User Impact</Label>
|
||||
<div className={Style.flyoutTextStyle}>{props.details?.impactDescription}</div>
|
||||
</div>
|
||||
<h2>All Updates</h2>
|
||||
{props.details?.posts
|
||||
?.sort((a, b) => {
|
||||
return new Date(b.createdDateTime).valueOf() - new Date(a.createdDateTime).valueOf(); // descending
|
||||
})
|
||||
.map((post: ServiceHealthIssuePost, index: number) => {
|
||||
return (
|
||||
<div key={index}>
|
||||
<div className={Style.flyoutContentDivider} />
|
||||
<div className={Style.textLabelForHistory}>{HelperService.getFormattedDateTime(post.createdDateTime)}</div>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: post.description?.content }}
|
||||
style={{ whiteSpace: "pre-line", margin: 0, boxSizing: "inherit", color: "rgb(50,49,48)", paddingBottom: 10 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -24,11 +24,11 @@ export const IssueList = (props: IIssueListProps): JSX.Element => {
|
|||
<span className={Style.rowItem}>
|
||||
<div className={Style.serviceStatusIcon}>
|
||||
<Icon
|
||||
iconName={item.classification === "advisory" ? "InfoSolid" : "WarningSolid"}
|
||||
styles={{ root: { color: item.classification === "advisory" ? "rgb(0, 120, 212)" : "rgb(197, 54, 1)", paddingTop: 3 } }}
|
||||
iconName={item.classification?.toLowerCase() === "advisory" ? "InfoSolid" : "WarningSolid"}
|
||||
styles={{ root: { color: item.classification?.toLowerCase() === "advisory" ? "rgb(0, 120, 212)" : "rgb(197, 54, 1)", paddingTop: 3 } }}
|
||||
/>
|
||||
<div className={Style.healthStatusLink}>
|
||||
<span style={{ color: item.classification === "advisory" ? "rgb(0, 120, 212)" : "rgb(197, 54, 1)" }}>
|
||||
<span style={{ color: item.classification?.toLowerCase() === "advisory" ? "rgb(0, 120, 212)" : "rgb(197, 54, 1)" }}>
|
||||
{item.classification.charAt(0).toUpperCase() + item.classification.slice(1)}
|
||||
</span>
|
||||
</div>
|
|
@ -22,7 +22,7 @@ export const ListItem = (props: IListItemProps): JSX.Element => {
|
|||
</span>
|
||||
) : (
|
||||
fieldContent.split(",").map((value) => {
|
||||
return value.indexOf("advisor") > -1 ? (
|
||||
return value?.toLowerCase()?.indexOf("advisor") > -1 ? (
|
||||
<span className={Style.rowItem}>
|
||||
<div className={Style.serviceStatusIcon}>
|
||||
<Icon iconName="InfoSolid" styles={{ root: { color: "rgb(0, 120, 212)", paddingTop: 3 } }} />
|
|
@ -0,0 +1,121 @@
|
|||
import { DefaultButton, DetailsList, IColumn, IPanelProps, IRenderFunction, Panel, PanelType, SelectionMode, Spinner } from "@fluentui/react";
|
||||
import { ServiceHealth, ServiceHealthIssue } from "@microsoft/microsoft-graph-types";
|
||||
import * as React from "react";
|
||||
import { IServiceHealthOverviewItem, IServiceHealthOverviewProps, IServiceHealthOverviewState } from "../../interfaces/ServiceHealthModels";
|
||||
import * as ListViewHelperService from "../../services/ListViewHelperService";
|
||||
import { backButtonStyles, cancelButtonStyle, issueDetailPanelStyles, issueListPanelStyles } from "./Constant";
|
||||
import { IssueDetail } from "./IssueDetail/IssueDetail";
|
||||
import { IssueList } from "./IssueList/IssueList";
|
||||
import { ListItem } from "./ListItem/ListItem";
|
||||
import { M365HealthService } from "../../../../common/services/M365HealthService";
|
||||
|
||||
export const ServiceHealthOverview = (props: IServiceHealthOverviewProps): JSX.Element => {
|
||||
const [state, setState] = React.useState<IServiceHealthOverviewState>({
|
||||
columns: ListViewHelperService.getOverviewListViewColumns(),
|
||||
items: [],
|
||||
showIssueListPanel: false,
|
||||
showIssueDetailPanel: false,
|
||||
selectedItem: null,
|
||||
selectedIssue: null,
|
||||
showBackButton: false,
|
||||
spinner: true,
|
||||
});
|
||||
/* eslint-disable */
|
||||
React.useEffect(() => {
|
||||
let listViewItems: IServiceHealthOverviewItem[] = [];
|
||||
(async () => {
|
||||
try {
|
||||
if (props.context && props.apiBaseUrl && props.audience) {
|
||||
const m365HealthService = new M365HealthService(props.context, props.apiBaseUrl, props.audience);
|
||||
await m365HealthService.initClint();
|
||||
const response: ServiceHealth[] = await m365HealthService.getServiceHealth();
|
||||
listViewItems = ListViewHelperService.getListViewItemsForOverview(response);
|
||||
}
|
||||
} catch (ex) {
|
||||
console.log(ex);
|
||||
}
|
||||
setState((prevState: IServiceHealthOverviewState) => ({ ...prevState, items: listViewItems, spinner: false }));
|
||||
})();
|
||||
}, [props.context, props.apiBaseUrl, props.audience]);
|
||||
|
||||
const _renderItemColumn = (item: IServiceHealthOverviewItem, index: number, column: IColumn) => {
|
||||
return <ListItem item={item} index={index} column={column} onLinkClick={handleLinkClick} />;
|
||||
};
|
||||
|
||||
const handleLinkClick = (item: IServiceHealthOverviewItem) => {
|
||||
if (item.InProgressItems.length > 1) {
|
||||
setState((prevState: IServiceHealthOverviewState) => ({ ...prevState, showIssueListPanel: true, selectedItem: item }));
|
||||
} else {
|
||||
setState((prevState: IServiceHealthOverviewState) => ({
|
||||
...prevState,
|
||||
showIssueDetailPanel: true,
|
||||
selectedItem: item,
|
||||
selectedIssue: item.InProgressItems[0],
|
||||
}));
|
||||
}
|
||||
};
|
||||
const handleDismissPanel = () => {
|
||||
setState((prevState: IServiceHealthOverviewState) => ({ ...prevState, showIssueDetailPanel: false, showIssueListPanel: false, showBackButton: false }));
|
||||
};
|
||||
|
||||
const handleIssueDetailClick = (item: ServiceHealthIssue) => {
|
||||
setState((prevState: IServiceHealthOverviewState) => ({
|
||||
...prevState,
|
||||
showIssueDetailPanel: true,
|
||||
showIssueListPanel: false,
|
||||
selectedIssue: item,
|
||||
showBackButton: true,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBackClick = () => {
|
||||
setState((prevState: IServiceHealthOverviewState) => ({
|
||||
...prevState,
|
||||
showIssueDetailPanel: false,
|
||||
showIssueListPanel: true,
|
||||
showBackButton: true,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCloseClick = () => {
|
||||
setState((prevState: IServiceHealthOverviewState) => ({
|
||||
...prevState,
|
||||
showIssueDetailPanel: false,
|
||||
showIssueListPanel: false,
|
||||
showBackButton: true,
|
||||
selectedItem: null,
|
||||
selectedIssue: null,
|
||||
}));
|
||||
};
|
||||
|
||||
const _renderNavigation: IRenderFunction<IPanelProps> = (props: IPanelProps, defaultRender): JSX.Element => {
|
||||
return state.showBackButton ? (
|
||||
<div style={{ display: "flex", minHeight: 32, position: "relative", justifyContent: "space-between", marginLeft: -32 }}>
|
||||
<DefaultButton iconProps={{ iconName: "Back" }} styles={backButtonStyles} onClick={handleBackClick}></DefaultButton>
|
||||
<DefaultButton iconProps={{ iconName: "Cancel" }} styles={cancelButtonStyle} onClick={handleCloseClick}></DefaultButton>
|
||||
</div>
|
||||
) : (
|
||||
defaultRender(props)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{state.spinner && <Spinner styles={{ root: { paddingTop: 20 } }} />}
|
||||
{!state.spinner && <DetailsList columns={state.columns} items={state.items} selectionMode={SelectionMode.none} onRenderItemColumn={_renderItemColumn} />}
|
||||
<Panel isOpen={state.showIssueListPanel} onDismiss={handleDismissPanel} type={PanelType.medium} styles={issueListPanelStyles}>
|
||||
<IssueList selectedItem={state.selectedItem} onClick={handleIssueDetailClick} />
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
isOpen={state.showIssueDetailPanel}
|
||||
onDismiss={handleDismissPanel}
|
||||
type={PanelType.medium}
|
||||
styles={issueDetailPanelStyles}
|
||||
onRenderNavigation={_renderNavigation}
|
||||
>
|
||||
<IssueDetail details={state.selectedIssue} />
|
||||
</Panel>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,44 @@
|
|||
import { IColumn } from "@fluentui/react";
|
||||
import { ServiceHealthIssue } from "@microsoft/microsoft-graph-types";
|
||||
import { WebPartContext } from "@microsoft/sp-webpart-base";
|
||||
|
||||
export interface IServiceHealthOverviewItem {
|
||||
Service: string;
|
||||
Status: string;
|
||||
InProgressItems?: ServiceHealthIssue[];
|
||||
}
|
||||
export interface IServiceHealthOverviewProps {
|
||||
apiBaseUrl: string;
|
||||
audience: string;
|
||||
context: WebPartContext;
|
||||
}
|
||||
|
||||
export interface IServiceHealthHeaderProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface IServiceHealthOverviewState {
|
||||
columns: IColumn[];
|
||||
items: IServiceHealthOverviewItem[];
|
||||
showIssueListPanel: boolean;
|
||||
showIssueDetailPanel: boolean;
|
||||
selectedItem: IServiceHealthOverviewItem;
|
||||
selectedIssue: ServiceHealthIssue;
|
||||
showBackButton: boolean;
|
||||
spinner: boolean;
|
||||
}
|
||||
|
||||
export interface IIssueDetailProps {
|
||||
details: ServiceHealthIssue;
|
||||
}
|
||||
|
||||
export interface IIssueListProps {
|
||||
selectedItem: IServiceHealthOverviewItem;
|
||||
onClick: (item: ServiceHealthIssue) => void;
|
||||
}
|
||||
export interface IListItemProps {
|
||||
column: IColumn;
|
||||
item: IServiceHealthOverviewItem;
|
||||
index: number;
|
||||
onLinkClick: (item: IServiceHealthOverviewItem) => void;
|
||||
}
|
|
@ -32,9 +32,9 @@ export const getListViewItemsForOverview = (response: ServiceHealth[]): IService
|
|||
if (inProgressIssues.length > 0) {
|
||||
const counts = inProgressIssues.reduce(
|
||||
(acc, curr) => {
|
||||
if (curr.classification === "advisory") {
|
||||
if (curr.classification?.toLowerCase() === "advisory") {
|
||||
acc.advisoryCount++;
|
||||
} else if (curr.classification === "incident" || curr.classification === "unknownFutureValue") {
|
||||
} else if (curr.classification?.toLowerCase() === "incident" || curr.classification?.toLowerCase() === "unknownFutureValue") {
|
||||
acc.incidentCount++;
|
||||
}
|
||||
return acc;
|
||||
|
@ -44,10 +44,10 @@ export const getListViewItemsForOverview = (response: ServiceHealth[]): IService
|
|||
|
||||
const status: string[] = [];
|
||||
if (counts.advisoryCount > 0) {
|
||||
status.push(`${counts.advisoryCount} ${counts.advisoryCount === 1 ? "advisory" : "advisories"}`);
|
||||
status.push(`${counts.advisoryCount} ${counts.advisoryCount === 1 ? "Advisory" : "Advisories"}`);
|
||||
}
|
||||
if (counts.incidentCount > 0) {
|
||||
status.push(`${counts.incidentCount} ${counts.incidentCount === 1 ? "incident" : "incidents"}`);
|
||||
status.push(`${counts.incidentCount} ${counts.incidentCount === 1 ? "Incident" : "Incidents"}`);
|
||||
}
|
||||
overviewItem.Status = status.join(",");
|
||||
overviewItem.InProgressItems = inProgressIssues;
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 542 B After Width: | Height: | Size: 542 B |
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"extends": "./node_modules/@microsoft/rush-stack-compiler-4.7/includes/tsconfig-web.json",
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"jsx": "react",
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"experimentalDecorators": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "lib",
|
||||
"inlineSources": false,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": true,
|
||||
|
||||
"typeRoots": ["./node_modules/@types", "./node_modules/@microsoft"],
|
||||
"types": ["webpack-env"],
|
||||
"lib": ["ES2017", "es5", "dom", "es2015.collection", "es2015.promise"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
After Width: | Height: | Size: 51 KiB |
After Width: | Height: | Size: 34 KiB |
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 118 KiB |
After Width: | Height: | Size: 8.8 KiB |
|
@ -10,7 +10,7 @@
|
|||
"Service Health for Microsoft 365 solution show the health status for all the M365 services"
|
||||
],
|
||||
"creationDateTime": "2023-02-03",
|
||||
"updateDateTime": "2023-02-03",
|
||||
"updateDateTime": "2024-02-10",
|
||||
"products": [
|
||||
"SharePoint"
|
||||
],
|
||||
|
|
|
@ -1,22 +0,0 @@
|
|||
import { ServiceHealth } from "@microsoft/microsoft-graph-types";
|
||||
import { MSGraphClientV3, GraphRequest } from "@microsoft/sp-http-msgraph";
|
||||
import { WebPartContext } from "@microsoft/sp-webpart-base";
|
||||
|
||||
export class GraphService {
|
||||
private context: WebPartContext;
|
||||
|
||||
constructor(context: WebPartContext) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
private getClient = async (): Promise<MSGraphClientV3> => {
|
||||
return await this.context.msGraphClientFactory.getClient("3");
|
||||
};
|
||||
|
||||
public getHealthOverviews = async (): Promise<ServiceHealth[]> => {
|
||||
const client = await this.getClient();
|
||||
const request: GraphRequest = client.api("/admin/serviceAnnouncement/healthOverviews");
|
||||
const response = await request.expand("issues").get();
|
||||
return Promise.resolve(response.value);
|
||||
};
|
||||
}
|
|
@ -1,64 +0,0 @@
|
|||
import * as React from "react";
|
||||
import * as ReactDom from "react-dom";
|
||||
import { Version } from "@microsoft/sp-core-library";
|
||||
import { IPropertyPaneConfiguration, PropertyPaneTextField } from "@microsoft/sp-property-pane";
|
||||
import { BaseClientSideWebPart } from "@microsoft/sp-webpart-base";
|
||||
|
||||
import * as strings from "M365ServicesHealthWebPartStrings";
|
||||
import M365ServicesHealth from "./components/M365ServicesHealth";
|
||||
import { IM365ServicesHealthProps } from "./components/IM365ServicesHealthProps";
|
||||
import { GraphService } from "../../common/services/GraphService";
|
||||
|
||||
export interface IM365ServicesHealthWebPartProps {
|
||||
title: string;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export default class M365ServicesHealthWebPart extends BaseClientSideWebPart<IM365ServicesHealthWebPartProps> {
|
||||
private graphService: GraphService;
|
||||
|
||||
public render(): void {
|
||||
const element: React.ReactElement<IM365ServicesHealthProps> = React.createElement(M365ServicesHealth, {
|
||||
title: this.properties.title,
|
||||
context: this.context,
|
||||
graphService: this.graphService,
|
||||
});
|
||||
|
||||
ReactDom.render(element, this.domElement);
|
||||
}
|
||||
|
||||
protected onInit(): Promise<void> {
|
||||
this.graphService = new GraphService(this.context);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
protected onDispose(): void {
|
||||
ReactDom.unmountComponentAtNode(this.domElement);
|
||||
}
|
||||
|
||||
protected get dataVersion(): Version {
|
||||
return Version.parse("1.0");
|
||||
}
|
||||
|
||||
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
|
||||
return {
|
||||
pages: [
|
||||
{
|
||||
header: {
|
||||
description: strings.PropertyPaneDescription,
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
groupName: strings.BasicGroupName,
|
||||
groupFields: [
|
||||
PropertyPaneTextField("title", {
|
||||
label: strings.TitleFieldLabel,
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
import { WebPartContext } from "@microsoft/sp-webpart-base";
|
||||
import { GraphService } from "../../../common/services/GraphService";
|
||||
export interface IM365ServicesHealthProps {
|
||||
title: string;
|
||||
context: WebPartContext;
|
||||
graphService: GraphService;
|
||||
}
|
|
@ -1,100 +0,0 @@
|
|||
import { ServiceHealthIssuePost } from "@microsoft/microsoft-graph-types";
|
||||
import Style from "./IssueDetail.module.scss";
|
||||
import * as React from "react";
|
||||
import * as HelperService from "../../../../../common/services/HelperService";
|
||||
import { Icon, Label } from "@fluentui/react";
|
||||
import { IIssueDetailProps } from "../../../interfaces/ServiceHealthModels";
|
||||
|
||||
export const IssueDetail = (props: IIssueDetailProps): JSX.Element => {
|
||||
return (
|
||||
<>
|
||||
<div className={Style.issueDetailRoot}>
|
||||
<div className={Style.issueDetailWrapper}>
|
||||
<h1 className={Style.issueDetailTitle}>{props.details?.title}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className={Style.subHeaderContainer}>
|
||||
<div className={Style.subHeaderText}>
|
||||
{props.details?.id}, Last updated: {HelperService.getFormattedDateTime(props.details?.lastModifiedDateTime)}
|
||||
</div>
|
||||
<div className={Style.subHeaderText}>Estimated Start time: {HelperService.getFormattedDateTime(props.details?.startDateTime)}</div>
|
||||
</div>
|
||||
<Label>Affected Services</Label>
|
||||
<div className={Style.flyoutTextStyle}>
|
||||
<Icon iconName={HelperService.getProductIcon(props.details?.service)} />
|
||||
<span> {props.details?.service}</span>
|
||||
</div>
|
||||
<div className={Style.flyoutTextStyle} style={{ paddingBottom: 10 }}>
|
||||
<div className={Style.itemsContainer}>
|
||||
<div className={Style.itemContainer}>
|
||||
<Label>Issue Type</Label>
|
||||
<div className={Style.flyoutTextStyle}>
|
||||
<span className={Style.rowItem}>
|
||||
<div className={Style.serviceStatusIcon}>
|
||||
<Icon
|
||||
iconName={props.details?.classification === "advisory" ? "InfoSolid" : "WarningSolid"}
|
||||
styles={{ root: { color: props.details?.classification === "advisory" ? "rgb(0, 120, 212)" : "rgb(197, 54, 1)" } }}
|
||||
/>
|
||||
<div style={{ fontSize: 14, fontWeight: 400, marginLeft: 6, verticalAlign: "middle" }}>
|
||||
<span style={{ color: props.details?.classification === "advisory" ? "rgb(0, 120, 212)" : "rgb(197, 54, 1)" }}>
|
||||
{props.details?.classification.charAt(0).toUpperCase() + props.details?.classification.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={Style.itemContainer}>
|
||||
<Label>Issue origin</Label>
|
||||
<div className={Style.flyoutTextStyle}>
|
||||
<span className={Style.rowItem}>
|
||||
<div className={Style.serviceStatusIcon}>
|
||||
<div style={{ fontSize: 14, fontWeight: 400, verticalAlign: "middle" }}>
|
||||
<span>{props.details?.origin.charAt(0).toUpperCase() + props.details?.origin.slice(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={Style.flyoutTextStyle} style={{ paddingBottom: 10 }}>
|
||||
<div className={Style.itemsContainer}>
|
||||
<div className={Style.itemContainer}>
|
||||
<Label>Status</Label>
|
||||
<div className={Style.flyoutTextStyle}>
|
||||
<span className={Style.rowItem}>
|
||||
<div className={Style.serviceStatusIcon}>
|
||||
<div style={{ fontSize: 14, fontWeight: 400, verticalAlign: "middle" }}>
|
||||
<span>{props.details?.status.charAt(0).toUpperCase() + props.details?.status.slice(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label> User Impact</Label>
|
||||
<div className={Style.flyoutTextStyle}>{props.details?.impactDescription}</div>
|
||||
</div>
|
||||
<h2>All Updates</h2>
|
||||
{props.details?.posts
|
||||
?.sort((a, b) => {
|
||||
return new Date(b.createdDateTime).valueOf() - new Date(a.createdDateTime).valueOf(); // descending
|
||||
})
|
||||
.map((post: ServiceHealthIssuePost, index: number) => {
|
||||
return (
|
||||
<div key={index}>
|
||||
<div className={Style.flyoutContentDivider} />
|
||||
<div className={Style.textLabelForHistory}>{HelperService.getFormattedDateTime(post.createdDateTime)}</div>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: post.description?.content }}
|
||||
style={{ whiteSpace: "pre-line", margin: 0, boxSizing: "inherit", color: "rgb(50,49,48)", paddingBottom: 10 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,116 +0,0 @@
|
|||
import { DefaultButton, DetailsList, IColumn, IPanelProps, IRenderFunction, Panel, PanelType, SelectionMode, Spinner } from "@fluentui/react";
|
||||
import { ServiceHealth, ServiceHealthIssue } from "@microsoft/microsoft-graph-types";
|
||||
import * as React from "react";
|
||||
import { IServiceHealthOverviewItem, IServiceHealthOverviewProps, IServiceHealthOverviewState } from "../../interfaces/ServiceHealthModels";
|
||||
import * as ListViewHelperService from "../../services/ListViewHelperService";
|
||||
import { backButtonStyles, cancelButtonStyle, issueDetailPanelStyles, issueListPanelStyles } from "./Constant";
|
||||
import { IssueDetail } from "./IssueDetail/IssueDetail";
|
||||
import { IssueList } from "./IssueList/IssueList";
|
||||
import { ListItem } from "./ListItem/ListItem";
|
||||
|
||||
export const ServiceHealthOverview = (props: IServiceHealthOverviewProps): JSX.Element => {
|
||||
const [state, setState] = React.useState<IServiceHealthOverviewState>({
|
||||
columns: ListViewHelperService.getOverviewListViewColumns(),
|
||||
items: [],
|
||||
showIssueListPanel: false,
|
||||
showIssueDetailPanel: false,
|
||||
selectedItem: null,
|
||||
selectedIssue: null,
|
||||
showBackButton: false,
|
||||
spinner: true,
|
||||
});
|
||||
/* eslint-disable */
|
||||
React.useEffect(() => {
|
||||
let listViewItems: IServiceHealthOverviewItem[] = [];
|
||||
(async () => {
|
||||
try {
|
||||
const response: ServiceHealth[] = await props.graphService.getHealthOverviews();
|
||||
listViewItems = ListViewHelperService.getListViewItemsForOverview(response);
|
||||
} catch (ex) {
|
||||
console.log(ex);
|
||||
}
|
||||
setState((prevState: IServiceHealthOverviewState) => ({ ...prevState, items: listViewItems, spinner: false }));
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const _renderItemColumn = (item: IServiceHealthOverviewItem, index: number, column: IColumn) => {
|
||||
return <ListItem item={item} index={index} column={column} onLinkClick={handleLinkClick} />;
|
||||
};
|
||||
|
||||
const handleLinkClick = (item: IServiceHealthOverviewItem) => {
|
||||
if (item.InProgressItems.length > 1) {
|
||||
setState((prevState: IServiceHealthOverviewState) => ({ ...prevState, showIssueListPanel: true, selectedItem: item }));
|
||||
} else {
|
||||
setState((prevState: IServiceHealthOverviewState) => ({
|
||||
...prevState,
|
||||
showIssueDetailPanel: true,
|
||||
selectedItem: item,
|
||||
selectedIssue: item.InProgressItems[0],
|
||||
}));
|
||||
}
|
||||
};
|
||||
const handleDismissPanel = () => {
|
||||
setState((prevState: IServiceHealthOverviewState) => ({ ...prevState, showIssueDetailPanel: false, showIssueListPanel: false, showBackButton: false }));
|
||||
};
|
||||
|
||||
const handleIssueDetailClick = (item: ServiceHealthIssue) => {
|
||||
setState((prevState: IServiceHealthOverviewState) => ({
|
||||
...prevState,
|
||||
showIssueDetailPanel: true,
|
||||
showIssueListPanel: false,
|
||||
selectedIssue: item,
|
||||
showBackButton: true,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBackClick = () => {
|
||||
setState((prevState: IServiceHealthOverviewState) => ({
|
||||
...prevState,
|
||||
showIssueDetailPanel: false,
|
||||
showIssueListPanel: true,
|
||||
showBackButton: true,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCloseClick = () => {
|
||||
setState((prevState: IServiceHealthOverviewState) => ({
|
||||
...prevState,
|
||||
showIssueDetailPanel: false,
|
||||
showIssueListPanel: false,
|
||||
showBackButton: true,
|
||||
selectedItem: null,
|
||||
selectedIssue: null,
|
||||
}));
|
||||
};
|
||||
|
||||
const _renderNavigation: IRenderFunction<IPanelProps> = (props: IPanelProps, defaultRender): JSX.Element => {
|
||||
return state.showBackButton ? (
|
||||
<div style={{ display: "flex", minHeight: 32, position: "relative", justifyContent: "space-between", marginLeft: -32 }}>
|
||||
<DefaultButton iconProps={{ iconName: "Back" }} styles={backButtonStyles} onClick={handleBackClick}></DefaultButton>
|
||||
<DefaultButton iconProps={{ iconName: "Cancel" }} styles={cancelButtonStyle} onClick={handleCloseClick}></DefaultButton>
|
||||
</div>
|
||||
) : (
|
||||
defaultRender(props)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{state.spinner && <Spinner styles={{ root: { paddingTop: 20 } }} />}
|
||||
{!state.spinner && <DetailsList columns={state.columns} items={state.items} selectionMode={SelectionMode.none} onRenderItemColumn={_renderItemColumn} />}
|
||||
<Panel isOpen={state.showIssueListPanel} onDismiss={handleDismissPanel} type={PanelType.medium} styles={issueListPanelStyles}>
|
||||
<IssueList selectedItem={state.selectedItem} onClick={handleIssueDetailClick} />
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
isOpen={state.showIssueDetailPanel}
|
||||
onDismiss={handleDismissPanel}
|
||||
type={PanelType.medium}
|
||||
styles={issueDetailPanelStyles}
|
||||
onRenderNavigation={_renderNavigation}
|
||||
>
|
||||
<IssueDetail details={state.selectedIssue} />
|
||||
</Panel>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,42 +0,0 @@
|
|||
import { IColumn } from "@fluentui/react";
|
||||
import { ServiceHealthIssue } from "@microsoft/microsoft-graph-types";
|
||||
import { GraphService } from "../../../common/services/GraphService";
|
||||
|
||||
export interface IServiceHealthOverviewItem {
|
||||
Service: string;
|
||||
Status: string;
|
||||
InProgressItems?: ServiceHealthIssue[];
|
||||
}
|
||||
export interface IServiceHealthOverviewProps {
|
||||
graphService: GraphService;
|
||||
}
|
||||
|
||||
export interface IServiceHealthHeaderProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface IServiceHealthOverviewState {
|
||||
columns: IColumn[];
|
||||
items: IServiceHealthOverviewItem[];
|
||||
showIssueListPanel: boolean;
|
||||
showIssueDetailPanel: boolean;
|
||||
selectedItem: IServiceHealthOverviewItem;
|
||||
selectedIssue: ServiceHealthIssue;
|
||||
showBackButton: boolean;
|
||||
spinner: boolean;
|
||||
}
|
||||
|
||||
export interface IIssueDetailProps {
|
||||
details: ServiceHealthIssue;
|
||||
}
|
||||
|
||||
export interface IIssueListProps {
|
||||
selectedItem: IServiceHealthOverviewItem;
|
||||
onClick: (item: ServiceHealthIssue) => void;
|
||||
}
|
||||
export interface IListItemProps {
|
||||
column: IColumn;
|
||||
item: IServiceHealthOverviewItem;
|
||||
index: number;
|
||||
onLinkClick: (item: IServiceHealthOverviewItem) => void;
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
{
|
||||
"extends": "./node_modules/@microsoft/rush-stack-compiler-4.5/includes/tsconfig-web.json",
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"jsx": "react",
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"experimentalDecorators": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "lib",
|
||||
"inlineSources": false,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": true,
|
||||
|
||||
"typeRoots": ["./node_modules/@types", "./node_modules/@microsoft"],
|
||||
"types": ["webpack-env"],
|
||||
"lib": ["ES2017", "es5", "dom", "es2015.collection", "es2015.promise"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|