mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
9f7b070e80
The V1 agent line is retired: no new V1 sessions since 2026-05-09, only orphaned straggler traffic remained on /api/agent/ingest (last seen 2026-06-29, events for long-cleaned-up sessions). Bootstrap/Intune/CI have deployed V2-only since the v2 cutover. Agent: - Delete AutopilotMonitor.Agent, .Agent.Core, .Agent.Core.Tests (projects + sln entries). V2 trio, SummaryDialog, DecisionCore.Tests and V2Isolation.targets stay. Backend: - Relocate the shared statics/DTOs V2 reused from the V1 partials (StampServerFields, SanitizeEventTimestamps, IsMaxLifetimeAgentShutdown, RecalculateAppDurations, EventClassification, AppInstallAggregationState) into EventIngestProcessor; IngestEventsOutput moves next to IngestTelemetryFunction (same namespace, zero ripple). - Delete IngestEventsFunction (route agent/ingest), BootstrapIngestEvents (route bootstrap/ingest), NdjsonParser and their DI registration. EventDataNormalizer stays (used by TelemetryPayloadParser). - Drop both routes from EndpointAccessPolicyCatalog and RequestTelemetryMiddleware.TenantHeaderTrustedPaths. - Retire the V1 update-hash oracle: LatestAgentV1*/LatestBootstrapV1* fields + GetAgentLine case 1 (default arm returns empty hashes, so stragglers gracefully skip the integrity check; ParseAgentMajor keeps its default of 1 on purpose — defaulting to 2 would feed V2 hashes to V1 binaries). TableConfigRepository Store+Map updated in lockstep. - Remove ApiEndpoints.IngestEvents/.BootstrapIngestEvents constants. Tests: NdjsonParserTests deleted; timestamp/critical-path/max-lifetime tests repointed to EventIngestProcessor; the two NDJSON pipeline tests removed (coverage retained by direct-list siblings); catalog/no-store InlineData rows updated. Docs: architecture.md (endpoint tables, ingest flow diagram, solution layout, test matrix), web SectionAgentInternals endpoint tables, build.ps1 help text. Azure: no config change needed — /api/agent is not in the client-cert exclusion paths (agent/ingest was always cert-enforced) and the /api/bootstrap prefix exclusion remains for the surviving bootstrap routes (verified via ARM on autopilotmonitor-api). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
168 lines
7.7 KiB
C#
168 lines
7.7 KiB
C#
using System.Linq;
|
|
using AutopilotMonitor.Functions.Services.Notifications;
|
|
using AutopilotMonitor.Shared.Models;
|
|
using AutopilotMonitor.Shared.Models.Notifications;
|
|
using Microsoft.Azure.Functions.Worker;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace AutopilotMonitor.Functions.Services
|
|
{
|
|
/// <summary>
|
|
/// External + real-time notification plumbing: outgoing webhook alerts (Teams/Slack-style)
|
|
/// and SignalR messages for the live UI push.
|
|
/// </summary>
|
|
public sealed partial class EventIngestProcessor
|
|
{
|
|
private async Task SendWebhookNotificationsAsync(
|
|
IngestEventsRequest request, string sessionPrefix, EventClassification c,
|
|
SessionSummary? updatedSession, bool statusTransitioned, bool whiteGloveStatusTransitioned, string? failureReason,
|
|
List<RuleResult> ruleResults)
|
|
{
|
|
var tenantConfig = await _configService.GetConfigurationAsync(request.TenantId);
|
|
var (webhookUrl, providerTypeInt) = tenantConfig.GetEffectiveWebhookConfig();
|
|
|
|
if (string.IsNullOrEmpty(webhookUrl) || providerTypeInt == 0)
|
|
return;
|
|
|
|
var providerType = (WebhookProviderType)providerTypeInt;
|
|
var customHeaders = tenantConfig.GetGenericWebhookHeaders();
|
|
var sessionUrl = updatedSession != null
|
|
? $"https://portal.autopilotmonitor.com/sessions/{request.SessionId}"
|
|
: null;
|
|
|
|
if (statusTransitioned && (c.CompletionEvent != null || c.FailureEvent != null))
|
|
{
|
|
var notifySuccess = c.CompletionEvent != null && tenantConfig.GetEffectiveNotifyOnSuccess();
|
|
var notifyFailure = c.FailureEvent != null && tenantConfig.GetEffectiveNotifyOnFailure();
|
|
if (notifySuccess || notifyFailure)
|
|
{
|
|
var duration = updatedSession?.DurationSeconds != null
|
|
? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value)
|
|
: (TimeSpan?)null;
|
|
|
|
if (updatedSession?.IsPreProvisioned == true && updatedSession?.ResumedAt != null)
|
|
{
|
|
var completionTime = c.CompletionEvent?.Timestamp ?? c.FailureEvent?.Timestamp;
|
|
if (completionTime.HasValue)
|
|
duration = completionTime.Value - updatedSession.ResumedAt.Value;
|
|
}
|
|
|
|
var alert = NotificationAlertBuilder.BuildEnrollmentAlert(
|
|
updatedSession?.DeviceName,
|
|
updatedSession?.SerialNumber,
|
|
updatedSession?.Manufacturer,
|
|
updatedSession?.Model,
|
|
success: c.CompletionEvent != null,
|
|
failureReason: failureReason,
|
|
duration: duration,
|
|
sessionUrl: sessionUrl);
|
|
NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults);
|
|
|
|
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders)
|
|
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
|
|
"Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted);
|
|
}
|
|
}
|
|
|
|
if (whiteGloveStatusTransitioned && c.WhiteGloveEvent != null && tenantConfig.GetEffectiveNotifyOnSuccess())
|
|
{
|
|
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);
|
|
NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults);
|
|
|
|
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders)
|
|
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
|
|
"Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted);
|
|
}
|
|
|
|
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);
|
|
NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults);
|
|
|
|
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders)
|
|
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
|
|
"Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted);
|
|
}
|
|
}
|
|
|
|
private SignalRMessageAction[] BuildSignalRMessages(
|
|
IngestEventsRequest request, SessionSummary? updatedSession, int processedCount,
|
|
List<RuleResult> newRuleResults)
|
|
{
|
|
object? sessionDelta = updatedSession != null ? new {
|
|
updatedSession.CurrentPhase,
|
|
updatedSession.CurrentPhaseDetail,
|
|
updatedSession.Status,
|
|
updatedSession.FailureReason,
|
|
updatedSession.EventCount,
|
|
updatedSession.DurationSeconds,
|
|
updatedSession.CompletedAt,
|
|
updatedSession.DiagnosticsBlobName,
|
|
updatedSession.IsPreProvisioned
|
|
} : null;
|
|
|
|
var summaryMessage = new SignalRMessageAction("newevents")
|
|
{
|
|
GroupName = $"tenant-{request.TenantId}",
|
|
Arguments = new object[] { new {
|
|
sessionId = request.SessionId,
|
|
tenantId = request.TenantId,
|
|
eventCount = processedCount,
|
|
sessionUpdate = sessionDelta
|
|
} }
|
|
};
|
|
|
|
var slimRuleResults = newRuleResults.Count > 0
|
|
? newRuleResults.Select(r => new {
|
|
r.ResultId,
|
|
r.RuleId,
|
|
r.RuleTitle,
|
|
r.Severity,
|
|
r.Category,
|
|
r.ConfidenceScore,
|
|
r.Explanation,
|
|
r.Remediation,
|
|
r.RelatedDocs,
|
|
r.MatchedConditions,
|
|
r.DetectedAt
|
|
}).ToList<object>()
|
|
: null;
|
|
|
|
var eventsMessage = new SignalRMessageAction("eventStream")
|
|
{
|
|
GroupName = $"session-{request.TenantId}-{request.SessionId}",
|
|
Arguments = new object[] { new {
|
|
sessionId = request.SessionId,
|
|
tenantId = request.TenantId,
|
|
newEventCount = processedCount,
|
|
newRuleResults = slimRuleResults
|
|
} }
|
|
};
|
|
|
|
return new[] { summaryMessage, eventsMessage };
|
|
}
|
|
}
|
|
}
|