mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
re-worked notification system to support more webhook providers (current, legacyTeamsProvider, TeamsWorkflowProvider, Slack)
This commit is contained in:
@@ -1 +1 @@
|
||||
443
|
||||
445
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Config
|
||||
{
|
||||
public class TestWebhookNotificationFunction
|
||||
{
|
||||
private readonly ILogger<TestWebhookNotificationFunction> _logger;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly WebhookNotificationService _webhookNotificationService;
|
||||
|
||||
public TestWebhookNotificationFunction(
|
||||
ILogger<TestWebhookNotificationFunction> logger,
|
||||
TenantConfigurationService configService,
|
||||
WebhookNotificationService webhookNotificationService)
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_webhookNotificationService = webhookNotificationService;
|
||||
}
|
||||
|
||||
[Function("TestWebhookNotification")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "config/{tenantId}/test-notification")] HttpRequestData req,
|
||||
string tenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware
|
||||
string userIdentifier = TenantHelper.GetUserIdentifier(req);
|
||||
|
||||
_logger.LogInformation("Test webhook notification requested by {User} for tenant {TenantId}",
|
||||
userIdentifier, tenantId);
|
||||
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
|
||||
var (webhookUrl, providerTypeInt) = tenantConfig.GetEffectiveWebhookConfig();
|
||||
|
||||
if (string.IsNullOrEmpty(webhookUrl) || providerTypeInt == 0)
|
||||
{
|
||||
var noConfigResponse = req.CreateResponse(HttpStatusCode.OK);
|
||||
await noConfigResponse.WriteAsJsonAsync(new
|
||||
{
|
||||
success = false,
|
||||
message = "No webhook is configured. Please set a webhook URL and provider first."
|
||||
});
|
||||
return noConfigResponse;
|
||||
}
|
||||
|
||||
var providerType = (WebhookProviderType)providerTypeInt;
|
||||
var testAlert = NotificationAlertBuilder.BuildTestAlert();
|
||||
var result = await _webhookNotificationService.SendNotificationWithResultAsync(webhookUrl, providerType, testAlert);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
{
|
||||
success = result.Success,
|
||||
statusCode = result.StatusCode,
|
||||
message = result.Message
|
||||
});
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error sending test webhook notification for tenant {TenantId}", tenantId);
|
||||
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error." });
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
-63
@@ -2,7 +2,9 @@ using System.Linq;
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
|
||||
@@ -235,8 +237,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
sessionPrefix, updatedSession?.IsPreProvisioned, updatedSession?.Status);
|
||||
}
|
||||
|
||||
// Send Teams notifications (fire-and-forget, non-fatal)
|
||||
await SendTeamsNotificationsAsync(
|
||||
// Send webhook notifications (fire-and-forget, non-fatal)
|
||||
await SendWebhookNotificationsAsync(
|
||||
request, sessionPrefix, classification, updatedSession,
|
||||
statusTransitioned, whiteGloveStatusTransitioned, failureReason);
|
||||
|
||||
@@ -406,86 +408,95 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends Teams notifications for enrollment completion, WhiteGlove, and ESP failure events.
|
||||
/// Sends webhook notifications for enrollment completion, WhiteGlove, and ESP failure events.
|
||||
/// Uses the channel-agnostic notification system with provider-specific renderers.
|
||||
/// </summary>
|
||||
private async Task SendTeamsNotificationsAsync(
|
||||
private async Task SendWebhookNotificationsAsync(
|
||||
IngestEventsRequest request, string sessionPrefix, EventClassification c,
|
||||
SessionSummary? updatedSession, bool statusTransitioned, bool whiteGloveStatusTransitioned, string? failureReason)
|
||||
{
|
||||
// Send Teams notification on enrollment completion (fire-and-forget, non-fatal)
|
||||
// Read config once (was 3 separate reads before)
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(request.TenantId);
|
||||
var (webhookUrl, providerTypeInt) = tenantConfig.GetEffectiveWebhookConfig();
|
||||
|
||||
if (string.IsNullOrEmpty(webhookUrl) || providerTypeInt == 0)
|
||||
return;
|
||||
|
||||
var providerType = (WebhookProviderType)providerTypeInt;
|
||||
var sessionUrl = updatedSession != null
|
||||
? $"https://www.autopilotmonitor.com/session/{request.TenantId}/{request.SessionId}"
|
||||
: null;
|
||||
|
||||
// Enrollment completion/failure notification
|
||||
// Only send when statusTransitioned=true to prevent duplicates on retry/double-upload
|
||||
if (statusTransitioned && (c.CompletionEvent != null || c.FailureEvent != null))
|
||||
{
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(request.TenantId);
|
||||
if (!string.IsNullOrEmpty(tenantConfig.TeamsWebhookUrl))
|
||||
var notifySuccess = c.CompletionEvent != null && tenantConfig.GetEffectiveNotifyOnSuccess();
|
||||
var notifyFailure = c.FailureEvent != null && tenantConfig.GetEffectiveNotifyOnFailure();
|
||||
if (notifySuccess || notifyFailure)
|
||||
{
|
||||
var notifySuccess = c.CompletionEvent != null && tenantConfig.TeamsNotifyOnSuccess;
|
||||
var notifyFailure = c.FailureEvent != null && tenantConfig.TeamsNotifyOnFailure;
|
||||
if (notifySuccess || notifyFailure)
|
||||
var duration = updatedSession?.DurationSeconds != null
|
||||
? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value)
|
||||
: (TimeSpan?)null;
|
||||
|
||||
// For WhiteGlove sessions: show user enrollment duration only (Duration 2)
|
||||
if (updatedSession?.IsPreProvisioned == true && updatedSession?.ResumedAt != null)
|
||||
{
|
||||
var duration = updatedSession?.DurationSeconds != null
|
||||
? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value)
|
||||
: (TimeSpan?)null;
|
||||
|
||||
// For WhiteGlove sessions: show user enrollment duration only (Duration 2)
|
||||
if (updatedSession?.IsPreProvisioned == true && updatedSession?.ResumedAt != null)
|
||||
{
|
||||
var completionTime = c.CompletionEvent?.Timestamp ?? c.FailureEvent?.Timestamp;
|
||||
if (completionTime.HasValue)
|
||||
duration = completionTime.Value - updatedSession.ResumedAt.Value;
|
||||
}
|
||||
|
||||
_ = _teamsNotificationService.SendEnrollmentNotificationAsync(
|
||||
tenantConfig.TeamsWebhookUrl,
|
||||
updatedSession?.DeviceName,
|
||||
updatedSession?.SerialNumber,
|
||||
updatedSession?.Manufacturer,
|
||||
updatedSession?.Model,
|
||||
success: c.CompletionEvent != null,
|
||||
failureReason: failureReason,
|
||||
duration: duration);
|
||||
var completionTime = c.CompletionEvent?.Timestamp ?? c.FailureEvent?.Timestamp;
|
||||
if (completionTime.HasValue)
|
||||
duration = completionTime.Value - updatedSession.ResumedAt.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send Teams notification on WhiteGlove pre-provisioning completion
|
||||
if (whiteGloveStatusTransitioned && c.WhiteGloveEvent != null)
|
||||
{
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(request.TenantId);
|
||||
if (!string.IsNullOrEmpty(tenantConfig.TeamsWebhookUrl) && tenantConfig.TeamsNotifyOnSuccess)
|
||||
{
|
||||
var duration = updatedSession?.DurationSeconds != null
|
||||
? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value)
|
||||
: (TimeSpan?)null;
|
||||
_ = _teamsNotificationService.SendWhiteGloveNotificationAsync(
|
||||
tenantConfig.TeamsWebhookUrl,
|
||||
var alert = NotificationAlertBuilder.BuildEnrollmentAlert(
|
||||
updatedSession?.DeviceName,
|
||||
updatedSession?.SerialNumber,
|
||||
updatedSession?.Manufacturer,
|
||||
updatedSession?.Model,
|
||||
success: true,
|
||||
duration: duration);
|
||||
success: c.CompletionEvent != null,
|
||||
failureReason: failureReason,
|
||||
duration: duration,
|
||||
sessionUrl: sessionUrl);
|
||||
|
||||
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert);
|
||||
}
|
||||
}
|
||||
|
||||
// Send Teams notification on WhiteGlove pre-provisioning failure via esp_failure
|
||||
if (c.EspFailureEvent != null && updatedSession?.IsPreProvisioned == true)
|
||||
// WhiteGlove pre-provisioning completion
|
||||
if (whiteGloveStatusTransitioned && c.WhiteGloveEvent != null && tenantConfig.GetEffectiveNotifyOnSuccess())
|
||||
{
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(request.TenantId);
|
||||
if (!string.IsNullOrEmpty(tenantConfig.TeamsWebhookUrl) && tenantConfig.TeamsNotifyOnFailure)
|
||||
{
|
||||
var duration = updatedSession?.DurationSeconds != null
|
||||
? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value)
|
||||
: (TimeSpan?)null;
|
||||
_ = _teamsNotificationService.SendWhiteGloveNotificationAsync(
|
||||
tenantConfig.TeamsWebhookUrl,
|
||||
updatedSession?.DeviceName,
|
||||
updatedSession?.SerialNumber,
|
||||
updatedSession?.Manufacturer,
|
||||
updatedSession?.Model,
|
||||
success: false,
|
||||
duration: duration);
|
||||
}
|
||||
var duration = updatedSession?.DurationSeconds != null
|
||||
? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value)
|
||||
: (TimeSpan?)null;
|
||||
|
||||
var alert = NotificationAlertBuilder.BuildWhiteGloveAlert(
|
||||
updatedSession?.DeviceName,
|
||||
updatedSession?.SerialNumber,
|
||||
updatedSession?.Manufacturer,
|
||||
updatedSession?.Model,
|
||||
success: true,
|
||||
duration: duration,
|
||||
sessionUrl: sessionUrl);
|
||||
|
||||
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert);
|
||||
}
|
||||
|
||||
// WhiteGlove pre-provisioning failure via esp_failure
|
||||
if (c.EspFailureEvent != null && updatedSession?.IsPreProvisioned == true && tenantConfig.GetEffectiveNotifyOnFailure())
|
||||
{
|
||||
var duration = updatedSession?.DurationSeconds != null
|
||||
? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value)
|
||||
: (TimeSpan?)null;
|
||||
|
||||
var alert = NotificationAlertBuilder.BuildWhiteGloveAlert(
|
||||
updatedSession?.DeviceName,
|
||||
updatedSession?.SerialNumber,
|
||||
updatedSession?.Manufacturer,
|
||||
updatedSession?.Model,
|
||||
success: false,
|
||||
duration: duration,
|
||||
sessionUrl: sessionUrl);
|
||||
|
||||
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -18,7 +19,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
|
||||
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
|
||||
private readonly AnalyzeRuleService _analyzeRuleService;
|
||||
private readonly TeamsNotificationService _teamsNotificationService;
|
||||
private readonly WebhookNotificationService _webhookNotificationService;
|
||||
private readonly BlockedDeviceService _blockedDeviceService;
|
||||
private readonly BlockedVersionService _blockedVersionService;
|
||||
private readonly BootstrapSessionService _bootstrapSessionService;
|
||||
@@ -31,7 +32,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
AutopilotDeviceValidator autopilotDeviceValidator,
|
||||
CorporateIdentifierValidator corporateIdentifierValidator,
|
||||
AnalyzeRuleService analyzeRuleService,
|
||||
TeamsNotificationService teamsNotificationService,
|
||||
WebhookNotificationService webhookNotificationService,
|
||||
BlockedDeviceService blockedDeviceService,
|
||||
BlockedVersionService blockedVersionService,
|
||||
BootstrapSessionService bootstrapSessionService)
|
||||
@@ -43,7 +44,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
_autopilotDeviceValidator = autopilotDeviceValidator;
|
||||
_corporateIdentifierValidator = corporateIdentifierValidator;
|
||||
_analyzeRuleService = analyzeRuleService;
|
||||
_teamsNotificationService = teamsNotificationService;
|
||||
_webhookNotificationService = webhookNotificationService;
|
||||
_blockedDeviceService = blockedDeviceService;
|
||||
_blockedVersionService = blockedVersionService;
|
||||
_bootstrapSessionService = bootstrapSessionService;
|
||||
|
||||
@@ -126,7 +126,7 @@ builder.Services.AddSingleton<ReportAgentErrorFunction>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.GraphTokenService>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.AutopilotDeviceValidator>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.CorporateIdentifierValidator>();
|
||||
builder.Services.AddHttpClient<TeamsNotificationService>();
|
||||
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService>();
|
||||
builder.Services.AddHttpClient<TelegramNotificationService>();
|
||||
builder.Services.AddSingleton<ResendEmailService>();
|
||||
builder.Services.AddSingleton<GalacticNotificationService>();
|
||||
|
||||
@@ -135,6 +135,7 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("DELETE", "tenants/{tenantId}/offboard", EndpointPolicy.TenantAdminOrGA),
|
||||
new("GET", "config/{tenantId}/autopilot-device-validation/consent-url", EndpointPolicy.TenantAdminOrGA),
|
||||
new("GET", "config/{tenantId}/autopilot-device-validation/consent-status", EndpointPolicy.TenantAdminOrGA),
|
||||
new("POST", "config/{tenantId}/test-notification", EndpointPolicy.TenantAdminOrGA),
|
||||
|
||||
// ── BootstrapManagerOrGA ────────────────────────────────────────
|
||||
new("GET", "bootstrap/sessions", EndpointPolicy.BootstrapManagerOrGA),
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
{
|
||||
/// <summary>
|
||||
/// Transforms a channel-agnostic NotificationAlert into a provider-specific JSON payload.
|
||||
/// </summary>
|
||||
public interface INotificationRenderer
|
||||
{
|
||||
WebhookProviderType ProviderType { get; }
|
||||
string RenderToJson(NotificationAlert alert);
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
{
|
||||
/// <summary>
|
||||
/// Renders a NotificationAlert as a Microsoft Teams MessageCard (legacy Office 365 Connector format).
|
||||
/// </summary>
|
||||
public class LegacyTeamsConnectorRenderer : INotificationRenderer
|
||||
{
|
||||
public WebhookProviderType ProviderType => WebhookProviderType.TeamsLegacyConnector;
|
||||
|
||||
public string RenderToJson(NotificationAlert alert)
|
||||
{
|
||||
var facts = alert.Facts.Select(f => new { name = f.Name, value = f.Value }).ToArray();
|
||||
|
||||
var potentialAction = alert.Actions.Count > 0
|
||||
? alert.Actions.Select(a => new
|
||||
{
|
||||
type = "OpenUri",
|
||||
name = a.Title,
|
||||
targets = new[] { new { os = "default", uri = a.Url } }
|
||||
}).ToArray()
|
||||
: null;
|
||||
|
||||
object card;
|
||||
if (potentialAction != null)
|
||||
{
|
||||
card = new
|
||||
{
|
||||
type = "MessageCard",
|
||||
context = "http://schema.org/extensions",
|
||||
themeColor = alert.ThemeColor ?? "0078D4",
|
||||
summary = alert.Summary,
|
||||
sections = new[]
|
||||
{
|
||||
new { activityTitle = alert.Title, facts }
|
||||
},
|
||||
potentialAction
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
card = new
|
||||
{
|
||||
type = "MessageCard",
|
||||
context = "http://schema.org/extensions",
|
||||
themeColor = alert.ThemeColor ?? "0078D4",
|
||||
summary = alert.Summary,
|
||||
sections = new[]
|
||||
{
|
||||
new { activityTitle = alert.Title, facts }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
var json = JsonConvert.SerializeObject(card, new JsonSerializerSettings
|
||||
{
|
||||
ContractResolver = new Newtonsoft.Json.Serialization.DefaultContractResolver
|
||||
{
|
||||
NamingStrategy = new Newtonsoft.Json.Serialization.CamelCaseNamingStrategy()
|
||||
}
|
||||
});
|
||||
|
||||
// Teams MessageCard requires @type and @context prefixes
|
||||
json = json.Replace("\"type\":", "\"@type\":")
|
||||
.Replace("\"context\":", "\"@context\":");
|
||||
|
||||
return json;
|
||||
}
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds channel-agnostic NotificationAlert instances for enrollment events.
|
||||
/// </summary>
|
||||
public static class NotificationAlertBuilder
|
||||
{
|
||||
public static NotificationAlert BuildEnrollmentAlert(
|
||||
string? deviceName,
|
||||
string? serialNumber,
|
||||
string? manufacturer,
|
||||
string? model,
|
||||
bool success,
|
||||
string? failureReason,
|
||||
TimeSpan? duration,
|
||||
string? sessionUrl = null)
|
||||
{
|
||||
var title = success ? "\u2705 Enrollment Succeeded" : "\u274c Enrollment Failed";
|
||||
var themeColor = success ? "00B050" : "FF0000";
|
||||
var severity = success ? NotificationSeverity.Success : NotificationSeverity.Error;
|
||||
var summary = success
|
||||
? $"Enrollment Succeeded: {deviceName ?? "Unknown Device"}"
|
||||
: $"Enrollment Failed: {deviceName ?? "Unknown Device"}";
|
||||
|
||||
var durationText = duration.HasValue
|
||||
? $"{(int)duration.Value.TotalMinutes}m {duration.Value.Seconds}s"
|
||||
: "\u2013";
|
||||
|
||||
var hardwareText = BuildHardwareText(manufacturer, model);
|
||||
|
||||
var facts = new List<NotificationFact>
|
||||
{
|
||||
new() { Name = "Device", Value = deviceName ?? "\u2013" },
|
||||
new() { Name = "Serial", Value = serialNumber ?? "\u2013" },
|
||||
new() { Name = "Hardware", Value = hardwareText },
|
||||
new() { Name = "Duration", Value = durationText },
|
||||
};
|
||||
|
||||
if (!success && !string.IsNullOrEmpty(failureReason))
|
||||
facts.Add(new NotificationFact { Name = "Failure Reason", Value = failureReason });
|
||||
|
||||
var alert = new NotificationAlert
|
||||
{
|
||||
Title = title,
|
||||
Summary = summary,
|
||||
Severity = severity,
|
||||
ThemeColor = themeColor,
|
||||
Facts = facts,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(sessionUrl))
|
||||
alert.Actions.Add(new NotificationAction { Type = "openUrl", Title = "Open session", Url = sessionUrl });
|
||||
|
||||
return alert;
|
||||
}
|
||||
|
||||
public static NotificationAlert BuildWhiteGloveAlert(
|
||||
string? deviceName,
|
||||
string? serialNumber,
|
||||
string? manufacturer,
|
||||
string? model,
|
||||
bool success,
|
||||
TimeSpan? duration,
|
||||
string? sessionUrl = null)
|
||||
{
|
||||
var title = success ? "\ud83d\udfe2 Pre-Provisioning Completed" : "\u274c Pre-Provisioning Failed";
|
||||
var themeColor = success ? "0078D4" : "FF0000";
|
||||
var severity = success ? NotificationSeverity.Success : NotificationSeverity.Error;
|
||||
var summary = success
|
||||
? $"Pre-Provisioning Completed: {deviceName ?? "Unknown Device"}"
|
||||
: $"Pre-Provisioning Failed: {deviceName ?? "Unknown Device"}";
|
||||
|
||||
var durationText = duration.HasValue
|
||||
? $"{(int)duration.Value.TotalMinutes}m {duration.Value.Seconds}s"
|
||||
: "\u2013";
|
||||
|
||||
var hardwareText = BuildHardwareText(manufacturer, model);
|
||||
|
||||
var alert = new NotificationAlert
|
||||
{
|
||||
Title = title,
|
||||
Summary = summary,
|
||||
Severity = severity,
|
||||
ThemeColor = themeColor,
|
||||
Facts = new List<NotificationFact>
|
||||
{
|
||||
new() { Name = "Device", Value = deviceName ?? "\u2013" },
|
||||
new() { Name = "Serial", Value = serialNumber ?? "\u2013" },
|
||||
new() { Name = "Hardware", Value = hardwareText },
|
||||
new() { Name = "Duration", Value = durationText },
|
||||
},
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(sessionUrl))
|
||||
alert.Actions.Add(new NotificationAction { Type = "openUrl", Title = "Open session", Url = sessionUrl });
|
||||
|
||||
return alert;
|
||||
}
|
||||
|
||||
public static NotificationAlert BuildTestAlert()
|
||||
{
|
||||
return new NotificationAlert
|
||||
{
|
||||
Title = "\ud83d\udd14 Test Notification",
|
||||
Summary = "This is a test notification from Autopilot Monitor.",
|
||||
Severity = NotificationSeverity.Info,
|
||||
ThemeColor = "0078D4",
|
||||
Facts = new List<NotificationFact>
|
||||
{
|
||||
new() { Name = "Device", Value = "TEST-DEVICE-001" },
|
||||
new() { Name = "Serial", Value = "SN-TEST-12345" },
|
||||
new() { Name = "Hardware", Value = "Test Manufacturer TestModel" },
|
||||
new() { Name = "Duration", Value = "5m 30s" },
|
||||
},
|
||||
Actions = new List<NotificationAction>
|
||||
{
|
||||
new() { Type = "openUrl", Title = "Open Autopilot Monitor", Url = "https://www.autopilotmonitor.com" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildHardwareText(string? manufacturer, string? model)
|
||||
{
|
||||
var parts = new[]
|
||||
{
|
||||
string.IsNullOrEmpty(manufacturer) ? null : manufacturer.Trim(),
|
||||
string.IsNullOrEmpty(model) ? null : model.Trim()
|
||||
};
|
||||
|
||||
var result = string.Join(" ", Array.FindAll(parts, p => p != null));
|
||||
return string.IsNullOrEmpty(result) ? "\u2013" : result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
{
|
||||
/// <summary>
|
||||
/// Renders a NotificationAlert as a Slack Block Kit payload for Incoming Webhooks.
|
||||
/// </summary>
|
||||
public class SlackRenderer : INotificationRenderer
|
||||
{
|
||||
public WebhookProviderType ProviderType => WebhookProviderType.Slack;
|
||||
|
||||
public string RenderToJson(NotificationAlert alert)
|
||||
{
|
||||
var blocks = new List<object>();
|
||||
|
||||
// Header with severity emoji
|
||||
var emoji = GetSeverityEmoji(alert.Severity);
|
||||
blocks.Add(new
|
||||
{
|
||||
type = "header",
|
||||
text = new { type = "plain_text", text = $"{emoji} {alert.Title}", emoji = true }
|
||||
});
|
||||
|
||||
// Summary
|
||||
if (!string.IsNullOrEmpty(alert.Summary))
|
||||
{
|
||||
blocks.Add(new
|
||||
{
|
||||
type = "section",
|
||||
text = new { type = "mrkdwn", text = alert.Summary }
|
||||
});
|
||||
}
|
||||
|
||||
// Facts as fields (Slack allows max 10 fields per section)
|
||||
if (alert.Facts.Count > 0)
|
||||
{
|
||||
var fields = alert.Facts
|
||||
.Select(f => new { type = "mrkdwn", text = $"*{f.Name}:*\n{f.Value}" })
|
||||
.ToArray();
|
||||
|
||||
// Split into chunks of 10
|
||||
for (int i = 0; i < fields.Length; i += 10)
|
||||
{
|
||||
var chunk = fields.Skip(i).Take(10).ToArray();
|
||||
blocks.Add(new
|
||||
{
|
||||
type = "section",
|
||||
fields = chunk
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Additional sections
|
||||
foreach (var section in alert.Sections)
|
||||
{
|
||||
var text = "";
|
||||
if (!string.IsNullOrEmpty(section.Title))
|
||||
text += $"*{section.Title}*\n";
|
||||
if (!string.IsNullOrEmpty(section.Text))
|
||||
text += section.Text;
|
||||
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
{
|
||||
blocks.Add(new
|
||||
{
|
||||
type = "section",
|
||||
text = new { type = "mrkdwn", text }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
var actionElements = alert.Actions
|
||||
.Where(a => a.Type == "openUrl" && !string.IsNullOrEmpty(a.Url))
|
||||
.Select(a => new
|
||||
{
|
||||
type = "button",
|
||||
text = new { type = "plain_text", text = a.Title, emoji = true },
|
||||
url = a.Url
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
if (actionElements.Length > 0)
|
||||
{
|
||||
blocks.Add(new
|
||||
{
|
||||
type = "actions",
|
||||
elements = actionElements
|
||||
});
|
||||
}
|
||||
|
||||
var payload = new { blocks };
|
||||
|
||||
return JsonConvert.SerializeObject(payload, new JsonSerializerSettings
|
||||
{
|
||||
ContractResolver = new Newtonsoft.Json.Serialization.DefaultContractResolver
|
||||
{
|
||||
NamingStrategy = new Newtonsoft.Json.Serialization.CamelCaseNamingStrategy()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static string GetSeverityEmoji(NotificationSeverity severity)
|
||||
{
|
||||
return severity switch
|
||||
{
|
||||
NotificationSeverity.Success => "\u2705",
|
||||
NotificationSeverity.Error => "\u274c",
|
||||
NotificationSeverity.Warning => "\u26a0\ufe0f",
|
||||
NotificationSeverity.Info => "\u2139\ufe0f",
|
||||
_ => "\u2139\ufe0f"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
{
|
||||
/// <summary>
|
||||
/// Renders a NotificationAlert as a Teams Workflow webhook payload with an Adaptive Card attachment.
|
||||
/// </summary>
|
||||
public class TeamsWorkflowAdaptiveCardRenderer : INotificationRenderer
|
||||
{
|
||||
public WebhookProviderType ProviderType => WebhookProviderType.TeamsWorkflowWebhook;
|
||||
|
||||
public string RenderToJson(NotificationAlert alert)
|
||||
{
|
||||
var body = new List<object>();
|
||||
|
||||
// Title
|
||||
body.Add(new
|
||||
{
|
||||
type = "TextBlock",
|
||||
text = alert.Title,
|
||||
weight = "Bolder",
|
||||
size = "Medium",
|
||||
wrap = true
|
||||
});
|
||||
|
||||
// Summary
|
||||
if (!string.IsNullOrEmpty(alert.Summary))
|
||||
{
|
||||
body.Add(new
|
||||
{
|
||||
type = "TextBlock",
|
||||
text = alert.Summary,
|
||||
wrap = true,
|
||||
isSubtle = true
|
||||
});
|
||||
}
|
||||
|
||||
// Facts
|
||||
if (alert.Facts.Count > 0)
|
||||
{
|
||||
body.Add(new
|
||||
{
|
||||
type = "FactSet",
|
||||
facts = alert.Facts.Select(f => new { title = f.Name, value = f.Value }).ToArray()
|
||||
});
|
||||
}
|
||||
|
||||
// Additional sections
|
||||
foreach (var section in alert.Sections)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(section.Title))
|
||||
{
|
||||
body.Add(new
|
||||
{
|
||||
type = "TextBlock",
|
||||
text = section.Title,
|
||||
weight = "Bolder",
|
||||
wrap = true
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(section.Text))
|
||||
{
|
||||
body.Add(new
|
||||
{
|
||||
type = "TextBlock",
|
||||
text = section.Text,
|
||||
wrap = true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Actions
|
||||
var actions = alert.Actions
|
||||
.Where(a => a.Type == "openUrl" && !string.IsNullOrEmpty(a.Url))
|
||||
.Select(a => new
|
||||
{
|
||||
type = "Action.OpenUrl",
|
||||
title = a.Title,
|
||||
url = a.Url
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
// Build Adaptive Card
|
||||
var adaptiveCard = new Dictionary<string, object>
|
||||
{
|
||||
["$schema"] = "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||
["type"] = "AdaptiveCard",
|
||||
["version"] = "1.4",
|
||||
["body"] = body,
|
||||
};
|
||||
|
||||
if (actions.Length > 0)
|
||||
adaptiveCard["actions"] = actions;
|
||||
|
||||
// Add color accent via msteams width property based on severity
|
||||
adaptiveCard["msteams"] = new { width = "Full" };
|
||||
|
||||
// Wrap in Workflow webhook envelope
|
||||
var payload = new
|
||||
{
|
||||
type = "message",
|
||||
attachments = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
contentType = "application/vnd.microsoft.card.adaptive",
|
||||
content = adaptiveCard
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return JsonConvert.SerializeObject(payload, new JsonSerializerSettings
|
||||
{
|
||||
ContractResolver = new Newtonsoft.Json.Serialization.DefaultContractResolver
|
||||
{
|
||||
NamingStrategy = new Newtonsoft.Json.Serialization.CamelCaseNamingStrategy()
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
{
|
||||
/// <summary>
|
||||
/// Dispatches NotificationAlert payloads to webhook URLs using provider-specific renderers.
|
||||
/// Replaces TeamsNotificationService with a channel-agnostic approach.
|
||||
/// </summary>
|
||||
public class WebhookNotificationService
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<WebhookNotificationService> _logger;
|
||||
private readonly Dictionary<WebhookProviderType, INotificationRenderer> _renderers;
|
||||
|
||||
public WebhookNotificationService(HttpClient http, ILogger<WebhookNotificationService> logger)
|
||||
{
|
||||
_http = http;
|
||||
_logger = logger;
|
||||
_renderers = new Dictionary<WebhookProviderType, INotificationRenderer>
|
||||
{
|
||||
[WebhookProviderType.TeamsLegacyConnector] = new LegacyTeamsConnectorRenderer(),
|
||||
[WebhookProviderType.TeamsWorkflowWebhook] = new TeamsWorkflowAdaptiveCardRenderer(),
|
||||
[WebhookProviderType.Slack] = new SlackRenderer(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a notification (fire-and-forget, non-fatal). Exceptions are logged as warnings.
|
||||
/// </summary>
|
||||
public async Task SendNotificationAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert)
|
||||
{
|
||||
if (string.IsNullOrEmpty(webhookUrl) || providerType == WebhookProviderType.None)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
if (!_renderers.TryGetValue(providerType, out var renderer))
|
||||
{
|
||||
_logger.LogWarning("No renderer registered for webhook provider type {ProviderType}", providerType);
|
||||
return;
|
||||
}
|
||||
|
||||
var json = renderer.RenderToJson(alert);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
var sent = await PostWithRetryAsync(webhookUrl, content, alert.Summary ?? "notification");
|
||||
|
||||
if (sent)
|
||||
_logger.LogInformation("Webhook notification sent: {Summary} (provider={ProviderType})", alert.Summary, providerType);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send webhook notification: {Summary}", alert.Summary);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a notification and returns the result (for test endpoint). Not fire-and-forget.
|
||||
/// </summary>
|
||||
public async Task<WebhookTestResult> SendNotificationWithResultAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert)
|
||||
{
|
||||
if (string.IsNullOrEmpty(webhookUrl))
|
||||
return new WebhookTestResult { Success = false, Message = "Webhook URL is not configured." };
|
||||
|
||||
if (providerType == WebhookProviderType.None)
|
||||
return new WebhookTestResult { Success = false, Message = "No webhook provider selected." };
|
||||
|
||||
if (!_renderers.TryGetValue(providerType, out var renderer))
|
||||
return new WebhookTestResult { Success = false, Message = $"Unknown provider type: {providerType}" };
|
||||
|
||||
try
|
||||
{
|
||||
var json = renderer.RenderToJson(alert);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
var response = await _http.PostAsync(webhookUrl, content);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return new WebhookTestResult { Success = true, StatusCode = statusCode, Message = "Test notification sent successfully." };
|
||||
}
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
return new WebhookTestResult
|
||||
{
|
||||
Success = false,
|
||||
StatusCode = statusCode,
|
||||
Message = $"Webhook returned HTTP {statusCode}: {(body.Length > 200 ? body[..200] : body)}"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new WebhookTestResult { Success = false, Message = $"Connection error: {ex.Message}" };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Posts content to a webhook URL with a single retry on transient failures (429, 5xx, network errors).
|
||||
/// </summary>
|
||||
private async Task<bool> PostWithRetryAsync(string webhookUrl, StringContent content, string context)
|
||||
{
|
||||
const int maxAttempts = 2;
|
||||
for (int attempt = 1; attempt <= maxAttempts; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _http.PostAsync(webhookUrl, content);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
return true;
|
||||
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var isTransient = statusCode == 429 || statusCode >= 500 && statusCode <= 599;
|
||||
|
||||
if (!isTransient || attempt >= maxAttempts)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogWarning("Webhook returned {StatusCode} for {Context}: {Body}", statusCode, context, body);
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Webhook returned {StatusCode} for {Context} (attempt {Attempt}/{MaxAttempts}). Retrying in 2s",
|
||||
statusCode, context, attempt, maxAttempts);
|
||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
catch (Exception ex) when (attempt < maxAttempts && (ex is HttpRequestException or TaskCanceledException))
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Webhook network error for {Context} (attempt {Attempt}/{MaxAttempts}). Retrying in 2s",
|
||||
context, attempt, maxAttempts);
|
||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public class WebhookTestResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public int? StatusCode { get; set; }
|
||||
public string Message { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends enrollment notifications to a configured Microsoft Teams Incoming Webhook.
|
||||
/// Uses the MessageCard format which is reliably supported by all Teams Incoming Webhooks.
|
||||
/// </summary>
|
||||
public class TeamsNotificationService
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<TeamsNotificationService> _logger;
|
||||
|
||||
public TeamsNotificationService(HttpClient http, ILogger<TeamsNotificationService> logger)
|
||||
{
|
||||
_http = http;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends an enrollment completion notification to the given Teams Incoming Webhook URL.
|
||||
/// This method is non-fatal: exceptions are caught and logged as warnings.
|
||||
/// </summary>
|
||||
public async Task SendEnrollmentNotificationAsync(
|
||||
string webhookUrl,
|
||||
string? deviceName,
|
||||
string? serialNumber,
|
||||
string? manufacturer,
|
||||
string? model,
|
||||
bool success,
|
||||
string? failureReason,
|
||||
TimeSpan? duration)
|
||||
{
|
||||
if (string.IsNullOrEmpty(webhookUrl))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var title = success ? "✅ Enrollment Succeeded" : "❌ Enrollment Failed";
|
||||
var themeColor = success ? "00B050" : "FF0000";
|
||||
var summary = success
|
||||
? $"Enrollment Succeeded: {deviceName ?? "Unknown Device"}"
|
||||
: $"Enrollment Failed: {deviceName ?? "Unknown Device"}";
|
||||
|
||||
var durationText = duration.HasValue
|
||||
? $"{(int)duration.Value.TotalMinutes}m {duration.Value.Seconds}s"
|
||||
: "–";
|
||||
|
||||
var hardwareText = BuildHardwareText(manufacturer, model);
|
||||
|
||||
var facts = new[]
|
||||
{
|
||||
new { name = "Device", value = deviceName ?? "–" },
|
||||
new { name = "Serial", value = serialNumber ?? "–" },
|
||||
new { name = "Hardware", value = hardwareText },
|
||||
new { name = "Duration", value = durationText }
|
||||
};
|
||||
|
||||
object card;
|
||||
|
||||
if (!success && !string.IsNullOrEmpty(failureReason))
|
||||
{
|
||||
// Add failure reason fact
|
||||
var factsWithReason = new[]
|
||||
{
|
||||
new { name = "Device", value = deviceName ?? "–" },
|
||||
new { name = "Serial", value = serialNumber ?? "–" },
|
||||
new { name = "Hardware", value = hardwareText },
|
||||
new { name = "Duration", value = durationText },
|
||||
new { name = "Failure Reason", value = failureReason }
|
||||
};
|
||||
|
||||
card = new
|
||||
{
|
||||
type = "MessageCard",
|
||||
context = "http://schema.org/extensions",
|
||||
themeColor,
|
||||
summary,
|
||||
sections = new[]
|
||||
{
|
||||
new { activityTitle = title, facts = factsWithReason }
|
||||
}
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
card = new
|
||||
{
|
||||
type = "MessageCard",
|
||||
context = "http://schema.org/extensions",
|
||||
themeColor,
|
||||
summary,
|
||||
sections = new[]
|
||||
{
|
||||
new { activityTitle = title, facts }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
var json = JsonConvert.SerializeObject(card, new JsonSerializerSettings
|
||||
{
|
||||
ContractResolver = new Newtonsoft.Json.Serialization.DefaultContractResolver
|
||||
{
|
||||
NamingStrategy = new Newtonsoft.Json.Serialization.CamelCaseNamingStrategy()
|
||||
}
|
||||
});
|
||||
|
||||
// Teams MessageCard requires @type and @context — serialize with @ prefix manually
|
||||
json = json.Replace("\"type\":", "\"@type\":")
|
||||
.Replace("\"context\":", "\"@context\":");
|
||||
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
var sent = await PostWithRetryAsync(webhookUrl, content, $"enrollment:{deviceName}");
|
||||
|
||||
if (sent)
|
||||
_logger.LogInformation("Teams enrollment notification sent for device '{DeviceName}' (success={Success})", deviceName, success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send Teams enrollment notification for device '{DeviceName}'", deviceName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a WhiteGlove pre-provisioning notification to the given Teams Incoming Webhook URL.
|
||||
/// This method is non-fatal: exceptions are caught and logged as warnings.
|
||||
/// </summary>
|
||||
public async Task SendWhiteGloveNotificationAsync(
|
||||
string webhookUrl,
|
||||
string? deviceName,
|
||||
string? serialNumber,
|
||||
string? manufacturer,
|
||||
string? model,
|
||||
bool success,
|
||||
TimeSpan? duration)
|
||||
{
|
||||
if (string.IsNullOrEmpty(webhookUrl))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var title = success ? "🔵 Pre-Provisioning Completed" : "❌ Pre-Provisioning Failed";
|
||||
var themeColor = success ? "0078D4" : "FF0000";
|
||||
var summary = success
|
||||
? $"Pre-Provisioning Completed: {deviceName ?? "Unknown Device"}"
|
||||
: $"Pre-Provisioning Failed: {deviceName ?? "Unknown Device"}";
|
||||
|
||||
var durationText = duration.HasValue
|
||||
? $"{(int)duration.Value.TotalMinutes}m {duration.Value.Seconds}s"
|
||||
: "–";
|
||||
|
||||
var hardwareText = BuildHardwareText(manufacturer, model);
|
||||
|
||||
var facts = new[]
|
||||
{
|
||||
new { name = "Device", value = deviceName ?? "–" },
|
||||
new { name = "Serial", value = serialNumber ?? "–" },
|
||||
new { name = "Hardware", value = hardwareText },
|
||||
new { name = "Duration", value = durationText }
|
||||
};
|
||||
|
||||
var card = new
|
||||
{
|
||||
type = "MessageCard",
|
||||
context = "http://schema.org/extensions",
|
||||
themeColor,
|
||||
summary,
|
||||
sections = new[]
|
||||
{
|
||||
new { activityTitle = title, facts }
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(card, new JsonSerializerSettings
|
||||
{
|
||||
ContractResolver = new Newtonsoft.Json.Serialization.DefaultContractResolver
|
||||
{
|
||||
NamingStrategy = new Newtonsoft.Json.Serialization.CamelCaseNamingStrategy()
|
||||
}
|
||||
});
|
||||
|
||||
json = json.Replace("\"type\":", "\"@type\":")
|
||||
.Replace("\"context\":", "\"@context\":");
|
||||
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
var sent = await PostWithRetryAsync(webhookUrl, content, $"whiteglove:{deviceName}");
|
||||
|
||||
if (sent)
|
||||
_logger.LogInformation("Teams WhiteGlove notification sent for device '{DeviceName}' (success={Success})", deviceName, success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send Teams WhiteGlove notification for device '{DeviceName}'", deviceName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Posts content to a webhook URL with a single retry on transient failures (429, 5xx, network errors).
|
||||
/// </summary>
|
||||
private async Task<bool> PostWithRetryAsync(string webhookUrl, StringContent content, string context)
|
||||
{
|
||||
const int maxAttempts = 2;
|
||||
for (int attempt = 1; attempt <= maxAttempts; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _http.PostAsync(webhookUrl, content);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
return true;
|
||||
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var isTransient = statusCode == 429 || statusCode >= 500 && statusCode <= 599;
|
||||
|
||||
if (!isTransient || attempt >= maxAttempts)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogWarning("Teams webhook returned {StatusCode} for {Context}: {Body}", statusCode, context, body);
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Teams webhook returned {StatusCode} for {Context} (attempt {Attempt}/{MaxAttempts}). Retrying in 2s",
|
||||
statusCode, context, attempt, maxAttempts);
|
||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
catch (Exception ex) when (attempt < maxAttempts && (ex is HttpRequestException or TaskCanceledException))
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Teams webhook network error for {Context} (attempt {Attempt}/{MaxAttempts}). Retrying in 2s",
|
||||
context, attempt, maxAttempts);
|
||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string BuildHardwareText(string? manufacturer, string? model)
|
||||
{
|
||||
var parts = new[]
|
||||
{
|
||||
string.IsNullOrEmpty(manufacturer) ? null : manufacturer.Trim(),
|
||||
string.IsNullOrEmpty(model) ? null : model.Trim()
|
||||
};
|
||||
|
||||
var result = string.Join(" ", Array.FindAll(parts, p => p != null));
|
||||
return string.IsNullOrEmpty(result) ? "–" : result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,6 +226,10 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{ "TeamsWebhookUrl", config.TeamsWebhookUrl },
|
||||
{ "TeamsNotifyOnSuccess", config.TeamsNotifyOnSuccess },
|
||||
{ "TeamsNotifyOnFailure", config.TeamsNotifyOnFailure },
|
||||
{ "WebhookProviderType", config.WebhookProviderType },
|
||||
{ "WebhookUrl", config.WebhookUrl },
|
||||
{ "WebhookNotifyOnSuccess", config.WebhookNotifyOnSuccess },
|
||||
{ "WebhookNotifyOnFailure", config.WebhookNotifyOnFailure },
|
||||
{ "ShowScriptOutput", config.ShowScriptOutput },
|
||||
{ "ShowEnrollmentSummary", config.ShowEnrollmentSummary },
|
||||
{ "EnrollmentSummaryTimeoutSeconds", config.EnrollmentSummaryTimeoutSeconds },
|
||||
@@ -280,6 +284,10 @@ namespace AutopilotMonitor.Functions.Services
|
||||
TeamsWebhookUrl = entity.GetString("TeamsWebhookUrl"),
|
||||
TeamsNotifyOnSuccess = entity.GetBoolean("TeamsNotifyOnSuccess") ?? true,
|
||||
TeamsNotifyOnFailure = entity.GetBoolean("TeamsNotifyOnFailure") ?? true,
|
||||
WebhookProviderType = entity.GetInt32("WebhookProviderType") ?? 0,
|
||||
WebhookUrl = entity.GetString("WebhookUrl"),
|
||||
WebhookNotifyOnSuccess = entity.GetBoolean("WebhookNotifyOnSuccess") ?? true,
|
||||
WebhookNotifyOnFailure = entity.GetBoolean("WebhookNotifyOnFailure") ?? true,
|
||||
ShowScriptOutput = entity.GetBoolean("ShowScriptOutput") ?? true,
|
||||
ShowEnrollmentSummary = entity.GetBoolean("ShowEnrollmentSummary"),
|
||||
EnrollmentSummaryTimeoutSeconds = entity.GetInt32("EnrollmentSummaryTimeoutSeconds"),
|
||||
|
||||
@@ -373,8 +373,62 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public bool TeamsNotifyOnFailure { get; set; } = true;
|
||||
|
||||
// ===== WEBHOOK NOTIFICATIONS =====
|
||||
|
||||
/// <summary>
|
||||
/// Webhook provider type. Determines which renderer formats the notification payload.
|
||||
/// 0=None, 1=TeamsLegacyConnector, 2=TeamsWorkflowWebhook, 10=Slack.
|
||||
/// Legacy tenants with TeamsWebhookUrl are auto-resolved via GetEffectiveWebhookConfig().
|
||||
/// </summary>
|
||||
public int WebhookProviderType { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Generic webhook URL for enrollment notifications.
|
||||
/// Replaces TeamsWebhookUrl for new configurations.
|
||||
/// </summary>
|
||||
public string WebhookUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Send a webhook notification when enrollment succeeds. Default: true.
|
||||
/// </summary>
|
||||
public bool WebhookNotifyOnSuccess { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Send a webhook notification when enrollment fails. Default: true.
|
||||
/// </summary>
|
||||
public bool WebhookNotifyOnFailure { get; set; } = true;
|
||||
|
||||
// ===== HELPER METHODS =====
|
||||
|
||||
/// <summary>
|
||||
/// Returns the effective webhook URL and provider type, handling legacy TeamsWebhookUrl migration.
|
||||
/// New fields take priority; falls back to TeamsWebhookUrl as TeamsLegacyConnector.
|
||||
/// </summary>
|
||||
public (string Url, int ProviderType) GetEffectiveWebhookConfig()
|
||||
{
|
||||
// New fields take priority
|
||||
if (!string.IsNullOrEmpty(WebhookUrl) && WebhookProviderType != 0)
|
||||
return (WebhookUrl, WebhookProviderType);
|
||||
|
||||
// Legacy fallback: existing TeamsWebhookUrl → treat as Legacy Connector
|
||||
if (!string.IsNullOrEmpty(TeamsWebhookUrl))
|
||||
return (TeamsWebhookUrl, (int)Notifications.WebhookProviderType.TeamsLegacyConnector);
|
||||
|
||||
return (null, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns effective notify-on-success setting, preferring new fields over legacy.
|
||||
/// </summary>
|
||||
public bool GetEffectiveNotifyOnSuccess()
|
||||
=> !string.IsNullOrEmpty(WebhookUrl) ? WebhookNotifyOnSuccess : TeamsNotifyOnSuccess;
|
||||
|
||||
/// <summary>
|
||||
/// Returns effective notify-on-failure setting, preferring new fields over legacy.
|
||||
/// </summary>
|
||||
public bool GetEffectiveNotifyOnFailure()
|
||||
=> !string.IsNullOrEmpty(WebhookUrl) ? WebhookNotifyOnFailure : TeamsNotifyOnFailure;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the tenant is currently disabled
|
||||
/// Takes into account DisabledUntil if set
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace AutopilotMonitor.Shared.Models.Notifications
|
||||
{
|
||||
public class NotificationAction
|
||||
{
|
||||
public string Type { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Url { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AutopilotMonitor.Shared.Models.Notifications
|
||||
{
|
||||
/// <summary>
|
||||
/// Channel-agnostic notification alert model.
|
||||
/// Provider-specific renderers transform this into the target format
|
||||
/// (Teams MessageCard, Adaptive Card, Slack Block Kit, etc.).
|
||||
/// </summary>
|
||||
public class NotificationAlert
|
||||
{
|
||||
/// <summary>Main title, e.g. "Enrollment Succeeded".</summary>
|
||||
public string Title { get; set; }
|
||||
|
||||
/// <summary>One-line summary for toast/preview text.</summary>
|
||||
public string Summary { get; set; }
|
||||
|
||||
/// <summary>Severity level used for color/icon mapping in renderers.</summary>
|
||||
public NotificationSeverity Severity { get; set; }
|
||||
|
||||
/// <summary>Hex color code (without #). Used by legacy Teams renderer, mapped to styles by others.</summary>
|
||||
public string ThemeColor { get; set; }
|
||||
|
||||
/// <summary>Key-value fact pairs (Device, Serial, Hardware, Duration, etc.).</summary>
|
||||
public List<NotificationFact> Facts { get; set; } = new();
|
||||
|
||||
/// <summary>Optional additional detail sections.</summary>
|
||||
public List<NotificationSection> Sections { get; set; } = new();
|
||||
|
||||
/// <summary>Action buttons (e.g. "Open session" deep-link).</summary>
|
||||
public List<NotificationAction> Actions { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace AutopilotMonitor.Shared.Models.Notifications
|
||||
{
|
||||
public class NotificationFact
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Value { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace AutopilotMonitor.Shared.Models.Notifications
|
||||
{
|
||||
public class NotificationSection
|
||||
{
|
||||
public string Title { get; set; }
|
||||
public string Text { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace AutopilotMonitor.Shared.Models.Notifications
|
||||
{
|
||||
public enum NotificationSeverity
|
||||
{
|
||||
Success,
|
||||
Error,
|
||||
Info,
|
||||
Warning,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace AutopilotMonitor.Shared.Models.Notifications
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines which renderer formats the notification payload for a webhook.
|
||||
/// </summary>
|
||||
public enum WebhookProviderType
|
||||
{
|
||||
/// <summary>No webhook configured.</summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>Microsoft Teams legacy Office 365 Connector (MessageCard format). Deprecated by Microsoft.</summary>
|
||||
TeamsLegacyConnector = 1,
|
||||
|
||||
/// <summary>Microsoft Teams Workflow webhook (Adaptive Card format). Recommended replacement.</summary>
|
||||
TeamsWorkflowWebhook = 2,
|
||||
|
||||
/// <summary>Slack Incoming Webhook (Block Kit format).</summary>
|
||||
Slack = 10,
|
||||
}
|
||||
}
|
||||
@@ -170,20 +170,42 @@ export function SectionSettings() {
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* Teams Notifications */}
|
||||
<SettingsGroup title="Teams Notifications" color="bg-indigo-50 text-indigo-900" borderColor="border-indigo-200">
|
||||
<SettingsRow name="Incoming Webhook URL">
|
||||
A Microsoft Teams Incoming Webhook URL. Once configured, the portal can send enrollment outcome notifications
|
||||
directly to a Teams channel. To create one: open the target channel in Teams → <em>Connectors</em> → search
|
||||
for <em>Incoming Webhook</em> → configure and copy the URL.
|
||||
{/* Notifications */}
|
||||
<SettingsGroup title="Notifications" color="bg-indigo-50 text-indigo-900" borderColor="border-indigo-200">
|
||||
<SettingsRow name="Notification Provider">
|
||||
Select how you want to receive enrollment notifications. Available providers:
|
||||
<ul className="list-disc ml-6 mt-1 space-y-1">
|
||||
<li>
|
||||
<strong>Microsoft Teams (Workflow Webhook)</strong> <span className="text-green-700 text-xs font-medium">(Recommended)</span> —
|
||||
In Teams, go to the target channel → <em>Manage channel</em> → <em>Workflows</em> →
|
||||
add <em>"Post to a channel when a webhook request is received"</em> → copy the generated URL.
|
||||
Workflow webhooks are free and do not require a Power Automate Premium license.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Microsoft Teams (Legacy Connector)</strong> <span className="text-amber-700 text-xs font-medium">(Deprecated)</span> —
|
||||
Uses the legacy Office 365 Connector webhook format (MessageCard). Microsoft has deprecated this method.
|
||||
Existing configurations will continue to work, but switching to Workflow Webhooks is recommended.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Slack</strong> —
|
||||
In Slack, go to your workspace → <em>Apps</em> → <em>Incoming Webhooks</em> →
|
||||
create a new webhook for the target channel → copy the webhook URL.
|
||||
</li>
|
||||
</ul>
|
||||
</SettingsRow>
|
||||
<SettingsRow name="Webhook URL">
|
||||
The webhook URL for your selected notification provider. Paste the URL generated during the provider setup.
|
||||
</SettingsRow>
|
||||
<SettingsRow name="Notify on Success" defaultVal="Enabled (if webhook configured)">
|
||||
Send a Teams notification when an enrollment session completes successfully.
|
||||
Send a notification when an enrollment session completes successfully.
|
||||
</SettingsRow>
|
||||
<SettingsRow name="Notify on Failure" defaultVal="Enabled (if webhook configured)">
|
||||
Send a Teams notification when an enrollment session ends in failure. Recommended to keep enabled so
|
||||
Send a notification when an enrollment session ends in failure. Recommended to keep enabled so
|
||||
failed enrollments are surfaced immediately without having to check the portal manually.
|
||||
</SettingsRow>
|
||||
<SettingsRow name="Send Test Notification">
|
||||
Sends a sample notification to your configured webhook to verify the connection is working correctly.
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* Diagnostics Package */}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
interface NotificationsSectionProps {
|
||||
webhookProviderType: number;
|
||||
setWebhookProviderType: (v: number) => void;
|
||||
webhookUrl: string;
|
||||
setWebhookUrl: (v: string) => void;
|
||||
webhookNotifyOnSuccess: boolean;
|
||||
setWebhookNotifyOnSuccess: (v: boolean) => void;
|
||||
webhookNotifyOnFailure: boolean;
|
||||
setWebhookNotifyOnFailure: (v: boolean) => void;
|
||||
onTestWebhook: () => Promise<void>;
|
||||
testingWebhook: boolean;
|
||||
testWebhookResult: { success: boolean; message: string } | null;
|
||||
}
|
||||
|
||||
const PROVIDERS = [
|
||||
{ value: 0, label: "None (disabled)", placeholder: "" },
|
||||
{ value: 1, label: "Microsoft Teams (Legacy Connector)", placeholder: "https://your-org.webhook.office.com/webhookb2/...", badge: "Deprecated", badgeColor: "bg-amber-100 text-amber-800" },
|
||||
{ value: 2, label: "Microsoft Teams (Workflow Webhook)", placeholder: "https://prod-xx.westeurope.logic.azure.com:443/workflows/...", badge: "Recommended", badgeColor: "bg-green-100 text-green-800" },
|
||||
{ value: 10, label: "Slack", placeholder: "https://hooks.slack.com/services/T.../B.../..." },
|
||||
];
|
||||
|
||||
export default function NotificationsSection({
|
||||
webhookProviderType,
|
||||
setWebhookProviderType,
|
||||
webhookUrl,
|
||||
setWebhookUrl,
|
||||
webhookNotifyOnSuccess,
|
||||
setWebhookNotifyOnSuccess,
|
||||
webhookNotifyOnFailure,
|
||||
setWebhookNotifyOnFailure,
|
||||
onTestWebhook,
|
||||
testingWebhook,
|
||||
testWebhookResult,
|
||||
}: NotificationsSectionProps) {
|
||||
const selectedProvider = PROVIDERS.find((p) => p.value === webhookProviderType) ?? PROVIDERS[0];
|
||||
const isActive = webhookProviderType !== 0 && webhookUrl.length > 0;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-sky-50 to-blue-50">
|
||||
<div className="flex items-center space-x-2">
|
||||
<svg className="w-6 h-6 text-sky-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Notifications</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">Send enrollment status notifications to a channel via webhook.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
|
||||
{/* Provider Dropdown */}
|
||||
<div>
|
||||
<label className="block">
|
||||
<span className="text-gray-700 font-medium">Notification Provider</span>
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
Select how you want to receive enrollment notifications.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={webhookProviderType}
|
||||
onChange={(e) => setWebhookProviderType(Number(e.target.value))}
|
||||
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors"
|
||||
>
|
||||
{PROVIDERS.map((p) => (
|
||||
<option key={p.value} value={p.value}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedProvider.badge && (
|
||||
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium whitespace-nowrap ${selectedProvider.badgeColor}`}>
|
||||
{selectedProvider.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Legacy deprecation warning */}
|
||||
{webhookProviderType === 1 && (
|
||||
<div className="p-3 rounded-lg bg-amber-50 border border-amber-200">
|
||||
<p className="text-sm text-amber-800">
|
||||
<strong>Note:</strong> Office 365 Connectors are deprecated by Microsoft. Consider switching to <strong>Microsoft Teams (Workflow Webhook)</strong> for continued support.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Webhook URL */}
|
||||
{webhookProviderType !== 0 && (
|
||||
<div>
|
||||
<label className="block">
|
||||
<span className="text-gray-700 font-medium">Webhook URL</span>
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
{webhookProviderType === 2
|
||||
? "Create a Workflow in Teams (Channel → Manage channel → Workflows → \"Post to a channel when a webhook request is received\") and paste the URL here."
|
||||
: webhookProviderType === 10
|
||||
? "Create an Incoming Webhook in Slack (Apps → Incoming Webhooks → Add to channel) and paste the URL here."
|
||||
: "Create an Incoming Webhook in your Teams channel (Channel → Connectors → Incoming Webhook) and paste the URL here."}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={webhookUrl}
|
||||
onChange={(e) => setWebhookUrl(e.target.value)}
|
||||
placeholder={selectedProvider.placeholder}
|
||||
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors font-mono text-sm"
|
||||
/>
|
||||
{isActive && (
|
||||
<span className="mt-1 inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 whitespace-nowrap">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notify on Success */}
|
||||
{webhookProviderType !== 0 && (
|
||||
<div className={`flex items-center justify-between p-4 rounded-lg border transition-colors ${isActive ? 'border-gray-200 hover:border-sky-200' : 'border-gray-100 opacity-50'}`}>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Notify on Success</p>
|
||||
<p className="text-sm text-gray-500">Send a notification when an enrollment completes successfully</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => isActive && setWebhookNotifyOnSuccess(!webhookNotifyOnSuccess)}
|
||||
disabled={!isActive}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors disabled:cursor-not-allowed ${webhookNotifyOnSuccess && isActive ? 'bg-sky-500' : 'bg-gray-300'}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${webhookNotifyOnSuccess && isActive ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notify on Failure */}
|
||||
{webhookProviderType !== 0 && (
|
||||
<div className={`flex items-center justify-between p-4 rounded-lg border transition-colors ${isActive ? 'border-gray-200 hover:border-sky-200' : 'border-gray-100 opacity-50'}`}>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Notify on Failure</p>
|
||||
<p className="text-sm text-gray-500">Send a notification when an enrollment fails</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => isActive && setWebhookNotifyOnFailure(!webhookNotifyOnFailure)}
|
||||
disabled={!isActive}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors disabled:cursor-not-allowed ${webhookNotifyOnFailure && isActive ? 'bg-sky-500' : 'bg-gray-300'}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${webhookNotifyOnFailure && isActive ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test Notification Button */}
|
||||
{webhookProviderType !== 0 && (
|
||||
<div className="pt-2">
|
||||
<button
|
||||
onClick={onTestWebhook}
|
||||
disabled={!isActive || testingWebhook}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{testingWebhook ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
Send Test Notification
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{testWebhookResult && (
|
||||
<span className={`ml-3 inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${testWebhookResult.success ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{testWebhookResult.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
"use client";
|
||||
|
||||
interface TeamsNotificationsSectionProps {
|
||||
teamsWebhookUrl: string;
|
||||
setTeamsWebhookUrl: (value: string) => void;
|
||||
teamsNotifyOnSuccess: boolean;
|
||||
setTeamsNotifyOnSuccess: (value: boolean) => void;
|
||||
teamsNotifyOnFailure: boolean;
|
||||
setTeamsNotifyOnFailure: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export default function TeamsNotificationsSection({
|
||||
teamsWebhookUrl,
|
||||
setTeamsWebhookUrl,
|
||||
teamsNotifyOnSuccess,
|
||||
setTeamsNotifyOnSuccess,
|
||||
teamsNotifyOnFailure,
|
||||
setTeamsNotifyOnFailure,
|
||||
}: TeamsNotificationsSectionProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-sky-50 to-blue-50">
|
||||
<div className="flex items-center space-x-2">
|
||||
<svg className="w-6 h-6 text-sky-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Teams Notifications</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">Send enrollment status notifications to a Microsoft Teams channel via Incoming Webhook.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
|
||||
{/* Webhook URL */}
|
||||
<div>
|
||||
<label className="block">
|
||||
<span className="text-gray-700 font-medium">Incoming Webhook URL</span>
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
Create an Incoming Webhook in your Teams channel (Channel → Connectors → Incoming Webhook) and paste the URL here.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={teamsWebhookUrl}
|
||||
onChange={(e) => setTeamsWebhookUrl(e.target.value)}
|
||||
placeholder="https://your-org.webhook.office.com/webhookb2/..."
|
||||
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors font-mono text-sm"
|
||||
/>
|
||||
{teamsWebhookUrl && (
|
||||
<span className="mt-1 inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 whitespace-nowrap">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Notify on Success */}
|
||||
<div className={`flex items-center justify-between p-4 rounded-lg border transition-colors ${teamsWebhookUrl ? 'border-gray-200 hover:border-sky-200' : 'border-gray-100 opacity-50'}`}>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Notify on Success</p>
|
||||
<p className="text-sm text-gray-500">Send a notification when an enrollment completes successfully</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => teamsWebhookUrl && setTeamsNotifyOnSuccess(!teamsNotifyOnSuccess)}
|
||||
disabled={!teamsWebhookUrl}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors disabled:cursor-not-allowed ${teamsNotifyOnSuccess && teamsWebhookUrl ? 'bg-sky-500' : 'bg-gray-300'}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${teamsNotifyOnSuccess && teamsWebhookUrl ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Notify on Failure */}
|
||||
<div className={`flex items-center justify-between p-4 rounded-lg border transition-colors ${teamsWebhookUrl ? 'border-gray-200 hover:border-sky-200' : 'border-gray-100 opacity-50'}`}>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Notify on Failure</p>
|
||||
<p className="text-sm text-gray-500">Send a notification when an enrollment fails</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => teamsWebhookUrl && setTeamsNotifyOnFailure(!teamsNotifyOnFailure)}
|
||||
disabled={!teamsWebhookUrl}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors disabled:cursor-not-allowed ${teamsNotifyOnFailure && teamsWebhookUrl ? 'bg-sky-500' : 'bg-gray-300'}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${teamsNotifyOnFailure && teamsWebhookUrl ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import AdminManagementSection from "./components/AdminManagementSection";
|
||||
import HardwareWhitelistSection from "./components/HardwareWhitelistSection";
|
||||
import AgentSettingsSection from "./components/AgentSettingsSection";
|
||||
import AgentAnalyzersSection from "./components/AgentAnalyzersSection";
|
||||
import TeamsNotificationsSection from "./components/TeamsNotificationsSection";
|
||||
import NotificationsSection from "./components/NotificationsSection";
|
||||
import DiagnosticsSection, { parseSasExpiry } from "./components/DiagnosticsSection";
|
||||
import DataManagementSection from "./components/DataManagementSection";
|
||||
import OffboardingSection from "./components/OffboardingSection";
|
||||
@@ -51,10 +51,15 @@ export interface TenantConfiguration {
|
||||
enableGeoLocation?: boolean;
|
||||
enableImeMatchLog?: boolean;
|
||||
logLevel?: string;
|
||||
// Teams notifications
|
||||
// Teams notifications (legacy)
|
||||
teamsWebhookUrl?: string;
|
||||
teamsNotifyOnSuccess?: boolean;
|
||||
teamsNotifyOnFailure?: boolean;
|
||||
// Webhook notifications (new)
|
||||
webhookProviderType?: number;
|
||||
webhookUrl?: string;
|
||||
webhookNotifyOnSuccess?: boolean;
|
||||
webhookNotifyOnFailure?: boolean;
|
||||
// Diagnostics package
|
||||
diagnosticsBlobSasUrl?: string;
|
||||
diagnosticsUploadMode?: string;
|
||||
@@ -145,10 +150,13 @@ export default function SettingsPage() {
|
||||
const [enrollmentSummaryBrandingImageUrl, setEnrollmentSummaryBrandingImageUrl] = useState("");
|
||||
const [enrollmentSummaryLaunchRetrySeconds, setEnrollmentSummaryLaunchRetrySeconds] = useState(120);
|
||||
|
||||
// Teams notifications state
|
||||
const [teamsWebhookUrl, setTeamsWebhookUrl] = useState("");
|
||||
const [teamsNotifyOnSuccess, setTeamsNotifyOnSuccess] = useState(true);
|
||||
const [teamsNotifyOnFailure, setTeamsNotifyOnFailure] = useState(true);
|
||||
// Webhook notifications state
|
||||
const [webhookProviderType, setWebhookProviderType] = useState(0);
|
||||
const [webhookUrl, setWebhookUrl] = useState("");
|
||||
const [webhookNotifyOnSuccess, setWebhookNotifyOnSuccess] = useState(true);
|
||||
const [webhookNotifyOnFailure, setWebhookNotifyOnFailure] = useState(true);
|
||||
const [testingWebhook, setTestingWebhook] = useState(false);
|
||||
const [testWebhookResult, setTestWebhookResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
// Diagnostics package state
|
||||
const [diagnosticsBlobSasUrl, setDiagnosticsBlobSasUrl] = useState("");
|
||||
@@ -189,9 +197,10 @@ export default function SettingsPage() {
|
||||
enrollmentSummaryTimeoutSeconds !== (config.enrollmentSummaryTimeoutSeconds ?? 60) ||
|
||||
enrollmentSummaryBrandingImageUrl !== (config.enrollmentSummaryBrandingImageUrl ?? "") ||
|
||||
enrollmentSummaryLaunchRetrySeconds !== (config.enrollmentSummaryLaunchRetrySeconds ?? 120) ||
|
||||
teamsWebhookUrl !== (config.teamsWebhookUrl ?? "") ||
|
||||
teamsNotifyOnSuccess !== (config.teamsNotifyOnSuccess ?? true) ||
|
||||
teamsNotifyOnFailure !== (config.teamsNotifyOnFailure ?? true) ||
|
||||
webhookProviderType !== (config.webhookProviderType ?? (config.teamsWebhookUrl ? 1 : 0)) ||
|
||||
webhookUrl !== (config.webhookUrl ?? config.teamsWebhookUrl ?? "") ||
|
||||
webhookNotifyOnSuccess !== (config.webhookNotifyOnSuccess ?? config.teamsNotifyOnSuccess ?? true) ||
|
||||
webhookNotifyOnFailure !== (config.webhookNotifyOnFailure ?? config.teamsNotifyOnFailure ?? true) ||
|
||||
diagnosticsBlobSasUrl !== (config.diagnosticsBlobSasUrl ?? "") ||
|
||||
diagnosticsUploadMode !== (config.diagnosticsUploadMode ?? "Off") ||
|
||||
JSON.stringify(tenantDiagPaths) !== JSON.stringify(
|
||||
@@ -210,7 +219,7 @@ export default function SettingsPage() {
|
||||
rebootOnComplete, rebootDelaySeconds, enableGeoLocation, enableImeMatchLog,
|
||||
logLevel, showScriptOutput, showEnrollmentSummary, enrollmentSummaryTimeoutSeconds,
|
||||
enrollmentSummaryBrandingImageUrl, enrollmentSummaryLaunchRetrySeconds,
|
||||
teamsWebhookUrl, teamsNotifyOnSuccess, teamsNotifyOnFailure,
|
||||
webhookProviderType, webhookUrl, webhookNotifyOnSuccess, webhookNotifyOnFailure,
|
||||
diagnosticsBlobSasUrl, diagnosticsUploadMode, tenantDiagPaths,
|
||||
enableLocalAdminAnalyzer, localAdminAllowedAccounts,
|
||||
]);
|
||||
@@ -256,9 +265,23 @@ export default function SettingsPage() {
|
||||
setEnrollmentSummaryTimeoutSeconds(data.enrollmentSummaryTimeoutSeconds ?? 60);
|
||||
setEnrollmentSummaryBrandingImageUrl(data.enrollmentSummaryBrandingImageUrl ?? "");
|
||||
setEnrollmentSummaryLaunchRetrySeconds(data.enrollmentSummaryLaunchRetrySeconds ?? 120);
|
||||
setTeamsWebhookUrl(data.teamsWebhookUrl ?? "");
|
||||
setTeamsNotifyOnSuccess(data.teamsNotifyOnSuccess ?? true);
|
||||
setTeamsNotifyOnFailure(data.teamsNotifyOnFailure ?? true);
|
||||
// Webhook notifications: auto-migrate from legacy fields
|
||||
if (data.webhookUrl && data.webhookProviderType) {
|
||||
setWebhookProviderType(data.webhookProviderType);
|
||||
setWebhookUrl(data.webhookUrl);
|
||||
setWebhookNotifyOnSuccess(data.webhookNotifyOnSuccess ?? true);
|
||||
setWebhookNotifyOnFailure(data.webhookNotifyOnFailure ?? true);
|
||||
} else if (data.teamsWebhookUrl) {
|
||||
setWebhookProviderType(1); // TeamsLegacyConnector
|
||||
setWebhookUrl(data.teamsWebhookUrl);
|
||||
setWebhookNotifyOnSuccess(data.teamsNotifyOnSuccess ?? true);
|
||||
setWebhookNotifyOnFailure(data.teamsNotifyOnFailure ?? true);
|
||||
} else {
|
||||
setWebhookProviderType(0);
|
||||
setWebhookUrl("");
|
||||
setWebhookNotifyOnSuccess(true);
|
||||
setWebhookNotifyOnFailure(true);
|
||||
}
|
||||
const sasUrl = data.diagnosticsBlobSasUrl ?? "";
|
||||
setDiagnosticsBlobSasUrl(sasUrl);
|
||||
setDiagnosticsUploadMode(data.diagnosticsUploadMode ?? "Off");
|
||||
@@ -423,6 +446,23 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestWebhook = async () => {
|
||||
if (!tenantId) return;
|
||||
setTestingWebhook(true);
|
||||
setTestWebhookResult(null);
|
||||
try {
|
||||
const response = await authenticatedFetch(`${API_BASE_URL}/api/config/${tenantId}/test-notification`, getAccessToken, {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await response.json();
|
||||
setTestWebhookResult({ success: data.success, message: data.message });
|
||||
} catch (err) {
|
||||
setTestWebhookResult({ success: false, message: err instanceof Error ? err.message : "Failed to send test notification." });
|
||||
} finally {
|
||||
setTestingWebhook(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAndNavigate = async () => {
|
||||
setShowUnsavedModal(false);
|
||||
const destination = pendingNavigationRef.current;
|
||||
@@ -483,9 +523,15 @@ export default function SettingsPage() {
|
||||
enrollmentSummaryTimeoutSeconds,
|
||||
enrollmentSummaryBrandingImageUrl: enrollmentSummaryBrandingImageUrl || undefined,
|
||||
enrollmentSummaryLaunchRetrySeconds,
|
||||
teamsWebhookUrl: teamsWebhookUrl || undefined,
|
||||
teamsNotifyOnSuccess,
|
||||
teamsNotifyOnFailure,
|
||||
// New webhook fields
|
||||
webhookProviderType,
|
||||
webhookUrl: webhookUrl || undefined,
|
||||
webhookNotifyOnSuccess,
|
||||
webhookNotifyOnFailure,
|
||||
// Legacy compat: mirror to old fields during transition
|
||||
teamsWebhookUrl: webhookProviderType === 1 ? (webhookUrl || undefined) : undefined,
|
||||
teamsNotifyOnSuccess: webhookNotifyOnSuccess,
|
||||
teamsNotifyOnFailure: webhookNotifyOnFailure,
|
||||
diagnosticsBlobSasUrl: diagnosticsBlobSasUrl || undefined,
|
||||
diagnosticsUploadMode,
|
||||
diagnosticsLogPathsJson: tenantDiagPaths.length > 0 ? JSON.stringify(tenantDiagPaths) : undefined,
|
||||
@@ -918,7 +964,7 @@ export default function SettingsPage() {
|
||||
{ id: "hardware-whitelist", label: "Hardware Whitelist" },
|
||||
{ id: "agent-settings", label: "Agent Settings" },
|
||||
{ id: "agent-analyzers", label: "Agent Analyzers" },
|
||||
{ id: "teams-notifications", label: "Teams Notifications" },
|
||||
{ id: "notifications", label: "Notifications" },
|
||||
{ id: "diagnostics", label: "Diagnostics" },
|
||||
);
|
||||
}
|
||||
@@ -1123,14 +1169,19 @@ export default function SettingsPage() {
|
||||
)}
|
||||
|
||||
{user?.isTenantAdmin && (
|
||||
<div id="teams-notifications">
|
||||
<TeamsNotificationsSection
|
||||
teamsWebhookUrl={teamsWebhookUrl}
|
||||
setTeamsWebhookUrl={setTeamsWebhookUrl}
|
||||
teamsNotifyOnSuccess={teamsNotifyOnSuccess}
|
||||
setTeamsNotifyOnSuccess={setTeamsNotifyOnSuccess}
|
||||
teamsNotifyOnFailure={teamsNotifyOnFailure}
|
||||
setTeamsNotifyOnFailure={setTeamsNotifyOnFailure}
|
||||
<div id="notifications">
|
||||
<NotificationsSection
|
||||
webhookProviderType={webhookProviderType}
|
||||
setWebhookProviderType={setWebhookProviderType}
|
||||
webhookUrl={webhookUrl}
|
||||
setWebhookUrl={setWebhookUrl}
|
||||
webhookNotifyOnSuccess={webhookNotifyOnSuccess}
|
||||
setWebhookNotifyOnSuccess={setWebhookNotifyOnSuccess}
|
||||
webhookNotifyOnFailure={webhookNotifyOnFailure}
|
||||
setWebhookNotifyOnFailure={setWebhookNotifyOnFailure}
|
||||
onTestWebhook={handleTestWebhook}
|
||||
testingWebhook={testingWebhook}
|
||||
testWebhookResult={testWebhookResult}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user