Updated to latest SPFx version and graph call replaced with azure function

This commit is contained in:
Harminder Singh 2024-02-10 23:21:51 +05:30
parent 8c33ebadca
commit e9f898780e
74 changed files with 19651 additions and 21528 deletions

View File

@ -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

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
</configuration>

View File

@ -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;
}
}
}
}

View File

@ -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>

View File

@ -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

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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();

View File

@ -0,0 +1,9 @@
{
"profiles": {
"M365ServiceHealth": {
"commandName": "Project",
"commandLineArgs": "--port 7097",
"launchBrowser": false
}
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -0,0 +1,12 @@
{
"version": "2.0",
"logging": {
"applicationInsights": {
"samplingSettings": {
"isEnabled": true,
"excludedTypes": "Request"
},
"enableLiveMetricsFilters": true
}
}
}

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
v16.16.0

View File

@ -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",

View File

@ -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": {

View File

@ -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"
}

View File

@ -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"
}
}

View File

@ -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);
};
}

View File

@ -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;
}
}

View File

@ -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",
}),
],
},
],
},
],
};
}
}

View File

@ -0,0 +1,8 @@
import { WebPartContext } from "@microsoft/sp-webpart-base";
export interface IM365ServicesHealthProps {
title: string;
context: WebPartContext;
apiBaseUrl: string;
audience: string;
}

View File

@ -7,7 +7,7 @@ const M365ServicesHealth = (props: IM365ServicesHealthProps): JSX.Element => {
return (
<>
<ServiceHealthHeader title={props.title} />
<ServiceHealthOverview graphService={props.graphService} />
<ServiceHealthOverview {...props} />
</>
);
};

View File

@ -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>
);
})}
</>
);
};

View File

@ -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>

View File

@ -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 } }} />

View File

@ -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>
</>
);
};

View File

@ -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;
}

View File

@ -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;

View File

@ -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"]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@ -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"
],

View File

@ -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);
};
}

View File

@ -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,
}),
],
},
],
},
],
};
}
}

View File

@ -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;
}

View File

@ -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>
);
})}
</>
);
};

View File

@ -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>
</>
);
};

View File

@ -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;
}

View File

@ -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"]
}