re-worked notification system to support more webhook providers (current, legacyTeamsProvider, TeamsWorkflowProvider, Slack)

This commit is contained in:
Oliver Kieselbach
2026-03-11 21:59:14 +01:00
parent dce65cce0f
commit cc085d5d55
26 changed files with 1227 additions and 449 deletions
@@ -1 +1 @@
443
445
@@ -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;
}
}
}
}
@@ -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),
@@ -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);
}
}
@@ -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;
}
}
}
@@ -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"
};
}
}
}
@@ -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()
}
});
}
}
}
@@ -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 &rarr; <em>Connectors</em> &rarr; search
for <em>Incoming Webhook</em> &rarr; 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> &mdash;
In Teams, go to the target channel &rarr; <em>Manage channel</em> &rarr; <em>Workflows</em> &rarr;
add <em>&quot;Post to a channel when a webhook request is received&quot;</em> &rarr; 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> &mdash;
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> &mdash;
In Slack, go to your workspace &rarr; <em>Apps</em> &rarr; <em>Incoming Webhooks</em> &rarr;
create a new webhook for the target channel &rarr; 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