From d763a885e21bb00865b367eb290cff188f5fcc46 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 1 Sep 2026 16:57:41 +0200 Subject: [PATCH] Ops alert channels, ops-events server-side filters, and generated MCP tool vocabularies (#140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(backend,web): Telegram als Channel-Provider + ein einziger Dispatch-Pfad Notifications hatten zwei Sendewege: WebhookNotificationService.SendToChannelsAsync fuer tenant-konfigurierte Channels, und TelegramNotificationService direkt aus dem Ops-Alert-Pfad. Ein neuer Provider haette beide anfassen muessen. Neu: NotificationChannelDispatcher ist DIE Channel-Sende-API und routet pro Provider — WebhookProviderType.Telegram zum Bot, alles andere als gerenderten Webhook-POST. SendToChannelsAsync wurde aus WebhookNotificationService entfernt statt dupliziert, damit es keinen zweiten Pfad gibt; alle sieben Call-Sites ziehen um (EventIngestProcessor, RegisterSession, ReportDistress, SlaBreachEvaluation, AnalyzeOnEnrollmentEnd, TestWebhookNotification). SendOpsAlertAsync/SendNotificationAsync sind virtual (Test-Seam), TelegramNotificationService bekommt SendAlertWithResultAsync fuer die Test-Endpoints und RenderAlertText als testbaren Kern. Telegram (=40) ist der Sonderfall: kein Webhook. Der Channel-Url traegt die CHAT-ID, der Bot-Token gehoert der Plattform (PreviewConfig). Ein Tenant-Admin wuerde durch UNSEREN Bot senden — deshalb ist das Gate eine Server-Regel, keine versteckte Dropdown-Option: ValidateTelegramChannelGate sitzt in TenantConfigValidation.ValidateModel und deckt damit BEIDE Schreibpfade ab (Full-Model-PUT und den MCP-Field-Patch ueber TenantConfigPatchService) — in der Update-Function allein waere der Patch-Pfad daran vorbeigelaufen. Ein unveraenderter, GA-angelegter Channel passiert das Gate, damit ein Tenant-Admin unrelated Config weiter speichern kann. Telegram-Ziele ueberspringen SsrfGuard.ValidateWebhookUrlFormat (eine Chat-ID ist keine URL und wuerde immer failen) und werden stattdessen als numerische Id oder @username geprueft. Mitgeliefert, weil der Dispatcher und die Ops-Seite sie brauchen: NotificationChannel.RedactList / RestoreRedactedList als geteilte Helfer hochgezogen (TenantConfiguration delegiert jetzt dorthin, statt eine zweite Kopie zu pflegen), AdminConfiguration.OpsNotificationChannelsJson + GetOpsNotificationChannels() mit Legacy-Slot-Synthese, OpsAlertRule.NotifyChannelIds/IncludePayload, NotificationAlert.DataJson (nur GenericJsonRenderer serialisiert es, additiv in schemaVersion 1.0). Web: der Channel-Editor liegt jetzt in components/notifications/ChannelEditor.tsx und wird von Tenant- und Admin-Seite geteilt (showEventToggles trennt die Faelle). Telegram erscheint im Provider-Dropdown nur fuer Global Admins (Prop aus useAuth().user?.isGlobalAdmin, Hausmuster aus SectionAutopilotValidation); ein bereits konfigurierter Telegram-Channel bleibt fuer alle sichtbar, damit ein Speichern ihn nicht still verwirft. Tests: TelegramChannelProviderTests (27) — Dispatcher-Routing inkl. "Telegram erreicht NIE den SSRF-Pfad", Chat-ID-Validierung, GA-Gate auf beiden Schreibpfaden. Mutationsgeprueft: Gate invertiert => 5 rot, zurueck => 27 gruen. OpsNotificationChannelsTests + OpsAlertPayloadTests decken die mitgelieferten Modell-Additionen ab. TestNotifications ist der gemeinsame inerte Stub, der die 17 bestehenden OpsAlertDispatchService-Konstruktionen ersetzt. Co-Authored-By: Claude Opus 5 (1M context) * feat(backend,web): Ops-Alerts routen auf benannte Channels statt auf drei feste Slots OpsAlertRule hatte kein Ziel. Jede matchende Regel ging an alle drei fest verdrahteten Slots in AdminConfiguration (Telegram-ChatId / Teams-URL / Slack-URL), und nur die ERSTE matchende Regel wurde ueberhaupt ausgewertet (FirstOrDefault). Ein Conversion-Signal an ein Sales-Team zu binden hiess also: das Sales-Team bekommt auch jede Security-Regel. Jetzt: AdminConfiguration.OpsNotificationChannelsJson haelt eine benannte Channel-Liste (dasselbe NotificationChannel-Modell wie tenant-seitig, nur anderer Scope), und OpsAlertRule.NotifyChannelIds bindet Regeln daran. Dispatch wertet ALLE matchenden Regeln aus (Where statt FirstOrDefault) und vereinigt ihre Ziele. Rueckwaertskompatibel ohne Migrationsjob: - Leere Channel-Liste => GetOpsNotificationChannels() synthetisiert je konfiguriertem Legacy-Slot einen Channel mit stabiler Id (legacy-telegram heisst "Autopilot Monitor IT Engineer") und uebernimmt die Legacy-Enabled-Flags — ein bewusst abgeschalteter Provider bleibt abgeschaltet. - GetAdminConfigurationFunction projiziert DIESELBE Synthese in das Read-Model, damit der Editor zeigt, was heute tatsaechlich Alerts empfaengt, und der erste Save exakt das materialisiert. Die Alternative waere eine zweite Kopie der Synthese-Regeln im Web gewesen, die vom Dispatcher wegdriftet. - Leere NotifyChannelIds = alle aktivierten Channels. Das ist, was jede heute existierende Regel traegt, also verhaelt sich am Deploy-Tag nichts anders. - Ids, die nicht mehr aufloesen, werden verworfen statt auf Broadcast zurueckzufallen: eine Regel, deren einziger Channel geloescht wurde, muss NICHTS erreichen, nicht alles. Die Legacy-Felder bleiben als Migrationsquelle stehen und werden von der Alerts-Section nicht mehr geschrieben — aufraeumen erst, wenn produktiv Channels stehen. Sicherheit: OpsNotificationChannelsJson ist secret-tragend und steht in der Deny-List von RedactedCopyForReader() (ConfigRedactionTests bewacht Drift). Der Admin-PUT hat bisher keinen Redacted-Restore — er bekommt jetzt einen fuer die Channel-Liste, damit ein zurueckgespieltes Reader-View nicht "***REDACTED***" als Chat-ID persistiert, plus die strukturelle Validierung, die auch die Tenant-Liste passiert. Neuer Endpoint POST /api/global/config/test-ops-channel (GlobalAdminOnly, im Policy-Catalog registriert). Kein neues Response-DTO: TestWebhookNotificationResponse hat identische Semantik und wird geteilt — nur der Doc-Kommentar nennt jetzt beide Routen. Web: die "Alert Providers"-Karte weicht der Channel-Liste (geteilter ChannelEditor, im Admin-Kontext mit Telegram und ohne die tenant-spezifischen Event-Toggles). Jede Regel bekommt einen Channel-Picker; leere Auswahl rendert als "all channels", nicht als Warnung — hier ist das der gueltige Default. Die Bindungslogik liegt testbar in opsChannelRouting.ts und spiegelt ResolveTargets: der erste explizite Pick startet vom LEEREN Set, sonst liesse sich eine Broadcast-Regel nie verengen. handleSaveOpsAlertConfig nimmt jetzt ein Objekt statt elf positionaler Argumente — bei einem Dutzend Feldern schreibt ein vertauschtes Paar sonst still eine Webhook-URL in den falschen Provider-Slot. Co-Authored-By: Claude Opus 5 (1M context) * feat(backend): TenantTrialStarted + Event-Payload optional pro Regel Der Trial-Start war stumm. POST config/{tenantId}/trial schrieb nur einen Audit-Entry, und der GA-Pfad (PATCH plan) feuerte nur beim Downgrade — also war genau der Conversion-Moment, den Sales/Support interessiert, das einzige Plan-Ereignis ohne Ops-Event. RecordTenantTrialStartedAsync (Tenant/Info) feuert jetzt aus BEIDEN Schreibpfaden, unterschieden per selfService-Flag, und jeweils NACH dem persistierten Save (SaveConfigurationAsync ist fail-loud), damit kein Alert eine Trial ankuendigt, die nicht gespeichert wurde. Der GA-Pfad haengt an der aufgezeichneten Aenderung, nicht am Request: ein reiner planTier-Edit bleibt still, ein idempotenter Save mit demselben Datum ebenfalls, und das Beenden einer Trial (explizit null) ist ein Downgrade und bleibt bei TenantPlanDowngraded. Payload-Zustellung: WriteAsync reicht die serialisierten Details an DispatchAsync durch — aber ein Channel bekommt sie nur, wenn eine Regel OpsAlertRule.IncludePayload setzt. Default AUS, bewusst: ein Ops-Alert ist ein "da ist was, schau nach"-Signal, die meisten Payloads sind im Chat Rauschen, und manche tragen Felder, die die Baseline nie traegt (Tenant-Domain, Contact-Adresse). Das Informationsniveau aller bestehenden Regeln bleibt damit unveraendert. ResolveTargets liefert deshalb zwei Gruppen. Ein Channel, den sowohl eine Payload-Regel als auch eine schlichte Regel adressiert, landet nur in der Payload-Gruppe: eine Nachricht pro Channel, und ein unbeteiligtes Geschwister-Match kann ein bewusstes Opt-in nicht aushebeln. Fuer den angereicherten Alert flacht BuildAlert die Top-Level-Skalare in Facts ab (max 12, Werte <= 256 Zeichen, Nulls und Verschachteltes raus), damit Card-Formate und der Telegram-Klartext dieselbe Information tragen wie der GenericJson-Consumer unter "data". EventType wird immer gesetzt — derselbe Wert steckt schon im "Event"-Fact, also keine zusaetzliche Information, nur ein stabiler Schluessel zum Verzweigen. Tests: TenantTrialStartedEventTests (8) — aufgezeichnete Event-Form inkl. Payload-Feldern, und die Emissionsbedingung des GA-Pfads gegen ApplyPlanChanges gepinnt (greift bei Grant und Verlaengerung, schweigt bei Trial-Ende, planTier-only und idempotentem Save). Die HTTP-Entry-Points bleiben ungetestet — Suite-Konvention ist, die reinen Kerne zu testen. Docs: OKF-Concept backend/ops-notification-channels.md. Registrierung in docs/index.md + docs/log.md fehlt bewusst — beide Dateien tragen noch fremdes uncommittetes WIP (ops-events Query-Surface), und ein Teil-Commit darin haette es mitgenommen. Co-Authored-By: Claude Opus 5 (1M context) * feat(backend,mcp): ops-events server-side field filters + generated MCP tool vocabularies The ops-events read could only narrow by category and date window, so answering "did any AgentEmergencyBreak fire yesterday?" meant pulling a whole category and filtering it client-side — and for the MCP tool that client is a language model, so the question cost a category-sized response in model context. Ops-events filter surface (mirrors the audit-log filter pattern one-for-one): - OpsEventQueryFilters (Shared) + OpsEventFilterRequest (query -> filters + pagination extras) + TableOpsEventRepository.AppendFieldFilters (filters -> OData clauses). BOTH filter builders call the appender — the single-category / unpaged one and the per-partition fan-out builder that adds the RowKey bound — so "no category named" cannot silently return unfiltered rows. - minSeverity expands to an OR-set, not a range: Table Storage compares strings lexicographically and the severity names sort Critical < Error < Info < Warning, so `Severity ge 'Warning'` would drop exactly the alert-worthy half. The ladder now lives in OpsEventSeverity.Rank, shared with OpsAlertDispatchService, so "Warning and above" in a read means what it meant when the alert rules fired. - severity is normalised onto the canonical vocabulary and an unknown value is a 400: `eq` is case-sensitive, so "?severity=warning" would otherwise return an empty page that reads exactly like "no such events happened". eventType stays verbatim — stored rows outlive the vocabulary, so a retired type must remain searchable in the historical window. - Every filter value is folded into the continuation fingerprint and echoed on nextLink; extras are appended AFTER the pre-existing category/tenantId discriminators and skipped when empty, so tokens minted before the filters existed still fingerprint identically. The tenantId drill deliberately stays in memory (OrdinalIgnoreCase; an OData eq would lose rows on casing). - MCP get_ops_events gains eventType / severity / minSeverity plus a `days` shorthand resolved client-side to a concrete dateFrom (a backend-side "last N days" would re-resolve "now" on every page and blow the token fingerprint); an explicit bound always wins over the shorthand. Fixed alongside: the paged cross-category fan-out skipped Platform (Azure Monitor alerts) because its category list had drifted from OpsEventCategory — written from day one, invisible to every paged reader, still visible on the unpaged full-table path. The list is now OpsEventCategory.All plus a reflection coverage test. That drift had a structural cause, so the same round closes it for the MCP as a whole: wire TYPES reached the MCP from day one, but vocabularies did not — types are erased at runtime and z.enum() needs values, so every tool schema hand-typed its own copy with nothing to compare against. The copies had drifted: search_sessions offered 5 of 8 session statuses (no AwaitingUser, no Incomplete — both real stored statuses), both event readers offered 4 of 6 severities (no Debug, no Trace), get_ops_events named 6 of 7 ops categories. - The codegen now emits src/generated/wire-vocabularies.generated.ts (VALUES: session statuses, event severities, ops categories/severities/types, annotation lanes/verdicts) from the manifest sections listed in MCP_VOCABULARIES; SharedManifestParityTests exports the two new ops vocabularies. Freshness is pinned twice — byte-equality against a fresh codegen run in the MCP suite, and the shared-manifests-in-sync CI job. - Tool schemas derive their z.enum() from it and descriptions interpolate the list instead of spelling it. - vocabulary-drift.guard.test.ts ratchets what is left: every inline z.enum([..]) must sit on a baseline naming why it has no backend owner (MCP-local knobs, plus rule type / connection type / CVE risk, which have no C# constants class yet). A new hand-typed list fails, and so does a stale baseline entry. - OpsEventTypes (Shared) gives the 77 ops event types a canonical home: the OpsEventService write sites use the constants (a raw literal now fails OpsEventTypeDualRegisterTests, which moved from regex-scraping to reflection), the manifest exports them, and get_resource(name="ops_event_types") serves categories + severities + types to the model from the generated copy. Suites: Backend 5427, MCP 629 + tsc, Web tsc + vitest 965, generator idempotent. Deploy order: backend BEFORE the MCP redeploy — an MCP that sends filters to an older backend gets silently unfiltered rows back and presents them as filtered. Co-Authored-By: Claude Opus 5 (1M context) * docs: register the ops notification channels doc in the index and log The concept doc landed with the feature commits; its index entry and log entry were still uncommitted in the working tree. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 6 +- docs/backend/ops-events-query-surface.md | 134 ++++++ docs/backend/ops-notification-channels.md | 151 +++++++ docs/drift-guards.md | 45 +- docs/index.md | 4 +- docs/log.md | 12 + .../AppHomingServiceTests.cs | 8 +- .../CollectLogsQuickConfigOpsEventTests.cs | 7 +- .../KillSwitchEvaluatorTests.cs | 8 +- ...antOffboardingHandlerDrainContractTests.cs | 8 +- .../TenantOffboardingHandlerOrderingTests.cs | 8 +- .../TenantOffboardingWorkerPoisonTests.cs | 8 +- .../OpsAlertPayloadTests.cs | 135 ++++++ .../OpsEventCategoryCoverageTests.cs | 42 ++ .../OpsEventFieldFilterTests.cs | 230 +++++++++++ .../OpsEventTypeDualRegisterTests.cs | 87 +++- .../OpsNotificationChannelsTests.cs | 269 ++++++++++++ .../ReportAgentErrorFunctionTests.cs | 8 +- ...SessionDeletionMaintenanceFunctionTests.cs | 6 +- .../SessionDeletionPr4cCodexFixesTests.cs | 6 +- .../SessionDeletionWorkerTests.cs | 6 +- .../SessionOwnerBindingObserverTests.cs | 7 +- .../SharedManifestParityTests.cs | 7 + .../SlaTenantStatusLifecycleTests.cs | 23 +- .../SubmitOffboardingFeedbackFunctionTests.cs | 8 +- .../TelegramChannelProviderTests.cs | 225 ++++++++++ .../TenantApprovalServiceTests.cs | 8 +- .../TenantAutoApproveHandlerTests.cs | 8 +- .../TenantConfigPatchServiceTests.cs | 8 +- .../TenantTrialStartedEventTests.cs | 144 +++++++ .../TestNotifications.cs | 30 ++ .../TrialExpirySweepFunctionTests.cs | 12 +- .../TableStorage/TableConfigRepository.cs | 2 + .../TableStorage/TableOpsEventRepository.cs | 74 +++- .../Functions/Admin/GetOpsEventsFunction.cs | 22 +- .../Functions/Admin/OpsEventFilterRequest.cs | 82 ++++ .../Config/GetAdminConfigurationFunction.cs | 23 +- .../Config/PlanManagementFunction.cs | 28 ++ .../Config/TestOpsChannelFunction.cs | 104 +++++ .../Config/TestWebhookNotificationFunction.cs | 11 +- .../UpdateAdminConfigurationFunction.cs | 20 + .../Ingest/ReportDistressFunction.cs | 8 +- .../Sessions/RegisterSessionFunction.cs | 8 +- .../Helpers/TenantConfigValidation.cs | 97 ++++- .../AutopilotMonitor.Functions/Program.cs | 3 + .../Security/EndpointAccessPolicyCatalog.cs | 1 + .../Analyze/AnalyzeOnEnrollmentEndHandler.cs | 8 +- .../EventIngestProcessor.Notifications.cs | 6 +- .../Services/EventIngestProcessor.cs | 6 +- .../Notifications/GenericJsonRenderer.cs | 28 +- .../NotificationChannelDispatcher.cs | 82 ++++ .../WebhookNotificationService.cs | 32 +- .../Services/OpsAlertDispatchService.cs | 222 ++++++++-- .../Services/OpsEventService.cs | 182 ++++---- .../Services/SlaBreachEvaluationService.cs | 12 +- .../Services/TelegramNotificationService.cs | 97 +++-- .../src/__tests__/tools-ops-events.test.ts | 136 ++++++ .../__tests__/vocabulary-drift.guard.test.ts | 97 +++++ .../__tests__/wire-types-freshness.test.ts | 23 +- .../src/generated/wire-types.generated.ts | 4 +- .../generated/wire-vocabularies.generated.ts | 153 +++++++ .../src/resource-catalog.ts | 21 + .../autopilot-monitor-mcp/src/resources.ts | 22 + .../autopilot-monitor-mcp/src/tools/admin.ts | 61 ++- .../src/tools/sessions.ts | 6 +- .../DataAccess/IOpsEventRepository.cs | 91 +++- .../DataAccess/OpsEventTypes.cs | 126 ++++++ .../Models/Config/AdminConfiguration.cs | 96 +++++ .../Models/Config/OpsAlertRule.cs | 30 ++ .../Models/Config/TenantConfiguration.cs | 59 +-- .../Models/ConfigApiModels.cs | 6 +- .../Models/Notifications/NotificationAlert.cs | 13 + .../Notifications/NotificationChannel.cs | 97 ++++- .../Notifications/WebhookProviderType.cs | 10 + .../app/admin/AdminConfigContext.tsx | 125 ++++-- .../admin/components/OpsAlertRulesSection.tsx | 265 ++++++------ .../__tests__/opsChannelRouting.test.ts | 53 +++ .../app/admin/components/opsChannelRouting.ts | 44 ++ .../admin/settings/sections/SectionAlerts.tsx | 20 +- .../components/NotificationsSection.tsx | 351 +--------------- .../tenant/sections/SectionNotifications.tsx | 8 + .../notifications/ChannelEditor.tsx | 388 ++++++++++++++++++ src/Web/autopilot-monitor-web/lib/api.ts | 2 + .../scripts/generate-shared-manifest-types.js | 79 +++- .../types/adminConfig.ts | 13 + .../utils/shared-manifests.generated.ts | 98 ++++- .../utils/shared-manifests.json | 108 ++++- .../utils/wire-types.generated.ts | 4 +- 88 files changed, 4456 insertions(+), 979 deletions(-) create mode 100644 docs/backend/ops-events-query-surface.md create mode 100644 docs/backend/ops-notification-channels.md create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/OpsAlertPayloadTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/OpsEventCategoryCoverageTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/OpsEventFieldFilterTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/OpsNotificationChannelsTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/TelegramChannelProviderTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/TenantTrialStartedEventTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/TestNotifications.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Admin/OpsEventFilterRequest.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Config/TestOpsChannelFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationChannelDispatcher.cs create mode 100644 src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-ops-events.test.ts create mode 100644 src/McpServer/autopilot-monitor-mcp/src/__tests__/vocabulary-drift.guard.test.ts create mode 100644 src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts create mode 100644 src/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs create mode 100644 src/Web/autopilot-monitor-web/app/admin/components/__tests__/opsChannelRouting.test.ts create mode 100644 src/Web/autopilot-monitor-web/app/admin/components/opsChannelRouting.ts create mode 100644 src/Web/autopilot-monitor-web/components/notifications/ChannelEditor.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43aea2ea..4db0d7c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -155,14 +155,16 @@ jobs: node-version: '22' # JSON ↔ C# parity is the dotnet job's SharedManifestParityTests; this job pins - # JSON ↔ generated TS (shared-manifests.generated.ts + wire-types.generated.ts). + # JSON ↔ generated TS (shared-manifests.generated.ts, wire-types.generated.ts and the + # MCP's wire-types + wire-vocabularies copies - the vocabularies are what the MCP + # tool input schemas derive their z.enum() from). - name: Regenerate run: node scripts/generate-shared-manifest-types.js working-directory: src/Web/autopilot-monitor-web - name: Fail if the generated files drifted run: | - if ! git diff --quiet -- src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts src/Web/autopilot-monitor-web/utils/wire-types.generated.ts src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts; then + if ! git diff --quiet -- src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts src/Web/autopilot-monitor-web/utils/wire-types.generated.ts src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts; then echo "::error::Generated wire types are out of date. Run 'npm run generate:manifests' in src/Web/autopilot-monitor-web and commit the result." git diff --stat exit 1 diff --git a/docs/backend/ops-events-query-surface.md b/docs/backend/ops-events-query-surface.md new file mode 100644 index 00000000..e0670539 --- /dev/null +++ b/docs/backend/ops-events-query-surface.md @@ -0,0 +1,134 @@ +--- +type: Concept +title: Ops-Events Query Surface +description: The server-side filter surface of GET /api/global/ops-events (category, eventType, severity/minSeverity, tenant drill, date window), why every filter is folded into the continuation fingerprint, why minSeverity expands to an OR-set, and why the paged cross-category read has to fan out over the complete category vocabulary. +resource: /src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs +tags: + - backend + - table-storage + - ops-events + - pagination + - mcp +timestamp: 2026-09-01T00:00:00+02:00 +--- + +# Problem + +`OpsEvents` is the platform operator's log: consent flows, maintenance runs, security blocks, +tenant offboards, agent emergency breaks, SLA and Azure Monitor alerts. It is partitioned by +**category** (`PartitionKey`), with a reverse-tick `RowKey` for newest-first ordering. + +Until 2026-09-01 the read endpoint could only narrow by category and date window. Every other +question — *"did any `AgentEmergencyBreak` fire yesterday?"*, *"show me everything at Error or +worse"* — had to be answered by pulling the whole category and filtering it on the client. For the +MCP tool that client is a language model, so an event-type question cost a category-sized response +in model context. + +# Schema + +`GET /api/global/ops-events` (GA + Global Reader; `TenantScoping.None`, so a delegated admin cannot +reach it at all): + +| Param | Where it is applied | Notes | +| --- | --- | --- | +| `category` | `PartitionKey eq` — indexed | One partition; omit to fan out over all of them | +| `eventType` | `EventType eq` — server-side scan | **Exact and case-sensitive**; Table Storage cannot fold case | +| `severity` | `Severity eq` — server-side scan | Normalised onto the canonical vocabulary before use | +| `minSeverity` | OR-set over `Severity` — server-side scan | Threshold: that level and everything above | +| `dateFrom` / `dateTo` | RowKey range — indexed | Both absent ⇒ backend resolves the last 30 days | +| `tenantId` | **in-memory**, post-fetch | Drill, not an isolation boundary — see below | +| `pageSize` / `continuation` | — | `pageSize` opts into pagination; absent ⇒ full window, no cap | + +The filter surface lives in two shared pieces so no path can honour a different one: +`OpsEventFilterRequest` (query → `OpsEventQueryFilters` + pagination extras) and +`TableOpsEventRepository.AppendFieldFilters` (filters → OData clauses), which **both** filter +builders call — the single-category/unpaged builder and the per-partition fan-out builder that adds +the `RowKey gt` bound. This mirrors `AuditLogFilterRequest` / `AuditLogQueryFilters` on the audit +endpoints one-for-one. + +## Why `minSeverity` is an OR-set, not a range + +Table Storage compares strings lexicographically, and the severity names sort +`Critical < Error < Info < Warning` — nothing to do with severity order. A `Severity ge 'Warning'` +clause would return `Warning` only, silently dropping `Error` and `Critical`, which is precisely the +alert-worthy half. `minSeverity` therefore expands to +`(Severity eq 'Warning' or Severity eq 'Error' or Severity eq 'Critical')`. The ladder itself lives +in `OpsEventSeverity.Rank` and is shared with `OpsAlertDispatchService`, so *"Warning and above"* in +a read means exactly what *"Warning and above"* meant when the alert rules decided to fire. + +`minSeverity=Info` emits no clause at all — it is the floor, so the clause would only cost query +length. + +## Why severity is normalised but eventType is not + +Both columns are matched with `eq`, which is case-sensitive. Severity has a closed vocabulary of +four values, so `?severity=warning` is normalised to `Warning`, and anything outside the vocabulary +is a **400** — an unnormalised value would return an empty page that reads exactly like *"no such +events happened"*. + +Event types have a declared vocabulary too (`OpsEventTypes`, reflected into the shared manifest and +served to the model as `get_resource(name="ops_event_types")`), but the filter deliberately does +**not** validate against it: stored rows outlive the vocabulary, so a type that was retired after a +refactor must stay searchable in the historical window. The value is passed verbatim and a typo +legitimately returns zero rows — the catalog is there so nobody has to guess. + +## Continuation fingerprint + +Every filter value is appended to the pagination *extras*, which are both folded into the +continuation-token fingerprint and echoed on `nextLink`. Consequences: + +* a token minted for one filter cannot page a different one (it is rejected with a 400), +* the follow-up request is self-contained — the resolved date window and the filters travel on the + link, so no page re-resolves "now", +* extras are appended **after** the pre-existing `category` / `tenantId` discriminators and skipped + when empty, so tokens minted before the field filters existed still fingerprint identically. + +Because severity values are normalised *before* fingerprinting, `?severity=error` and +`?severity=Error` share one token instead of minting two that each reject the other. + +## The tenant drill stays in memory + +`?tenantId=` is filtered post-fetch (`GetOpsEventsFunction.FilterByTenant`) because OpsEvents is +partitioned by category, and — deliberately — because the in-memory comparison is +`OrdinalIgnoreCase` while an OData `eq` would be case-sensitive: stored tenant casing is not +guaranteed, so pushing this one down would lose rows. It is drill *correctness*, not isolation: +every caller that reaches this route already has full cross-tenant scope. Pages therefore can report +fewer items than `pageSize` when the drill is narrow. + +## The category vocabulary must be complete + +The paged cross-category read does not issue one cross-partition query (Azure would page it by +`PartitionKey asc`, so the first page would come entirely from the alphabetically first category). +It fans out per partition over a fixed list and merge-sorts. That list is therefore load-bearing: a +category that is written but not listed is **invisible to every paged reader** while the unpaged +full-table path still returns it. `Platform` (Azure Monitor alerts relayed through the ops alert +webhook) was missing that way from its introduction until 2026-09-01. + +The list is now owned by `OpsEventCategory.All`, and `OpsEventCategoryCoverageTests` reflects over +the constants to fail the build when a new category is not added to it. + +# Examples + +``` +# One event type, last 24h, cross-category +GET /api/global/ops-events?eventType=AgentEmergencyBreak&dateFrom=2026-08-31T12:00:00Z&pageSize=200 + +# Everything alert-worthy in the maintenance partition this month +GET /api/global/ops-events?category=Maintenance&minSeverity=Error&dateFrom=2026-08-01T00:00:00Z +``` + +MCP `get_ops_events` exposes the same surface plus a `days` shorthand, which it resolves to a +concrete `dateFrom` **client-side** — a backend-side "last N days" would re-resolve "now" on every +page and blow the token fingerprint. An explicit `dateFrom`/`dateTo` always wins over the shorthand. + +# Citations + +* `/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs` +* `/src/Backend/AutopilotMonitor.Functions/Functions/Admin/OpsEventFilterRequest.cs` +* `/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs` +* `/src/Shared/AutopilotMonitor.Shared/DataAccess/IOpsEventRepository.cs` +* `/src/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs` — the declared event-type vocabulary +* `/src/Backend/AutopilotMonitor.Functions.Tests/OpsEventFieldFilterTests.cs` +* `/src/Backend/AutopilotMonitor.Functions.Tests/OpsEventCategoryCoverageTests.cs` +* `/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts` +* [Business Timestamps](business-timestamps.md) — why the date window filters on the RowKey diff --git a/docs/backend/ops-notification-channels.md b/docs/backend/ops-notification-channels.md new file mode 100644 index 00000000..1d425dd8 --- /dev/null +++ b/docs/backend/ops-notification-channels.md @@ -0,0 +1,151 @@ +--- +type: Concept +title: Ops Notification Channels +description: How platform alerts are routed to named channels instead of three fixed provider slots — the shared NotificationChannel model on both the tenant and platform side, one NotificationChannelDispatcher that routes each channel to its transport, the legacy-slot synthesis that keeps pre-migration dispatch behaviour identical, the empty-means-all rule binding, why Telegram is Global-Admin-only by construction, and why an ops event's structured payload only reaches a destination when a rule opts in. +resource: /src/Backend/AutopilotMonitor.Functions/Services/OpsAlertDispatchService.cs +tags: + - backend + - notifications + - ops-events + - configuration + - security +timestamp: 2026-09-01T00:00:00+02:00 +--- + +# Problem + +Platform (ops) alerting had no routing. An `OpsAlertRule` was +`{ EventType, MinSeverity, Enabled }` with no destination, and `OpsAlertDispatchService` +broadcast every matching rule to all three hard-wired slots in `AdminConfiguration` +(`OpsAlertTelegramChatId`, `OpsAlertTeamsWebhookUrl`, `OpsAlertSlackWebhookUrl`). Three +consequences followed: + +1. **No per-event destinations.** Binding a conversion signal to a sales channel meant every + other rule reached that channel too. +2. **Only the first matching rule was evaluated** (`FirstOrDefault`), so two rules on the same + event type could not coexist. +3. **The payload never left the backend.** `OpsEventService.WriteAsync` serialized a `details` + object into the `OpsEvents` table but passed only `(category, eventType, severity, message, + tenantId)` to the dispatch — a receiving system saw a tenant GUID and a sentence. + +The tenant side already had the right model: `NotificationChannel` (a named list in +`TenantConfiguration.NotificationChannelsJson`) with analyze rules binding channels by id +(`RuleState.NotifyChannelIdsJson`). The platform side needed that model, not a second one. + +# Schema + +## One model, two scopes + +`NotificationChannel` is unchanged and now backs both lists: + +| Scope | Storage | Bound by | +| --- | --- | --- | +| Tenant | `TenantConfiguration.NotificationChannelsJson` | per-channel `NotifyOn*` toggles + analyze rules (`NotifyChannelIdsJson`) | +| Platform | `AdminConfiguration.OpsNotificationChannelsJson` | `OpsAlertRule.NotifyChannelIds` | + +The scopes stay separate on purpose — platform events resolve against platform channels, and a +tenant's channel list can never receive an ops alert. What is shared is the *model* and the +*dispatch path*, so a new provider is added once. + +## One dispatch path + +`NotificationChannelDispatcher` is the channel-level send API. It routes by provider: +`WebhookProviderType.Telegram` goes to `TelegramNotificationService`; everything else is a +rendered webhook POST through `WebhookNotificationService`. Every caller that sends to +configured channels — enrollment (`EventIngestProcessor`), session start +(`RegisterSessionFunction`), hardware rejection (`ReportDistressFunction`), SLA +(`SlaBreachEvaluationService`), analyze rules (`AnalyzeOnEnrollmentEndHandler`), ops alerts +(`OpsAlertDispatchService`) and both test endpoints — goes through it. + +## Telegram is Global-Admin-only by construction + +Telegram (`WebhookProviderType.Telegram = 40`) is the odd provider: it is not a webhook. The +channel's `Url` carries the destination **chat ID**, and the bot token is platform-owned +(PreviewConfig `WebhookUrl`). A tenant admin configuring one would be sending through *our* +bot, which is why the gate is a server-side rule, not a hidden dropdown option: + +* `TenantConfigValidation.ValidateTelegramChannelGate` refuses a non-GA caller who **adds** a + Telegram channel or changes an existing one's destination or enabled state. It lives in + `ValidateModel`, so it covers both write paths — the full-model PUT *and* the MCP field-patch + flow (`TenantConfigPatchService`). A check placed in `UpdateTenantConfigurationFunction` alone + would not. +* An **unchanged** GA-created channel passes, so a tenant admin can still save unrelated config. +* Telegram destinations skip `SsrfGuard.ValidateWebhookUrlFormat` (a chat ID is not a URL and + would always fail it) and are format-checked as a numeric id or `@username` instead. + +## Migration without a data job + +`AdminConfiguration.GetOpsNotificationChannels()` returns the stored list, or — when it is empty — +synthesizes one channel per *configured* legacy slot with stable ids (`legacy-telegram`, +`legacy-teams`, `legacy-slack`) and the legacy enabled flags. `GetAdminConfigurationFunction` +projects the same synthesis into the read model, so the editor shows exactly what receives +alerts today and the first save materializes it. The legacy fields stay as the migration source +and are no longer written by the Alerts section. + +## Rule binding: empty means all + +`OpsAlertRule.NotifyChannelIds` null or empty = every enabled channel. That is what every rule +written before routing existed carries, so behaviour on the deploy day is identical. Dispatch +resolves **all** matching rules (`Where`, not `FirstOrDefault`) and unions their channels; +ids that no longer resolve are dropped rather than falling back to broadcast — a rule whose only +channel was deleted must reach nothing, not everything. The web editor mirrors the same rule in +`app/admin/components/opsChannelRouting.ts`. + +## Payload delivery is opt-in per rule + +`WriteAsync` hands the serialized `details` to `DispatchAsync`, but a channel only receives it when +a rule sets `OpsAlertRule.IncludePayload`. **Default false**: an ops alert is a "something happened, +go look" signal, most payloads are operational noise in a chat, and some carry data the baseline +never does (tenant domain, administrator contact address). Widening what leaves the platform stays +a deliberate per-rule decision. + +`ResolveTargets` therefore returns two groups. Channels reached only by plain rules get an alert +built with `detailsJson: null`; channels reached by an opted-in rule get the enriched one. A channel +in both groups appears in the payload group only — one message per channel, and an unrelated +sibling rule cannot cancel an explicit opt-in. + +For the enriched alert, `BuildAlert`: + +* flattens top-level **scalars** into `NotificationFact`s — capped at 12 facts and 256 characters, + nulls and nested values skipped — so card formats and the plain-text Telegram message carry the + same information, +* passes the raw JSON through `NotificationAlert.DataJson`, which only `GenericJsonRenderer` + emits, as a real JSON object under `data`. Additive within `schemaVersion` 1.0. + +`NotificationAlert.EventType` is set either way: it is the same value the `Event` fact already +carries, so it adds no information — only a stable key for a generic consumer to branch on. + +# Examples + +A trial-conversion signal reaching only a sales webhook: + +1. Admin → Alerts → add a channel `Sales` (Generic JSON, the internal endpoint). +2. Enable the `TenantTrialStarted` rule at `Info`, select **only** `Sales`, and switch its + **Payload** toggle on (off by default, so every other rule keeps the baseline alert). +3. `PlanManagementFunction` fires `TenantTrialStarted` from both plan write paths — the + self-service `POST config/{tenantId}/trial` and a GA grant via `PATCH config/{tenantId}/plan` + (distinguished by `selfService`). Ending a trial is a downgrade and stays with + `TenantPlanDowngraded`. +4. The webhook receives `eventType`, the rendered facts, and `data` with `domainName`, + `contactEmail`, `trialStartedUtc`, `trialExpiresUtc`, `grantedBy`, `selfService`. The operator + push channel receives nothing, because it is not bound to that rule. + +# Gotchas + +* **A new ops event type must be dual-registered** in `OPS_EVENT_TYPES` + (`app/admin/components/OpsAlertRulesSection.tsx`) or it cannot be selected. `OpsEventTypeDualRegisterTests` + enforces it. +* **`OpsNotificationChannelsJson` is secret-bearing** and is on the deny-list in + `AdminConfiguration.RedactedCopyForReader()`. `NotificationChannel.RedactList` / + `RestoreRedactedList` are shared with the tenant list — do not add a second copy. +* **Details are capped at 4096 characters** in the `OpsEvents` table (`TableOpsEventRepository`) + before they ever reach a channel. + +# Citations + +* `/src/Backend/AutopilotMonitor.Functions/Services/OpsAlertDispatchService.cs` +* `/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationChannelDispatcher.cs` +* `/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationChannel.cs` +* `/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs` +* `/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigValidation.cs` +* `/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs` diff --git a/docs/drift-guards.md b/docs/drift-guards.md index a5afe666..13d3d540 100644 --- a/docs/drift-guards.md +++ b/docs/drift-guards.md @@ -1,7 +1,7 @@ --- type: Concept -title: Drift Guards — SignalR Names, RowKeys, Wire DTOs, Config Sections, Scoped Routing -description: The 2026-08 fragility-audit follow-up contracts that turn formerly hand-synchronized mirrors into enforced ones — SignalR message-name catalog, the inverted-tick RowKey codec, tombstone table tagging, typed wire DTOs with generated TypeScript interfaces, per-section config PATCH, and the web's scoped-endpoint builders. +title: Drift Guards — SignalR Names, RowKeys, Wire DTOs, Tool Vocabularies, Config Sections, Scoped Routing +description: The 2026-08 fragility-audit follow-up contracts that turn formerly hand-synchronized mirrors into enforced ones — SignalR message-name catalog, the inverted-tick RowKey codec, tombstone table tagging, typed wire DTOs with generated TypeScript interfaces, the MCP's generated tool vocabularies with their inline-enum ratchet, per-section config PATCH, and the web's scoped-endpoint builders. resource: /src/Backend/AutopilotMonitor.Functions.Tests/TypedResponseGuardTests.cs tags: - contracts @@ -100,6 +100,44 @@ it. Freshness is pinned by the vitest suite and the `shared-manifests-in-sync` C job. Unmappable shapes (non-string dictionary keys, foreign BCL classes) fail the manifest build instead of degrading to `unknown`. +## Tool vocabularies — the MCP derives them, it no longer retypes them + +Wire TYPES reached the MCP from day one (`src/generated/wire-types.generated.ts`, the +same codegen output the web gets). Vocabularies did not: types are erased at runtime and +a `z.enum([...])` needs values, so every MCP tool schema hand-typed its own copy of +statuses, severities and categories — with nothing to compare against. The copies drifted +exactly as you would expect: `search_sessions` offered 5 of 8 session statuses (no +`AwaitingUser`, no `Incomplete`), both event readers offered 4 of 6 severities (no `Debug`, +no `Trace`), and `get_ops_events` named 6 ops categories while the backend wrote 7. None of +that surfaces as an error — the tool just quietly cannot ask for the missing value. + +The vocabularies now ship as VALUES. `MCP_VOCABULARIES` in the codegen maps manifest +sections onto `src/generated/wire-vocabularies.generated.ts` (`export const +SESSION_STATUSES = [...] as const` plus its union type); the tool schemas do +`z.enum(SESSION_STATUSES)` and descriptions interpolate the list instead of spelling it. +Freshness is pinned twice: `wire-types-freshness.test.ts` compares the committed file +byte-for-byte against a fresh codegen run, and the `shared-manifests-in-sync` CI job +diffs it after regenerating. + +`vocabulary-drift.guard.test.ts` is the ratchet that keeps it that way: every inline +`z.enum([...])` left in `src/tools/*.ts` must be on a baseline that names why it has no +backend owner (MCP-local knobs like `depth: fast|deep`, plus three free-string columns — +rule type, connection type, CVE risk — that have no C# constants class yet). A new +hand-typed list fails until it is derived or reviewed onto the baseline, and a stale +baseline entry fails too. + +Ops event types got the missing owner: they were bare literals at the `OpsEventService` +call sites, so nothing could enumerate them and both the portal alert-rule catalog and the +MCP had to retype the list. `OpsEventTypes` (Shared) now declares all 77; the write sites +use the constants (a raw literal fails `OpsEventTypeDualRegisterTests`), the manifest +exports them, and `get_resource(name="ops_event_types")` serves categories + severities + +types to the model from the generated copy. + +Backend-internal lists follow the same rule: a list that enumerates a constants class IS +the class. `TableOpsEventRepository.AllCategories` is `OpsEventCategory.All` rather than a +retyped copy — the copy had gone stale and silently hid every `Platform` (Azure Monitor) +event from the paged cross-category read while the unpaged path still showed it. + ## Tenant settings — per-section PATCH The web's Settings sections no longer PUT the full ~92-field configuration. Each @@ -141,6 +179,9 @@ list in `sectionFieldMap.test.ts`. # Citations - `src/Shared/AutopilotMonitor.Shared/Constants.cs` — SignalRMessages catalog +- `src/Web/autopilot-monitor-web/scripts/generate-shared-manifest-types.js` — `MCP_VOCABULARIES`, the vocabulary codegen +- `src/McpServer/autopilot-monitor-mcp/src/__tests__/vocabulary-drift.guard.test.ts` — the inline-enum ratchet +- `src/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs` + `OpsEventTypeDualRegisterTests.cs` - `src/Backend/AutopilotMonitor.Functions/Helpers/RowKeyCodec.cs` + `RowKeyCodecTests.cs` - `src/Shared/AutopilotMonitor.Shared/Models/Deletion/DeletionManifest.cs` — `DeletionRowDump.Table`, `DeletionTombstoneTables` - `src/Backend/AutopilotMonitor.Functions.Tests/TypedResponseGuardTests.cs` + `WireTypeManifestBuilder.cs` + the `*WireParityTests` files diff --git a/docs/index.md b/docs/index.md index d2aa5563..92d84d41 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,7 +15,7 @@ https://docs.autopilotmonitor.com (separate repository). * [Version Contract](versioning.md) - One version shape for agent, backend and MCP: the ETag-CAS counter blob that mints build numbers, and the manifests published only after a deploy is verified live. * [Customer Script Publishing](script-publishing.md) - How the customer-facing PowerShell scripts reach download.autopilotmonitor.com/agent/, why publishing is decoupled from agent releases, and the guards keeping the published copy, the version badge and the portal's version oracle in agreement. * [URL Registry](url-registry.md) - Every well-known host lives in one registry file per component (Constants.cs / utils/config.ts / src/config.ts), enforced by guard tests; includes the agent-download migration to the download alias and its deploy-order constraint. -* [Drift Guards](drift-guards.md) - The 2026-08 fragility-audit follow-up contracts: SignalR message-name catalog (typed on both ends), the inverted-tick RowKey codec, tombstone table tagging, typed wire DTOs (IApiResponse + generated wire-types.generated.ts, empty anonymous-body baselines), per-section config PATCH with the section field map, and the web's scoped-endpoint builders + sidebar visibility matrix. +* [Drift Guards](drift-guards.md) - The 2026-08 fragility-audit follow-up contracts: SignalR message-name catalog (typed on both ends), the inverted-tick RowKey codec, tombstone table tagging, typed wire DTOs (IApiResponse + generated wire-types.generated.ts, empty anonymous-body baselines), the MCP's generated tool vocabularies (wire-vocabularies.generated.ts + the inline-z.enum ratchet), per-section config PATCH with the section field map, and the web's scoped-endpoint builders + sidebar visibility matrix. # Agent @@ -54,6 +54,8 @@ https://docs.autopilotmonitor.com (separate repository). * [Telemetry Ingest Shaping](backend/telemetry-ingest-shaping.md) - One canonical App Insights request record (worker copy with tenant context, per-item sampling bypass; host HTTP duplicate dropped by a workspace transformation DCR), expected storage outcomes 404/412/409 and successful SignalR REST calls filtered in the worker, and the count()/sum(itemCount) query rules that follow. * [Table Schema Sentinel](backend/table-schema-sentinel.md) - Startup no longer issues one CreateTableIfNotExists per table: a SHA-256 over TableNames.All in AdminConfiguration gates the full pass (derived, never hand-maintained), the SessionsIndex backfill runs only after a full pass behind a conditional-insert claim, and daily maintenance repairs out-of-band deletions. * [Business Timestamps](backend/business-timestamps.md) - Why the Azure Tables system Timestamp is never authoritative (migrations reset it), and the OccurredUtc + RowKey-decode compensation for AuditLogs/OpsEvents/Events after the 2026-07-18 migration. +* [Ops-Events Query Surface](backend/ops-events-query-surface.md) - The server-side filter surface of the ops-events read (category, eventType, severity/minSeverity, in-memory tenant drill, RowKey date window), why every filter is folded into the continuation fingerprint, why minSeverity expands to an OR-set instead of a lexicographic range, and why the paged cross-category fan-out must cover the complete category vocabulary (Platform was missing). +* [Ops Notification Channels](backend/ops-notification-channels.md) - Platform alerts route to NAMED channels instead of three fixed provider slots: the tenant-side NotificationChannel model now backs both scopes, one NotificationChannelDispatcher routes each channel to its transport (Telegram is not a webhook - chat id, platform-owned bot, Global-Admin-only enforced in ValidateModel so the MCP patch path is covered too), legacy slots are synthesized with stable ids so pre-migration dispatch is unchanged, a rule's empty channel binding means all, and an ops event's structured details reach the destination as facts plus a `data` object only when a rule opts in (IncludePayload, off by default, so the baseline alert is unchanged). * [Send-Time Frame Separation](backend/send-time-frame-separation.md) - One device-clock send timestamp per upload batch (X-Send-Time-Utc → SentAt on Events) splits ReceivedAt − OccurredUtc into pure spool delay and pure device-vs-server clock offset; the clock_skew condition measures SentAt batches directly (spool-immune), the CMTrace tripwire deliberately stays on its differential math. * [OOBE Bootstrap go-Code Flow](backend/oobe-bootstrap-go-flow.md) - The bootstrap short-code lifecycle (portal-created session → `irm go.autopilotmonitor.com/CODE | iex` at Shift+F10 → GetBootstrapScriptFunction), the always-HTTP-200 text/plain contract, the two-line injection defense (GUID/URL/expiry validator + single-quoted PS literals, golden-pinned against the legacy TS template), and the per-IP rate-limit story behind Front Door. * [Silent-Session Classification](backend/silent-session-classification.md) - How a session whose agent went silent gets its honest status: the two-stage maintenance sweep (2h Stalled marker with the WhiteGlove Part-2 AwaitingUser gate and the self-deploying Succeeded gate, 5h timeout classification), the EnrollmentTimeoutClassifier rule order (explicit failure → WG Part 2 → self-deploying → completion evidence → emergency break → desktop+Hello → Device Setup/grace), the ingest paths sharing it (max-lifetime, late-telemetry heal, stall probe) and the admin retro-reconcile modes incl. self_deploying_silent. diff --git a/docs/log.md b/docs/log.md index 33bd3a42..67dc600d 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,17 @@ # Log +## 2026-09-01 (4) + +* **Update**: `drift-guards.md` — MCP tool vocabularies are generated, not retyped. The codegen now emits `wire-vocabularies.generated.ts` (VALUES: session statuses, event severities, ops categories/severities/types, annotation lanes/verdicts) alongside the MCP's wire types, byte-pinned by the MCP freshness suite and the `shared-manifests-in-sync` CI job; the tool schemas derive their `z.enum()` from it. Found by the switch: `search_sessions` had offered 5 of 8 session statuses (no `AwaitingUser`/`Incomplete`) and both event readers 4 of 6 severities (no `Debug`/`Trace`). `vocabulary-drift.guard.test.ts` ratchets the remaining inline enums against a reasoned baseline. New `OpsEventTypes` (Shared) gives the 77 ops event types a canonical home — `OpsEventService` write sites use the constants (a raw literal now fails the dual-register test), and `get_resource(name="ops_event_types")` serves the vocabulary to the model. + +## 2026-09-01 (3) + +* **New**: `backend/ops-notification-channels.md` - platform alerting gains named channels. `AdminConfiguration.OpsNotificationChannelsJson` reuses the tenant `NotificationChannel` model (redaction/restore helpers hoisted onto the model so both scopes share one implementation); `OpsAlertRule.NotifyChannelIds` binds rules to channels with empty = all, and dispatch now evaluates ALL matching rules (`Where`, not `FirstOrDefault`) and unions their targets, dropping ids that no longer resolve instead of falling back to broadcast. New `NotificationChannelDispatcher` is the single channel-level send API for every caller (enrollment, session start, hardware rejection, SLA, analyze rules, ops alerts, both test endpoints). New provider `WebhookProviderType.Telegram = 40` carries a chat id rather than a URL and sends through the platform-owned bot - Global-Admin-only, enforced in `TenantConfigValidation.ValidateModel` so the MCP field-patch path is gated too, not just the PUT. Legacy provider slots are synthesized into channels with stable ids (`legacy-telegram` named "Autopilot Monitor IT Engineer") and projected into the GET read model, so day-one behaviour is unchanged and the first save materializes the list. New ops event `TenantTrialStarted` fires from both plan write paths, and `OpsEventService.WriteAsync` hands the serialized details to the dispatch - but delivery is opt-in per rule (`OpsAlertRule.IncludePayload`, default false), so every existing alert keeps its category/event/severity/tenant baseline and the trust-page description of operator alerting stays accurate. `ResolveTargets` splits targets into a payload group and a plain group (a channel in both gets the payload once); for the enriched alert `BuildAlert` flattens top-level scalars into facts (12 / 256 chars) and passes the raw object to `GenericJsonRenderer` as `data` (additive, schemaVersion stays 1.0). `eventType` is set either way - same value the `Event` fact already carried. + +## 2026-09-01 (2) + +* **New**: `backend/ops-events-query-surface.md` - the ops-events read gains server-side `eventType` / `severity` / `minSeverity` filters (audit-log filter pattern: one parse helper, one clause appender used by both filter builders, values folded into the continuation fingerprint and echoed on nextLink). `minSeverity` expands to an OR-set because the severity names sort `Critical < Error < Info < Warning` lexicographically; the ladder is now shared with `OpsAlertDispatchService`. Fixed alongside: the paged cross-category fan-out skipped `Platform` (Azure Monitor alerts) because its category list had drifted from `OpsEventCategory` - the list is now `OpsEventCategory.All` with a reflection coverage test. MCP `get_ops_events` exposes the filters plus a client-resolved `days` shorthand. + ## 2026-09-01 (1) * **Update**: `agent/cmtrace-time-resolution.md` — the backend skew tripwire now judges only the session's most recent ingest era (batch stamps walked back from the newest, split at the first `ReceivedAt` gap > 2 h). Pre-provisioning partitions hold a Part 1 leg written weeks earlier by an older agent build: on 2026-09-01 three sessions of one tenant fired on 26 IME samples at −60 min left by agent 2.0.1409 on 2026-08-20 (pre per-line anchoring), against a device already self-updated to 2.0.1445. `SessionSkewScan` carries `ReceivedAt` per sample (distinct-batch counts derive from the era-filtered set; the cap keeps the newest samples), and the ops event reports `eraStartUtc` plus the excluded per-side counts. diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AppHomingServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AppHomingServiceTests.cs index b5aa5cd4..ff11d8dc 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AppHomingServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AppHomingServiceTests.cs @@ -79,13 +79,7 @@ public class AppHomingServiceTests opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny())) .Callback(e => { lock (_savedOpsEvents) _savedOpsEvents.Add(e); }) .Returns(Task.CompletedTask); - var alertDispatch = new OpsAlertDispatchService( - _adminConfigMock.Object, - new TelegramNotificationService(new HttpClient(), configRepo, - NullLogger.Instance), - new WebhookNotificationService(new HttpClient(), - NullLogger.Instance), - NullLogger.Instance); + var alertDispatch = TestNotifications.InertOpsAlertDispatch(_adminConfigMock.Object); var opsService = new OpsEventService(opsRepo.Object, NullLogger.Instance, alertDispatch); _sut = new AppHomingService( diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/CollectLogsQuickConfigOpsEventTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/CollectLogsQuickConfigOpsEventTests.cs index a3f2247b..b19f4bb8 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/CollectLogsQuickConfigOpsEventTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/CollectLogsQuickConfigOpsEventTests.cs @@ -30,12 +30,7 @@ public class CollectLogsQuickConfigOpsEventTests var adminConfig = new Mock( Mock.Of(), NullLogger.Instance, new MemoryCache(new MemoryCacheOptions())) { CallBase = false }; - var alertDispatch = new OpsAlertDispatchService( - adminConfig.Object, - new TelegramNotificationService(new HttpClient(), Mock.Of(), - NullLogger.Instance), - new WebhookNotificationService(new HttpClient(), NullLogger.Instance), - NullLogger.Instance); + var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object); return (new OpsEventService(opsRepo.Object, NullLogger.Instance, alertDispatch), saved); } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/KillSwitchEvaluatorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/KillSwitchEvaluatorTests.cs index f2c766b0..484dbc15 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/KillSwitchEvaluatorTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/KillSwitchEvaluatorTests.cs @@ -181,13 +181,7 @@ public class KillSwitchEvaluatorTests var memCache = new MemoryCache(new MemoryCacheOptions()); var adminConfig = new Mock( Mock.Of(), NullLogger.Instance, memCache); - var alertDispatch = new OpsAlertDispatchService( - adminConfig.Object, - new TelegramNotificationService(new HttpClient(), Mock.Of(), - NullLogger.Instance), - new WebhookNotificationService(new HttpClient(), - NullLogger.Instance), - NullLogger.Instance); + var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object); var opsService = new OpsEventService(opsRepo.Object, NullLogger.Instance, alertDispatch); var evaluator = new KillSwitchEvaluator( diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerDrainContractTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerDrainContractTests.cs index d33405a4..6ea6eacd 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerDrainContractTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerDrainContractTests.cs @@ -585,13 +585,7 @@ public class TenantOffboardingHandlerDrainContractTests var memCache = new MemoryCache(new MemoryCacheOptions()); var adminConfig = new Mock( Mock.Of(), NullLogger.Instance, memCache); - var alertDispatch = new OpsAlertDispatchService( - adminConfig.Object, - new TelegramNotificationService(new HttpClient(), Mock.Of(), - NullLogger.Instance), - new WebhookNotificationService(new HttpClient(), - NullLogger.Instance), - NullLogger.Instance); + var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object); return new OpsEventService(opsRepo.Object, NullLogger.Instance, alertDispatch); } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs index 6c7f4697..3b8de3c3 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs @@ -451,13 +451,7 @@ public class TenantOffboardingHandlerOrderingTests var memCache = new MemoryCache(new MemoryCacheOptions()); var adminConfig = new Mock( Mock.Of(), NullLogger.Instance, memCache); - var alertDispatch = new OpsAlertDispatchService( - adminConfig.Object, - new TelegramNotificationService(new HttpClient(), Mock.Of(), - NullLogger.Instance), - new WebhookNotificationService(new HttpClient(), - NullLogger.Instance), - NullLogger.Instance); + var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object); return new OpsEventService(opsRepo.Object, NullLogger.Instance, alertDispatch); } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingWorkerPoisonTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingWorkerPoisonTests.cs index 47f556fc..4c90acbc 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingWorkerPoisonTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingWorkerPoisonTests.cs @@ -277,13 +277,7 @@ public class TenantOffboardingWorkerPoisonTests var memCache = new MemoryCache(new MemoryCacheOptions()); var adminConfig = new Mock( Mock.Of(), NullLogger.Instance, memCache); - return new OpsAlertDispatchService( - adminConfig.Object, - new TelegramNotificationService(new HttpClient(), Mock.Of(), - NullLogger.Instance), - new WebhookNotificationService(new HttpClient(), - NullLogger.Instance), - NullLogger.Instance); + return TestNotifications.InertOpsAlertDispatch(adminConfig.Object); } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/OpsAlertPayloadTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/OpsAlertPayloadTests.cs new file mode 100644 index 00000000..498fe713 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/OpsAlertPayloadTests.cs @@ -0,0 +1,135 @@ +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Functions.Services.Notifications; +using AutopilotMonitor.Shared.Models.Notifications; +using Newtonsoft.Json.Linq; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Ops-event payload delivery: the structured Details object reaches outbound channels, both as +/// readable facts (card + Telegram formats) and verbatim under "data" (generic JSON consumers). +/// Before this, an alert carried only category/event/severity and a tenant GUID. +/// +public class OpsAlertPayloadTests +{ + // ── Fact flattening ─────────────────────────────────────────────────── + + [Fact] + public void FlattenDetails_ProjectsTopLevelScalars() + { + var facts = OpsAlertDispatchService.FlattenDetails( + """{"domainName":"contoso.example","daysLeft":3,"selfService":true}"""); + + Assert.Equal(3, facts.Count); + Assert.Equal("Domain Name", facts[0].Name); + Assert.Equal("contoso.example", facts[0].Value); + Assert.Equal("Days Left", facts[1].Name); + Assert.Equal("3", facts[1].Value); + Assert.Equal("Self Service", facts[2].Name); + Assert.Equal("true", facts[2].Value); + } + + [Fact] + public void FlattenDetails_SkipsNullsAndNestedValues() + { + var facts = OpsAlertDispatchService.FlattenDetails( + """{"domainName":null,"nested":{"a":1},"list":[1,2],"keep":"yes"}"""); + + Assert.Equal(new[] { "Keep" }, facts.Select(f => f.Name)); + } + + [Fact] + public void FlattenDetails_CapsFactCount() + { + var props = string.Join(",", Enumerable.Range(0, 30).Select(i => $"\"p{i}\":\"v\"")); + + Assert.Equal(OpsAlertDispatchService.MaxDetailFacts, + OpsAlertDispatchService.FlattenDetails("{" + props + "}").Count); + } + + [Fact] + public void FlattenDetails_TruncatesLongValues() + { + var longValue = new string('x', 400); + + var fact = Assert.Single(OpsAlertDispatchService.FlattenDetails($$"""{"note":"{{longValue}}"}""")); + + Assert.Equal(OpsAlertDispatchService.MaxDetailValueLength + 1, fact.Value.Length); // + ellipsis + Assert.EndsWith("…", fact.Value); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("not-json")] + [InlineData("[1,2,3]")] // array root + [InlineData("\"a string\"")] // scalar root + public void FlattenDetails_MalformedOrNonObjectPayloadCostsNothing(string? json) + => Assert.Empty(OpsAlertDispatchService.FlattenDetails(json)); + + [Theory] + [InlineData("domainName", "Domain Name")] + [InlineData("trialExpiresUtc", "Trial Expires Utc")] + [InlineData("id", "Id")] + [InlineData("URL", "URL")] // consecutive capitals are not split + public void Humanize_TitleCasesCamelCaseKeys(string input, string expected) + => Assert.Equal(expected, OpsAlertDispatchService.Humanize(input)); + + // ── Generic JSON renderer contract ──────────────────────────────────── + + [Fact] + public void GenericJsonRenderer_EmbedsDataAsRealJsonNotAString() + { + var alert = new NotificationAlert + { + Title = "t", Summary = "s", EventType = "TenantTrialStarted", + DataJson = """{"domainName":"contoso.example","selfService":true}""", + }; + + var payload = JObject.Parse(new GenericJsonRenderer().RenderToJson(alert)); + + Assert.Equal(JTokenType.Object, payload["data"]!.Type); + Assert.Equal("contoso.example", payload["data"]!["domainName"]!.Value()); + Assert.True(payload["data"]!["selfService"]!.Value()); + Assert.Equal("TenantTrialStarted", payload["eventType"]!.Value()); + } + + [Fact] + public void GenericJsonRenderer_OmitsDataWhenAbsentOrUnusable() + { + var renderer = new GenericJsonRenderer(); + + Assert.Null(JObject.Parse(renderer.RenderToJson( + new NotificationAlert { Title = "t", Summary = "s" }))["data"]); + + // Malformed or non-object payloads must not change the shape consumers parse against. + Assert.Null(JObject.Parse(renderer.RenderToJson( + new NotificationAlert { Title = "t", Summary = "s", DataJson = "not-json" }))["data"]); + Assert.Null(JObject.Parse(renderer.RenderToJson( + new NotificationAlert { Title = "t", Summary = "s", DataJson = "[1,2]" }))["data"]); + } + + [Fact] + public void GenericJsonRenderer_SchemaVersionUnchangedByTheAdditiveDataKey() + => Assert.Equal("1.0", JObject.Parse(new GenericJsonRenderer().RenderToJson( + new NotificationAlert { Title = "t", Summary = "s", DataJson = """{"a":1}""" }))["schemaVersion"]!.Value()); + + // ── Telegram text carries the same information ──────────────────────── + + [Fact] + public void TelegramRendering_IncludesFlattenedFacts() + { + var alert = new NotificationAlert + { + Title = "Ops Alert: Tenant/TenantTrialStarted", + Summary = "Pro trial started", + Severity = NotificationSeverity.Info, + Facts = OpsAlertDispatchService.FlattenDetails("""{"domainName":"contoso.example"}"""), + }; + + var text = TelegramNotificationService.RenderAlertText(alert); + + Assert.Contains("Domain Name: contoso.example", text); + Assert.Contains("Pro trial started", text); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/OpsEventCategoryCoverageTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/OpsEventCategoryCoverageTests.cs new file mode 100644 index 00000000..58e7ad3a --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/OpsEventCategoryCoverageTests.cs @@ -0,0 +1,42 @@ +using System.Linq; +using System.Reflection; +using AutopilotMonitor.Functions.DataAccess.TableStorage; +using AutopilotMonitor.Shared.DataAccess; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// The cross-category PAGED read fans out over a fixed category list. A category that is written +/// but not listed is invisible to every paged reader (MCP get_ops_events, the ops dashboard) while +/// the unpaged full-table path still returns it — a silent, one-sided blind spot. That happened to +/// Platform (Azure Monitor alerts via the ops alert webhook), which was recorded from day one and +/// never fanned out. These tests fail the build when a new OpsEventCategory constant is added +/// without extending the fan-out list. +/// +public class OpsEventCategoryCoverageTests +{ + private static string[] DeclaredCategories() => + typeof(OpsEventCategory) + .GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) + .Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string)) + .Select(f => (string)f.GetRawConstantValue()!) + .ToArray(); + + [Fact] + public void All_CoversEveryDeclaredCategoryConstant() + { + var declared = DeclaredCategories(); + + Assert.NotEmpty(declared); + Assert.Equal(declared.OrderBy(c => c), OpsEventCategory.All.OrderBy(c => c)); + } + + [Fact] + public void PagedFanOut_UsesTheFullCategoryVocabulary() + { + // Same array instance on purpose: the fan-out must not keep a private copy that can drift. + Assert.Same(OpsEventCategory.All, TableOpsEventRepository.AllCategories); + Assert.Contains(OpsEventCategory.Platform, TableOpsEventRepository.AllCategories); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/OpsEventFieldFilterTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/OpsEventFieldFilterTests.cs new file mode 100644 index 00000000..73ed4108 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/OpsEventFieldFilterTests.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using AutopilotMonitor.Functions.DataAccess.TableStorage; +using AutopilotMonitor.Functions.Functions.Admin; +using AutopilotMonitor.Functions.Pagination; +using AutopilotMonitor.Shared.DataAccess; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Pins the ops-events field filters (eventType / severity / minSeverity). Two properties matter: +/// (1) they are SERVER-SIDE — an operator asking for one event type must not pull a whole category +/// over the wire, and the paged fan-out must honour the exact same clauses as the single-category +/// and unpaged paths; (2) they are bound into the continuation fingerprint, so a token minted for +/// one filter cannot page a different one. +/// +public class OpsEventFieldFilterTests +{ + private static NameValueCollection Query(params (string Key, string Value)[] pairs) + { + var q = new NameValueCollection(); + foreach (var (k, v) in pairs) q[k] = v; + return q; + } + + // ── Parsing ──────────────────────────────────────────────────────────────── + + [Fact] + public void Parse_NoFilterParams_YieldsEmptyFilters_AndNoExtras() + { + var result = OpsEventFilterRequest.Parse(Query(("category", "Agent"))); + + Assert.Null(result.Error); + Assert.True(result.Filters.IsEmpty); + Assert.Empty(OpsEventFilterRequest.ToExtras(result.Filters)); + } + + [Fact] + public void Parse_EventType_IsVerbatim_NotCaseFolded() + { + // Table Storage cannot fold case: whatever the caller names is what the store is asked for. + var result = OpsEventFilterRequest.Parse(Query(("eventType", "AgentEmergencyBreak"))); + + Assert.Null(result.Error); + Assert.Equal("AgentEmergencyBreak", result.Filters.EventType); + } + + [Theory] + [InlineData("warning", "Warning")] + [InlineData("ERROR", "Error")] + [InlineData("Critical", "Critical")] + public void Parse_Severity_IsNormalisedToCanonicalCasing(string input, string expected) + { + // Without normalisation a "?severity=warning" would produce a Severity eq 'warning' + // clause, which matches zero rows and reads like "nothing happened". + var result = OpsEventFilterRequest.Parse(Query(("severity", input))); + + Assert.Null(result.Error); + Assert.Equal(expected, result.Filters.Severity); + } + + [Fact] + public void Parse_UnknownSeverity_IsRejected_NotSilentlyDropped() + { + var severity = OpsEventFilterRequest.Parse(Query(("severity", "Warn"))); + var minSeverity = OpsEventFilterRequest.Parse(Query(("minSeverity", "sev2"))); + + Assert.NotNull(severity.Error); + Assert.Contains("severity must be one of", severity.Error); + Assert.Contains("Warn", severity.Error); + Assert.NotNull(minSeverity.Error); + Assert.Contains("minSeverity must be one of", minSeverity.Error); + } + + [Fact] + public void ToExtras_EmitsNormalisedValues_InFixedOrder() + { + // Fixed order + normalised values: the fingerprint minted here must match the one + // recomputed when the echoed nextLink params come back on the follow-up request. + var result = OpsEventFilterRequest.Parse(Query( + ("severity", "error"), ("eventType", "AgentEmergencyBreak"), ("minSeverity", "warning"))); + + var extras = OpsEventFilterRequest.ToExtras(result.Filters); + + Assert.Equal(new[] { "eventType", "severity", "minSeverity" }, extras.Select(e => e.Key).ToArray()); + Assert.Equal(new[] { "AgentEmergencyBreak", "Error", "Warning" }, extras.Select(e => e.Value).ToArray()); + } + + // ── Severity ladder ──────────────────────────────────────────────────────── + + [Fact] + public void SeverityLadder_RanksInfoLowest_CriticalHighest() + { + Assert.True(OpsEventSeverity.Rank(OpsEventSeverity.Info) < OpsEventSeverity.Rank(OpsEventSeverity.Warning)); + Assert.True(OpsEventSeverity.Rank(OpsEventSeverity.Warning) < OpsEventSeverity.Rank(OpsEventSeverity.Error)); + Assert.True(OpsEventSeverity.Rank(OpsEventSeverity.Error) < OpsEventSeverity.Rank(OpsEventSeverity.Critical)); + Assert.Equal(-1, OpsEventSeverity.Rank("Warn")); + Assert.Equal(-1, OpsEventSeverity.Rank(null)); + } + + [Fact] + public void AtOrAbove_ReturnsTheThresholdAndEverythingAboveIt() + { + Assert.Equal(new[] { "Warning", "Error", "Critical" }, OpsEventSeverity.AtOrAbove("Warning").ToArray()); + Assert.Equal(new[] { "Critical" }, OpsEventSeverity.AtOrAbove("Critical").ToArray()); + Assert.Equal(OpsEventSeverity.All, OpsEventSeverity.AtOrAbove("Info").ToArray()); + Assert.Empty(OpsEventSeverity.AtOrAbove("Warn")); + } + + // ── Clause building (both builders) ──────────────────────────────────────── + + [Fact] + public void BuildFilter_EmitsServerSideClauses_ForEveryField() + { + var f = TableOpsEventRepository.BuildFilter( + "Agent", null, null, + new OpsEventQueryFilters { EventType = "AgentEmergencyBreak", Severity = "Error" }); + + Assert.NotNull(f); + Assert.Contains("EventType eq 'AgentEmergencyBreak'", f); + Assert.Contains("Severity eq 'Error'", f); + } + + [Fact] + public void BuildFilter_MinSeverity_ExpandsToOrSet_NotALexicographicRange() + { + // "Critical" < "Error" < "Info" < "Warning" lexicographically — a ge comparison would + // silently return the wrong rows, so the threshold has to become an OR-set. + var f = TableOpsEventRepository.BuildFilter( + "Agent", null, null, new OpsEventQueryFilters { MinSeverity = "Warning" }); + + Assert.NotNull(f); + Assert.Contains("(Severity eq 'Warning' or Severity eq 'Error' or Severity eq 'Critical')", f); + Assert.DoesNotContain("Severity ge", f); + Assert.DoesNotContain("Severity eq 'Info'", f); + } + + [Fact] + public void BuildFilter_MinSeverityInfo_EmitsNoClause_ItIsTheFloor() + { + var f = TableOpsEventRepository.BuildFilter( + "Agent", null, null, new OpsEventQueryFilters { MinSeverity = "Info" }); + + Assert.Equal("PartitionKey eq 'Agent'", f); + } + + [Fact] + public void BuildFilter_NoFilters_IsUnchanged() + { + // Regression guard: the pre-existing filter surface must not shift when no field filter + // is named — every stored continuation token depends on the same rows coming back. + Assert.Equal( + TableOpsEventRepository.BuildFilter("Agent", null, null), + TableOpsEventRepository.BuildFilter("Agent", null, null, new OpsEventQueryFilters())); + Assert.Null(TableOpsEventRepository.BuildFilter(null, null, null, new OpsEventQueryFilters())); + } + + [Fact] + public void BuildFilter_EscapesQuotes_SoAFilterCannotBreakOutOfItsLiteral() + { + var f = TableOpsEventRepository.BuildFilter( + null, null, null, new OpsEventQueryFilters { EventType = "Agent'Break" }); + + Assert.Equal("EventType eq 'Agent''Break'", f); + } + + [Fact] + public void FanOutBuilder_HonoursTheSameFieldClauses_AsTheSingleCategoryBuilder() + { + // The all-categories paged path builds its own filter (it adds the per-partition RowKey + // bound). If it skipped the field filters, "no category named" would silently return + // unfiltered rows — the exact bug this test exists to prevent. + var filters = new OpsEventQueryFilters { EventType = "AgentEmergencyBreak", MinSeverity = "Error" }; + + var fanOut = TableOpsEventRepository.BuildFilterWithRowKeyBound("Agent", null, null, "0009", filters); + + Assert.Contains("EventType eq 'AgentEmergencyBreak'", fanOut); + Assert.Contains("(Severity eq 'Error' or Severity eq 'Critical')", fanOut); + Assert.Contains("RowKey gt '0009'", fanOut); + } + + // ── Continuation fingerprint ─────────────────────────────────────────────── + + [Fact] + public void Fingerprint_BindsTheFieldFilters_SoATokenCannotCrossFilters() + { + var from = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc); + var to = new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc); + const string caller = "11111111-1111-1111-1111-111111111111"; + + string Fp(params (string Key, string Value)[] pairs) => DateWindowPagination.Fingerprint( + scope: "ops-events", callerTenantId: caller, dateFrom: from, dateTo: to, + extras: OpsEventFilterRequest.ToExtras(OpsEventFilterRequest.Parse(Query(pairs)).Filters)); + + var unfiltered = Fp(); + var byType = Fp(("eventType", "AgentEmergencyBreak")); + var byOtherType = Fp(("eventType", "AgentTimeout")); + var byMinSeverity = Fp(("minSeverity", "Error")); + + Assert.NotEqual(unfiltered, byType); + Assert.NotEqual(byType, byOtherType); + Assert.NotEqual(unfiltered, byMinSeverity); + // Casing is normalised before fingerprinting, so the same query in two spellings + // shares one token instead of minting two that each reject the other. + Assert.Equal(byMinSeverity, Fp(("minSeverity", "error"))); + } + + [Fact] + public void Fingerprint_WithNoFieldFilters_MatchesThePreFilterToken() + { + // Backward compatibility: tokens minted before the field filters existed carry only the + // category/tenantId extras. Appending the (empty) field extras must not change the hash, + // or every in-flight pagination would break on deploy. + var from = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc); + const string caller = "11111111-1111-1111-1111-111111111111"; + var legacyExtras = new List> + { + new KeyValuePair("category", "Agent"), + }; + var withEmptyFilters = new List>(legacyExtras); + withEmptyFilters.AddRange(OpsEventFilterRequest.ToExtras(new OpsEventQueryFilters())); + + Assert.Equal( + DateWindowPagination.Fingerprint("ops-events", caller, from, null, legacyExtras), + DateWindowPagination.Fingerprint("ops-events", caller, from, null, withEmptyFilters)); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/OpsEventTypeDualRegisterTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/OpsEventTypeDualRegisterTests.cs index 5de4102b..5602c223 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/OpsEventTypeDualRegisterTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/OpsEventTypeDualRegisterTests.cs @@ -1,13 +1,17 @@ +using System.Reflection; using System.Text.RegularExpressions; +using AutopilotMonitor.Shared.DataAccess; using Xunit; namespace AutopilotMonitor.Functions.Tests; /// -/// Every ops event type the backend can write must be selectable in the portal's alert-rule -/// editor (OpsAlertRulesSection.tsx OPS_EVENT_TYPES) — the "dual register" that -/// used to be a memory-only convention. A type written but not listed cannot be routed to a -/// Telegram/webhook rule, which is exactly how a new Warning stays silent. +/// Every ops event type the backend can write must be (a) declared in the canonical +/// vocabulary and (b) selectable in the portal's alert-rule editor +/// (OpsAlertRulesSection.tsx OPS_EVENT_TYPES). A type written but not declared is +/// invisible to every consumer that enumerates the vocabulary (the shared manifest, and through +/// it the MCP); a type declared but not listed in the editor cannot be routed to a channel, which +/// is exactly how a new Warning stays silent. /// public class OpsEventTypeDualRegisterTests { @@ -20,27 +24,78 @@ public class OpsEventTypeDualRegisterTests return dir!.FullName; } - [Fact] - public void Every_backend_ops_event_type_is_listed_in_the_web_alert_rule_editor() - { - var root = RepoRoot(); - var service = File.ReadAllText(Path.Combine(root, "src", "Backend", "AutopilotMonitor.Functions", "Services", "OpsEventService.cs")); - var web = File.ReadAllText(Path.Combine(root, "src", "Web", "autopilot-monitor-web", "app", "admin", "components", "OpsAlertRulesSection.tsx")); + private static string OpsEventServiceSource() => File.ReadAllText(Path.Combine( + RepoRoot(), "src", "Backend", "AutopilotMonitor.Functions", "Services", "OpsEventService.cs")); - // WriteAsync(OpsEventCategory.X, "TypeName", ...) — the second argument is the wire type. - var backendTypes = Regex.Matches(service, @"WriteAsync\(\s*OpsEventCategory\.\w+\s*,\s*""([A-Za-z0-9]+)""") + /// The vocabulary as the assembly declares it — reflection, not a source scan. + private static string[] DeclaredTypes() => + typeof(OpsEventTypes) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.IsLiteral && f.FieldType == typeof(string)) + .Select(f => (string)f.GetRawConstantValue()!) + .ToArray(); + + [Fact] + public void All_listsEveryDeclaredConstant() + { + // OpsEventTypes.All is what the manifest and every enumerating consumer read. + Assert.Equal( + DeclaredTypes().OrderBy(t => t, StringComparer.Ordinal), + OpsEventTypes.All.OrderBy(t => t, StringComparer.Ordinal)); + } + + [Fact] + public void Every_write_site_uses_the_vocabulary_constant_not_a_raw_literal() + { + // The guard that keeps the reflection above honest: a bare string at a call site would + // write a real event type that the vocabulary — and therefore every consumer — never sees. + var literalWrites = Regex.Matches( + OpsEventServiceSource(), + @"WriteAsync\(\s*OpsEventCategory\.\w+\s*,\s*""([A-Za-z0-9]+)""") .Select(m => m.Groups[1].Value) .Distinct(StringComparer.Ordinal) - .OrderBy(t => t, StringComparer.Ordinal) .ToList(); - Assert.NotEmpty(backendTypes); + + Assert.True(literalWrites.Count == 0, + "OpsEventService writes these event types as raw string literals — declare them in " + + "OpsEventTypes and use the constant: " + string.Join(", ", literalWrites)); + } + + [Fact] + public void Every_write_site_names_a_declared_constant() + { + // Catches the other direction of the same seam: `OpsEventTypes.Foo` cannot compile unless + // it exists, so this asserts the extraction below still SEES the call sites — a refactor + // that renames WriteAsync would otherwise silently reduce this suite to vacuous truth. + var used = Regex.Matches( + OpsEventServiceSource(), + @"WriteAsync\(\s*OpsEventCategory\.\w+\s*,\s*OpsEventTypes\.(\w+)") + .Select(m => m.Groups[1].Value) + .Distinct(StringComparer.Ordinal) + .ToList(); + + Assert.True(used.Count > 50, + $"Only {used.Count} ops-event write sites found — the extraction pattern is likely " + + "broken (WriteAsync renamed?), not the call sites."); + } + + [Fact] + public void Every_declared_ops_event_type_is_listed_in_the_web_alert_rule_editor() + { + var web = File.ReadAllText(Path.Combine(RepoRoot(), "src", "Web", "autopilot-monitor-web", + "app", "admin", "components", "OpsAlertRulesSection.tsx")); var webTypes = Regex.Matches(web, @"""([A-Za-z0-9]+)""") .Select(m => m.Groups[1].Value) .ToHashSet(StringComparer.Ordinal); - var missing = backendTypes.Where(t => !webTypes.Contains(t)).ToList(); + var missing = DeclaredTypes() + .Where(t => !webTypes.Contains(t)) + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + Assert.True(missing.Count == 0, - "Ops event types written by OpsEventService but missing from OPS_EVENT_TYPES in OpsAlertRulesSection.tsx: " + string.Join(", ", missing)); + "Ops event types declared in OpsEventTypes but missing from OPS_EVENT_TYPES in " + + "OpsAlertRulesSection.tsx: " + string.Join(", ", missing)); } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/OpsNotificationChannelsTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/OpsNotificationChannelsTests.cs new file mode 100644 index 00000000..612417c6 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/OpsNotificationChannelsTests.cs @@ -0,0 +1,269 @@ +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Models.Notifications; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Platform (ops) notification channels: legacy-slot synthesis so the pre-channels dispatch +/// behavior survives the migration untouched, per-rule channel routing, and redaction of the +/// new secret-bearing field. +/// +public class OpsNotificationChannelsTests +{ + private static AdminConfiguration LegacyConfig( + bool telegramEnabled = true, string telegramChat = "-1003785642894", + bool teamsEnabled = true, string teamsUrl = "https://teams.example/hook", + bool slackEnabled = false, string slackUrl = "") + => new() + { + UpdatedBy = "test", + OpsAlertTelegramEnabled = telegramEnabled, + OpsAlertTelegramChatId = telegramChat, + OpsAlertTeamsEnabled = teamsEnabled, + OpsAlertTeamsWebhookUrl = teamsUrl, + OpsAlertSlackEnabled = slackEnabled, + OpsAlertSlackWebhookUrl = slackUrl, + }; + + private static NotificationChannel Channel(string id, bool enabled = true, int providerType = 20) + => new() { Id = id, Name = id, ProviderType = providerType, Url = "https://x.example/" + id, Enabled = enabled }; + + private static OpsAlertRule Rule(string eventType, params string[] channelIds) + => new() + { + EventType = eventType, + MinSeverity = OpsEventSeverity.Info, + Enabled = true, + NotifyChannelIds = channelIds.Length == 0 ? null : channelIds.ToList(), + }; + + private static OpsAlertRule PayloadRule(string eventType, params string[] channelIds) + { + var rule = Rule(eventType, channelIds); + rule.IncludePayload = true; + return rule; + } + + /// Channels that receive the baseline alert (no payload) - the default for every rule. + private static IEnumerable Plain(IReadOnlyList rules, IReadOnlyList channels) + => OpsAlertDispatchService.ResolveTargets(rules, channels).Plain.Select(c => c.Id); + + /// Channels that receive the event structured payload (opted in per rule). + private static IEnumerable WithPayload(IReadOnlyList rules, IReadOnlyList channels) + => OpsAlertDispatchService.ResolveTargets(rules, channels).WithPayload.Select(c => c.Id); + + // ── Legacy synthesis ────────────────────────────────────────────────── + + [Fact] + public void GetOpsNotificationChannels_SynthesizesConfiguredLegacySlots() + { + var channels = LegacyConfig().GetOpsNotificationChannels(); + + Assert.Equal(2, channels.Count); // Slack slot is empty → not synthesized + var telegram = channels[0]; + Assert.Equal(AdminConfiguration.LegacyTelegramChannelId, telegram.Id); + Assert.Equal(AdminConfiguration.LegacyTelegramChannelName, telegram.Name); + Assert.Equal((int)WebhookProviderType.Telegram, telegram.ProviderType); + Assert.Equal("-1003785642894", telegram.Url); + Assert.True(telegram.Enabled); + + Assert.Equal(AdminConfiguration.LegacyTeamsChannelId, channels[1].Id); + Assert.Equal((int)WebhookProviderType.TeamsWorkflowWebhook, channels[1].ProviderType); + } + + [Fact] + public void GetOpsNotificationChannels_CarriesLegacyEnabledFlags() + { + // A configured-but-disabled provider must stay disabled, or the migration would start + // sending to a destination the operator had deliberately switched off. + var channels = LegacyConfig(telegramEnabled: false).GetOpsNotificationChannels(); + + Assert.False(channels.Single(c => c.Id == AdminConfiguration.LegacyTelegramChannelId).Enabled); + } + + [Fact] + public void GetOpsNotificationChannels_EmptyWhenNothingConfigured() + => Assert.Empty(new AdminConfiguration { UpdatedBy = "t" }.GetOpsNotificationChannels()); + + [Fact] + public void GetOpsNotificationChannels_StoredListWinsOverLegacySlots() + { + var config = LegacyConfig(); + config.OpsNotificationChannelsJson = NotificationChannel.SerializeList(new[] { Channel("sales") }); + + var channels = config.GetOpsNotificationChannels(); + + Assert.Single(channels); + Assert.Equal("sales", channels[0].Id); + } + + // ── Rule → channel routing ──────────────────────────────────────────── + + [Fact] + public void ResolveTargets_RuleWithoutChannelIds_HitsEveryEnabledChannel() + { + var channels = new[] { Channel("a"), Channel("b") }; + + Assert.Equal(new[] { "a", "b" }, Plain(new[] { Rule("X") }, channels)); + } + + [Fact] + public void ResolveTargets_RuleWithChannelIds_HitsOnlyThose() + { + var channels = new[] { Channel("push"), Channel("sales") }; + + Assert.Equal(new[] { "sales" }, Plain(new[] { Rule("X", "sales") }, channels)); + } + + [Fact] + public void ResolveTargets_NeverSendsToADisabledChannel() + { + var channels = new[] { Channel("push"), Channel("sales", enabled: false) }; + + Assert.Empty(Plain(new[] { Rule("X", "sales") }, channels)); + } + + [Fact] + public void ResolveTargets_UnknownChannelIdDoesNotFallBackToBroadcast() + { + // A rule whose only channel was deleted must go nowhere. Falling back to "all" would + // silently leak e.g. a sales-only event into the operator push channel. + var channels = new[] { Channel("push") }; + + Assert.Empty(Plain(new[] { Rule("X", "deleted-channel") }, channels)); + } + + [Fact] + public void ResolveTargets_MultipleRules_UnionOfChannelsWithoutDuplicates() + { + var channels = new[] { Channel("push"), Channel("sales"), Channel("audit") }; + + Assert.Equal(new[] { "sales", "audit" }, + Plain(new[] { Rule("X", "sales"), Rule("X", "sales", "audit") }, channels)); + } + + [Fact] + public void ResolveTargets_OneBroadcastRuleWins() + { + // If any matching rule is unrestricted, the union is everything - a narrower sibling rule + // cannot shrink an existing broadcast rule reach. + var channels = new[] { Channel("push"), Channel("sales") }; + + Assert.Equal(2, Plain(new[] { Rule("X", "sales"), Rule("X") }, channels).Count()); + } + + [Fact] + public void ResolveTargets_ChannelIdMatchIsCaseInsensitive() + { + var channels = new[] { Channel("Sales") }; + + Assert.Single(Plain(new[] { Rule("X", "sales") }, channels)); + } + + // ── Payload opt-in ──────────────────────────── + + [Fact] + public void ResolveTargets_PayloadIsOffUnlessTheRuleOptsIn() + { + // The default information level of every pre-existing rule: category, event, severity, + // tenant id. Nothing from the event payload. + var channels = new[] { Channel("push") }; + + Assert.Empty(WithPayload(new[] { Rule("X") }, channels)); + Assert.Equal(new[] { "push" }, Plain(new[] { Rule("X") }, channels)); + } + + [Fact] + public void ResolveTargets_OptedInRuleSendsPayloadToItsChannelsOnly() + { + var channels = new[] { Channel("push"), Channel("sales") }; + var rules = new[] { Rule("X", "push"), PayloadRule("X", "sales") }; + + Assert.Equal(new[] { "sales" }, WithPayload(rules, channels)); + Assert.Equal(new[] { "push" }, Plain(rules, channels)); + } + + [Fact] + public void ResolveTargets_ChannelInBothGroupsGetsThePayloadOnceOnly() + { + // One message per channel, and the explicit opt-in must not be cancelled by an + // unrelated sibling rule that happens to target the same channel. + var channels = new[] { Channel("sales") }; + var rules = new[] { Rule("X", "sales"), PayloadRule("X", "sales") }; + + Assert.Equal(new[] { "sales" }, WithPayload(rules, channels)); + Assert.Empty(Plain(rules, channels)); + } + + [Fact] + public void ResolveTargets_OptedInBroadcastRuleReachesEveryEnabledChannel() + { + var channels = new[] { Channel("push"), Channel("sales") }; + + Assert.Equal(new[] { "push", "sales" }, WithPayload(new[] { PayloadRule("X") }, channels)); + Assert.Empty(Plain(new[] { PayloadRule("X") }, channels)); + } + + // ── Redaction ───────────────────────────────────────────────────────── + + [Fact] + public void RedactedCopyForReader_RedactsOpsChannelDestinationsButKeepsStructure() + { + var config = new AdminConfiguration { UpdatedBy = "t" }; + config.OpsNotificationChannelsJson = NotificationChannel.SerializeList(new[] + { + new NotificationChannel + { + Id = "sales", Name = "Sales", ProviderType = 20, + Url = "https://sales.example/hook", SigningSecret = "s3cr3t-signing-key-value", + Enabled = true, + }, + }); + + var redacted = NotificationChannel.ParseList( + config.RedactedCopyForReader().OpsNotificationChannelsJson).Single(); + + Assert.Equal(Constants.RedactedSecretPlaceholder, redacted.Url); + Assert.Equal(Constants.RedactedSecretPlaceholder, redacted.SigningSecret); + Assert.Equal("Sales", redacted.Name); // structure stays readable + Assert.Equal(20, redacted.ProviderType); + } + + [Fact] + public void RestoreRedactedList_RestoresSentinelDestinationById() + { + var stored = NotificationChannel.SerializeList(new[] + { + new NotificationChannel { Id = "sales", Name = "Sales", ProviderType = 20, Url = "https://sales.example/hook", Enabled = true }, + }); + var roundTripped = NotificationChannel.SerializeList(new[] + { + new NotificationChannel { Id = "sales", Name = "Sales renamed", ProviderType = 20, Url = Constants.RedactedSecretPlaceholder, Enabled = true }, + }); + + var restored = NotificationChannel.ParseList( + NotificationChannel.RestoreRedactedList(roundTripped, stored)).Single(); + + Assert.Equal("https://sales.example/hook", restored.Url); + Assert.Equal("Sales renamed", restored.Name); // non-secret edits survive + } + + [Fact] + public void RestoreRedactedList_WholeStringSentinelKeepsStoredList() + { + var stored = NotificationChannel.SerializeList(new[] { Channel("sales") }); + + Assert.Equal(stored, NotificationChannel.RestoreRedactedList(Constants.RedactedSecretPlaceholder, stored)); + } + + [Fact] + public void RestoreRedactedList_PassesThroughWhenNoSentinelPresent() + { + var candidate = NotificationChannel.SerializeList(new[] { Channel("sales") }); + + Assert.Equal(candidate, NotificationChannel.RestoreRedactedList(candidate, "")); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/ReportAgentErrorFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/ReportAgentErrorFunctionTests.cs index 3fb41f1e..40be5e09 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/ReportAgentErrorFunctionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/ReportAgentErrorFunctionTests.cs @@ -121,13 +121,7 @@ public class ReportAgentErrorFunctionTests var adminConfig = new Mock( Mock.Of(), NullLogger.Instance, new MemoryCache(new MemoryCacheOptions())); - var alertDispatch = new OpsAlertDispatchService( - adminConfig.Object, - new TelegramNotificationService(new HttpClient(), Mock.Of(), - NullLogger.Instance), - new WebhookNotificationService(new HttpClient(), - NullLogger.Instance), - NullLogger.Instance); + var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object); OpsService = new OpsEventService(opsRepo.Object, NullLogger.Instance, alertDispatch); } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionDeletionMaintenanceFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionDeletionMaintenanceFunctionTests.cs index c3c2f51d..42536ded 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SessionDeletionMaintenanceFunctionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionDeletionMaintenanceFunctionTests.cs @@ -397,11 +397,7 @@ public class SessionDeletionMaintenanceFunctionTests } return Task.CompletedTask; }); - var alertDispatch = new OpsAlertDispatchService( - AdminConfig.Object, - new TelegramNotificationService(new HttpClient(), Mock.Of(), NullLogger.Instance), - new AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService(new HttpClient(), NullLogger.Instance), - NullLogger.Instance); + var alertDispatch = TestNotifications.InertOpsAlertDispatch(AdminConfig.Object); var opsService = new OpsEventService(opsRepo.Object, NullLogger.Instance, alertDispatch); var maintRepo = new Mock(); diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionDeletionPr4cCodexFixesTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionDeletionPr4cCodexFixesTests.cs index 22fa3894..a2c265b0 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SessionDeletionPr4cCodexFixesTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionDeletionPr4cCodexFixesTests.cs @@ -659,11 +659,7 @@ public class SessionDeletionPr4cCodexFixesTests // backed by a noop repository — F5 tests assert CAS ordering, not the OpsEvent itself. var opsRepoStub = new Mock(); opsRepoStub.Setup(r => r.SaveOpsEventAsync(It.IsAny())).Returns(Task.CompletedTask); - var alertDispatchStub = new OpsAlertDispatchService( - AdminConfig.Object, - new TelegramNotificationService(new HttpClient(), Mock.Of(), NullLogger.Instance), - new AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService(new HttpClient(), NullLogger.Instance), - NullLogger.Instance); + var alertDispatchStub = TestNotifications.InertOpsAlertDispatch(AdminConfig.Object); var opsServiceStub = new OpsEventService(opsRepoStub.Object, NullLogger.Instance, alertDispatchStub); // PR-B Codex F4 follow-up: worker now reads DeletionProgress on the poison path. diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionDeletionWorkerTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionDeletionWorkerTests.cs index f0f887b1..d07117e7 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SessionDeletionWorkerTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionDeletionWorkerTests.cs @@ -679,11 +679,7 @@ public class SessionDeletionWorkerTests lock (OpsEventCalls) OpsEventCalls.Add(CapturedOpsEvent.From(e)); return Task.CompletedTask; }); - var alertDispatch = new OpsAlertDispatchService( - AdminConfig.Object, - new TelegramNotificationService(new HttpClient(), Mock.Of(), NullLogger.Instance), - new AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService(new HttpClient(), NullLogger.Instance), - NullLogger.Instance); + var alertDispatch = TestNotifications.InertOpsAlertDispatch(AdminConfig.Object); var opsService = new OpsEventService(opsRepo.Object, NullLogger.Instance, alertDispatch); Sut = new SessionDeletionWorker( diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionOwnerBindingObserverTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionOwnerBindingObserverTests.cs index 5abf72bb..e0838519 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SessionOwnerBindingObserverTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionOwnerBindingObserverTests.cs @@ -46,12 +46,7 @@ public class SessionOwnerBindingObserverTests var adminConfig = new Mock( Mock.Of(), NullLogger.Instance, new MemoryCache(new MemoryCacheOptions())) { CallBase = false }; - var alertDispatch = new OpsAlertDispatchService( - adminConfig.Object, - new TelegramNotificationService(new HttpClient(), Mock.Of(), - NullLogger.Instance), - new WebhookNotificationService(new HttpClient(), NullLogger.Instance), - NullLogger.Instance); + var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object); var opsService = new OpsEventService(opsRepo.Object, NullLogger.Instance, alertDispatch); Sut = new SessionOwnerBindingObserver( diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs index bd34822d..d26ed397 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs @@ -2,6 +2,7 @@ using System.Reflection; using System.Text.Json; using AutopilotMonitor.Functions.Functions.Rules; using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using AutopilotMonitor.Shared.Models.Notifications; using Xunit; @@ -74,6 +75,12 @@ public sealed class SharedManifestParityTests ["webhookProviderTypes"] = EnumMap(), ["annotationLanes"] = AnnotationLanes.All, ["annotationVerdicts"] = AnnotationVerdicts.All, + // Ops vocabularies. Declaration order kept for severities — it IS the ladder + // (Info < Warning < Error < Critical), and the MCP renders it in that order. + ["opsEventCategories"] = ConstStrings(typeof(OpsEventCategory)), + ["opsEventSeverities"] = ConstStrings(typeof(OpsEventSeverity)), + // Declaration order (grouped by category) — the MCP renders this catalog verbatim. + ["opsEventTypes"] = OpsEventTypes.All, ["tenantRoles"] = ConstStrings(typeof(Constants.TenantRoles)), ["globalRoles"] = ConstStrings(typeof(Constants.GlobalRoles)), ["delegatedRoles"] = ConstStrings(typeof(Constants.DelegatedRoles)), diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SlaTenantStatusLifecycleTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SlaTenantStatusLifecycleTests.cs index 93b3b939..85e40b3d 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SlaTenantStatusLifecycleTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SlaTenantStatusLifecycleTests.cs @@ -168,11 +168,8 @@ public class SlaTenantStatusLifecycleTests opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny())).Returns(Task.CompletedTask); var webhook = new WebhookNotificationService(new HttpClient(), NullLogger.Instance); - var alertDispatch = new OpsAlertDispatchService( - _adminConfig.Object, - new TelegramNotificationService(new HttpClient(), Mock.Of(), NullLogger.Instance), - webhook, - NullLogger.Instance); + var channelDispatcher = new NotificationChannelDispatcher(webhook, new TelegramNotificationService(new HttpClient(), Mock.Of(), NullLogger.Instance)); + var alertDispatch = TestNotifications.InertOpsAlertDispatch(_adminConfig.Object); var opsService = new OpsEventService(opsRepo.Object, NullLogger.Instance, alertDispatch); var telemetry = new TelemetryClient(new TelemetryConfiguration()); @@ -183,7 +180,7 @@ public class SlaTenantStatusLifecycleTests _maintenanceRepo.Object, _sessionRepo.Object, _metricsRepo.Object, - webhook, + channelDispatcher, NotificationService.Object, StatusRepo, _adminConfig.Object, @@ -419,9 +416,8 @@ public class SlaTenantStatusLifecycleTests .ReturnsAsync(new RawPage(recentPage, null)); var webhook = new WebhookNotificationService(new HttpClient(), NullLogger.Instance); - var alertDispatch = new OpsAlertDispatchService(adminCfg.Object, - new TelegramNotificationService(new HttpClient(), Mock.Of(), NullLogger.Instance), - webhook, NullLogger.Instance); + var channelDispatcher = new NotificationChannelDispatcher(webhook, new TelegramNotificationService(new HttpClient(), Mock.Of(), NullLogger.Instance)); + var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminCfg.Object); var opsRepo = new Mock(); opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny())).Returns(Task.CompletedTask); var opsSvc = new OpsEventService(opsRepo.Object, NullLogger.Instance, alertDispatch); @@ -432,7 +428,7 @@ public class SlaTenantStatusLifecycleTests var svc = new SlaBreachEvaluationService( tenantConfigSvc, configRepoMock.Object, maintenanceRepo.Object, sessionRepo.Object, metricsRepo.Object, - webhook, notif.Object, statusRepo, adminCfg.Object, opsSvc, + channelDispatcher, notif.Object, statusRepo, adminCfg.Object, opsSvc, new TelemetryClient(new TelemetryConfiguration()), NullLogger.Instance); @@ -850,14 +846,13 @@ public class SlaTenantStatusLifecycleTests .Returns(Task.CompletedTask); var webhook = new WebhookNotificationService(new HttpClient(), NullLogger.Instance); - var alertDispatch = new OpsAlertDispatchService(adminConfig.Object, - new TelegramNotificationService(new HttpClient(), Mock.Of(), NullLogger.Instance), - webhook, NullLogger.Instance); + var channelDispatcher = new NotificationChannelDispatcher(webhook, new TelegramNotificationService(new HttpClient(), Mock.Of(), NullLogger.Instance)); + var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object); var opsService = new OpsEventService(opsRepo.Object, NullLogger.Instance, alertDispatch); Service = new SlaBreachEvaluationService( tenantConfigService.Object, _configRepo.Object, _maintenanceRepo.Object, _sessionRepo.Object, - _metricsRepo.Object, webhook, notifService.Object, StatusRepo, adminConfig.Object, opsService, + _metricsRepo.Object, channelDispatcher, notifService.Object, StatusRepo, adminConfig.Object, opsService, new TelemetryClient(new TelemetryConfiguration()), NullLogger.Instance); _sessionRepo.Setup(r => r.GetSessionsPageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SubmitOffboardingFeedbackFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SubmitOffboardingFeedbackFunctionTests.cs index 93e1af4e..fd22e023 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SubmitOffboardingFeedbackFunctionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SubmitOffboardingFeedbackFunctionTests.cs @@ -283,13 +283,7 @@ public sealed class SubmitOffboardingFeedbackFunctionTests Mock.Of(), NullLogger.Instance, memCache); - var alertDispatch = new OpsAlertDispatchService( - adminConfig.Object, - new TelegramNotificationService(new HttpClient(), Mock.Of(), - NullLogger.Instance), - new WebhookNotificationService(new HttpClient(), - NullLogger.Instance), - NullLogger.Instance); + var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object); return ( new OpsEventService(opsRepo.Object, NullLogger.Instance, alertDispatch), diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TelegramChannelProviderTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TelegramChannelProviderTests.cs new file mode 100644 index 00000000..42e17e7b --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TelegramChannelProviderTests.cs @@ -0,0 +1,225 @@ +using System.Net.Http; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Functions.Services.Notifications; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Models.Notifications; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Telegram as a notification-channel provider: dispatch routing (it must never reach the +/// SSRF-guarded webhook path), chat-ID validation, and the Global-Admin gate that keeps a +/// tenant admin from sending through the platform-owned bot. +/// +public class TelegramChannelProviderTests +{ + private static Mock WebhookMock() + => new(new HttpClient(), NullLogger.Instance) { CallBase = false }; + + private static Mock TelegramMock() + => new(new HttpClient(), Mock.Of(), NullLogger.Instance) + { CallBase = false }; + + private static NotificationChannel Channel(int providerType, string? url, string id = "c1", bool enabled = true) + => new() { Id = id, Name = id, ProviderType = providerType, Url = url, Enabled = enabled }; + + private static string ChannelJson(int providerType, string url, string id = "c1", bool enabled = true) + => $"[{{\"id\":\"{id}\",\"name\":\"{id}\",\"providerType\":{providerType},\"url\":\"{url}\",\"enabled\":{(enabled ? "true" : "false")}}}]"; + + // ── Dispatch routing ────────────────────────────────────────────────── + + [Fact] + public async Task SendToChannels_RoutesTelegramToBotAndNeverToWebhook() + { + var webhook = WebhookMock(); + var telegram = TelegramMock(); + var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object); + var alert = new NotificationAlert { Title = "t", Summary = "s" }; + + await dispatcher.SendToChannelsAsync( + new[] { Channel((int)WebhookProviderType.Telegram, "-1003785642894") }, alert); + + telegram.Verify(t => t.SendOpsAlertAsync("-1003785642894", alert), Times.Once); + webhook.Verify(w => w.SendNotificationAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task SendToChannels_RoutesWebhookProvidersToWebhookService() + { + var webhook = WebhookMock(); + var telegram = TelegramMock(); + var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object); + var alert = new NotificationAlert { Title = "t", Summary = "s" }; + + await dispatcher.SendToChannelsAsync( + new[] { Channel((int)WebhookProviderType.Slack, "https://hooks.slack.example/x") }, alert); + + webhook.Verify(w => w.SendNotificationAsync( + "https://hooks.slack.example/x", WebhookProviderType.Slack, alert, + It.IsAny>(), It.IsAny()), Times.Once); + telegram.Verify(t => t.SendOpsAlertAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task SendToChannels_MixedList_EachChannelReachesItsOwnTransport() + { + var webhook = WebhookMock(); + var telegram = TelegramMock(); + var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object); + var alert = new NotificationAlert { Title = "t", Summary = "s" }; + + await dispatcher.SendToChannelsAsync(new[] + { + Channel((int)WebhookProviderType.Telegram, "-100123", "tg"), + Channel((int)WebhookProviderType.GenericJson, "https://sales.example/hook", "sales"), + }, alert); + + telegram.Verify(t => t.SendOpsAlertAsync("-100123", alert), Times.Once); + webhook.Verify(w => w.SendNotificationAsync( + "https://sales.example/hook", WebhookProviderType.GenericJson, alert, + It.IsAny>(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task SendToChannels_SkipsChannelsWithoutDestination() + { + var webhook = WebhookMock(); + var telegram = TelegramMock(); + var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object); + + await dispatcher.SendToChannelsAsync(new[] + { + Channel((int)WebhookProviderType.Telegram, null, "tg"), + Channel((int)WebhookProviderType.Slack, "", "slack"), + }, new NotificationAlert { Title = "t", Summary = "s" }); + + telegram.Verify(t => t.SendOpsAlertAsync(It.IsAny(), It.IsAny()), Times.Never); + webhook.Verify(w => w.SendNotificationAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task SendWithResult_RoutesTelegramToBot() + { + var webhook = WebhookMock(); + var telegram = TelegramMock(); + telegram.Setup(t => t.SendAlertWithResultAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new WebhookTestResult { Success = true, Message = "ok" }); + var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object); + + var result = await dispatcher.SendWithResultAsync( + Channel((int)WebhookProviderType.Telegram, "@salesdesk"), + new NotificationAlert { Title = "t", Summary = "s" }); + + Assert.True(result.Success); + telegram.Verify(t => t.SendAlertWithResultAsync("@salesdesk", It.IsAny()), Times.Once); + } + + // ── Chat-ID validation ──────────────────────────────────────────────── + + [Theory] + [InlineData("-1003785642894")] + [InlineData("123456789")] + [InlineData("@salesdesk")] + [InlineData("@Sales_Desk_01")] + public void ValidateTelegramChatId_AcceptsNumericAndUsernameForms(string chatId) + => Assert.Null(TenantConfigValidation.ValidateTelegramChatId(chatId)); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("https://api.telegram.org/bot123/sendMessage")] + [InlineData("-100abc")] + [InlineData("@abc")] // too short + [InlineData("@1sales")] // must start with a letter + [InlineData("@sales-desk")] // hyphen not allowed + [InlineData("123456789012345678901")] // 21 digits + public void ValidateTelegramChatId_RejectsEverythingElse(string? chatId) + => Assert.NotNull(TenantConfigValidation.ValidateTelegramChatId(chatId)); + + [Fact] + public void ValidateNotificationChannels_TelegramChatIdBypassesSsrfUrlGate() + { + // A chat ID is not a URL — the webhook SSRF gate would reject it. Proves the + // provider-specific destination branch is wired, not just the format helper. + Assert.Null(TenantConfigValidation.ValidateNotificationChannels( + ChannelJson((int)WebhookProviderType.Telegram, "-1003785642894"))); + + Assert.NotNull(TenantConfigValidation.ValidateNotificationChannels( + ChannelJson((int)WebhookProviderType.Slack, "-1003785642894"))); + } + + [Fact] + public void ValidateNotificationChannels_RejectsTelegramChannelWithUrlDestination() + => Assert.NotNull(TenantConfigValidation.ValidateNotificationChannels( + ChannelJson((int)WebhookProviderType.Telegram, "https://hooks.example/x"))); + + // ── Global-Admin gate ───────────────────────────────────────────────── + + [Fact] + public void TelegramGate_GlobalAdminMayAddChannel() + => Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate( + ChannelJson((int)WebhookProviderType.Telegram, "-100123"), null, isGlobalAdmin: true)); + + [Fact] + public void TelegramGate_TenantAdminMayNotAddChannel() + => Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate( + ChannelJson((int)WebhookProviderType.Telegram, "-100123"), null, isGlobalAdmin: false)); + + [Fact] + public void TelegramGate_TenantAdminMayNotRetargetExistingChannel() + { + var existing = ChannelJson((int)WebhookProviderType.Telegram, "-100123"); + var retargeted = ChannelJson((int)WebhookProviderType.Telegram, "-100999"); + + Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(retargeted, existing, isGlobalAdmin: false)); + } + + [Fact] + public void TelegramGate_TenantAdminMayNotFlipEnabledState() + { + var existing = ChannelJson((int)WebhookProviderType.Telegram, "-100123", enabled: true); + var disabled = ChannelJson((int)WebhookProviderType.Telegram, "-100123", enabled: false); + + Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(disabled, existing, isGlobalAdmin: false)); + } + + [Fact] + public void TelegramGate_TenantAdminMaySaveWithUnchangedGaCreatedChannel() + { + // The gate must not turn a GA-created Telegram channel into a permanent save block + // for the tenant admin's unrelated configuration changes. + var json = ChannelJson((int)WebhookProviderType.Telegram, "-100123"); + + Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(json, json, isGlobalAdmin: false)); + } + + [Fact] + public void TelegramGate_IgnoresNonTelegramChannels() + => Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate( + ChannelJson((int)WebhookProviderType.GenericJson, "https://sales.example/hook"), null, isGlobalAdmin: false)); + + // ── Both write paths (PUT + field patch) go through ValidateModel ───── + + [Fact] + public void ValidateModel_EnforcesTelegramGateForNonGlobalAdmin() + { + var candidate = new TenantConfiguration + { + TenantId = "t1", + NotificationChannelsJson = ChannelJson((int)WebhookProviderType.Telegram, "-100123"), + }; + var existing = new TenantConfiguration { TenantId = "t1" }; + + Assert.NotNull(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: false)); + Assert.Null(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: true)); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantApprovalServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantApprovalServiceTests.cs index 0c4eb666..5a56d9e9 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TenantApprovalServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantApprovalServiceTests.cs @@ -51,13 +51,7 @@ public class TenantApprovalServiceTests .Returns(Task.CompletedTask); var adminConfig = new Mock( configRepo, NullLogger.Instance, cache); - var alertDispatch = new OpsAlertDispatchService( - adminConfig.Object, - new TelegramNotificationService(new HttpClient(), configRepo, - NullLogger.Instance), - new WebhookNotificationService(new HttpClient(), - NullLogger.Instance), - NullLogger.Instance); + var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object); _sut = new TenantApprovalService( NullLogger.Instance, diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantAutoApproveHandlerTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantAutoApproveHandlerTests.cs index 224e86c3..17ca45a2 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TenantAutoApproveHandlerTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantAutoApproveHandlerTests.cs @@ -48,13 +48,7 @@ public class TenantAutoApproveHandlerTests opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny())) .Callback(e => { lock (_savedOpsEvents) _savedOpsEvents.Add(e); }) .Returns(Task.CompletedTask); - var alertDispatch = new OpsAlertDispatchService( - _adminConfigMock.Object, - new TelegramNotificationService(new HttpClient(), configRepo, - NullLogger.Instance), - new WebhookNotificationService(new HttpClient(), - NullLogger.Instance), - NullLogger.Instance); + var alertDispatch = TestNotifications.InertOpsAlertDispatch(_adminConfigMock.Object); var opsService = new OpsEventService(opsRepo.Object, NullLogger.Instance, alertDispatch); _approvalMock = new Mock( diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigPatchServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigPatchServiceTests.cs index f1d062a0..9461b208 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigPatchServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigPatchServiceTests.cs @@ -99,13 +99,7 @@ public class TenantConfigPatchServiceTests var adminConfig = new Mock( Mock.Of(), NullLogger.Instance, new MemoryCache(new MemoryCacheOptions())) { CallBase = false }; - var alertDispatch = new OpsAlertDispatchService( - adminConfig.Object, - new TelegramNotificationService(new HttpClient(), Mock.Of(), - NullLogger.Instance), - new WebhookNotificationService(new HttpClient(), - NullLogger.Instance), - NullLogger.Instance); + var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object); var opsService = new OpsEventService(OpsRepo.Object, NullLogger.Instance, alertDispatch); Sut = new TenantConfigPatchService( diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantTrialStartedEventTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantTrialStartedEventTests.cs new file mode 100644 index 00000000..cfcdaa4c --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantTrialStartedEventTests.cs @@ -0,0 +1,144 @@ +using System.Text.Json; +using AutopilotMonitor.Functions.Functions.Config; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// The TenantTrialStarted ops event — the conversion signal a sales/support channel binds to. +/// Covers the recorded shape (category, severity, payload) and the GA-path emission predicate. +/// +/// The HTTP entry points themselves are not exercised: per this suite's convention the pure +/// cores are tested instead (see PlanManagementTransitionTests), so the GA path is pinned via +/// the changes dictionary that gates the call. +/// +/// +public class TenantTrialStartedEventTests +{ + private static readonly DateTime Now = new(2026, 9, 1, 12, 0, 0, DateTimeKind.Utc); + private const string Caller = "ga@operator.example"; + + private static (OpsEventService Service, List Events) NewService() + { + var events = new List(); + var repo = new Mock(); + repo.Setup(r => r.SaveOpsEventAsync(It.IsAny())) + .Callback(events.Add) + .Returns(Task.CompletedTask); + + var adminConfig = new Mock( + Mock.Of(), NullLogger.Instance, + new MemoryCache(new MemoryCacheOptions())) { CallBase = false }; + adminConfig.Setup(a => a.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration { UpdatedBy = "test" }); + + return (new OpsEventService(repo.Object, NullLogger.Instance, + TestNotifications.InertOpsAlertDispatch(adminConfig.Object)), events); + } + + // ── Recorded shape ──────────────────────────────────────────────────── + + [Fact] + public async Task RecordTenantTrialStarted_WritesTenantInfoEventWithContactPayload() + { + var (service, events) = NewService(); + + await service.RecordTenantTrialStartedAsync( + "t1", "contoso.example", "admin@contoso.example", Now, Now.AddDays(30), Caller, selfService: true); + + var entry = Assert.Single(events); + Assert.Equal(OpsEventCategory.Tenant, entry.Category); + Assert.Equal("TenantTrialStarted", entry.EventType); + Assert.Equal(OpsEventSeverity.Info, entry.Severity); + Assert.Equal("t1", entry.TenantId); + Assert.Equal(Caller, entry.UserId); + Assert.Contains("contoso.example", entry.Message); + + using var details = JsonDocument.Parse(entry.Details!); + var root = details.RootElement; + Assert.Equal("contoso.example", root.GetProperty("domainName").GetString()); + Assert.Equal("admin@contoso.example", root.GetProperty("contactEmail").GetString()); + Assert.Equal(Caller, root.GetProperty("grantedBy").GetString()); + Assert.True(root.GetProperty("selfService").GetBoolean()); + } + + [Fact] + public async Task RecordTenantTrialStarted_DistinguishesOperatorGrantFromSelfService() + { + var (service, events) = NewService(); + + await service.RecordTenantTrialStartedAsync( + "t1", "contoso.example", null, Now, Now.AddDays(90), Caller, selfService: false); + + Assert.Contains("granted by an operator", events.Single().Message); + using var details = JsonDocument.Parse(events.Single().Details!); + Assert.False(details.RootElement.GetProperty("selfService").GetBoolean()); + } + + [Fact] + public async Task RecordTenantTrialStarted_FallsBackToTenantIdWhenDomainUnknown() + { + var (service, events) = NewService(); + + await service.RecordTenantTrialStartedAsync("t1", null, null, Now, Now.AddDays(30), Caller, selfService: true); + + Assert.Contains("t1", events.Single().Message); + } + + // ── GA-path emission predicate ──────────────────────────────────────── + // SetPlanTier fires when: changes["TrialExpiresUtc"] was recorded AND a trial end remains. + + private static (Dictionary Changes, TenantConfiguration Config) Apply( + TenantConfiguration config, string? planTier = null, bool trialProvided = false, DateTime? trialExpiresUtc = null) + { + var changes = new Dictionary(); + PlanManagementFunction.ApplyPlanChanges(config, planTier, trialProvided, trialExpiresUtc, Caller, Now, changes); + return (changes, config); + } + + private static bool WouldFire((Dictionary Changes, TenantConfiguration Config) result) + => result.Changes.ContainsKey("TrialExpiresUtc") && result.Config.TrialExpiresUtc.HasValue; + + [Fact] + public void GaGrantingATrial_Fires() + => Assert.True(WouldFire(Apply( + new TenantConfiguration { TenantId = "t1", PlanTier = "community" }, + trialProvided: true, trialExpiresUtc: Now.AddDays(30)))); + + [Fact] + public void GaExtendingATrial_Fires() + => Assert.True(WouldFire(Apply( + new TenantConfiguration { TenantId = "t1", PlanTier = "community", TrialExpiresUtc = Now.AddDays(5) }, + trialProvided: true, trialExpiresUtc: Now.AddDays(60)))); + + [Fact] + public void GaEndingATrial_DoesNotFire() + { + // Explicit null ends the trial — that is a downgrade, already covered by + // TenantPlanDowngraded. Firing "started" there would be plainly wrong. + Assert.False(WouldFire(Apply( + new TenantConfiguration { TenantId = "t1", PlanTier = "community", TrialExpiresUtc = Now.AddDays(5) }, + trialProvided: true, trialExpiresUtc: null))); + } + + [Fact] + public void PlanTierOnlyChange_DoesNotFire() + => Assert.False(WouldFire(Apply( + new TenantConfiguration { TenantId = "t1", PlanTier = "community" }, planTier: "pro"))); + + [Fact] + public void ResubmittingTheSameTrialDate_DoesNotFire() + { + // ApplyPlanChanges only records a change when the value actually moved, so an idempotent + // save must not re-announce the conversion. + var expiry = Now.AddDays(30); + + Assert.False(WouldFire(Apply( + new TenantConfiguration { TenantId = "t1", PlanTier = "community", TrialExpiresUtc = expiry }, + trialProvided: true, trialExpiresUtc: expiry))); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TestNotifications.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TestNotifications.cs new file mode 100644 index 00000000..7d61c130 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TestNotifications.cs @@ -0,0 +1,30 @@ +using System.Net.Http; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Functions.Services.Notifications; +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Inert notification plumbing for tests that need an but do not +/// assert on anything being sent. Nothing leaves the process: the webhook transport gets a bare +/// HttpClient it never uses (no channel is configured in these stubs), and the Telegram transport +/// resolves its bot URL from a mocked repository that returns nothing. +/// +/// Tests that DO assert on dispatch should mock the transports directly — see +/// TelegramChannelProviderTests — rather than extending this helper. +/// +/// +internal static class TestNotifications +{ + internal static NotificationChannelDispatcher InertDispatcher() + => new( + new WebhookNotificationService(new HttpClient(), NullLogger.Instance), + new TelegramNotificationService(new HttpClient(), Mock.Of(), + NullLogger.Instance)); + + internal static OpsAlertDispatchService InertOpsAlertDispatch(AdminConfigurationService adminConfigService) + => new(adminConfigService, InertDispatcher(), NullLogger.Instance); +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TrialExpirySweepFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TrialExpirySweepFunctionTests.cs index 2a88544b..37d823c3 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TrialExpirySweepFunctionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TrialExpirySweepFunctionTests.cs @@ -50,11 +50,7 @@ public class TrialExpirySweepFunctionTests { CallBase = false }; adminConfig.Setup(a => a.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration { UpdatedBy = "test" }); - var alertDispatch = new OpsAlertDispatchService( - adminConfig.Object, - new TelegramNotificationService(new HttpClient(), Mock.Of(), NullLogger.Instance), - new WebhookNotificationService(new HttpClient(), NullLogger.Instance), - NullLogger.Instance); + var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object); var opsService = new OpsEventService(opsRepo.Object, NullLogger.Instance, alertDispatch); var sut = new TrialExpirySweepFunction( @@ -248,11 +244,7 @@ public class TrialExpirySweepFunctionTests Mock.Of(), NullLogger.Instance, cache) { CallBase = false }; adminConfig.Setup(a => a.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration { UpdatedBy = "test" }); - var alertDispatch = new OpsAlertDispatchService( - adminConfig.Object, - new TelegramNotificationService(new HttpClient(), Mock.Of(), NullLogger.Instance), - new WebhookNotificationService(new HttpClient(), NullLogger.Instance), - NullLogger.Instance); + var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object); var sut = new TrialExpirySweepFunction( configRepo.Object, diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs index 5a6f65f0..0dbd375b 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs @@ -914,6 +914,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { "OpsAlertTeamsWebhookUrl", config.OpsAlertTeamsWebhookUrl ?? string.Empty }, { "OpsAlertSlackEnabled", config.OpsAlertSlackEnabled }, { "OpsAlertSlackWebhookUrl", config.OpsAlertSlackWebhookUrl ?? string.Empty }, + { "OpsNotificationChannelsJson", config.OpsNotificationChannelsJson ?? string.Empty }, // Per-line agent binary integrity (written by build scripts via Merge). // V2 is the only wired line; future V3 = add field set here. Retired columns // (V1-suffix and the even older unsuffixed "LatestAgent*") are never written, @@ -985,6 +986,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage OpsAlertTeamsWebhookUrl = entity.GetString("OpsAlertTeamsWebhookUrl"), OpsAlertSlackEnabled = entity.GetBoolean("OpsAlertSlackEnabled") ?? false, OpsAlertSlackWebhookUrl = entity.GetString("OpsAlertSlackWebhookUrl"), + OpsNotificationChannelsJson = entity.GetString("OpsNotificationChannelsJson"), // Per-line agent binary integrity. V2 is the only wired line (V1 retired). AllowAgentDowngrade = entity.GetBoolean("AllowAgentDowngrade") ?? false, LatestAgentV2Version = entity.GetString("LatestAgentV2Version") ?? string.Empty, diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs index 2571b386..6d934d11 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs @@ -53,10 +53,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage } public async Task> GetOpsEventsAsync( - string? category = null, DateTime? dateFrom = null, DateTime? dateTo = null) + string? category = null, DateTime? dateFrom = null, DateTime? dateTo = null, + OpsEventQueryFilters? filters = null) { var result = new List(); - var filter = BuildFilter(category, dateFrom, dateTo); + var filter = BuildFilter(category, dateFrom, dateTo, filters); var query = string.IsNullOrEmpty(filter) ? _table.QueryAsync() : _table.QueryAsync(filter: filter); @@ -71,7 +72,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage } public async Task> GetOpsEventsPageAsync( - string? category, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation) + string? category, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, + OpsEventQueryFilters? filters = null) { if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize)); try @@ -80,7 +82,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage // Azure-native (PK asc, RK asc) already yields newest-first. No re-sort. if (!string.IsNullOrEmpty(category)) { - var filter = BuildFilter(category, dateFrom, dateTo); + var filter = BuildFilter(category, dateFrom, dateTo, filters); var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync( client: _table, filter: filter, @@ -97,7 +99,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage // the first page would come entirely from the alphabetically-first // category — defeating "newest first globally". Mirrors the same // pattern used for cross-tenant session queries. - return await FanOutAcrossCategoriesAsync(dateFrom, dateTo, pageSize, continuation); + return await FanOutAcrossCategoriesAsync(dateFrom, dateTo, pageSize, continuation, filters); } catch (Exception ex) { @@ -106,21 +108,16 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage } } - // Fixed list — keep in sync with OpsEventCategory. New categories are rare - // and require a deploy anyway, so a runtime probe of the table partitions - // would just trade certainty for cost. - internal static readonly string[] AllCategories = new[] - { - OpsEventCategory.Consent, - OpsEventCategory.Maintenance, - OpsEventCategory.Security, - OpsEventCategory.Tenant, - OpsEventCategory.Agent, - OpsEventCategory.Sla, - }; + // Fixed list, owned by OpsEventCategory.All so it cannot drift from the vocabulary. It did + // once: Platform (Azure Monitor alerts) was written but never listed, so every PAGED + // cross-category read silently skipped it while the unpaged full-table path still showed + // it. New categories are rare and need a deploy anyway, so a runtime probe of the table + // partitions would just trade certainty for cost — the coverage test guards the list instead. + internal static readonly string[] AllCategories = OpsEventCategory.All; private async Task> FanOutAcrossCategoriesAsync( - DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation) + DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, + OpsEventQueryFilters? filters = null) { var continuations = PerPartitionFanOutMerge.DecodeMultiContinuation(continuation); // First-page request fans out across every category; subsequent @@ -139,7 +136,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage var fetchTasks = activeCats.Select(async cat => { continuations.TryGetValue(cat, out var catContinuation); - var filter = BuildFilterWithRowKeyBound(cat, dateFrom, dateTo, catContinuation?.LastRowKey); + var filter = BuildFilterWithRowKeyBound(cat, dateFrom, dateTo, catContinuation?.LastRowKey, filters); var fetched = new List<(string RowKey, OpsEventEntry Item)>(); await foreach (var e in _table.QueryAsync(filter: filter, maxPerPage: pageSize)) @@ -162,7 +159,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage return new RawPage(items, nextRawToken); } - private static string BuildFilterWithRowKeyBound(string category, DateTime? dateFrom, DateTime? dateTo, string? lastRowKey) + internal static string BuildFilterWithRowKeyBound( + string category, DateTime? dateFrom, DateTime? dateTo, string? lastRowKey, + OpsEventQueryFilters? filters = null) { var clauses = new List { @@ -174,13 +173,45 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage clauses.Add(BusinessTimestamp.OpsDateFromClause(ToUtc(dateFrom.Value))); if (dateTo.HasValue) clauses.Add(BusinessTimestamp.OpsDateToClause(ToUtc(dateTo.Value))); + AppendFieldFilters(clauses, filters); return string.Join(" and ", clauses); } + /// + /// Appends the optional non-key field filters (eventType / severity / minSeverity) as + /// SERVER-SIDE clauses. Both filter builders funnel through here so the paged fan-out and + /// the single-category / unpaged paths can never honour a different filter surface — a + /// drift would make the same query return different rows depending on whether a category + /// was named. minSeverity expands to an OR-set instead of a range comparison because Table + /// Storage compares strings lexicographically ("Critical" < "Error" < "Info" < + /// "Warning"), which has nothing to do with severity order. + /// + private static void AppendFieldFilters(List clauses, OpsEventQueryFilters? filters) + { + if (filters == null || filters.IsEmpty) return; + if (!string.IsNullOrEmpty(filters.EventType)) + clauses.Add($"EventType eq '{filters.EventType!.Replace("'", "''")}'"); + if (!string.IsNullOrEmpty(filters.Severity)) + clauses.Add($"Severity eq '{filters.Severity!.Replace("'", "''")}'"); + if (!string.IsNullOrEmpty(filters.MinSeverity)) + { + var allowed = OpsEventSeverity.AtOrAbove(filters.MinSeverity!); + // Info is the floor — every canonical severity qualifies, so the clause would be a + // no-op that only costs query length. An unknown value yields an empty set; the + // endpoint rejects those up front (400), so this stays defensive-only. + if (allowed.Count > 0 && allowed.Count < OpsEventSeverity.All.Length) + { + var terms = allowed.Select(s => $"Severity eq '{s}'"); + clauses.Add($"({string.Join(" or ", terms)})"); + } + } + } + // Date windows filter on the RowKey (reverse-tick, index-backed): the system // Timestamp is reset by storage migrations, and an OccurredUtc property filter // would exclude rows written before that column existed. - internal static string? BuildFilter(string? category, DateTime? dateFrom, DateTime? dateTo) + internal static string? BuildFilter( + string? category, DateTime? dateFrom, DateTime? dateTo, OpsEventQueryFilters? filters = null) { var clauses = new List(); if (!string.IsNullOrEmpty(category)) @@ -195,6 +226,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { clauses.Add(BusinessTimestamp.OpsDateToClause(ToUtc(dateTo.Value))); } + AppendFieldFilters(clauses, filters); return clauses.Count == 0 ? null : string.Join(" and ", clauses); } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs index 3ce09d58..ef6934c3 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs @@ -14,7 +14,8 @@ namespace AutopilotMonitor.Functions.Functions.Admin { /// /// Cross-tenant READ endpoint for PLATFORM-OPERATIONAL events (Consent/Maintenance/Security/Tenant/Agent/ - /// SLA). Supports optional category + dateFrom/dateTo filters and opt-in pagination. Authentication + + /// SLA). Supports optional category + eventType/severity/minSeverity + dateFrom/dateTo filters (all + /// server-side except the tenant drill) and opt-in pagination. Authentication + /// GlobalReadOrAdmin + TenantScoping.None enforced by PolicyEnforcementMiddleware — a Global Admin and the /// read-only Global Reader reach it, but a delegated ("MSP") admin does NOT: ops-events is the platform /// operator's view, not customer telemetry, so it stays GA/Reader-only (None ⇒ no scoped-route rescue for @@ -56,19 +57,34 @@ namespace AutopilotMonitor.Functions.Functions.Admin return bad; } + // Optional exact-match field filters (eventType / severity / minSeverity). Unlike + // the tenantId drill below these are pushed into the STORAGE query, so an operator + // asking for one event type no longer pulls a whole category over the wire. + var fieldFilters = OpsEventFilterRequest.Parse(query); + if (fieldFilters.Error != null) + { + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new { success = false, message = fieldFilters.Error }); + return bad; + } + var filters = fieldFilters.Filters; + // OpsEvents are partitioned by category, not tenant — tenantId filter is applied // post-fetch. Pages may report fewer items than pageSize when the filter is // narrow (Azure scan returns pageSize raw rows; we filter and emit what passes). + // The field filters are appended AFTER the pre-existing discriminators so a token + // minted before they existed still fingerprints identically when none are named. var extrasList = new List>(); if (!string.IsNullOrEmpty(category)) extrasList.Add(new KeyValuePair("category", category)); if (!string.IsNullOrEmpty(filterTenantId)) extrasList.Add(new KeyValuePair("tenantId", filterTenantId)); + extrasList.AddRange(OpsEventFilterRequest.ToExtras(filters)); var extras = extrasList.Count > 0 ? extrasList.ToArray() : null; if (parsed.PageSize == null) { - var events = await _repository.GetOpsEventsAsync(category, parsed.DateFrom, parsed.DateTo); + var events = await _repository.GetOpsEventsAsync(category, parsed.DateFrom, parsed.DateTo, filters); // Drill correctness: apply the optional ?tenantId= GA/Reader drill in-memory (OpsEvents PK // = category, so the storage query can't). Not a tenant-isolation boundary — see class doc. var filtered = FilterByTenant(events, filterTenantId).ToList(); @@ -100,7 +116,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin } var page = await _repository.GetOpsEventsPageAsync( - category, parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken); + category, parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken, filters); // Drill correctness: same optional ?tenantId= GA/Reader drill on the paged path — see FilterByTenant. var pageItems = FilterByTenant(page.Items, filterTenantId).ToList(); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/OpsEventFilterRequest.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/OpsEventFilterRequest.cs new file mode 100644 index 00000000..4602934c --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/OpsEventFilterRequest.cs @@ -0,0 +1,82 @@ +using System.Collections.Generic; +using System.Collections.Specialized; +using AutopilotMonitor.Shared.DataAccess; + +namespace AutopilotMonitor.Functions.Functions.Admin +{ + /// + /// Parses the optional ops-event field filters (eventType / severity / + /// minSeverity) from the request query and projects them into both the storage + /// and the pagination extras (key/value pairs folded into + /// the continuation fingerprint and echoed on nextLink). Mirrors + /// — same mechanism, same guarantees. + /// + internal static class OpsEventFilterRequest + { + /// Parse outcome: either (possibly empty) or an for a 400. + public sealed class Result + { + public OpsEventQueryFilters Filters { get; init; } = new OpsEventQueryFilters(); + public string? Error { get; init; } + } + + /// + /// Severity values are normalised onto the canonical vocabulary and REJECTED when unknown. + /// Table Storage eq is case-sensitive and the store only ever holds the four + /// canonical strings, so an unnormalised "warning" — or a typo like "Warn" — would return + /// an empty page that reads exactly like "no such events happened". A 400 says which it is. + /// + public static Result Parse(NameValueCollection query) + { + var filters = new OpsEventQueryFilters + { + EventType = NullIfEmpty(query["eventType"]), + }; + + var severityRaw = NullIfEmpty(query["severity"]); + if (severityRaw != null) + { + if (!OpsEventSeverity.TryNormalize(severityRaw, out var canonical)) + return new Result { Error = InvalidSeverityMessage("severity", severityRaw) }; + filters.Severity = canonical; + } + + var minSeverityRaw = NullIfEmpty(query["minSeverity"]); + if (minSeverityRaw != null) + { + if (!OpsEventSeverity.TryNormalize(minSeverityRaw, out var canonical)) + return new Result { Error = InvalidSeverityMessage("minSeverity", minSeverityRaw) }; + filters.MinSeverity = canonical; + } + + return new Result { Filters = filters }; + } + + /// + /// Returns the non-empty filter values as ordered query-param pairs. The order is fixed so + /// the fingerprint computed at mint time matches the one recomputed when the echoed + /// nextLink params are re-parsed on the follow-up request. Values are the NORMALISED ones, + /// so "?severity=error" and "?severity=Error" share one token instead of minting two that + /// each reject the other. + /// + public static List> ToExtras(OpsEventQueryFilters filters) + { + var extras = new List>(); + Add(extras, "eventType", filters.EventType); + Add(extras, "severity", filters.Severity); + Add(extras, "minSeverity", filters.MinSeverity); + return extras; + } + + private static string InvalidSeverityMessage(string param, string value) => + $"{param} must be one of {string.Join(", ", OpsEventSeverity.All)} (got '{value}')"; + + private static void Add(List> extras, string key, string? value) + { + if (!string.IsNullOrEmpty(value)) + extras.Add(new KeyValuePair(key, value)); + } + + private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAdminConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAdminConfigurationFunction.cs index 00abb7dc..99fe7182 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAdminConfigurationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAdminConfigurationFunction.cs @@ -3,6 +3,7 @@ using System.Net; using System.Threading.Tasks; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.Models.Notifications; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -36,9 +37,25 @@ namespace AutopilotMonitor.Functions.Functions.Config var config = await _adminConfigService.GetConfigurationAsync(); - // A read-only GlobalReader gets secrets (NvdApiKey, SAS, ops webhook URLs) redacted; a - // Global Admin gets the full config. Redaction returns a copy — the cached instance is - // never mutated. + // Ops channels: a tenant that predates named channels stores an empty list, and the + // dispatcher synthesizes one channel per configured legacy provider slot. Project the + // SAME synthesis into the read model so the editor shows what actually receives + // alerts today — the alternative is a second copy of the synthesis rules in the web + // app, drifting from the one the dispatcher uses. The first save then persists + // exactly the list the operator was shown. Never mutates the cached instance. + if (string.IsNullOrWhiteSpace(config.OpsNotificationChannelsJson)) + { + var synthesized = config.GetOpsNotificationChannels(); + if (synthesized.Count > 0) + { + config = config.ShallowCopy(); + config.OpsNotificationChannelsJson = NotificationChannel.SerializeList(synthesized); + } + } + + // A read-only GlobalReader gets secrets (NvdApiKey, SAS, ops webhook URLs and the + // per-channel destinations) redacted; a Global Admin gets the full config. Redaction + // returns a copy — the cached instance is never mutated. if (requestCtx.IsGlobalReader) config = config.RedactedCopyForReader(); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs index fd0e8eac..f4c3e9ef 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs @@ -171,6 +171,23 @@ namespace AutopilotMonitor.Functions.Functions.Config TenantEntitlementService.GetRetentionGraceEndUtc(config, nowUtc), config.DataRetentionDays); } + + // The mirror image of the downgrade event: a GA granting or extending a trial + // is the same conversion moment as the self-service start, and sales/support + // channels bind to one event type, not two. Keyed off the recorded change so + // an unrelated planTier-only edit stays silent; ending a trial (explicit null) + // is a downgrade, already covered above. + if (changes.ContainsKey("TrialExpiresUtc") && config.TrialExpiresUtc.HasValue) + { + await _opsEvents.RecordTenantTrialStartedAsync( + requestCtx.TargetTenantId, + config.DomainName, + config.ContactEmail, + config.TrialStartedUtc, + config.TrialExpiresUtc, + caller, + selfService: false); + } } var response = req.CreateResponse(HttpStatusCode.OK); @@ -335,6 +352,17 @@ namespace AutopilotMonitor.Functions.Functions.Config { "TrialExpiresUtc", FormatUtc(config.TrialExpiresUtc) } }); + // The conversion moment. Fired after the write persisted (SaveConfigurationAsync + // is fail-loud), so an alert never announces a trial that was not stored. + await _opsEvents.RecordTenantTrialStartedAsync( + requestCtx.TargetTenantId, + config.DomainName, + config.ContactEmail, + config.TrialStartedUtc, + config.TrialExpiresUtc, + caller, + selfService: true); + var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new StartTenantTrialResponse { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/TestOpsChannelFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/TestOpsChannelFunction.cs new file mode 100644 index 00000000..6c4d4529 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/TestOpsChannelFunction.cs @@ -0,0 +1,104 @@ +using System; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Functions.Services.Notifications; +using AutopilotMonitor.Shared.Models; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Functions.Functions.Config +{ + /// + /// Sends a test notification to one PLATFORM ops channel — the operator-side counterpart of + /// , which tests a tenant's channels. Same + /// response contract; the only difference is the channel list it resolves against. + /// + public class TestOpsChannelFunction + { + private readonly ILogger _logger; + private readonly AdminConfigurationService _adminConfigService; + private readonly NotificationChannelDispatcher _channelDispatcher; + + public TestOpsChannelFunction( + ILogger logger, + AdminConfigurationService adminConfigService, + NotificationChannelDispatcher channelDispatcher) + { + _logger = logger; + _adminConfigService = adminConfigService; + _channelDispatcher = channelDispatcher; + } + + [Function("TestOpsChannel")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/config/test-ops-channel")] HttpRequestData req) + { + try + { + // Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware + var userIdentifier = TenantHelper.GetUserIdentifier(req); + _logger.LogInformation("Test ops channel requested by {User}", userIdentifier); + + string? channelId = null; + var body = await req.ReadAsStringAsync(); + if (!string.IsNullOrWhiteSpace(body)) + { + try + { + channelId = JsonConvert.DeserializeAnonymousType(body, new { channelId = (string?)null })?.channelId; + } + catch (JsonException) + { + // Malformed body → treat as no channel selection. + } + } + + var config = await _adminConfigService.GetConfigurationAsync(); + + // Resolves against the synthesized legacy channels too, so an operator can test + // the existing setup before ever saving the migrated list. + var channels = config.GetOpsNotificationChannels(); + var channel = channelId != null + ? channels.FirstOrDefault(c => string.Equals(c.Id, channelId, StringComparison.OrdinalIgnoreCase)) + : channels.FirstOrDefault(); + + if (channel == null || string.IsNullOrEmpty(channel.Url)) + { + var notFound = req.CreateResponse(HttpStatusCode.OK); + await notFound.WriteAsJsonAsync(new TestWebhookNotificationResponse + { + Success = false, + Message = channelId != null + ? "The selected channel was not found. Save your changes before testing." + : "No ops notification channel is configured. Please add one first." + }); + return notFound; + } + + var testAlert = NotificationAlertBuilder.BuildTestAlert(); + var result = await _channelDispatcher.SendWithResultAsync(channel, testAlert); + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new TestWebhookNotificationResponse + { + Success = result.Success, + StatusCode = result.StatusCode, + Message = result.Message + }); + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error sending ops channel test notification"); + var response = req.CreateResponse(HttpStatusCode.InternalServerError); + await response.WriteAsJsonAsync(new { error = "Internal server error" }); + return response; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/TestWebhookNotificationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/TestWebhookNotificationFunction.cs index cff6e2d9..90675393 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/TestWebhookNotificationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/TestWebhookNotificationFunction.cs @@ -18,16 +18,16 @@ namespace AutopilotMonitor.Functions.Functions.Config { private readonly ILogger _logger; private readonly TenantConfigurationService _configService; - private readonly WebhookNotificationService _webhookNotificationService; + private readonly NotificationChannelDispatcher _channelDispatcher; public TestWebhookNotificationFunction( ILogger logger, TenantConfigurationService configService, - WebhookNotificationService webhookNotificationService) + NotificationChannelDispatcher channelDispatcher) { _logger = logger; _configService = configService; - _webhookNotificationService = webhookNotificationService; + _channelDispatcher = channelDispatcher; } [Function("TestWebhookNotification")] @@ -76,14 +76,13 @@ namespace AutopilotMonitor.Functions.Functions.Config success = false, message = channelId != null ? "The selected channel was not found. Save your changes before testing." - : "No webhook is configured. Please set a webhook URL and provider first." + : "No notification channel is configured. Please add one first." }); return noConfigResponse; } var testAlert = NotificationAlertBuilder.BuildTestAlert(); - var result = await _webhookNotificationService.SendNotificationWithResultAsync( - channel.Url, (WebhookProviderType)channel.ProviderType, testAlert, channel.GetCustomHeaders(), channel.GetSigningSecret()); + var result = await _channelDispatcher.SendWithResultAsync(channel, testAlert); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new TestWebhookNotificationResponse diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateAdminConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateAdminConfigurationFunction.cs index 72f8bd02..3776e4cb 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateAdminConfigurationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateAdminConfigurationFunction.cs @@ -7,6 +7,7 @@ using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Models.Notifications; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -83,6 +84,25 @@ namespace AutopilotMonitor.Functions.Functions.Config // Load existing config for diff before saving var existingConfig = await _adminConfigService.GetConfigurationAsync(); + + // Ops channels: a GlobalReader is served a redacted copy (destinations replaced + // with the ***REDACTED*** sentinel). Only a GA can reach this endpoint, so this is + // defense-in-depth — but persisting the literal sentinel as a chat ID or webhook + // URL would silently break every alert, so restore per channel id first. + config.OpsNotificationChannelsJson = NotificationChannel.RestoreRedactedList( + config.OpsNotificationChannelsJson, existingConfig.OpsNotificationChannelsJson); + + // Same structural gate the tenant channel list passes (ids, provider types, + // SSRF-checked URLs / Telegram chat IDs, header and signing-secret shape). No GA + // gate on Telegram here: this endpoint is GlobalAdminOnly by policy. + var channelsError = TenantConfigValidation.ValidateNotificationChannels(config.OpsNotificationChannelsJson); + if (channelsError != null) + { + var badChannels = req.CreateResponse(HttpStatusCode.BadRequest); + await badChannels.WriteAsJsonAsync(new { success = false, message = $"Invalid ops notification channels: {channelsError}" }); + return badChannels; + } + var changes = ConfigDiffHelper.GetChanges(existingConfig, config); // Save configuration diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportDistressFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportDistressFunction.cs index efd75066..0fe77f2c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportDistressFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportDistressFunction.cs @@ -37,7 +37,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest private readonly IDistressReportRepository _repository; private readonly TelemetryClient _telemetryClient; private readonly HardwareRejectionThrottleService _hardwareThrottle; - private readonly WebhookNotificationService _webhookNotification; + private readonly NotificationChannelDispatcher _channelDispatcher; private readonly IHardwareRejectionNotificationTracker _hardwareBellTracker; private readonly TenantNotificationService _tenantNotificationService; @@ -77,7 +77,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest IDistressReportRepository repository, TelemetryClient telemetryClient, HardwareRejectionThrottleService hardwareThrottle, - WebhookNotificationService webhookNotification, + NotificationChannelDispatcher channelDispatcher, IHardwareRejectionNotificationTracker hardwareBellTracker, TenantNotificationService tenantNotificationService) { @@ -87,7 +87,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest _repository = repository; _telemetryClient = telemetryClient; _hardwareThrottle = hardwareThrottle; - _webhookNotification = webhookNotification; + _channelDispatcher = channelDispatcher; _hardwareBellTracker = hardwareBellTracker; _tenantNotificationService = tenantNotificationService; } @@ -232,7 +232,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest if (hwChannels.Count > 0) { var alert = NotificationAlertBuilder.BuildHardwareRejectedAlert(manufacturer, model, serialNumber); - _ = _webhookNotification.SendToChannelsAsync(hwChannels, alert); + _ = _channelDispatcher.SendToChannelsAsync(hwChannels, alert); } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs index 46480e0f..7a4fd9d9 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs @@ -34,7 +34,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions private readonly CloudPcDeviceValidator _cloudPcDeviceValidator; private readonly IntuneDeviceBindingValidator _intuneDeviceBindingValidator; private readonly BootstrapSessionService _bootstrapSessionService; - private readonly WebhookNotificationService _webhookNotificationService; + private readonly NotificationChannelDispatcher _channelDispatcher; private readonly SessionDeletionGuard _deletionGuard; private readonly AdminConfigurationService _adminConfigService; private readonly SessionOwnerBindingObserver _ownerBinding; @@ -53,7 +53,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions CloudPcDeviceValidator cloudPcDeviceValidator, IntuneDeviceBindingValidator intuneDeviceBindingValidator, BootstrapSessionService bootstrapSessionService, - WebhookNotificationService webhookNotificationService, + NotificationChannelDispatcher channelDispatcher, SessionDeletionGuard deletionGuard, SessionOwnerBindingObserver ownerBinding, OpsEventService opsEvents) @@ -70,7 +70,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions _cloudPcDeviceValidator = cloudPcDeviceValidator; _intuneDeviceBindingValidator = intuneDeviceBindingValidator; _bootstrapSessionService = bootstrapSessionService; - _webhookNotificationService = webhookNotificationService; + _channelDispatcher = channelDispatcher; _deletionGuard = deletionGuard; _ownerBinding = ownerBinding; _opsEvents = opsEvents; @@ -412,7 +412,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions isResume: isResume, sessionUrl: sessionUrl); - await _webhookNotificationService.SendToChannelsAsync(startChannels, alert); + await _channelDispatcher.SendToChannelsAsync(startChannels, alert); } catch (Exception ex) { diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigValidation.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigValidation.cs index 09d86d5f..50a1ce88 100644 --- a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigValidation.cs +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigValidation.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared.Models; @@ -173,6 +174,11 @@ namespace AutopilotMonitor.Functions.Helpers if (channelsError != null) return $"Invalid notification channels: {channelsError}"; + var telegramGateError = ValidateTelegramChannelGate( + candidate.NotificationChannelsJson, existing.NotificationChannelsJson, isGlobalAdmin); + if (telegramGateError != null) + return telegramGateError; + var brandingUrlError = ValidateBrandingImageUrl(candidate.EnrollmentSummaryBrandingImageUrl); if (brandingUrlError != null) return $"Invalid enrollment summary branding image URL: {brandingUrlError}"; @@ -302,9 +308,15 @@ namespace AutopilotMonitor.Functions.Helpers || channel.ProviderType == (int)Shared.Models.Notifications.WebhookProviderType.None) return $"channel \"{label}\" has an invalid provider type."; - var urlError = SsrfGuard.ValidateWebhookUrlFormat(channel.Url); - if (urlError != null) - return $"channel \"{label}\": {urlError}"; + // Telegram's destination is a chat ID, not a URL — the SSRF gate does not apply + // (there is no caller-supplied endpoint; the bot URL is platform-owned) and would + // reject every valid value. Format-check the chat ID instead. + var destinationError = + channel.ProviderType == (int)Shared.Models.Notifications.WebhookProviderType.Telegram + ? ValidateTelegramChatId(channel.Url) + : SsrfGuard.ValidateWebhookUrlFormat(channel.Url); + if (destinationError != null) + return $"channel \"{label}\": {destinationError}"; var headerError = ValidateWebhookCustomHeaders(channel.CustomHeadersJson); if (headerError != null) @@ -318,6 +330,85 @@ namespace AutopilotMonitor.Functions.Helpers return null; } + /// + /// Validates a Telegram destination chat ID. Returns an error message, or null when valid. + /// Accepts a numeric id (negative for groups/channels, e.g. -1003785642894) or an @username. + /// Empty is rejected: unlike a webhook URL, a Telegram channel with no chat ID cannot be + /// saved as "configured but idle" — it would silently drop every message. + /// + internal static string? ValidateTelegramChatId(string? chatId) + { + if (string.IsNullOrWhiteSpace(chatId)) + return "a Telegram chat ID is required."; + + var trimmed = chatId!.Trim(); + + if (trimmed.StartsWith("@", StringComparison.Ordinal)) + { + // Telegram public usernames: 5-32 chars, letters/digits/underscore, letter first. + var name = trimmed.Substring(1); + if (name.Length < 5 || name.Length > 32 || !char.IsLetter(name[0])) + return "the @username must be 5-32 characters and start with a letter."; + foreach (var ch in name) + { + if (!char.IsLetterOrDigit(ch) && ch != '_') + return "the @username may only contain letters, digits and underscores."; + } + return null; + } + + var digits = trimmed.StartsWith("-", StringComparison.Ordinal) ? trimmed.Substring(1) : trimmed; + if (digits.Length == 0 || digits.Length > 20) + return "must be a numeric chat ID (up to 20 digits) or an @username."; + foreach (var ch in digits) + { + if (!char.IsDigit(ch)) + return "must be a numeric chat ID (up to 20 digits) or an @username."; + } + + return null; + } + + /// + /// Global-Admin gate for Telegram channels. A Telegram channel sends through the PLATFORM's + /// bot (the token lives in PreviewConfig, not in tenant config), so a tenant admin must not + /// be able to create or retarget one — hiding the provider in the UI is not a control. + /// + /// Compares against the stored config so a non-GA caller can still save unrelated changes + /// while a GA-created Telegram channel is present: only ADDING one, or changing an existing + /// one's destination/enabled state, is refused. Same shape as the retention-cap check. + /// + /// + internal static string? ValidateTelegramChannelGate(string? candidateJson, string? existingJson, bool isGlobalAdmin) + { + if (isGlobalAdmin) + return null; + + var candidates = Shared.Models.Notifications.NotificationChannel.ParseList(candidateJson) + .Where(c => c.ProviderType == (int)Shared.Models.Notifications.WebhookProviderType.Telegram) + .ToList(); + + if (candidates.Count == 0) + return null; + + var existingById = Shared.Models.Notifications.NotificationChannel.ParseList(existingJson) + .Where(c => c.ProviderType == (int)Shared.Models.Notifications.WebhookProviderType.Telegram) + .GroupBy(c => c.Id, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + + foreach (var channel in candidates) + { + if (!existingById.TryGetValue(channel.Id, out var stored) + || !string.Equals(stored.Url, channel.Url, StringComparison.Ordinal) + || stored.Enabled != channel.Enabled) + { + return "Telegram channels can only be configured by a Global Administrator."; + } + } + + return null; + } + /// /// Validates a generic-webhook HMAC signing secret. Returns an error message, or null when /// valid/absent. A short key is brute-forceable as an HMAC key; the printable-ASCII rule diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index bb2ee1b2..86071e1c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -447,6 +447,9 @@ builder.Services.AddHttpClient sp.GetRequiredService().Notification); builder.Services.AddHttpClient() .AddPolicyHandler((sp, _) => sp.GetRequiredService().Notification); +// Channel-level send API — routes each NotificationChannel to its transport (webhook renderer +// vs. the platform Telegram bot). Transient: both transports are typed HttpClients. +builder.Services.AddTransient(); // Transactional email (welcome / farewell). Typed client like the other outbound notifiers; // the provider is an implementation detail of EmailService + the Email:* settings. builder.Services.AddSingleton(); diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index f83a4857..45a8d40b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -429,6 +429,7 @@ public static class EndpointAccessPolicyCatalog new("GET", "global/config", EndpointPolicy.GlobalReadOrAdmin), new("PUT", "global/config", EndpointPolicy.GlobalAdminOnly), new("POST", "global/config", EndpointPolicy.GlobalAdminOnly), + new("POST", "global/config/test-ops-channel", EndpointPolicy.GlobalAdminOnly), new("GET", "config/all", EndpointPolicy.GlobalReadOrDelegatedSubset), // Transactional field-level config writes + snapshot list/revert (MCP + web Settings). // PATCH is TenantAdminOrGA (phase 2, the web's per-section save): RouteParam binds a diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Analyze/AnalyzeOnEnrollmentEndHandler.cs b/src/Backend/AutopilotMonitor.Functions/Services/Analyze/AnalyzeOnEnrollmentEndHandler.cs index 719c4269..31d385a9 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Analyze/AnalyzeOnEnrollmentEndHandler.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Analyze/AnalyzeOnEnrollmentEndHandler.cs @@ -49,7 +49,7 @@ namespace AutopilotMonitor.Functions.Services.Analyze private readonly IMetricsRepository _metricsRepo; private readonly SignalRNotificationService _signalRNotification; private readonly TenantConfigurationService _configService; - private readonly Notifications.WebhookNotificationService _webhookNotification; + private readonly Notifications.NotificationChannelDispatcher _channelDispatcher; private readonly ILogger _logger; public const string ReasonEnrollmentComplete = "enrollment_complete"; @@ -65,7 +65,7 @@ namespace AutopilotMonitor.Functions.Services.Analyze IMetricsRepository metricsRepo, SignalRNotificationService signalRNotification, TenantConfigurationService configService, - Notifications.WebhookNotificationService webhookNotification, + Notifications.NotificationChannelDispatcher channelDispatcher, ILogger logger) { _ruleService = ruleService; @@ -74,7 +74,7 @@ namespace AutopilotMonitor.Functions.Services.Analyze _metricsRepo = metricsRepo; _signalRNotification = signalRNotification; _configService = configService; - _webhookNotification = webhookNotification; + _channelDispatcher = channelDispatcher; _logger = logger; } @@ -216,7 +216,7 @@ namespace AutopilotMonitor.Functions.Services.Analyze var alert = Notifications.NotificationAlertBuilder.BuildRuleFiredAlert( result, session?.DeviceName, session?.SerialNumber, sessionUrl); - await _webhookNotification.SendToChannelsAsync(targets, alert).ConfigureAwait(false); + await _channelDispatcher.SendToChannelsAsync(targets, alert).ConfigureAwait(false); _logger.LogInformation( "{Prefix} Rule-notify: {RuleId} → {ChannelCount} channel(s)", diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs index 085bd9bb..2e4bd6f7 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs @@ -72,7 +72,7 @@ namespace AutopilotMonitor.Functions.Services sessionUrl: sessionUrl); NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults); - _ = _webhookNotificationService.SendToChannelsAsync(targets, alert) + _ = _channelDispatcher.SendToChannelsAsync(targets, alert) .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted); } @@ -94,7 +94,7 @@ namespace AutopilotMonitor.Functions.Services sessionUrl: sessionUrl); NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults); - _ = _webhookNotificationService.SendToChannelsAsync(successChannels, alert) + _ = _channelDispatcher.SendToChannelsAsync(successChannels, alert) .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted); } @@ -115,7 +115,7 @@ namespace AutopilotMonitor.Functions.Services sessionUrl: sessionUrl); NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults); - _ = _webhookNotificationService.SendToChannelsAsync(failureChannels, alert) + _ = _channelDispatcher.SendToChannelsAsync(failureChannels, alert) .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted); } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs index 2e051109..357234ce 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs @@ -38,7 +38,7 @@ namespace AutopilotMonitor.Functions.Services private readonly TenantConfigurationService _configService; private readonly AnalyzeRuleService _analyzeRuleService; private readonly GatherRuleService _gatherRuleService; - private readonly WebhookNotificationService _webhookNotificationService; + private readonly NotificationChannelDispatcher _channelDispatcher; private readonly AdminConfigurationService _adminConfigService; private readonly OpsEventService _opsEventService; private readonly SlaBreachEvaluationService _slaBreachService; @@ -61,7 +61,7 @@ namespace AutopilotMonitor.Functions.Services TenantConfigurationService configService, AnalyzeRuleService analyzeRuleService, GatherRuleService gatherRuleService, - WebhookNotificationService webhookNotificationService, + NotificationChannelDispatcher channelDispatcher, AdminConfigurationService adminConfigService, OpsEventService opsEventService, SlaBreachEvaluationService slaBreachService, @@ -81,7 +81,7 @@ namespace AutopilotMonitor.Functions.Services _configService = configService; _analyzeRuleService = analyzeRuleService; _gatherRuleService = gatherRuleService; - _webhookNotificationService = webhookNotificationService; + _channelDispatcher = channelDispatcher; _adminConfigService = adminConfigService; _opsEventService = opsEventService; _slaBreachService = slaBreachService; diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/GenericJsonRenderer.cs b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/GenericJsonRenderer.cs index c67751af..99506d57 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/GenericJsonRenderer.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/GenericJsonRenderer.cs @@ -1,6 +1,7 @@ using System.Linq; using AutopilotMonitor.Shared.Models.Notifications; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace AutopilotMonitor.Functions.Services.Notifications { @@ -17,8 +18,12 @@ namespace AutopilotMonitor.Functions.Services.Notifications /// "primaryUrl": "https://.../sessions/{id}", // first openUrl action (session/dashboard link), omitted when none /// "facts": [ { "name": "Device", "value": "..." } ], /// "sections": [ { "title": "...", "text": "..." } ], - /// "actions": [ { "type": "openUrl", "title": "...", "url": "..." } ] + /// "actions": [ { "type": "openUrl", "title": "...", "url": "..." } ], + /// "data": { ... } // structured event payload, omitted when absent /// } + /// + /// "data" is additive within schemaVersion 1.0: consumers that ignore unknown keys are + /// unaffected, and the same values remain readable in facts[]. /// public class GenericJsonRenderer : INotificationRenderer { @@ -47,6 +52,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications facts = alert.Facts.Select(f => new { name = f.Name, value = f.Value }).ToArray(), sections = alert.Sections.Select(s => new { title = s.Title, text = s.Text }).ToArray(), actions = alert.Actions.Select(a => new { type = a.Type, title = a.Title, url = a.Url }).ToArray(), + data = ParseData(alert.DataJson), }; return JsonConvert.SerializeObject(payload, new JsonSerializerSettings @@ -54,5 +60,25 @@ namespace AutopilotMonitor.Functions.Services.Notifications NullValueHandling = NullValueHandling.Ignore, }); } + + /// + /// Embeds the structured payload as real JSON rather than a quoted string. Anything that + /// is not a JSON object is dropped: a malformed or non-object payload must not change the + /// shape of data that consumers parse against. + /// + private static JObject? ParseData(string? dataJson) + { + if (string.IsNullOrWhiteSpace(dataJson)) + return null; + + try + { + return JsonConvert.DeserializeObject(dataJson) as JObject; + } + catch (JsonException) + { + return null; + } + } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationChannelDispatcher.cs b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationChannelDispatcher.cs new file mode 100644 index 00000000..b7fbd1ae --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationChannelDispatcher.cs @@ -0,0 +1,82 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using AutopilotMonitor.Shared.Models.Notifications; + +namespace AutopilotMonitor.Functions.Services.Notifications +{ + /// + /// The channel-level send API: takes objects and routes each + /// one to the transport its provider needs. Every notification that targets configured channels + /// (enrollment, SLA, analyze rules, ops alerts) goes through here, so a new provider is added + /// in exactly one place. + /// + /// Two transports today: goes to + /// (plain text through the platform bot, the channel's + /// Url field carrying the chat ID); everything else is a rendered webhook POST via + /// . Telegram is deliberately NOT a renderer — it has no + /// endpoint URL of its own and must never reach the SSRF-guarded webhook path. + /// + /// + public class NotificationChannelDispatcher + { + private readonly WebhookNotificationService _webhook; + private readonly TelegramNotificationService _telegram; + + public NotificationChannelDispatcher( + WebhookNotificationService webhook, + TelegramNotificationService telegram) + { + _webhook = webhook; + _telegram = telegram; + } + + /// + /// Sends a notification to every channel in (callers pre-filter + /// by and the relevant NotifyOn* toggle, or by + /// rule-level channel ids). Channels are dispatched sequentially and independently — a + /// failing destination only logs a warning and never blocks the remaining channels or the + /// caller's pipeline. + /// + public virtual async Task SendToChannelsAsync(IEnumerable channels, NotificationAlert alert) + { + foreach (var channel in channels) + { + if (channel == null || string.IsNullOrEmpty(channel.Url)) + continue; + + if (channel.ProviderType == (int)WebhookProviderType.Telegram) + { + await _telegram.SendOpsAlertAsync(channel.Url!, alert); + continue; + } + + await _webhook.SendNotificationAsync( + channel.Url!, + (WebhookProviderType)channel.ProviderType, + alert, + channel.GetCustomHeaders(), + channel.GetSigningSecret()); + } + } + + /// + /// Sends to a single channel and REPORTS the outcome — the "send test notification" + /// endpoints. Not fire-and-forget; never throws. + /// + public virtual async Task SendWithResultAsync(NotificationChannel channel, NotificationAlert alert) + { + if (channel == null || string.IsNullOrWhiteSpace(channel.Url)) + return new WebhookTestResult { Success = false, Message = "This channel has no destination configured." }; + + if (channel.ProviderType == (int)WebhookProviderType.Telegram) + return await _telegram.SendAlertWithResultAsync(channel.Url!, alert); + + return await _webhook.SendNotificationWithResultAsync( + channel.Url!, + (WebhookProviderType)channel.ProviderType, + alert, + channel.GetCustomHeaders(), + channel.GetSigningSecret()); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/WebhookNotificationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/WebhookNotificationService.cs index 76824e00..d187690e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/WebhookNotificationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/WebhookNotificationService.cs @@ -40,7 +40,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications /// (generic webhooks only) adds HMAC signature headers /// (see ). /// - public async Task SendNotificationAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert, + public virtual async Task SendNotificationAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert, IReadOnlyDictionary? customHeaders = null, string? signingSecret = null) { if (string.IsNullOrEmpty(webhookUrl) || providerType == WebhookProviderType.None) @@ -73,35 +73,12 @@ namespace AutopilotMonitor.Functions.Services.Notifications } } - /// - /// Sends a notification to every channel in (callers pre-filter - /// by and the relevant NotifyOn* toggle). - /// Channels are dispatched sequentially and independently — a failing endpoint only logs - /// a warning (via ) and never blocks the remaining - /// channels or the caller's pipeline. - /// - public async Task SendToChannelsAsync(IEnumerable channels, NotificationAlert alert) - { - foreach (var channel in channels) - { - if (channel == null || string.IsNullOrEmpty(channel.Url)) - continue; - - await SendNotificationAsync( - channel.Url, - (WebhookProviderType)channel.ProviderType, - alert, - channel.GetCustomHeaders(), - channel.GetSigningSecret()); - } - } - /// /// Sends a notification and returns the result (for test endpoint). Not fire-and-forget. /// and (generic /// webhooks only) are applied exactly like in . /// - public async Task SendNotificationWithResultAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert, + public virtual async Task SendNotificationWithResultAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert, IReadOnlyDictionary? customHeaders = null, string? signingSecret = null) { if (string.IsNullOrEmpty(webhookUrl)) @@ -181,6 +158,11 @@ namespace AutopilotMonitor.Functions.Services.Notifications } } + /// + /// Outcome of a single "send a test notification" attempt. Shared by every channel provider — + /// returns it for Telegram channels too, which is + /// why the type is not webhook-specific despite the name. + /// public class WebhookTestResult { public bool Success { get; set; } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/OpsAlertDispatchService.cs b/src/Backend/AutopilotMonitor.Functions/Services/OpsAlertDispatchService.cs index 5ea1b0ba..48d14b2e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/OpsAlertDispatchService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/OpsAlertDispatchService.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Threading.Tasks; using AutopilotMonitor.Functions.Services.Notifications; using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; using AutopilotMonitor.Shared.Models.Notifications; using Microsoft.Extensions.Logging; @@ -16,61 +18,61 @@ namespace AutopilotMonitor.Functions.Services public class OpsAlertDispatchService { private readonly AdminConfigurationService _adminConfigService; - private readonly TelegramNotificationService _telegram; - private readonly WebhookNotificationService _webhook; + private readonly NotificationChannelDispatcher _channelDispatcher; private readonly ILogger _logger; public OpsAlertDispatchService( AdminConfigurationService adminConfigService, - TelegramNotificationService telegram, - WebhookNotificationService webhook, + NotificationChannelDispatcher channelDispatcher, ILogger logger) { _adminConfigService = adminConfigService; - _telegram = telegram; - _webhook = webhook; + _channelDispatcher = channelDispatcher; _logger = logger; } /// - /// Evaluates alert rules for the given ops event and dispatches to all enabled providers. - /// Safe to call fire-and-forget — never throws. + /// Evaluates alert rules for the given ops event and dispatches to the channels those + /// rules target. Safe to call fire-and-forget — never throws. /// public async Task DispatchAsync(string category, string eventType, string severity, - string message, string? tenantId) + string message, string? tenantId, string? detailsJson = null) { try { var config = await _adminConfigService.GetConfigurationAsync(); if (config == null) return; - var rules = config.GetOpsAlertRules(); - var matchingRule = rules.FirstOrDefault(r => - r.Enabled && - r.EventType == eventType && - SeverityRank(severity) >= SeverityRank(r.MinSeverity)); + // ALL matching rules, not just the first: two rules on the same event type with + // different severities and different channels is the whole point of per-rule + // routing (e.g. "Info → Sales" alongside "Error → operator push"). + var matchingRules = config.GetOpsAlertRules() + .Where(r => r.Enabled + && r.EventType == eventType + && SeverityRank(severity) >= SeverityRank(r.MinSeverity)) + .ToList(); - if (matchingRule == null) return; + if (matchingRules.Count == 0) return; - var alert = BuildAlert(category, eventType, severity, message, tenantId); + var channels = config.GetOpsNotificationChannels(); + var (withPayload, plain) = ResolveTargets(matchingRules, channels); + if (withPayload.Count == 0 && plain.Count == 0) return; - var tasks = new List(); - - if (config.OpsAlertTelegramEnabled && !string.IsNullOrWhiteSpace(config.OpsAlertTelegramChatId)) - tasks.Add(_telegram.SendOpsAlertAsync(config.OpsAlertTelegramChatId, alert)); - - if (config.OpsAlertTeamsEnabled && !string.IsNullOrWhiteSpace(config.OpsAlertTeamsWebhookUrl)) - tasks.Add(_webhook.SendNotificationAsync(config.OpsAlertTeamsWebhookUrl, WebhookProviderType.TeamsWorkflowWebhook, alert)); - - if (config.OpsAlertSlackEnabled && !string.IsNullOrWhiteSpace(config.OpsAlertSlackWebhookUrl)) - tasks.Add(_webhook.SendNotificationAsync(config.OpsAlertSlackWebhookUrl, WebhookProviderType.Slack, alert)); - - if (tasks.Count > 0) + if (plain.Count > 0) { - await Task.WhenAll(tasks); - _logger.LogInformation("Ops alert dispatched for {Category}/{EventType} to {ProviderCount} provider(s)", - category, eventType, tasks.Count); + await _channelDispatcher.SendToChannelsAsync(plain, + BuildAlert(category, eventType, severity, message, tenantId, detailsJson: null)); } + + if (withPayload.Count > 0) + { + await _channelDispatcher.SendToChannelsAsync(withPayload, + BuildAlert(category, eventType, severity, message, tenantId, detailsJson)); + } + + _logger.LogInformation( + "Ops alert dispatched for {Category}/{EventType} to {ChannelCount} channel(s) ({PayloadCount} with payload)", + category, eventType, plain.Count + withPayload.Count, withPayload.Count); } catch (Exception ex) { @@ -78,8 +80,69 @@ namespace AutopilotMonitor.Functions.Services } } + /// + /// Splits the channels the matching rules target into the ones that get the event's + /// payload and the ones that get the baseline alert only, preserving the configured + /// channel order and never sending twice to the same channel. + /// + /// A rule with no channel ids means "every enabled channel" — that is what every rule + /// written before per-rule routing existed carries, so their behavior is unchanged. + /// Ids that no longer resolve are dropped silently: a rule whose only channel was deleted + /// must go nowhere, NOT fall back to broadcasting. + /// + /// + /// When a channel is targeted by both a payload rule and a plain one it appears in the + /// payload group only — one message per channel, and the explicit opt-in wins over the + /// default rather than being cancelled by an unrelated sibling rule. + /// + /// + internal static (List WithPayload, List Plain) ResolveTargets( + IReadOnlyList matchingRules, + IReadOnlyList channels) + { + var enabled = channels.Where(c => c.Enabled).ToList(); + if (enabled.Count == 0) + return (new List(), new List()); + + var payloadIds = TargetedIds(matchingRules.Where(r => r.IncludePayload), enabled); + var plainIds = TargetedIds(matchingRules.Where(r => !r.IncludePayload), enabled); + + return ( + enabled.Where(c => payloadIds.Contains(c.Id)).ToList(), + enabled.Where(c => plainIds.Contains(c.Id) && !payloadIds.Contains(c.Id)).ToList()); + } + + /// Channel ids one group of rules targets; empty binding = every enabled channel. + private static HashSet TargetedIds( + IEnumerable rules, IReadOnlyList enabled) + { + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + var materialized = rules.ToList(); + if (materialized.Count == 0) + return ids; + + if (materialized.Any(r => r.NotifyChannelIds == null || r.NotifyChannelIds.Count == 0)) + { + foreach (var channel in enabled) + ids.Add(channel.Id); + return ids; + } + + foreach (var rule in materialized) + { + foreach (var id in rule.NotifyChannelIds!) + ids.Add(id); + } + + return ids; + } + + /// Caps on the flattened detail facts — a card must stay readable. + internal const int MaxDetailFacts = 12; + internal const int MaxDetailValueLength = 256; + private static NotificationAlert BuildAlert(string category, string eventType, - string severity, string message, string? tenantId) + string severity, string message, string? tenantId, string? detailsJson) { var notifSeverity = severity switch { @@ -107,23 +170,102 @@ namespace AutopilotMonitor.Functions.Services if (!string.IsNullOrWhiteSpace(tenantId)) facts.Add(new NotificationFact { Name = "Tenant", Value = tenantId }); + facts.AddRange(FlattenDetails(detailsJson)); + return new NotificationAlert { + // Machine-readable routing key for generic consumers — the same value the + // "Event" fact already carries, so this adds no information, only a stable key + // to branch on. Set regardless of the payload opt-in. + EventType = eventType, Title = $"Ops Alert: {category}/{eventType}", Summary = message, Severity = notifSeverity, ThemeColor = themeColor, - Facts = facts + Facts = facts, + DataJson = detailsJson, }; } - private static int SeverityRank(string severity) => severity switch + /// + /// Turns the ops event's structured details into readable facts, so card channels (Teams, + /// Slack, Discord) and the plain-text Telegram message carry the same information the + /// generic JSON consumer gets in data. Reached only for rules that opted in via + /// — the caller passes null otherwise, so the + /// default alert stays the category/event/severity/tenant baseline. + /// + /// Deliberately shallow: only top-level scalars. Nested objects and arrays have no + /// sensible one-line rendering, and a payload is not a UI. Nulls are skipped (an absent + /// value is not news), values are truncated, and the count is capped. + /// + /// + internal static List FlattenDetails(string? detailsJson) { - OpsEventSeverity.Info => 0, - OpsEventSeverity.Warning => 1, - OpsEventSeverity.Error => 2, - OpsEventSeverity.Critical => 3, - _ => -1 - }; + var facts = new List(); + if (string.IsNullOrWhiteSpace(detailsJson)) + return facts; + + try + { + using var doc = JsonDocument.Parse(detailsJson!); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + return facts; + + foreach (var property in doc.RootElement.EnumerateObject()) + { + if (facts.Count >= MaxDetailFacts) + break; + + var value = property.Value.ValueKind switch + { + JsonValueKind.String => property.Value.GetString(), + JsonValueKind.Number => property.Value.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + _ => null, // null, object, array + }; + + if (string.IsNullOrWhiteSpace(value)) + continue; + + if (value!.Length > MaxDetailValueLength) + value = value.Substring(0, MaxDetailValueLength) + "…"; + + facts.Add(new NotificationFact { Name = Humanize(property.Name), Value = value }); + } + } + catch (JsonException) + { + // Details are best-effort context; a malformed payload must never cost the alert. + } + + return facts; + } + + /// "trialExpiresUtc" → "Trial Expires Utc" — the payload uses camelCase keys, the + /// surrounding facts use title case, and a card mixing both reads like a bug. + internal static string Humanize(string propertyName) + { + if (string.IsNullOrEmpty(propertyName)) + return propertyName; + + var sb = new System.Text.StringBuilder(propertyName.Length + 8); + sb.Append(char.ToUpperInvariant(propertyName[0])); + + for (var i = 1; i < propertyName.Length; i++) + { + var ch = propertyName[i]; + if (char.IsUpper(ch) && !char.IsUpper(propertyName[i - 1])) + sb.Append(' '); + sb.Append(ch); + } + + return sb.ToString(); + } + + // Single source for the severity ladder — shared with the ops-events query filters + // (OpsEventQueryFilters.MinSeverity), which must rank identically or an operator's + // "Warning and above" read would disagree with what the alert rules actually fired on. + private static int SeverityRank(string severity) => OpsEventSeverity.Rank(severity); } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs index 447f837b..9eb0a348 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs @@ -31,44 +31,44 @@ namespace AutopilotMonitor.Functions.Services // ── Consent ──────────────────────────────────────────────────────────── public Task RecordConsentFlowStartedAsync(string tenantId, string userId, string redirectUri) - => WriteAsync(OpsEventCategory.Consent, "ConsentFlowStarted", OpsEventSeverity.Info, + => WriteAsync(OpsEventCategory.Consent, OpsEventTypes.ConsentFlowStarted, OpsEventSeverity.Info, $"Admin consent flow started by {userId}", tenantId, userId, new { redirectUri }); public Task RecordConsentFlowSuccessAsync(string tenantId, string userId, string trigger) - => WriteAsync(OpsEventCategory.Consent, "ConsentFlowSuccess", OpsEventSeverity.Info, + => WriteAsync(OpsEventCategory.Consent, OpsEventTypes.ConsentFlowSuccess, OpsEventSeverity.Info, $"Admin consent confirmed for {trigger} by {userId}", tenantId, userId, new { trigger }); public Task RecordConsentFlowFailedAsync(string tenantId, string userId, string error, string errorDescription) - => WriteAsync(OpsEventCategory.Consent, "ConsentFlowFailed", OpsEventSeverity.Error, + => WriteAsync(OpsEventCategory.Consent, OpsEventTypes.ConsentFlowFailed, OpsEventSeverity.Error, $"Admin consent failed: {error}", tenantId, userId, new { error, errorDescription }); public Task RecordConsentRedirectUriMismatchAsync(string tenantId, string userId, string redirectUri, string redirectPath) - => WriteAsync(OpsEventCategory.Consent, "ConsentRedirectUriMismatch", OpsEventSeverity.Critical, + => WriteAsync(OpsEventCategory.Consent, OpsEventTypes.ConsentRedirectUriMismatch, OpsEventSeverity.Critical, $"Redirect URI path '{redirectPath}' not in registered paths — consent will fail with AADSTS50011", tenantId, userId, new { redirectUri, redirectPath }); public Task RecordAppHomingFlippedAsync(string tenantId, string userId, string oldApp, string newApp, string reason, bool forced) - => WriteAsync(OpsEventCategory.Consent, "AppHomingFlipped", OpsEventSeverity.Info, + => WriteAsync(OpsEventCategory.Consent, OpsEventTypes.AppHomingFlipped, OpsEventSeverity.Info, $"App-reg homing flipped {oldApp} -> {newApp} by {userId} ({reason}{(forced ? ", FORCED" : "")})", tenantId, userId, new { oldApp, newApp, reason, forced }); public Task RecordAppHomingFlippedWithEntraRolesAsync(string tenantId, string userId, string oldApp, string newApp) - => WriteAsync(OpsEventCategory.Consent, "AppHomingFlippedWithEntraRoles", OpsEventSeverity.Warning, + => WriteAsync(OpsEventCategory.Consent, OpsEventTypes.AppHomingFlippedWithEntraRoles, OpsEventSeverity.Warning, $"App-reg homing flipped {oldApp} -> {newApp} for a tenant with Entra app roles — re-assign roles on the new enterprise app", tenantId, userId, new { oldApp, newApp }); // ── Maintenance ──────────────────────────────────────────────────────── public Task RecordMaintenanceCompletedAsync(int durationMs, string triggeredBy) - => WriteAsync(OpsEventCategory.Maintenance, "MaintenanceCompleted", OpsEventSeverity.Info, + => WriteAsync(OpsEventCategory.Maintenance, OpsEventTypes.MaintenanceCompleted, OpsEventSeverity.Info, $"Maintenance completed in {durationMs}ms (triggered by {triggeredBy})", null, triggeredBy, new { durationMs }); public Task RecordMaintenanceFailedAsync(string error, string triggeredBy) - => WriteAsync(OpsEventCategory.Maintenance, "MaintenanceFailed", OpsEventSeverity.Error, + => WriteAsync(OpsEventCategory.Maintenance, OpsEventTypes.MaintenanceFailed, OpsEventSeverity.Error, $"Maintenance failed: {error}", null, triggeredBy, new { error }); @@ -79,7 +79,7 @@ namespace AutopilotMonitor.Functions.Services /// operators are alerted before a future run is hard-aborted (which would emit no event at all). /// public Task RecordMaintenanceLongRunningAsync(int durationMs, int thresholdMinutes, string triggeredBy) - => WriteAsync(OpsEventCategory.Maintenance, "MaintenanceLongRunning", OpsEventSeverity.Warning, + => WriteAsync(OpsEventCategory.Maintenance, OpsEventTypes.MaintenanceLongRunning, OpsEventSeverity.Warning, $"Maintenance took {durationMs}ms (> {thresholdMinutes}min soft threshold; host aborts at 60min) — triggered by {triggeredBy}", null, triggeredBy, new { durationMs, thresholdMinutes }); @@ -90,17 +90,17 @@ namespace AutopilotMonitor.Functions.Services /// from full maintenance runs. /// public Task RecordSessionSweepCompletedAsync(int stalledMarked, int timedOut, int durationMs) - => WriteAsync(OpsEventCategory.Maintenance, "SessionSweepCompleted", OpsEventSeverity.Info, + => WriteAsync(OpsEventCategory.Maintenance, OpsEventTypes.SessionSweepCompleted, OpsEventSeverity.Info, $"Hourly session sweep completed in {durationMs}ms — {stalledMarked} marked Stalled, {timedOut} terminalized", null, "System.SessionSweep", new { stalledMarked, timedOut, durationMs }); public Task RecordSessionSweepFailedAsync(string error) - => WriteAsync(OpsEventCategory.Maintenance, "SessionSweepFailed", OpsEventSeverity.Error, + => WriteAsync(OpsEventCategory.Maintenance, OpsEventTypes.SessionSweepFailed, OpsEventSeverity.Error, $"Hourly session sweep failed: {error}", null, "System.SessionSweep", new { error }); public Task RecordOpsEventCleanupAsync(int deletedCount, int retentionDays) - => WriteAsync(OpsEventCategory.Maintenance, "OpsEventCleanup", OpsEventSeverity.Info, + => WriteAsync(OpsEventCategory.Maintenance, OpsEventTypes.OpsEventCleanup, OpsEventSeverity.Info, $"Cleaned up {deletedCount} ops events older than {retentionDays} days", null, "System.Maintenance", new { deletedCount, retentionDays }); @@ -125,7 +125,7 @@ namespace AutopilotMonitor.Functions.Services .Select(o => new { tenantId = o.TenantId, sessionId = o.SessionId, eventCount = o.EventCount }) .ToList(); - return WriteAsync(OpsEventCategory.Maintenance, "OrphanEventsCleaned", OpsEventSeverity.Warning, + return WriteAsync(OpsEventCategory.Maintenance, OpsEventTypes.OrphanEventsCleaned, OpsEventSeverity.Warning, $"Cleaned {totalEventsDeleted} orphaned events across {orphanSessions} sessions", null, "System.Maintenance", new @@ -149,7 +149,7 @@ namespace AutopilotMonitor.Functions.Services /// UI banner treats "latest Started newer than latest Completed/Failed" as run-active. /// public Task RecordSessionDeletionMaintenanceStartedAsync(string triggeredBy) - => WriteAsync(OpsEventCategory.Maintenance, "SessionDeletionMaintenanceStarted", OpsEventSeverity.Info, + => WriteAsync(OpsEventCategory.Maintenance, OpsEventTypes.SessionDeletionMaintenanceStarted, OpsEventSeverity.Info, $"SessionDeletionMaintenance run started (triggered by {triggeredBy})", null, "System.Maintenance", new { triggeredBy }); @@ -159,7 +159,7 @@ namespace AutopilotMonitor.Functions.Services /// Paired with a Completed event whose details carry abortedByBudget=true. /// public Task RecordSessionDeletionMaintenanceBudgetExceededAsync(int budgetMinutes, int tenantsProcessed, int sessionsEnqueued) - => WriteAsync(OpsEventCategory.Maintenance, "SessionDeletionMaintenanceBudgetExceeded", OpsEventSeverity.Warning, + => WriteAsync(OpsEventCategory.Maintenance, OpsEventTypes.SessionDeletionMaintenanceBudgetExceeded, OpsEventSeverity.Warning, $"SessionDeletionMaintenance stopped at the {budgetMinutes}min run budget — tenants={tenantsProcessed} enqueued={sessionsEnqueued}; remaining backlog resumes on the next run", null, "System.Maintenance", new { budgetMinutes, tenantsProcessed, sessionsEnqueued }); @@ -168,31 +168,31 @@ namespace AutopilotMonitor.Functions.Services /// maintenance lease. Mirrors RecordCriticalTableBackupSkippedLockedAsync. /// public Task RecordSessionDeletionMaintenanceSkippedLockedAsync(string triggeredBy) - => WriteAsync(OpsEventCategory.Maintenance, "SessionDeletionMaintenanceSkippedLocked", OpsEventSeverity.Info, + => WriteAsync(OpsEventCategory.Maintenance, OpsEventTypes.SessionDeletionMaintenanceSkippedLocked, OpsEventSeverity.Info, $"SessionDeletionMaintenance skipped — another run holds the maintenance lease (triggeredBy={triggeredBy})", null, "System.Maintenance", new { reason = "lease held by another run", triggeredBy }); /// Watchdog: maintenance run still in flight 30 minutes after start. Warning-level early signal. public Task RecordSessionDeletionMaintenanceLongRunningAsync(int elapsedMinutes, int tenantsProcessed, int sessionsEnqueued) - => WriteAsync(OpsEventCategory.Maintenance, "SessionDeletionMaintenanceLongRunning", OpsEventSeverity.Warning, + => WriteAsync(OpsEventCategory.Maintenance, OpsEventTypes.SessionDeletionMaintenanceLongRunning, OpsEventSeverity.Warning, $"SessionDeletionMaintenance still running after {elapsedMinutes}min (tenants={tenantsProcessed}, enqueued={sessionsEnqueued})", null, "System.Maintenance", new { elapsedMinutes, tenantsProcessed, sessionsEnqueued }); /// Watchdog: maintenance run still in flight 60 minutes after start. Error-level escalation in case the operator missed the 30min warning. public Task RecordSessionDeletionMaintenanceLongRunningSevereAsync(int elapsedMinutes, int tenantsProcessed, int sessionsEnqueued) - => WriteAsync(OpsEventCategory.Maintenance, "SessionDeletionMaintenanceLongRunningSevere", OpsEventSeverity.Error, + => WriteAsync(OpsEventCategory.Maintenance, OpsEventTypes.SessionDeletionMaintenanceLongRunningSevere, OpsEventSeverity.Error, $"SessionDeletionMaintenance has been running for {elapsedMinutes}min — Azure Functions host will abort at 60min (tenants={tenantsProcessed}, enqueued={sessionsEnqueued})", null, "System.Maintenance", new { elapsedMinutes, tenantsProcessed, sessionsEnqueued }); /// Unhandled exception path. Re-thrown after this audit so the Azure Functions runtime records the failure. public Task RecordSessionDeletionMaintenanceFailedAsync(string exceptionType, string message, string stackPreview) - => WriteAsync(OpsEventCategory.Maintenance, "SessionDeletionMaintenanceFailed", OpsEventSeverity.Error, + => WriteAsync(OpsEventCategory.Maintenance, OpsEventTypes.SessionDeletionMaintenanceFailed, OpsEventSeverity.Error, $"SessionDeletionMaintenance failed: {exceptionType}: {message}", null, "System.Maintenance", new { exceptionType, message, stackPreview }); /// Stale Queued state detected (no worker pickup) — operator must inspect the manifest + progress blobs. No auto-clear. public Task RecordSessionDeletionStrandedQueuedAsync(string tenantId, string sessionId, DateTime queuedSince, string manifestId) - => WriteAsync(OpsEventCategory.Maintenance, "SessionDeletionStrandedQueued", OpsEventSeverity.Warning, + => WriteAsync(OpsEventCategory.Maintenance, OpsEventTypes.SessionDeletionStrandedQueued, OpsEventSeverity.Warning, $"Session {sessionId} stuck in DeletionState=Queued since {queuedSince:o} (manifestId={manifestId})", tenantId, "System.Maintenance", new { tenantId, sessionId, queuedSince = queuedSince.ToString("o"), manifestId }); @@ -233,7 +233,7 @@ namespace AutopilotMonitor.Functions.Services var cause = !string.IsNullOrEmpty(failureType) ? $" — cause: {failureType}{(string.IsNullOrEmpty(failureMessage) ? "" : $" ({failureMessage})")}" : string.Empty; - return WriteAsync(OpsEventCategory.Maintenance, "SessionDeletionPoisoned", OpsEventSeverity.Error, + return WriteAsync(OpsEventCategory.Maintenance, OpsEventTypes.SessionDeletionPoisoned, OpsEventSeverity.Error, $"Session {sessionId} cascade poisoned after {dequeueCount} attempts (manifestId={manifestId}){cause}", tenantId, "System.Maintenance", new @@ -254,7 +254,7 @@ namespace AutopilotMonitor.Functions.Services bool killSwitchActive, int tenantsProcessed, int sessionsEnqueued, int sessionsSkipped, int rateLimitedTenants, int blobsTtlGced, int preparingRowsCleared, int strandedQueuedDetected, int durationMs, bool abortedByKillSwitch, bool abortedByBudget) - => WriteAsync(OpsEventCategory.Maintenance, "SessionDeletionMaintenanceCompleted", OpsEventSeverity.Info, + => WriteAsync(OpsEventCategory.Maintenance, OpsEventTypes.SessionDeletionMaintenanceCompleted, OpsEventSeverity.Info, $"SessionDeletionMaintenance completed in {durationMs}ms — tenants={tenantsProcessed} enqueued={sessionsEnqueued} skipped={sessionsSkipped} blobsTtlGced={blobsTtlGced} preparingCleared={preparingRowsCleared} stranded={strandedQueuedDetected} killSwitch={killSwitchActive} abortedByBudget={abortedByBudget}", null, "System.Maintenance", new { killSwitchActive, tenantsProcessed, sessionsEnqueued, @@ -270,14 +270,14 @@ namespace AutopilotMonitor.Functions.Services /// public Task RecordSessionDeletionMaintenanceFanoutSkippedAsync( int blobsTtlGced, int preparingRowsCleared, int strandedQueuedDetected) - => WriteAsync(OpsEventCategory.Maintenance, "SessionDeletionMaintenanceFanoutSkipped", OpsEventSeverity.Info, + => WriteAsync(OpsEventCategory.Maintenance, OpsEventTypes.SessionDeletionMaintenanceFanoutSkipped, OpsEventSeverity.Info, $"SessionDeletionMaintenance fanout skipped (kill-switch active) — GCs ran: blobsTtlGced={blobsTtlGced} preparingCleared={preparingRowsCleared} stranded={strandedQueuedDetected}", null, "System.Maintenance", new { reason = "SessionDeletionKillSwitch", blobsTtlGced, preparingRowsCleared, strandedQueuedDetected }); // ── Security ─────────────────────────────────────────────────────────── public Task RecordDeviceBlockedAsync(string tenantId, string serialNumber, string reason, string blockedBy) - => WriteAsync(OpsEventCategory.Security, "DeviceBlocked", OpsEventSeverity.Warning, + => WriteAsync(OpsEventCategory.Security, OpsEventTypes.DeviceBlocked, OpsEventSeverity.Warning, $"Device {serialNumber} blocked: {reason}", tenantId, blockedBy, new { serialNumber, reason }); @@ -286,7 +286,7 @@ namespace AutopilotMonitor.Functions.Services // Automatic blocks now come only from the event-count path below. public Task RecordVersionBlockedAsync(string pattern, string blockedBy) - => WriteAsync(OpsEventCategory.Security, "VersionBlocked", OpsEventSeverity.Warning, + => WriteAsync(OpsEventCategory.Security, OpsEventTypes.VersionBlocked, OpsEventSeverity.Warning, $"Agent version pattern '{pattern}' blocked", null, blockedBy, new { pattern }); @@ -307,7 +307,7 @@ namespace AutopilotMonitor.Functions.Services /// public Task RecordSessionTenantConflictAsync( string requestedTenantId, string sessionId, string owningTenantId, string? certificateThumbprint, string? agentVersion, string endpoint) - => WriteAsync(OpsEventCategory.Security, "SessionTenantConflict", OpsEventSeverity.Warning, + => WriteAsync(OpsEventCategory.Security, OpsEventTypes.SessionTenantConflict, OpsEventSeverity.Warning, $"Registration refused on {endpoint}: session {sessionId} is owned by another tenant (409)", requestedTenantId, "System.SessionTenantLookup", new { sessionId, owningTenantId, certificateThumbprint, agentVersion, endpoint }); @@ -315,7 +315,7 @@ namespace AutopilotMonitor.Functions.Services public Task RecordSessionOwnerMismatchAsync( string tenantId, string sessionId, string outcome, string callerKind, string ownerKind, bool serialMatch, string? agentVersion, string endpoint) - => WriteAsync(OpsEventCategory.Security, "SessionOwnerMismatch", OpsEventSeverity.Warning, + => WriteAsync(OpsEventCategory.Security, OpsEventTypes.SessionOwnerMismatch, OpsEventSeverity.Warning, $"Session owner mismatch ({outcome}) on {endpoint}: {callerKind} caller vs {ownerKind}-owned session {sessionId} (serialMatch={serialMatch.ToString().ToLowerInvariant()}, shadow - allowed)", tenantId, "System.SessionOwnerBinding", new { sessionId, outcome, callerKind, ownerKind, serialMatch, agentVersion, endpoint, enforced = false }); @@ -331,7 +331,7 @@ namespace AutopilotMonitor.Functions.Services public Task RecordKillSignalDeliveredAsync( string tenantId, string? serialNumber, string? agentVersion, string? matchedPattern, string trigger, string channel) - => WriteAsync(OpsEventCategory.Security, "KillSignalDelivered", OpsEventSeverity.Warning, + => WriteAsync(OpsEventCategory.Security, OpsEventTypes.KillSignalDelivered, OpsEventSeverity.Warning, trigger == "version" ? $"Kill signal delivered via {channel} to agent {agentVersion ?? "?"} on device {serialNumber ?? "?"} (pattern: {matchedPattern})" : $"Kill signal delivered via {channel} to device {serialNumber ?? "?"}", @@ -339,19 +339,19 @@ namespace AutopilotMonitor.Functions.Services new { serialNumber, agentVersion, matchedPattern, trigger, channel }); public Task RecordEmbeddedCertExpiringSoonAsync(string role, string subject, string thumbprint, DateTime notAfterUtc, int daysUntilExpiry) - => WriteAsync(OpsEventCategory.Security, "EmbeddedCertExpiringSoon", OpsEventSeverity.Warning, + => WriteAsync(OpsEventCategory.Security, OpsEventTypes.EmbeddedCertExpiringSoon, OpsEventSeverity.Warning, $"Newest embedded Intune {role.ToLowerInvariant()} '{subject}' expires in {daysUntilExpiry}d ({notAfterUtc:u}) - source a successor PEM and embed it before {notAfterUtc:yyyy-MM-dd}", null, "System.Maintenance", new { role, subject, thumbprint, notAfterUtc = notAfterUtc.ToString("u"), daysUntilExpiry }); public Task RecordEmbeddedCertExpiringUrgentAsync(string role, string subject, string thumbprint, DateTime notAfterUtc, int daysUntilExpiry) - => WriteAsync(OpsEventCategory.Security, "EmbeddedCertExpiringUrgent", OpsEventSeverity.Error, + => WriteAsync(OpsEventCategory.Security, OpsEventTypes.EmbeddedCertExpiringUrgent, OpsEventSeverity.Error, $"URGENT: newest embedded Intune {role.ToLowerInvariant()} '{subject}' expires in {daysUntilExpiry}d ({notAfterUtc:u}) and no successor is in the bundle - agent mTLS will break without rotation", null, "System.Maintenance", new { role, subject, thumbprint, notAfterUtc = notAfterUtc.ToString("u"), daysUntilExpiry }); public Task RecordEmbeddedCertExpiredAsync(string role, string subject, string thumbprint, DateTime notAfterUtc, int daysUntilExpiry) - => WriteAsync(OpsEventCategory.Security, "EmbeddedCertExpired", OpsEventSeverity.Critical, + => WriteAsync(OpsEventCategory.Security, OpsEventTypes.EmbeddedCertExpired, OpsEventSeverity.Critical, daysUntilExpiry < 0 ? $"CRITICAL: newest embedded Intune {role.ToLowerInvariant()} '{subject}' EXPIRED {-daysUntilExpiry}d ago ({notAfterUtc:u}) and no successor is embedded - agent mTLS validation broken" : $"CRITICAL: newest embedded Intune {role.ToLowerInvariant()} '{subject}' expires in {daysUntilExpiry}d ({notAfterUtc:u}) and no successor is embedded", @@ -359,42 +359,42 @@ namespace AutopilotMonitor.Functions.Services new { role, subject, thumbprint, notAfterUtc = notAfterUtc.ToString("u"), daysUntilExpiry }); public Task RecordEmbeddedCertBundleEmptyAsync() - => WriteAsync(OpsEventCategory.Security, "EmbeddedCertBundleEmpty", OpsEventSeverity.Critical, + => WriteAsync(OpsEventCategory.Security, OpsEventTypes.EmbeddedCertBundleEmpty, OpsEventSeverity.Critical, "No embedded Intune root certificates loaded - agent mTLS validator is failing closed for ALL clients", null, "System.Maintenance", new { }); public Task RecordSignalRConnectionsHighAsync(int observed, int limit, int percent, string resourceId) - => WriteAsync(OpsEventCategory.Security, "SignalRConnectionsHigh", OpsEventSeverity.Warning, + => WriteAsync(OpsEventCategory.Security, OpsEventTypes.SignalRConnectionsHigh, OpsEventSeverity.Warning, $"SignalR concurrent connections at {percent}% of plan limit ({observed}/{limit}) - watch for 429s; consider adding units before saturation", null, "System.Monitoring", new { metric = "ConnectionCount", aggregation = "Maximum", windowMinutes = 60, observed, limit, percent, resourceId }); public Task RecordSignalRConnectionsCriticalAsync(int observed, int limit, int percent, string resourceId) - => WriteAsync(OpsEventCategory.Security, "SignalRConnectionsCritical", OpsEventSeverity.Error, + => WriteAsync(OpsEventCategory.Security, OpsEventTypes.SignalRConnectionsCritical, OpsEventSeverity.Error, $"CRITICAL: SignalR concurrent connections at {percent}% of plan limit ({observed}/{limit}) - new client connections will be 429'd at 100%; add units now", null, "System.Monitoring", new { metric = "ConnectionCount", aggregation = "Maximum", windowMinutes = 60, observed, limit, percent, resourceId }); public Task RecordSignalRMessagesHighAsync(long observed, long limit, int percent, string resourceId) - => WriteAsync(OpsEventCategory.Security, "SignalRMessagesHigh", OpsEventSeverity.Warning, + => WriteAsync(OpsEventCategory.Security, OpsEventTypes.SignalRMessagesHigh, OpsEventSeverity.Warning, $"SignalR daily message count at {percent}% of included plan quota ({observed}/{limit}) - resets at 00:00 UTC; overage is billed per extra million messages", null, "System.Monitoring", new { metric = "MessageCount", aggregation = "Total", windowDay = "UTC", observed, limit, percent, resourceId }); public Task RecordSignalRMessagesCriticalAsync(long observed, long limit, int percent, string resourceId) - => WriteAsync(OpsEventCategory.Security, "SignalRMessagesCritical", OpsEventSeverity.Error, + => WriteAsync(OpsEventCategory.Security, OpsEventTypes.SignalRMessagesCritical, OpsEventSeverity.Error, $"CRITICAL: SignalR daily message count at {percent}% of included plan quota ({observed}/{limit}) - overage beyond 100% is billed per extra million messages; review traffic or add units", null, "System.Monitoring", new { metric = "MessageCount", aggregation = "Total", windowDay = "UTC", observed, limit, percent, resourceId }); public Task RecordPoisonQueueBacklogHighAsync(string queueName, long count, int threshold) - => WriteAsync(OpsEventCategory.Security, "PoisonQueueBacklogHigh", OpsEventSeverity.Warning, + => WriteAsync(OpsEventCategory.Security, OpsEventTypes.PoisonQueueBacklogHigh, OpsEventSeverity.Warning, $"Poison queue '{queueName}' backlog at {count} message(s) (threshold: {threshold}) — async worker handler failing repeatedly; inspect dead-letter contents", null, "System.Maintenance", new { queueName, count, threshold }); public Task RecordPoisonQueueBacklogCriticalAsync(string queueName, long count, int threshold) - => WriteAsync(OpsEventCategory.Security, "PoisonQueueBacklogCritical", OpsEventSeverity.Error, + => WriteAsync(OpsEventCategory.Security, OpsEventTypes.PoisonQueueBacklogCritical, OpsEventSeverity.Error, $"CRITICAL: poison queue '{queueName}' backlog at {count} messages (threshold: {threshold}) — sustained handler failure, downstream work is silently dropping", null, "System.Maintenance", new { queueName, count, threshold }); @@ -403,28 +403,28 @@ namespace AutopilotMonitor.Functions.Services /// Backup run finished with all tables successfully captured. Info-level — visible in the timeline, not alertable by default. public Task RecordCriticalTableBackupCompletedAsync(string backupId, int tableCount, int durationMs, string container, string manifestBlobName, string triggeredBy) - => WriteAsync(OpsEventCategory.Maintenance, "CriticalTableBackupCompleted", OpsEventSeverity.Info, + => WriteAsync(OpsEventCategory.Maintenance, OpsEventTypes.CriticalTableBackupCompleted, OpsEventSeverity.Info, $"Critical-table backup {backupId} completed: {tableCount} tables, {durationMs}ms (triggeredBy={triggeredBy})", null, "System.Maintenance", new { backupId, tableCount, durationMs, container, manifestBlobName, triggeredBy }); /// Backup run wrote a manifest but at least one table Failed or Skipped. Warning-level — operator should inspect manifest perTableFailures. public Task RecordCriticalTableBackupPartialAsync(string backupId, int totalTables, int failedOrSkipped, int durationMs, string container, string manifestBlobName, string triggeredBy) - => WriteAsync(OpsEventCategory.Maintenance, "CriticalTableBackupPartial", OpsEventSeverity.Warning, + => WriteAsync(OpsEventCategory.Maintenance, OpsEventTypes.CriticalTableBackupPartial, OpsEventSeverity.Warning, $"Critical-table backup {backupId} PARTIAL: {failedOrSkipped}/{totalTables} tables failed or skipped, manifest written ({durationMs}ms, triggeredBy={triggeredBy})", null, "System.Maintenance", new { backupId, totalTables, failedOrSkipped, durationMs, container, manifestBlobName, triggeredBy }); /// Backup run never produced a valid manifest (fatal exception, storage outage). Error-level. Queue-path emits this AFTER 5x retry + poison-move; timer emits immediately. public Task RecordCriticalTableBackupFailedAsync(string? backupId, string errorMessage, string triggeredBy) - => WriteAsync(OpsEventCategory.Maintenance, "CriticalTableBackupFailed", OpsEventSeverity.Error, + => WriteAsync(OpsEventCategory.Maintenance, OpsEventTypes.CriticalTableBackupFailed, OpsEventSeverity.Error, $"Critical-table backup FAILED (backupId={backupId ?? "n/a"}, triggeredBy={triggeredBy}): {errorMessage}", null, "System.Maintenance", new { backupId, errorMessage, triggeredBy }); /// Backup or restore was skipped because the maintenance lease was already held by another job. Info-level — not a failure. public Task RecordCriticalTableBackupSkippedLockedAsync(string reason, string triggeredBy) - => WriteAsync(OpsEventCategory.Maintenance, "CriticalTableBackupSkippedLocked", OpsEventSeverity.Info, + => WriteAsync(OpsEventCategory.Maintenance, OpsEventTypes.CriticalTableBackupSkippedLocked, OpsEventSeverity.Info, $"Critical-table backup skipped — {reason} (triggeredBy={triggeredBy})", null, "System.Maintenance", new { reason, triggeredBy }); @@ -438,7 +438,7 @@ namespace AutopilotMonitor.Functions.Services /// public Task RecordBackupRowRestoredAsync( string backupId, string tableName, string partitionKey, string rowKey, string actor, string outcome) - => WriteAsync(OpsEventCategory.Maintenance, "BackupRowRestored", OpsEventSeverity.Warning, + => WriteAsync(OpsEventCategory.Maintenance, OpsEventTypes.BackupRowRestored, OpsEventSeverity.Warning, $"Critical-table row restored: {tableName} (pk='{partitionKey}', rk='{rowKey}') from backup {backupId} by {actor} → {outcome}", null, actor, new @@ -472,7 +472,7 @@ namespace AutopilotMonitor.Functions.Services ["historyRowKey"] = historyRowKey, }; - return WriteAsync(OpsEventCategory.Tenant, "OffboardingFeedbackReceived", OpsEventSeverity.Info, + return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.OffboardingFeedbackReceived, OpsEventSeverity.Info, $"Offboarding feedback received from {tenantLabel}", tenantId, submittedBy, details); } @@ -491,7 +491,7 @@ namespace AutopilotMonitor.Functions.Services ["deletedCounts"] = deletedCounts, }; - return WriteAsync(OpsEventCategory.Tenant, "TenantOffboarded", OpsEventSeverity.Warning, + return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.TenantOffboarded, OpsEventSeverity.Warning, $"Tenant {tenantLabel} offboarded — all data deleted", tenantId, performedBy, details); } @@ -510,7 +510,7 @@ namespace AutopilotMonitor.Functions.Services ? tenantId : $"{domainName} ({tenantId})"; - return WriteAsync(OpsEventCategory.Tenant, "TenantOffboardingFailed", OpsEventSeverity.Error, + return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.TenantOffboardingFailed, OpsEventSeverity.Error, $"Tenant {tenantLabel} offboarding failed at phase '{failedPhase}': {errorMessage}", tenantId, performedBy, new { domainName, failedPhase, errorMessage, retryCount }); @@ -526,7 +526,7 @@ namespace AutopilotMonitor.Functions.Services public Task RecordTenantAutoApprovedAsync(string tenantId, string? domainName, string signupUpn) { var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})"; - return WriteAsync(OpsEventCategory.Tenant, "TenantAutoApproved", OpsEventSeverity.Info, + return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.TenantAutoApproved, OpsEventSeverity.Info, $"Tenant {tenantLabel} was auto-activated after signup (signup by {signupUpn})", tenantId, "System (auto-approve)", new { domainName, signupUpn }); } @@ -543,7 +543,7 @@ namespace AutopilotMonitor.Functions.Services public Task RecordWelcomeEmailSentAsync(string tenantId, string? domainName, string toEmail, string addressSource) { var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})"; - return WriteAsync(OpsEventCategory.Tenant, "WelcomeEmailSent", OpsEventSeverity.Info, + return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.WelcomeEmailSent, OpsEventSeverity.Info, $"Welcome email sent to {toEmail} for tenant {tenantLabel} (address from {addressSource})", tenantId, "System.Activation", new { domainName, toEmail, addressSource }); } @@ -556,7 +556,7 @@ namespace AutopilotMonitor.Functions.Services public Task RecordWelcomeEmailSkippedAsync(string tenantId, string? domainName, string reason) { var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})"; - return WriteAsync(OpsEventCategory.Tenant, "WelcomeEmailSkipped", OpsEventSeverity.Warning, + return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.WelcomeEmailSkipped, OpsEventSeverity.Warning, $"No welcome email for tenant {tenantLabel} — {reason}", tenantId, "System.Activation", new { domainName, reason }); } @@ -568,7 +568,7 @@ namespace AutopilotMonitor.Functions.Services public Task RecordWelcomeEmailFailedAsync(string tenantId, string? domainName, string toEmail, string reason) { var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})"; - return WriteAsync(OpsEventCategory.Tenant, "WelcomeEmailFailed", OpsEventSeverity.Error, + return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.WelcomeEmailFailed, OpsEventSeverity.Error, $"Welcome email to {toEmail} failed for tenant {tenantLabel} — {reason}", tenantId, "System.Activation", new { domainName, toEmail, reason }); } @@ -577,11 +577,33 @@ namespace AutopilotMonitor.Functions.Services // Both types are dual-registered in OpsAlertRulesSection.tsx OPS_EVENT_TYPES // (memory feedback_ops_event_types_dual_register). Dispatched by TrialExpirySweepFunction. + /// + /// A Pro trial was started — the conversion moment. Fired from BOTH plan write paths: + /// the tenant-admin self-service POST and a GA grant/extension via PATCH plan + /// ( tells them apart). Info-tier, but the event a + /// sales/support channel is typically bound to, so the payload carries who to contact + /// rather than only the tenant GUID. + /// + public Task RecordTenantTrialStartedAsync( + string tenantId, string? domainName, string? contactEmail, + DateTime? trialStartedUtc, DateTime? trialExpiresUtc, string grantedBy, bool selfService) + { + var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})"; + var expiryNote = trialExpiresUtc is DateTime expiry + ? $"until {expiry:yyyy-MM-dd HH:mm}Z" + : "with no end date set"; + var origin = selfService ? "self-service" : "granted by an operator"; + return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.TenantTrialStarted, OpsEventSeverity.Info, + $"Pro trial started for tenant {tenantLabel} {expiryNote} ({origin}, by {grantedBy})", + tenantId, grantedBy, + new { domainName, contactEmail, trialStartedUtc, trialExpiresUtc, grantedBy, selfService }); + } + /// Heads-up: a Pro trial ends within the next few days. Info-tier visibility signal. public Task RecordTenantTrialExpiringAsync(string tenantId, string? domainName, DateTime trialExpiresUtc, int daysLeft) { var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})"; - return WriteAsync(OpsEventCategory.Tenant, "TenantTrialExpiring", OpsEventSeverity.Info, + return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.TenantTrialExpiring, OpsEventSeverity.Info, $"Pro trial for tenant {tenantLabel} expires in {daysLeft} day(s) ({trialExpiresUtc:yyyy-MM-dd HH:mm}Z)", tenantId, "System.TrialSweep", new { domainName, trialExpiresUtc, daysLeft }); } @@ -594,7 +616,7 @@ namespace AutopilotMonitor.Functions.Services public Task RecordTenantTrialExpiredAsync(string tenantId, string? domainName, DateTime trialExpiredUtc) { var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})"; - return WriteAsync(OpsEventCategory.Tenant, "TenantTrialExpired", OpsEventSeverity.Warning, + return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.TenantTrialExpired, OpsEventSeverity.Warning, $"Pro trial for tenant {tenantLabel} expired ({trialExpiredUtc:yyyy-MM-dd HH:mm}Z) — tenant is now Community", tenantId, "System.TrialSweep", new { domainName, trialExpiredUtc }); } @@ -617,7 +639,7 @@ namespace AutopilotMonitor.Functions.Services var graceNote = retentionGraceEndsUtc is DateTime graceEnd ? $"retention grace until {graceEnd:yyyy-MM-dd HH:mm}Z" : "no retention grace applicable"; - return WriteAsync(OpsEventCategory.Tenant, "TenantPlanDowngraded", OpsEventSeverity.Warning, + return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.TenantPlanDowngraded, OpsEventSeverity.Warning, $"Tenant {tenantLabel} was downgraded Pro → Community by {caller} — {graceNote} (stored retention {storedRetentionDays}d)", tenantId, caller, new { domainName, retentionGraceEndsUtc, storedRetentionDays }); } @@ -632,7 +654,7 @@ namespace AutopilotMonitor.Functions.Services string tenantId, string? domainName, DateTime graceEndsUtc, int daysLeft, int storedRetentionDays) { var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})"; - return WriteAsync(OpsEventCategory.Tenant, "TenantRetentionGraceExpiring", OpsEventSeverity.Warning, + return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.TenantRetentionGraceExpiring, OpsEventSeverity.Warning, $"Retention grace for downgraded tenant {tenantLabel} ends in {daysLeft} day(s) ({graceEndsUtc:yyyy-MM-dd HH:mm}Z) — " + $"data older than the Community cap will then be deleted (stored retention {storedRetentionDays}d)", tenantId, "System.TrialSweep", new { domainName, graceEndsUtc, daysLeft, storedRetentionDays }); @@ -647,7 +669,7 @@ namespace AutopilotMonitor.Functions.Services string tenantId, string? domainName, DateTime graceEndedUtc, int storedRetentionDays) { var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})"; - return WriteAsync(OpsEventCategory.Tenant, "TenantRetentionGraceEnded", OpsEventSeverity.Warning, + return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.TenantRetentionGraceEnded, OpsEventSeverity.Warning, $"Retention grace for downgraded tenant {tenantLabel} ended ({graceEndedUtc:yyyy-MM-dd HH:mm}Z) — " + $"the Community retention cap is now enforced and older data will be deleted (stored retention {storedRetentionDays}d)", tenantId, "System.TrialSweep", new { domainName, graceEndedUtc, storedRetentionDays }); @@ -666,7 +688,7 @@ namespace AutopilotMonitor.Functions.Services int windowFireCount, int windowSessionCount, double windowRatePct, int baselineFireCount, int baselineSessionCount, double baselineRatePct, double? lift, string? dimensionSummary) - => WriteAsync(OpsEventCategory.Tenant, "RuleFrequencyRegression", OpsEventSeverity.Warning, + => WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.RuleFrequencyRegression, OpsEventSeverity.Warning, $"Rule '{ruleTitle}' ({ruleId}) fired in {windowFireCount}/{windowSessionCount} sessions ({windowRatePct}%) over 7d " + $"vs {baselineRatePct}% baseline ({baselineFireCount}/{baselineSessionCount} over 28d)" + (lift.HasValue ? $" — lift {lift.Value}x" : " — new signal (no baseline fires)") + @@ -696,7 +718,7 @@ namespace AutopilotMonitor.Functions.Services string tenantId, string appName, string currentVersion, string previousVersion, int currentMedianSeconds, int previousMedianSeconds, int currentMeasuredCount, int previousMeasuredCount, double lift) - => WriteAsync(OpsEventCategory.Tenant, "AppVersionDurationRegression", OpsEventSeverity.Warning, + => WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.AppVersionDurationRegression, OpsEventSeverity.Warning, $"App '{appName}' median install duration rose from {Math.Round(previousMedianSeconds / 60.0, 1)} to " + $"{Math.Round(currentMedianSeconds / 60.0, 1)} min after version {currentVersion} " + $"({currentMeasuredCount} measured installs vs {previousMeasuredCount} on {previousVersion}) — lift {lift}x", @@ -727,7 +749,7 @@ namespace AutopilotMonitor.Functions.Services int windowHitCount, int windowSessionCount, double windowRatePct, int baselineHitCount, int baselineSessionCount, double baselineRatePct, double? lift, string? dimensionSummary) - => WriteAsync(OpsEventCategory.Maintenance, "VerdictCalibrationDrift", OpsEventSeverity.Warning, + => WriteAsync(OpsEventCategory.Maintenance, OpsEventTypes.VerdictCalibrationDrift, OpsEventSeverity.Warning, $"Verdict calibration [{kind}] {verdictPath}/{status}: {windowHitCount}/{windowSessionCount} ({windowRatePct}%) over 7d " + $"vs {baselineRatePct}% baseline ({baselineHitCount}/{baselineSessionCount} over 28d)" + (lift.HasValue ? $" — lift {lift.Value}x" : string.Empty) + @@ -765,16 +787,16 @@ namespace AutopilotMonitor.Functions.Services var quickConfig = string.Equals(source, Functions.Config.UpdateTenantConfigurationFunction.CollectLogsSource, StringComparison.Ordinal); if (change.Enabled && quickConfig) { - return WriteAsync(OpsEventCategory.Tenant, "CollectLogsQuickConfigEnabled", OpsEventSeverity.Info, + return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.CollectLogsQuickConfigEnabled, OpsEventSeverity.Info, $"Collect Logs: {changedBy} enabled diagnostics upload for tenant {tenantLabel} via the session quick-config dialog (destination {change.Destination ?? "?"}, mode {change.Mode})", tenantId, changedBy, new { domainName, change.Destination, change.Mode, source }); } return change.Enabled - ? WriteAsync(OpsEventCategory.Tenant, "DiagnosticsUploadEnabled", OpsEventSeverity.Info, + ? WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.DiagnosticsUploadEnabled, OpsEventSeverity.Info, $"Diagnostics upload enabled for tenant {tenantLabel} (destination {change.Destination ?? "?"}, mode {change.Mode}) via {source} by {changedBy}", tenantId, changedBy, new { domainName, change.Destination, change.Mode, source }) - : WriteAsync(OpsEventCategory.Tenant, "DiagnosticsUploadDisabled", OpsEventSeverity.Info, + : WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.DiagnosticsUploadDisabled, OpsEventSeverity.Info, $"Diagnostics upload disabled for tenant {tenantLabel} (mode {change.Mode}) via {source} by {changedBy}", tenantId, changedBy, new { domainName, change.Destination, change.Mode, source }); } @@ -789,12 +811,12 @@ namespace AutopilotMonitor.Functions.Services /// Dual-register per memory feedback_ops_event_types_dual_register. /// public Task RecordSessionActionQueuedAsync(string tenantId, string sessionId, string actionType, string? reason, string queuedBy) - => WriteAsync(OpsEventCategory.Agent, "SessionActionQueued", OpsEventSeverity.Info, + => WriteAsync(OpsEventCategory.Agent, OpsEventTypes.SessionActionQueued, OpsEventSeverity.Info, $"Server action '{actionType}' queued for session {sessionId} by {queuedBy}: {reason}", tenantId, queuedBy, new { sessionId, actionType, reason }); public Task RecordSessionTimeoutsAsync(string tenantId, int sessionCount, int timeoutHours) - => WriteAsync(OpsEventCategory.Agent, "SessionTimeouts", OpsEventSeverity.Info, + => WriteAsync(OpsEventCategory.Agent, OpsEventTypes.SessionTimeouts, OpsEventSeverity.Info, $"{sessionCount} session(s) timed out after {timeoutHours}h", tenantId, "System.Maintenance", new { sessionCount, timeoutHours }); @@ -808,7 +830,7 @@ namespace AutopilotMonitor.Functions.Services /// on the session is the durable record. /// public Task RecordAgentEmergencyBreakAsync(string tenantId, string sessionId, string? agentVersion, string message) - => WriteAsync(OpsEventCategory.Agent, "AgentEmergencyBreak", OpsEventSeverity.Warning, + => WriteAsync(OpsEventCategory.Agent, OpsEventTypes.AgentEmergencyBreak, OpsEventSeverity.Warning, $"Agent emergency break on session {sessionId} (agent {agentVersion ?? "?"}): {message}", tenantId, "System.EmergencyChannel", new { sessionId, agentVersion }); @@ -822,7 +844,7 @@ namespace AutopilotMonitor.Functions.Services /// Telegram rule; the message carries both hashes verbatim. /// public Task RecordAgentBinaryIntegrityMismatchAsync(string tenantId, string? sessionId, string? agentVersion, string message) - => WriteAsync(OpsEventCategory.Agent, "AgentBinaryIntegrityMismatch", OpsEventSeverity.Warning, + => WriteAsync(OpsEventCategory.Agent, OpsEventTypes.AgentBinaryIntegrityMismatch, OpsEventSeverity.Warning, $"Agent binary integrity mismatch on session {sessionId ?? "?"} (agent {agentVersion ?? "?"}): {message}", tenantId, "System.EmergencyChannel", new { sessionId, agentVersion }); @@ -838,12 +860,12 @@ namespace AutopilotMonitor.Functions.Services /// Dual-register per memory feedback_ops_event_types_dual_register. /// public Task RecordCmTraceTimeSkewRegressionAsync(string tenantId, string sessionId, string? agentVersion, string message, object details) - => WriteAsync(OpsEventCategory.Agent, "CmTraceTimeSkewRegression", OpsEventSeverity.Warning, + => WriteAsync(OpsEventCategory.Agent, OpsEventTypes.CmTraceTimeSkewRegression, OpsEventSeverity.Warning, $"CMTrace time-skew tripwire on session {sessionId} (agent {agentVersion ?? "?"}): {message}", tenantId, "System.Ingest", details); public Task RecordExcessiveSessionEventsAsync(string tenantId, string sessionId, int eventCount, int threshold) - => WriteAsync(OpsEventCategory.Agent, "ExcessiveSessionEvents", OpsEventSeverity.Warning, + => WriteAsync(OpsEventCategory.Agent, OpsEventTypes.ExcessiveSessionEvents, OpsEventSeverity.Warning, $"Session {sessionId} has {eventCount} events (threshold {threshold}) — likely agent loop bug", tenantId, "System.Maintenance", new { sessionId, eventCount, threshold }); @@ -857,13 +879,13 @@ namespace AutopilotMonitor.Functions.Services public Task RecordExcessiveSessionEventsAutoActionedAsync( string tenantId, string sessionId, string serialNumber, int eventCount, int threshold, string action, int durationHours) - => WriteAsync(OpsEventCategory.Security, "ExcessiveSessionEventsAutoActioned", OpsEventSeverity.Critical, + => WriteAsync(OpsEventCategory.Security, OpsEventTypes.ExcessiveSessionEventsAutoActioned, OpsEventSeverity.Critical, $"Auto-{action.ToLowerInvariant()} device {serialNumber} for session {sessionId} ({eventCount} events ≥ {threshold}, {durationHours}h)", tenantId, "System.Maintenance", new { sessionId, serialNumber, eventCount, threshold, action, durationHours }); public Task RecordNewImeVersionDetectedAsync(string version, string tenantId, string sessionId) - => WriteAsync(OpsEventCategory.Agent, "NewImeVersionDetected", OpsEventSeverity.Warning, + => WriteAsync(OpsEventCategory.Agent, OpsEventTypes.NewImeVersionDetected, OpsEventSeverity.Warning, $"New IME agent version detected: {version}", tenantId, "System.Ingest", new { version, sessionId }); @@ -874,17 +896,17 @@ namespace AutopilotMonitor.Functions.Services /// Dual-registered in the web OPS_EVENT_TYPES list (OpsAlertRulesSection.tsx). /// public Task RecordImePatternDriftSuspectedAsync(string version, string patternId, string baselineVersion, double baselineRate, int sessions) - => WriteAsync(OpsEventCategory.Agent, "ImePatternDriftSuspected", OpsEventSeverity.Warning, + => WriteAsync(OpsEventCategory.Agent, OpsEventTypes.ImePatternDriftSuspected, OpsEventSeverity.Warning, $"IME pattern {patternId} never matched in {sessions} sessions on IME {version} (baseline {baselineVersion}: {baselineRate:P0} of sessions) — log wording may have changed", null, "System.Ingest", new { version, patternId, baselineVersion, baselineRate, sessions }); public Task RecordBlobStorageMissingAsync(string missingItem, int statusCode) - => WriteAsync(OpsEventCategory.Agent, "BlobStorageMissing", OpsEventSeverity.Critical, + => WriteAsync(OpsEventCategory.Agent, OpsEventTypes.BlobStorageMissing, OpsEventSeverity.Critical, $"Agent blob storage check failed: {missingItem} is missing or unreachable (HTTP {statusCode})", null, "System.Maintenance", new { missingItem, statusCode }); public Task RecordBlobStorageUnreachableAsync(string error) - => WriteAsync(OpsEventCategory.Agent, "BlobStorageUnreachable", OpsEventSeverity.Critical, + => WriteAsync(OpsEventCategory.Agent, OpsEventTypes.BlobStorageUnreachable, OpsEventSeverity.Critical, $"Agent blob storage unreachable: {error}", null, "System.Maintenance", new { error }); @@ -896,20 +918,20 @@ namespace AutopilotMonitor.Functions.Services // Duration breaches carry the P95 in minutes, every other type a percentage — same units the // customer-facing alert (NotificationAlertBuilder.BuildSlaBreachAlert) prints. var unit = breachType == Shared.Models.SlaBreachType.Duration ? " min" : "%"; - return WriteAsync(OpsEventCategory.Sla, "SlaBreachNotification", OpsEventSeverity.Warning, + return WriteAsync(OpsEventCategory.Sla, OpsEventTypes.SlaBreachNotification, OpsEventSeverity.Warning, $"SLA breach notification sent for tenant {tenantId}: {breachType} {currentRate:F1}{unit} (target {targetRate:F1}{unit})", tenantId, "System.SlaEvaluation", new { breachType, currentRate, targetRate, totalSessions, failedSessions }); } public Task RecordSlaConsecutiveFailuresAsync(string tenantId, int count, string? lastDevice, string? lastReason) - => WriteAsync(OpsEventCategory.Sla, "SlaConsecutiveFailures", OpsEventSeverity.Error, + => WriteAsync(OpsEventCategory.Sla, OpsEventTypes.SlaConsecutiveFailures, OpsEventSeverity.Error, $"Consecutive failure alert for tenant {tenantId}: {count} failures in a row", tenantId, "System.SlaEvaluation", new { count, lastDevice, lastReason }); public Task RecordSlaEvaluationCompletedAsync(int tenantsEvaluated, int breachesDetected, int notificationsSent, int durationMs) - => WriteAsync(OpsEventCategory.Sla, "SlaEvaluationCompleted", OpsEventSeverity.Info, + => WriteAsync(OpsEventCategory.Sla, OpsEventTypes.SlaEvaluationCompleted, OpsEventSeverity.Info, $"SLA evaluation: {tenantsEvaluated} tenants checked, {breachesDetected} breaches, {notificationsSent} notifications sent", null, "System.SlaEvaluation", new { tenantsEvaluated, breachesDetected, notificationsSent, durationMs }); @@ -926,7 +948,7 @@ namespace AutopilotMonitor.Functions.Services double? metricValue) { var suffix = string.IsNullOrWhiteSpace(description) ? string.Empty : $": {description}"; - return WriteAsync(OpsEventCategory.Platform, "AzureMonitorAlert", severity, + return WriteAsync(OpsEventCategory.Platform, OpsEventTypes.AzureMonitorAlert, severity, $"Azure Monitor alert '{alertRule}' {monitorCondition}{suffix}", null, "System.AzureMonitor", new { alertRule, monitorCondition, azureSeverity, monitoringService, targetResource, metricValue }); @@ -953,9 +975,11 @@ namespace AutopilotMonitor.Functions.Services await _repository.SaveOpsEventAsync(entry); - // Fire-and-forget: dispatch alerts to enabled providers. - // TrySendAlerts has its own top-level try/catch so unobserved exceptions are safe. - _ = _alertDispatch.DispatchAsync(category, eventType, severity, message, tenantId); + // Fire-and-forget: dispatch alerts to the channels the rules target. + // DispatchAsync has its own top-level try/catch so unobserved exceptions are safe. + // The serialized details ride along: an outbound channel that only knows category, + // event and tenant GUID forces the reader back into the portal for every alert. + _ = _alertDispatch.DispatchAsync(category, eventType, severity, message, tenantId, entry.Details); } catch (Exception ex) { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs index 26886545..769c7883 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs @@ -41,7 +41,7 @@ namespace AutopilotMonitor.Functions.Services private readonly IMaintenanceRepository _maintenanceRepo; private readonly ISessionRepository _sessionRepo; private readonly IMetricsRepository _metricsRepo; - private readonly WebhookNotificationService _webhookService; + private readonly NotificationChannelDispatcher _channelDispatcher; private readonly TenantNotificationService _tenantNotificationService; private readonly ISlaTenantStatusRepository _statusRepo; private readonly AdminConfigurationService _adminConfigService; @@ -56,7 +56,7 @@ namespace AutopilotMonitor.Functions.Services IMaintenanceRepository maintenanceRepo, ISessionRepository sessionRepo, IMetricsRepository metricsRepo, - WebhookNotificationService webhookService, + NotificationChannelDispatcher channelDispatcher, TenantNotificationService tenantNotificationService, ISlaTenantStatusRepository statusRepo, AdminConfigurationService adminConfigService, @@ -70,7 +70,7 @@ namespace AutopilotMonitor.Functions.Services _maintenanceRepo = maintenanceRepo; _sessionRepo = sessionRepo; _metricsRepo = metricsRepo; - _webhookService = webhookService; + _channelDispatcher = channelDispatcher; _tenantNotificationService = tenantNotificationService; _statusRepo = statusRepo; _adminConfigService = adminConfigService; @@ -560,7 +560,7 @@ namespace AutopilotMonitor.Functions.Services var slaChannels = GetSlaChannels(config); if (slaChannels.Count > 0) { - await _webhookService.SendToChannelsAsync(slaChannels, alert); + await _channelDispatcher.SendToChannelsAsync(slaChannels, alert); } await _tenantNotificationService.CreateNotificationAsync( @@ -603,7 +603,7 @@ namespace AutopilotMonitor.Functions.Services var slaChannels = GetSlaChannels(config); if (slaChannels.Count > 0) { - await _webhookService.SendToChannelsAsync(slaChannels, alert); + await _channelDispatcher.SendToChannelsAsync(slaChannels, alert); } await _tenantNotificationService.CreateNotificationAsync( @@ -691,7 +691,7 @@ namespace AutopilotMonitor.Functions.Services var slaChannels = GetSlaChannels(config); if (slaChannels.Count > 0) { - await _webhookService.SendToChannelsAsync(slaChannels, alert); + await _channelDispatcher.SendToChannelsAsync(slaChannels, alert); } await _tenantNotificationService.CreateNotificationAsync( diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TelegramNotificationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TelegramNotificationService.cs index a34c58e8..b60a2ced 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TelegramNotificationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TelegramNotificationService.cs @@ -218,37 +218,19 @@ namespace AutopilotMonitor.Functions.Services } /// - /// Sends an ops alert to the specified Telegram chat. + /// Sends an alert to the specified Telegram chat. /// Converts the NotificationAlert into a plain-text Telegram message with severity emoji. /// Best-effort — silently no-ops on failure. + /// + /// Virtual: routes Telegram + /// channels here, and its routing tests need a seam. + /// /// - public async Task SendOpsAlertAsync(string chatId, NotificationAlert alert) + public virtual async Task SendOpsAlertAsync(string chatId, NotificationAlert alert) { try { - var webhookUrl = await GetWebhookUrlAsync(); - if (string.IsNullOrWhiteSpace(webhookUrl) || string.IsNullOrWhiteSpace(chatId)) - return; - - var icon = alert.Severity switch - { - NotificationSeverity.Error => "\U0001f7e0", // orange circle - NotificationSeverity.Warning => "\U0001f7e1", // yellow circle - NotificationSeverity.Info => "\u2139\ufe0f", // info - _ => "\U0001f534" // red circle (fallback / success) - }; - - var sb = new StringBuilder(); - sb.AppendLine($"{icon} {alert.Title}"); - sb.AppendLine(alert.Summary); - - foreach (var fact in alert.Facts) - sb.AppendLine($"{fact.Name}: {fact.Value}"); - - var payload = new { chat_id = chatId, text = sb.ToString().TrimEnd() }; - var json = JsonConvert.SerializeObject(payload); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - await PostWithRetryAsync(webhookUrl, content, $"OpsAlert:{alert.Title}"); + await SendAlertWithResultAsync(chatId, alert); } catch (Exception ex) { @@ -256,6 +238,71 @@ namespace AutopilotMonitor.Functions.Services } } + /// + /// Same message as , but REPORTS the outcome instead of + /// swallowing it — for the channel "send test" endpoints. Never throws. + /// + public virtual async Task SendAlertWithResultAsync(string chatId, NotificationAlert alert) + { + try + { + if (string.IsNullOrWhiteSpace(chatId)) + return new Notifications.WebhookTestResult { Success = false, Message = "No Telegram chat ID configured." }; + + var webhookUrl = await GetWebhookUrlAsync(); + if (string.IsNullOrWhiteSpace(webhookUrl)) + { + return new Notifications.WebhookTestResult + { + Success = false, + Message = "The platform Telegram bot is not configured — no message can be delivered." + }; + } + + var payload = new { chat_id = chatId, text = RenderAlertText(alert) }; + var json = JsonConvert.SerializeObject(payload); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + var sent = await PostWithRetryAsync(webhookUrl!, content, $"Alert:{alert.Title}"); + + return sent + ? new Notifications.WebhookTestResult { Success = true, Message = "Test notification sent successfully." } + : new Notifications.WebhookTestResult + { + Success = false, + Message = "Telegram rejected the message — check the chat ID and that the bot is a member of that chat." + }; + } + catch (Exception ex) + { + return new Notifications.WebhookTestResult { Success = false, Message = $"Connection error: {ex.Message}" }; + } + } + + /// + /// Flattens an alert into the plain-text form Telegram accepts. Telegram has no card + /// format, so facts become "Name: Value" lines — that is also what carries an ops event's + /// structured payload into the message. + /// + internal static string RenderAlertText(NotificationAlert alert) + { + var icon = alert.Severity switch + { + NotificationSeverity.Error => "\U0001f7e0", // orange circle + NotificationSeverity.Warning => "\U0001f7e1", // yellow circle + NotificationSeverity.Info => "\u2139\ufe0f", // info + _ => "\U0001f534" // red circle (fallback / success) + }; + + var sb = new StringBuilder(); + sb.AppendLine($"{icon} {alert.Title}"); + sb.AppendLine(alert.Summary); + + foreach (var fact in alert.Facts) + sb.AppendLine($"{fact.Name}: {fact.Value}"); + + return sb.ToString().TrimEnd(); + } + private async Task GetWebhookUrlAsync() { try diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-ops-events.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-ops-events.test.ts new file mode 100644 index 00000000..ca2daebe --- /dev/null +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-ops-events.test.ts @@ -0,0 +1,136 @@ +/** + * Unit tests for get_ops_events request shaping. + * + * The point of the tool's filter surface is that the STORAGE query narrows the result — a model + * asking "were there AgentEmergencyBreak events yesterday?" must not pull a whole category into + * context and filter it there. These tests pin that every filter actually reaches the backend + * query string, that the `days` shorthand resolves to a concrete dateFrom on the wire (a + * backend-side "last N days" would re-resolve "now" on every page and break the continuation + * fingerprint), and that an explicit bound always wins over the shorthand. + * + * apiFetch is mocked so these run with no backend / token. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const { apiFetchMock } = vi.hoisted(() => ({ apiFetchMock: vi.fn() })); + +vi.mock('../client.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, apiFetch: apiFetchMock }; +}); + +import { registerAdminTools } from '../tools/admin.js'; + +type Handler = (args: Record) => Promise<{ content: Array<{ text: string }>; isError?: boolean }>; + +function opsEventsHandler(): Handler { + const handlers: Record = {}; + const fake = { registerTool: (name: string, _def: unknown, handler: Handler) => { handlers[name] = handler; } }; + registerAdminTools(fake as never, true, true, false); + return handlers.get_ops_events; +} + +/** Runs the tool and returns the path it asked the backend for. */ +async function fetchedPath(args: Record): Promise { + apiFetchMock.mockResolvedValueOnce({ success: true, count: 0, events: [] }); + await opsEventsHandler()(args); + expect(apiFetchMock).toHaveBeenCalledTimes(1); + return apiFetchMock.mock.calls[0][0] as string; +} + +const params = (path: string) => new URLSearchParams(path.slice(path.indexOf('?') + 1)); + +describe('get_ops_events — registration', () => { + it('is a Global-Admin-only tool', () => { + const handlers: Record = {}; + const fake = { registerTool: (name: string, _d: unknown, h: Handler) => { handlers[name] = h; } }; + registerAdminTools(fake as never, false, false, false); + expect(handlers).not.toHaveProperty('get_ops_events'); + }); +}); + +describe('get_ops_events — server-side filters', () => { + beforeEach(() => apiFetchMock.mockReset()); + + it('passes every field filter to the backend query instead of post-filtering', async () => { + const path = await fetchedPath({ + category: 'Agent', + eventType: 'AgentEmergencyBreak', + severity: 'Error', + minSeverity: 'Warning', + tenantId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + pageSize: 50, + }); + + expect(path.startsWith('/api/global/ops-events?')).toBe(true); + const q = params(path); + expect(q.get('category')).toBe('Agent'); + expect(q.get('eventType')).toBe('AgentEmergencyBreak'); + expect(q.get('severity')).toBe('Error'); + expect(q.get('minSeverity')).toBe('Warning'); + expect(q.get('tenantId')).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890'); + expect(q.get('pageSize')).toBe('50'); + }); + + it('omits filters the caller did not name', async () => { + const q = params(await fetchedPath({ pageSize: 200 })); + + for (const key of ['category', 'eventType', 'severity', 'minSeverity', 'tenantId', 'dateFrom', 'dateTo']) { + expect(q.has(key), `${key} must not be sent when unset`).toBe(false); + } + }); +}); + +describe('get_ops_events — days shorthand', () => { + beforeEach(() => { + apiFetchMock.mockReset(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-01T12:00:00.000Z')); + }); + afterEach(() => vi.useRealTimers()); + + it('resolves days into a concrete dateFrom on the wire', async () => { + // Resolved client-side on purpose: the backend contract is the ISO window, so the bound that + // goes out here is the one echoed back on nextLink and folded into the token fingerprint. + const q = params(await fetchedPath({ days: 1, pageSize: 200 })); + + expect(q.get('dateFrom')).toBe('2026-08-31T12:00:00.000Z'); + expect(q.has('dateTo')).toBe(false); + expect(q.has('days')).toBe(false); + }); + + it('honours an explicit dateFrom over the shorthand', async () => { + const q = params(await fetchedPath({ days: 7, dateFrom: '2026-08-01T00:00:00Z', pageSize: 200 })); + + expect(q.get('dateFrom')).toBe('2026-08-01T00:00:00Z'); + }); + + it('ignores the shorthand when only dateTo is given — the caller owns the window', async () => { + const q = params(await fetchedPath({ days: 7, dateTo: '2026-08-15T00:00:00Z', pageSize: 200 })); + + expect(q.has('dateFrom')).toBe(false); + expect(q.get('dateTo')).toBe('2026-08-15T00:00:00Z'); + }); +}); + +describe('get_ops_events — pagination', () => { + beforeEach(() => apiFetchMock.mockReset()); + + it('follows a nextLink verbatim so the filters and window round-trip', async () => { + // The backend echoes the resolved filters on nextLink and binds them into the token + // fingerprint; re-synthesizing the query here would mismatch and get a 400. + const nextLink = '/api/global/ops-events?pageSize=200&continuation=abc123&dateFrom=2026-08-01T00%3A00%3A00.000Z&eventType=AgentEmergencyBreak'; + + const path = await fetchedPath({ continuation: nextLink, pageSize: 200, eventType: 'Other' }); + + expect(path).toBe(nextLink); + }); + + it('rejects a nextLink from a different endpoint', async () => { + apiFetchMock.mockResolvedValueOnce({ success: true, events: [] }); + const result = await opsEventsHandler()({ continuation: '/api/global/audit/logs?pageSize=10&continuation=x' }); + + expect(result.isError).toBe(true); + expect(apiFetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/vocabulary-drift.guard.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/vocabulary-drift.guard.test.ts new file mode 100644 index 00000000..c93e48fd --- /dev/null +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/vocabulary-drift.guard.test.ts @@ -0,0 +1,97 @@ +/** + * Ratchet: a tool input schema must not hand-type a vocabulary that the backend owns. + * + * Every `z.enum([...])` with an inline array is a frozen copy of something. When that + * something lives in C# — session statuses, event severities, ops categories — the copy + * drifts the moment the backend gains or loses a member, and NOTHING notices: the tool + * simply stops offering a filter value that exists (or offers one that no longer does), + * and a model troubleshooting with it silently sees a subset. That is how get_ops_events + * spent months advertising six ops categories while the backend wrote seven. + * + * The fix is `z.enum(SOME_GENERATED_CONST)` from wire-vocabularies.generated.ts, which is + * byte-pinned to the C# reflection (wire-types-freshness.test.ts). This guard keeps the + * remaining inline enums to a reviewed baseline: each one is listed below with the reason + * it has no backend owner. Adding a new inline enum fails here until it is either derived + * from the generated vocabularies or added to the baseline with a reason. + */ +import { describe, it, expect } from 'vitest'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { readdirSync, readFileSync } from 'node:fs'; + +const TOOLS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'tools'); + +/** + * Inline `z.enum([...])` vocabularies that legitimately have no C# owner: they are MCP-local + * (a knob on the tool itself) or a documented free-string column with no constants class. + * Key = the exact literal array source as it appears in the schema. + */ +const ALLOWED_INLINE_ENUMS: Record = { + "'country', 'region', 'city'": + 'get_geographic_metrics grouping level — an MCP-side knob, not a backend vocabulary.', + "'fast', 'deep'": + 'search_events search depth — an MCP-side ranking knob.', + "'all', 'analyze-rule', 'gather-rule', 'ime-log-pattern'": + 'search_knowledge corpus selector — MCP-side, spans two backend catalogs.', + "'inventory', 'unmatched'": + 'get_software_inventory scope selector — an MCP-side view switch.', + "'cmtrace', 'text'": + 'test_log_pattern input format — an MCP-side parser switch.', + "'analyze', 'gather'": + 'Rule type. Backend-owned in spirit but only documented as a comment on RuleStatsEntry.RuleType — ' + + 'no constants class to reflect. Canonicalise in C# first, then derive here.', + "'v1', 'v2'": + 'Enrollment rail. Free string on the session row; no C# constants class.', + "'WiFi', 'Ethernet'": + 'Connection type. Projected from agent telemetry as a free string (EventIngestProcessor); ' + + 'no C# constants class.', + "'low', 'medium', 'high', 'critical'": + 'CVE overall-risk filter. Risk ranking lives inline in TableStorageService.AgentApi; ' + + 'no C# constants class.', + "'event_types', 'ops_event_types', 'device_properties', 'diag_zip_layout', 'rule_authoring_guide', 'rule_schemas', 'rule_guardrails'": + 'get_resource names — the MCP resource catalog itself, pinned by ResourceName in resource-catalog.ts.', +}; + +/** Every inline z.enum([...]) literal across the tool sources, with its file. */ +function inlineEnums(): Array<{ file: string; literal: string }> { + const found: Array<{ file: string; literal: string }> = []; + for (const file of readdirSync(TOOLS_DIR).filter((f) => f.endsWith('.ts'))) { + const source = readFileSync(join(TOOLS_DIR, file), 'utf8'); + for (const m of source.matchAll(/z\s*\.?\s*enum\(\s*\[([^\]]*)\]/g)) { + found.push({ file, literal: m[1].trim().replace(/\s+/g, ' ') }); + } + } + return found; +} + +describe('tool input vocabularies do not drift from the backend', () => { + it('every inline z.enum is either MCP-local or a reviewed exception', () => { + const offenders = inlineEnums() + .filter((e) => !(e.literal in ALLOWED_INLINE_ENUMS)) + .map((e) => `${e.file}: z.enum([${e.literal}])`); + + expect( + offenders, + 'Hand-typed vocabulary in a tool schema. If the backend owns this list, import it from ' + + 'generated/wire-vocabularies.generated.js (add the manifest section + MCP_VOCABULARIES ' + + 'entry if it is not exported yet). If it is genuinely MCP-local, add it to ' + + 'ALLOWED_INLINE_ENUMS with the reason:', + ).toEqual([]); + }); + + it('the baseline has no stale entries', () => { + // A reason left behind for an enum that no longer exists is a lie about the current + // state — and it would silently re-allow the same literal if someone reintroduced it. + const present = new Set(inlineEnums().map((e) => e.literal)); + const stale = Object.keys(ALLOWED_INLINE_ENUMS).filter((k) => !present.has(k)); + + expect(stale, 'ALLOWED_INLINE_ENUMS entries that no longer match any tool schema:').toEqual([]); + }); + + it('the scan actually sees the tool sources', () => { + // Plausibility floor: a refactor that moves the schemas or renames the folder would + // otherwise turn both assertions above into vacuous truths. + expect(inlineEnums().length).toBeGreaterThan(5); + expect(readdirSync(TOOLS_DIR).filter((f) => f.endsWith('.ts')).length).toBeGreaterThan(3); + }); +}); diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/wire-types-freshness.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/wire-types-freshness.test.ts index 9579dbf5..a0c0bced 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/wire-types-freshness.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/wire-types-freshness.test.ts @@ -1,5 +1,5 @@ /** - * Freshness guard: src/generated/wire-types.generated.ts MUST be byte-identical to + * Freshness guard: the generated files under src/generated/ MUST be byte-identical to * a fresh run of the web codegen over shared-manifests.json (the C#-reflected wire * contract). The chain: SharedManifestParityTests pins JSON ↔ C#, the web suite pins * the web copy, this test pins the MCP copy — so a backend shape change that lands @@ -46,3 +46,24 @@ describe('generated wire types freshness (MCP copy)', () => { expect(committed.replace(/\r\n/g, '\n')).toBe(buildMcpWireTypesSource(json)); }); }); + +describe('generated wire vocabularies freshness (MCP copy)', () => { + const webRoot = findWebRoot(); + const itOrSkip = webRoot ? it : it.skip; + + // The vocabularies are VALUES the tool schemas derive their z.enum() from. If this file goes + // stale, a tool advertises a vocabulary the backend no longer has (or omits one it gained) — + // and unlike a type mismatch, nothing else would notice. + itOrSkip('src/generated/wire-vocabularies.generated.ts matches a fresh run of the codegen', () => { + const require = createRequire(import.meta.url); + const { buildMcpVocabulariesSource } = require( + join(webRoot!, 'scripts', 'generate-shared-manifest-types.js'), + ); + const json = readFileSync(join(webRoot!, 'utils', 'shared-manifests.json'), 'utf8'); + const committed = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), '..', 'generated', 'wire-vocabularies.generated.ts'), + 'utf8', + ); + expect(committed.replace(/\r\n/g, '\n')).toBe(buildMcpVocabulariesSource(json)); + }); +}); diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts index 9376e4d0..45cec28f 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts @@ -99,6 +99,8 @@ export interface AdminConfiguration { opsAlertSlackEnabled: boolean; /** Slack Incoming Webhook URL for ops alerts. */ opsAlertSlackWebhookUrl: string; + /** JSON array of platform notification channels (NotificationChannel), the destinations an OpsAlertRule can target by id. Supersedes the three fixed provider slots above: each channel carries its own provider, destination and name, so an operator can keep e.g. an "IT Engineer" push channel and a "Sales" webhook apart and bind each event type to only the channels that should see it. Null/empty = not migrated yet — then synthesizes the list from the legacy slots, so dispatch behavior is unchanged until an operator saves the section once. */ + opsNotificationChannelsJson: string; /** Version string of the latest published V2 agent (e.g. "2.0.647"). */ latestAgentV2Version: string; /** SHA-256 (lowercase hex) of the latest published V2 agent ZIP. */ @@ -3779,7 +3781,7 @@ export interface TestLogPatternResponse { result: LogPatternTestResult; } -/** Response of POST config/{tenantId}/test-notification: the delivery verdict of the test webhook send (HTTP 200 for both verdicts — Success carries the outcome). */ +/** Response of POST config/{tenantId}/test-notification AND POST global/config/test-ops-channel: the delivery verdict of a test send (HTTP 200 for both verdicts — Success carries the outcome). One shape for both because the semantics are identical; the platform endpoint only differs in which channel list it resolves the id against. */ export interface TestWebhookNotificationResponse { success: boolean; /** HTTP status returned by the webhook endpoint, or null when the send never got a response — the key is omitted when null. */ diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts new file mode 100644 index 00000000..d2eaafe7 --- /dev/null +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts @@ -0,0 +1,153 @@ +// GENERATED — do not edit by hand. Second copy for the MCP server. +// Source: src/Web/autopilot-monitor-web/utils/shared-manifests.json. +// Regenerate: npm run generate:manifests in src/Web/autopilot-monitor-web. +// +// Vocabularies reflected from AutopilotMonitor.Shared. These are VALUES (not just types) +// so tool input schemas can derive their z.enum() from the backend truth instead of +// retyping it — a hand-typed list drifts silently and the tool then advertises a +// vocabulary the backend no longer has (or omits one it gained). + + +/** Enrollment session statuses (C# SessionStatus). */ +export const SESSION_STATUSES = [ + "InProgress", + "Pending", + "Stalled", + "Succeeded", + "Failed", + "Unknown", + "AwaitingUser", + "Incomplete", +] as const; +export type SessionStatusName = (typeof SESSION_STATUSES)[number]; + +/** Telemetry event severities (C# EventSeverity) — includes Debug and Trace. */ +export const EVENT_SEVERITIES = [ + "Debug", + "Info", + "Warning", + "Error", + "Critical", + "Trace", +] as const; +export type EventSeverityName = (typeof EVENT_SEVERITIES)[number]; + +/** Ops event categories = OpsEvents partition keys (C# OpsEventCategory). */ +export const OPS_EVENT_CATEGORIES = [ + "Consent", + "Maintenance", + "Security", + "Tenant", + "Agent", + "SLA", + "Platform", +] as const; +export type OpsEventCategoryName = (typeof OPS_EVENT_CATEGORIES)[number]; + +/** Ops event severities, ascending (C# OpsEventSeverity) — the order IS the ladder. */ +export const OPS_EVENT_SEVERITIES = [ + "Info", + "Warning", + "Error", + "Critical", +] as const; +export type OpsEventSeverityName = (typeof OPS_EVENT_SEVERITIES)[number]; + +/** Every ops event type the backend can write (C# OpsEventTypes). */ +export const OPS_EVENT_TYPES = [ + "ConsentFlowStarted", + "ConsentFlowSuccess", + "ConsentFlowFailed", + "ConsentRedirectUriMismatch", + "AppHomingFlipped", + "AppHomingFlippedWithEntraRoles", + "MaintenanceCompleted", + "MaintenanceFailed", + "MaintenanceLongRunning", + "SessionSweepCompleted", + "SessionSweepFailed", + "OpsEventCleanup", + "OrphanEventsCleaned", + "SessionDeletionMaintenanceStarted", + "SessionDeletionMaintenanceBudgetExceeded", + "SessionDeletionMaintenanceSkippedLocked", + "SessionDeletionMaintenanceLongRunning", + "SessionDeletionMaintenanceLongRunningSevere", + "SessionDeletionMaintenanceFailed", + "SessionDeletionStrandedQueued", + "SessionDeletionPoisoned", + "SessionDeletionMaintenanceCompleted", + "SessionDeletionMaintenanceFanoutSkipped", + "CriticalTableBackupCompleted", + "CriticalTableBackupPartial", + "CriticalTableBackupFailed", + "CriticalTableBackupSkippedLocked", + "BackupRowRestored", + "VerdictCalibrationDrift", + "DeviceBlocked", + "VersionBlocked", + "SessionTenantConflict", + "SessionOwnerMismatch", + "KillSignalDelivered", + "EmbeddedCertExpiringSoon", + "EmbeddedCertExpiringUrgent", + "EmbeddedCertExpired", + "EmbeddedCertBundleEmpty", + "SignalRConnectionsHigh", + "SignalRConnectionsCritical", + "SignalRMessagesHigh", + "SignalRMessagesCritical", + "PoisonQueueBacklogHigh", + "PoisonQueueBacklogCritical", + "ExcessiveSessionEventsAutoActioned", + "OffboardingFeedbackReceived", + "TenantOffboarded", + "TenantOffboardingFailed", + "TenantAutoApproved", + "WelcomeEmailSent", + "WelcomeEmailSkipped", + "WelcomeEmailFailed", + "TenantTrialStarted", + "TenantTrialExpiring", + "TenantTrialExpired", + "TenantPlanDowngraded", + "TenantRetentionGraceExpiring", + "TenantRetentionGraceEnded", + "RuleFrequencyRegression", + "AppVersionDurationRegression", + "CollectLogsQuickConfigEnabled", + "DiagnosticsUploadEnabled", + "DiagnosticsUploadDisabled", + "SessionActionQueued", + "SessionTimeouts", + "AgentEmergencyBreak", + "AgentBinaryIntegrityMismatch", + "CmTraceTimeSkewRegression", + "ExcessiveSessionEvents", + "NewImeVersionDetected", + "ImePatternDriftSuspected", + "BlobStorageMissing", + "BlobStorageUnreachable", + "SlaBreachNotification", + "SlaConsecutiveFailures", + "SlaEvaluationCompleted", + "AzureMonitorAlert", +] as const; +export type OpsEventTypeName = (typeof OPS_EVENT_TYPES)[number]; + +/** Session-annotation lanes (C# AnnotationLanes). */ +export const ANNOTATION_LANES = [ + "operator", + "tenantadmin", + "globaladmin", +] as const; +export type AnnotationLane = (typeof ANNOTATION_LANES)[number]; + +/** Session-annotation verdicts (C# AnnotationVerdicts). */ +export const ANNOTATION_VERDICTS = [ + "root_cause_confirmed", + "analysis_wrong", + "different_problem", + "inconclusive", +] as const; +export type AnnotationVerdict = (typeof ANNOTATION_VERDICTS)[number]; diff --git a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts index a2ba772f..cb9c76a6 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts @@ -7,6 +7,11 @@ import { DIAG_ZIP_MAP } from './diag-zip-map.js'; import { RULE_AUTHORING_GUIDE } from './rule-authoring-guide.js'; import { GATHER_RULE_SCHEMA, ANALYZE_RULE_SCHEMA, RULE_GUARDRAILS } from './rule-authoring.generated.js'; +import { + OPS_EVENT_CATEGORIES, + OPS_EVENT_SEVERITIES, + OPS_EVENT_TYPES, +} from './generated/wire-vocabularies.generated.js'; // Single source of truth for the model-facing event-type catalog. MUST stay in // sync with C# `Constants.EventTypes` (Shared) — enforced by the vitest drift test @@ -403,8 +408,22 @@ export function assertKnownDevicePropertyKeys(keys: string[]): void { ); } +/** + * The three ops vocabularies in one resource: what get_ops_events can filter by. + * + * GENERATED upstream (C# OpsEventCategory / OpsEventSeverity / OpsEventTypes → shared manifest + * → wire-vocabularies.generated.ts), so unlike the hand-maintained catalogs above this one + * cannot drift: a backend type that nobody re-typed here would still show up. + */ +export const OPS_EVENTS_CATALOG = { + categories: OPS_EVENT_CATEGORIES, + severities: OPS_EVENT_SEVERITIES, + eventTypes: OPS_EVENT_TYPES, +} as const; + export type ResourceName = | 'event_types' + | 'ops_event_types' | 'device_properties' | 'diag_zip_layout' | 'rule_authoring_guide' @@ -415,6 +434,8 @@ export function getResourceContent(name: ResourceName): unknown { switch (name) { case 'event_types': return EVENT_TYPES_CATALOG; + case 'ops_event_types': + return OPS_EVENTS_CATALOG; case 'device_properties': return DEVICE_PROPERTIES_CATALOG; case 'diag_zip_layout': diff --git a/src/McpServer/autopilot-monitor-mcp/src/resources.ts b/src/McpServer/autopilot-monitor-mcp/src/resources.ts index ae33d05b..cfc45363 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/resources.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/resources.ts @@ -117,6 +117,28 @@ export function registerResources(server: McpServer): void { }) ); + server.registerResource( + 'ops_event_types', + 'autopilot://ops-event-types', + { + title: 'Ops Event Vocabularies', + mimeType: 'application/json', + description: + 'The three vocabularies get_ops_events filters by: OpsEvents categories (partition keys), ' + + 'severities in ladder order, and every ops event type the backend can write. Generated from ' + + 'the C# constants, so it never advertises a phantom type nor omits a real one.', + }, + async () => ({ + contents: [ + { + uri: 'autopilot://ops-event-types', + mimeType: 'application/json', + text: JSON.stringify(getResourceContent('ops_event_types'), null, 2), + }, + ], + }) + ); + server.registerResource( 'rule_guardrails', 'autopilot://rule-guardrails', diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts index aa02ff07..f0bae1e3 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -22,6 +22,17 @@ import type { SoftwareInventoryResponse, VerdictCalibrationResponse, } from '../generated/wire-types.generated.js'; +// Vocabularies as VALUES, generated from the C# constants (see wire-vocabularies.generated.ts). +// Tool enums derive from these — a hand-typed list is what let get_ops_events advertise a +// category vocabulary the backend had outgrown. +import { + ANNOTATION_LANES, + ANNOTATION_VERDICTS, + EVENT_SEVERITIES, + OPS_EVENT_CATEGORIES, + OPS_EVENT_SEVERITIES, + SESSION_STATUSES, +} from '../generated/wire-vocabularies.generated.js'; /** * Non-sensitive tenant fields safe to surface to the model. Keep-list (not @@ -811,30 +822,52 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo description: 'Get operational events for platform monitoring. Shows consent flow results, maintenance runs, security blocks, ' + 'tenant offboards, agent timeouts, and blob storage health. Global Admin only. ' + - 'Use category to narrow results (Consent, Maintenance, Security, Tenant, Agent, SLA). ' + - 'Forensics: dateFrom / dateTo (ISO 8601 UTC) bound the search window exactly; without either, the backend defaults to the last 30 days. ' + + 'FILTER FIRST — every filter below is applied by the STORAGE QUERY, so a narrow question costs a narrow response: ' + + 'eventType for one exact type (e.g. "AgentEmergencyBreak"), severity / minSeverity for one level or a threshold, ' + + `category to narrow to ${OPS_EVENT_CATEGORIES.join(', ')}, days / dateFrom / dateTo for the window. ` + + 'Do NOT pull a whole category and filter the events yourself — name the filter instead. ' + + 'Window: days=1 is the last 24h (shorthand, ignored when dateFrom or dateTo is given); dateFrom / dateTo (ISO 8601 UTC) ' + + 'bound it exactly; with none of the three, the backend defaults to the last 30 days. ' + + 'eventType is an EXACT, case-sensitive match against the stored type; call get_resource(name="ops_event_types") ' + + 'for the full vocabulary. An unknown type is not an error, it is an empty result. ' + 'Pagination: when "nextLink" is present in the response, more events are available — call this tool again and pass the ' + 'whole nextLink string (e.g. "/api/global/ops-events?pageSize=...&continuation=...&dateFrom=...&dateTo=...") as ' + - '"continuation". The tool follows it verbatim so the backend-defaulted date window round-trips correctly (otherwise ' + - 'a follow-up call would compute a fresh "now" and the token fingerprint would mismatch). Stop when nextLink is absent.', + '"continuation". The tool follows it verbatim so the backend-defaulted date window and the filters round-trip correctly ' + + '(otherwise a follow-up call would compute a fresh "now" and the token fingerprint would mismatch). Stop when nextLink is absent.', inputSchema: { - category: z.string().optional().describe('Filter by category: Consent, Maintenance, Security, Tenant, Agent, SLA'), + category: z.enum(OPS_EVENT_CATEGORIES).optional().describe('Filter by category (OpsEvents partition key).'), + eventType: z.string().optional() + .describe('Exact event type, case-sensitive (e.g. "AgentEmergencyBreak", "MaintenanceFailed"). Applied server-side — prefer this over fetching a whole category.'), + severity: z.enum(OPS_EVENT_SEVERITIES).optional() + .describe('Exact severity. Use minSeverity instead when you want "this level and worse".'), + minSeverity: z.enum(OPS_EVENT_SEVERITIES).optional() + .describe('Threshold: this severity and everything above it (Info < Warning < Error < Critical) — the same ladder the ops alert rules use.'), tenantId: z.string().optional().describe('Optional — filter events to a single tenant. Omit for cross-tenant view.'), + days: z.coerce.number().int().min(1).max(90).optional() + .describe('Shorthand window: only events from the last N days (1 = last 24h). Ignored when dateFrom or dateTo is given.'), dateFrom: z.string().optional().describe('ISO 8601 UTC timestamp — inclusive lower bound of the window.'), dateTo: z.string().optional().describe('ISO 8601 UTC timestamp — inclusive upper bound of the window.'), pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) .describe('Page size (1-1000, default 200). Returns this many events per call; follow nextLink to fetch more.'), continuation: z.string().optional() - .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params (incl. resolved dateFrom/dateTo) round-trip correctly.'), + .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params (incl. resolved dateFrom/dateTo and filters) round-trip correctly.'), }, annotations: READ_ONLY, }, async (args) => withToolTelemetry('get_ops_events', args, async () => { try { - const { category, tenantId, dateFrom, dateTo, pageSize, continuation } = args; + const { category, eventType, severity, minSeverity, tenantId, days, dateFrom, dateTo, pageSize, continuation } = args; + // "days" is client-side sugar for dateFrom: the backend contract is the ISO window, and + // resolving here keeps the resolved bound on the wire so it round-trips through nextLink + // (a backend-side "days" would re-resolve "now" on every page and blow the fingerprint). + // An explicit bound always wins — silently overriding one the caller named would be worse + // than ignoring the shorthand. + const resolvedDateFrom = dateFrom ?? (days && !dateTo + ? new Date(Date.now() - days * 86_400_000).toISOString() + : undefined); const path = followNextLink( '/api/global/ops-events', - { category, tenantId, dateFrom, dateTo, pageSize }, + { category, eventType, severity, minSeverity, tenantId, dateFrom: resolvedDateFrom, dateTo, pageSize }, continuation, ); const data = await apiFetch(path); @@ -943,9 +976,9 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo 'Pagination: when "nextLink" is present, pass the whole string back as "continuation"; stop when absent.', inputSchema: { tenantId: TenantGuidSchema.optional().describe('Optional — filter to a single tenant. Omit for cross-tenant view.'), - lane: z.enum(['operator', 'tenantadmin', 'globaladmin']).optional() + lane: z.enum(ANNOTATION_LANES).optional() .describe('Optional — filter to one annotation lane.'), - verdict: z.enum(['root_cause_confirmed', 'analysis_wrong', 'different_problem', 'inconclusive']).optional() + verdict: z.enum(ANNOTATION_VERDICTS).optional() .describe('Optional — filter to one structured verdict.'), ruleId: z.string().optional() .describe('Optional — only annotations whose rule-id snapshot contains this rule (e.g. "ANALYZE-ESP-001").'), @@ -991,7 +1024,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo sessionId: SessionIdSchema.describe('Session UUID to annotate.'), tenantId: TenantGuidSchema.optional() .describe('Optional — the session\'s tenant. If omitted, auto-resolved from the session.'), - verdict: z.enum(['root_cause_confirmed', 'analysis_wrong', 'different_problem', 'inconclusive']).nullable().optional() + verdict: z.enum(ANNOTATION_VERDICTS).nullable().optional() .describe('Structured verdict, or null. At least one of verdict/note must be non-null unless clearing.'), note: z.string().max(4096).nullable().optional() .describe('Free-text note (max 4096 chars), or null.'), @@ -1049,7 +1082,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated, 'Tenant ID. Omit for cross-tenant search, or to auto-resolve from a sessionId query (Global Admin only).', 'Optional tenant ID. Defaults to your tenant.')), sessionId: SessionIdSchema.optional().describe('Filter to a specific session'), eventType: z.string().optional().describe('Event type filter (e.g. "app_install_failed", "error_detected")'), - severity: z.enum(['Info', 'Warning', 'Error', 'Critical']).optional(), + severity: z.enum(EVENT_SEVERITIES).optional(), source: z.string().optional().describe('Filter by event source/app name (substring match)'), startedAfter: z.string().optional().describe('ISO 8601 datetime — only events after this'), startedBefore: z.string().optional().describe('ISO 8601 datetime — only events before this'), @@ -1103,7 +1136,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo 'Pass the whole nextLink string as "continuation" so all backend-echoed query params round-trip correctly.', inputSchema: { tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated, 'Tenant ID to query. Omit for cross-tenant access (Global Admin only).', 'Optional tenant ID. Defaults to your tenant.')), - status: z.enum(['InProgress', 'Pending', 'Stalled', 'Succeeded', 'Failed', 'AwaitingUser', 'Incomplete']).optional(), + status: z.enum(SESSION_STATUSES).optional(), startedAfter: z.string().optional().describe('ISO 8601 datetime'), startedBefore: z.string().optional().describe('ISO 8601 datetime'), serialNumber: z.string().optional(), @@ -1800,7 +1833,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo ' - "rule_guardrails": on-device collection allowlists a gather rule target must satisfy', inputSchema: { name: z - .enum(['event_types', 'device_properties', 'diag_zip_layout', 'rule_authoring_guide', 'rule_schemas', 'rule_guardrails']) + .enum(['event_types', 'ops_event_types', 'device_properties', 'diag_zip_layout', 'rule_authoring_guide', 'rule_schemas', 'rule_guardrails']) .describe('Resource name'), }, annotations: READ_ONLY_OPEN, diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts index 78174061..dbf94160 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts @@ -16,6 +16,8 @@ import type { GetSessionEventsResponse, GetSessionResponse, } from '../generated/wire-types.generated.js'; +// Generated vocabularies (values, not just types) — see wire-vocabularies.generated.ts. +import { EVENT_SEVERITIES, SESSION_STATUSES } from '../generated/wire-vocabularies.generated.js'; // ── Session summary constants ─────────────────────────────────────────── @@ -120,7 +122,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated: 'query params round-trip correctly.', inputSchema: { tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated, 'Tenant ID. Omit for cross-tenant search (Global Admin only).', 'Optional tenant ID. Defaults to your tenant.')), - status: z.enum(['InProgress', 'Pending', 'Stalled', 'Succeeded', 'Failed']).optional() + status: z.enum(SESSION_STATUSES).optional() .describe('Enrollment status filter. Pending = White Glove pre-provisioning done, awaiting user enrollment; ' + 'Stalled = no progress for a while (non-terminal, can heal back to InProgress).'), serialNumber: z.string().optional().describe('Device serial number (exact match)'), @@ -414,7 +416,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated: sessionId: SessionIdSchema.describe('Session UUID'), tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated, 'Tenant ID. If omitted, auto-resolved from the session.', 'Tenant ID. If omitted, auto-resolved from the session.')), eventType: z.string().optional().describe('Filter to only events of this type'), - severity: z.enum(['Info', 'Warning', 'Error', 'Critical']).optional(), + severity: z.enum(EVENT_SEVERITIES).optional(), source: z.string().optional().describe('Filter by event source/app name (e.g. "MicrosoftTeams")'), fields: z.string().optional() .describe('Comma-separated lean projection (e.g. "eventType,severity,timestamp,message"). Drops the heavy "data" payload unless "data" is listed. Valid keys: eventId, sessionId, tenantId, eventType, severity, source, phase, phaseName, timestamp, receivedAt, sentAt, message, sequence, rowKey, originalTimestamp, timestampClamped, causedByTransitionStepIndex, causedBySignalOrdinal, data.'), diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IOpsEventRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IOpsEventRepository.cs index af0326e9..060dd198 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IOpsEventRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IOpsEventRepository.cs @@ -18,9 +18,11 @@ namespace AutopilotMonitor.Shared.DataAccess /// is optional — when provided, scopes to a single PartitionKey for an /// indexed lookup. Sorted newest-first. No row cap; for unbounded /// windows on busy installations, prefer . + /// narrows on non-key columns server-side. /// Task> GetOpsEventsAsync( - string? category = null, DateTime? dateFrom = null, DateTime? dateTo = null); + string? category = null, DateTime? dateFrom = null, DateTime? dateTo = null, + OpsEventQueryFilters? filters = null); /// /// Reads a single page of ops events. The returned @@ -29,7 +31,8 @@ namespace AutopilotMonitor.Shared.DataAccess /// newest-first. /// Task> GetOpsEventsPageAsync( - string? category, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation); + string? category, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, + OpsEventQueryFilters? filters = null); Task DeleteOpsEventsOlderThanAsync(DateTime cutoff); } @@ -47,6 +50,15 @@ namespace AutopilotMonitor.Shared.DataAccess public const string Sla = "SLA"; /// Platform infrastructure alerts relayed from Azure Monitor (ops alert webhook). public const string Platform = "Platform"; + + /// + /// Every category = every OpsEvents PartitionKey. The cross-category paged read fans out + /// over exactly this list, so a category missing here is INVISIBLE to every paged reader + /// (MCP, the ops dashboard) while still being written — Platform was missing that way. + /// OpsEventCategoryCoverageTests reflects over the constants above and fails if a new one + /// is not listed here. + /// + public static readonly string[] All = { Consent, Maintenance, Security, Tenant, Agent, Sla, Platform }; } /// @@ -58,6 +70,81 @@ namespace AutopilotMonitor.Shared.DataAccess public const string Warning = "Warning"; public const string Error = "Error"; public const string Critical = "Critical"; + + /// Canonical severities, ascending by . + public static readonly string[] All = { Info, Warning, Error, Critical }; + + /// + /// Ordinal severity rank (Info=0 … Critical=3), -1 for anything outside the vocabulary. + /// Case-SENSITIVE on purpose: written severities always come from the constants above, and + /// the ops-alert dispatch has always compared them verbatim. Query input goes through + /// first. + /// + public static int Rank(string? severity) => severity switch + { + Info => 0, + Warning => 1, + Error => 2, + Critical => 3, + _ => -1, + }; + + /// + /// Maps caller-supplied text ("warning", "ERROR") onto the canonical constant. Returns false + /// for anything outside the vocabulary so an endpoint can 400 instead of silently returning + /// an empty page — Table Storage eq is case-sensitive, so an unnormalized "warning" + /// would match nothing at all. + /// + public static bool TryNormalize(string? value, out string canonical) + { + foreach (var s in All) + { + if (string.Equals(s, value, StringComparison.OrdinalIgnoreCase)) + { + canonical = s; + return true; + } + } + canonical = string.Empty; + return false; + } + + /// Canonical severities at or above (empty for an unknown value). + public static IReadOnlyList AtOrAbove(string minSeverity) + { + var min = Rank(minSeverity); + if (min < 0) return new string[0]; + var result = new List(); + foreach (var s in All) + { + if (Rank(s) >= min) result.Add(s); + } + return result; + } + } + + /// + /// Optional exact-match field filters for the ops-events reads. Every field maps to a + /// SERVER-SIDE OData clause (see TableOpsEventRepository.BuildFilter) — the point is that the + /// store returns only matching rows, so an operator asking for one event type does not pull a + /// whole category over the wire. Mirrors AuditLogQueryFilters. + /// + public class OpsEventQueryFilters + { + /// Exact match on the EventType column (e.g. "AgentEmergencyBreak"). Case-sensitive — Table Storage cannot fold case. + public string? EventType { get; set; } + + /// Exact match on the Severity column. Callers should pass a canonical value. + public string? Severity { get; set; } + + /// Threshold match: every severity at or above this one (Info < Warning < Error < Critical). Same vocabulary the ops-alert rules use. + public string? MinSeverity { get; set; } + + /// True when no filter field is set — callers can skip the filter plumbing entirely. + public bool IsEmpty => + string.IsNullOrEmpty(EventType) && + string.IsNullOrEmpty(Severity) && + string.IsNullOrEmpty(MinSeverity); } public class OpsEventEntry diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs new file mode 100644 index 00000000..11c6effb --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs @@ -0,0 +1,126 @@ +using System.Collections.Generic; + +namespace AutopilotMonitor.Shared.DataAccess +{ + /// + /// Every ops event type the backend can write — the canonical vocabulary. + /// + /// These used to be bare string literals at the call sites in OpsEventService, which + /// meant nothing could enumerate them: the portal alert-rule catalog and the MCP had to + /// retype the list by hand, and a type that nobody re-typed was written but unroutable and + /// undiscoverable. Declaring them here makes the vocabulary reflectable, so the alert-rule + /// catalog check and the shared manifest (which feeds the MCP) derive from ONE source. + /// + /// + /// Adding a type: declare it here, use the constant at the call site (a raw literal fails + /// OpsEventTypeDualRegisterTests), and list it in OPS_EVENT_TYPES in + /// OpsAlertRulesSection.tsx so an operator can route an alert on it. Constant name + /// == wire value. + /// + /// + public static class OpsEventTypes + { + // ── Consent ── Consent flow + dual app-registration homing. + public const string ConsentFlowStarted = "ConsentFlowStarted"; + public const string ConsentFlowSuccess = "ConsentFlowSuccess"; + public const string ConsentFlowFailed = "ConsentFlowFailed"; + public const string ConsentRedirectUriMismatch = "ConsentRedirectUriMismatch"; + public const string AppHomingFlipped = "AppHomingFlipped"; + public const string AppHomingFlippedWithEntraRoles = "AppHomingFlippedWithEntraRoles"; + + // ── Maintenance ── Scheduled maintenance, cascade deletion, critical-table backup. + public const string MaintenanceCompleted = "MaintenanceCompleted"; + public const string MaintenanceFailed = "MaintenanceFailed"; + public const string MaintenanceLongRunning = "MaintenanceLongRunning"; + public const string SessionSweepCompleted = "SessionSweepCompleted"; + public const string SessionSweepFailed = "SessionSweepFailed"; + public const string OpsEventCleanup = "OpsEventCleanup"; + public const string OrphanEventsCleaned = "OrphanEventsCleaned"; + public const string SessionDeletionMaintenanceStarted = "SessionDeletionMaintenanceStarted"; + public const string SessionDeletionMaintenanceBudgetExceeded = "SessionDeletionMaintenanceBudgetExceeded"; + public const string SessionDeletionMaintenanceSkippedLocked = "SessionDeletionMaintenanceSkippedLocked"; + public const string SessionDeletionMaintenanceLongRunning = "SessionDeletionMaintenanceLongRunning"; + public const string SessionDeletionMaintenanceLongRunningSevere = "SessionDeletionMaintenanceLongRunningSevere"; + public const string SessionDeletionMaintenanceFailed = "SessionDeletionMaintenanceFailed"; + public const string SessionDeletionStrandedQueued = "SessionDeletionStrandedQueued"; + public const string SessionDeletionPoisoned = "SessionDeletionPoisoned"; + public const string SessionDeletionMaintenanceCompleted = "SessionDeletionMaintenanceCompleted"; + public const string SessionDeletionMaintenanceFanoutSkipped = "SessionDeletionMaintenanceFanoutSkipped"; + public const string CriticalTableBackupCompleted = "CriticalTableBackupCompleted"; + public const string CriticalTableBackupPartial = "CriticalTableBackupPartial"; + public const string CriticalTableBackupFailed = "CriticalTableBackupFailed"; + public const string CriticalTableBackupSkippedLocked = "CriticalTableBackupSkippedLocked"; + public const string BackupRowRestored = "BackupRowRestored"; + public const string VerdictCalibrationDrift = "VerdictCalibrationDrift"; + + // ── Security ── Blocks, ownership conflicts, certificate expiry, capacity + poison-queue alarms. + public const string DeviceBlocked = "DeviceBlocked"; + public const string VersionBlocked = "VersionBlocked"; + public const string SessionTenantConflict = "SessionTenantConflict"; + public const string SessionOwnerMismatch = "SessionOwnerMismatch"; + public const string KillSignalDelivered = "KillSignalDelivered"; + public const string EmbeddedCertExpiringSoon = "EmbeddedCertExpiringSoon"; + public const string EmbeddedCertExpiringUrgent = "EmbeddedCertExpiringUrgent"; + public const string EmbeddedCertExpired = "EmbeddedCertExpired"; + public const string EmbeddedCertBundleEmpty = "EmbeddedCertBundleEmpty"; + public const string SignalRConnectionsHigh = "SignalRConnectionsHigh"; + public const string SignalRConnectionsCritical = "SignalRConnectionsCritical"; + public const string SignalRMessagesHigh = "SignalRMessagesHigh"; + public const string SignalRMessagesCritical = "SignalRMessagesCritical"; + public const string PoisonQueueBacklogHigh = "PoisonQueueBacklogHigh"; + public const string PoisonQueueBacklogCritical = "PoisonQueueBacklogCritical"; + public const string ExcessiveSessionEventsAutoActioned = "ExcessiveSessionEventsAutoActioned"; + + // ── Tenant ── Tenant lifecycle: offboarding, trials, plan changes, regression radars, config toggles. + public const string OffboardingFeedbackReceived = "OffboardingFeedbackReceived"; + public const string TenantOffboarded = "TenantOffboarded"; + public const string TenantOffboardingFailed = "TenantOffboardingFailed"; + public const string TenantAutoApproved = "TenantAutoApproved"; + public const string WelcomeEmailSent = "WelcomeEmailSent"; + public const string WelcomeEmailSkipped = "WelcomeEmailSkipped"; + public const string WelcomeEmailFailed = "WelcomeEmailFailed"; + public const string TenantTrialStarted = "TenantTrialStarted"; + public const string TenantTrialExpiring = "TenantTrialExpiring"; + public const string TenantTrialExpired = "TenantTrialExpired"; + public const string TenantPlanDowngraded = "TenantPlanDowngraded"; + public const string TenantRetentionGraceExpiring = "TenantRetentionGraceExpiring"; + public const string TenantRetentionGraceEnded = "TenantRetentionGraceEnded"; + public const string RuleFrequencyRegression = "RuleFrequencyRegression"; + public const string AppVersionDurationRegression = "AppVersionDurationRegression"; + public const string CollectLogsQuickConfigEnabled = "CollectLogsQuickConfigEnabled"; + public const string DiagnosticsUploadEnabled = "DiagnosticsUploadEnabled"; + public const string DiagnosticsUploadDisabled = "DiagnosticsUploadDisabled"; + + // ── Agent ── Agent + device-side health signals. + public const string SessionActionQueued = "SessionActionQueued"; + public const string SessionTimeouts = "SessionTimeouts"; + public const string AgentEmergencyBreak = "AgentEmergencyBreak"; + public const string AgentBinaryIntegrityMismatch = "AgentBinaryIntegrityMismatch"; + public const string CmTraceTimeSkewRegression = "CmTraceTimeSkewRegression"; + public const string ExcessiveSessionEvents = "ExcessiveSessionEvents"; + public const string NewImeVersionDetected = "NewImeVersionDetected"; + public const string ImePatternDriftSuspected = "ImePatternDriftSuspected"; + public const string BlobStorageMissing = "BlobStorageMissing"; + public const string BlobStorageUnreachable = "BlobStorageUnreachable"; + + // ── Sla ── SLA evaluation outcomes. + public const string SlaBreachNotification = "SlaBreachNotification"; + public const string SlaConsecutiveFailures = "SlaConsecutiveFailures"; + public const string SlaEvaluationCompleted = "SlaEvaluationCompleted"; + + // ── Platform ── Platform infrastructure alerts relayed from Azure Monitor. + public const string AzureMonitorAlert = "AzureMonitorAlert"; + + /// Every declared type, declaration order (grouped by category). + public static readonly IReadOnlyList All = new[] + { + ConsentFlowStarted, ConsentFlowSuccess, ConsentFlowFailed, ConsentRedirectUriMismatch, AppHomingFlipped, AppHomingFlippedWithEntraRoles, + MaintenanceCompleted, MaintenanceFailed, MaintenanceLongRunning, SessionSweepCompleted, SessionSweepFailed, OpsEventCleanup, OrphanEventsCleaned, SessionDeletionMaintenanceStarted, SessionDeletionMaintenanceBudgetExceeded, SessionDeletionMaintenanceSkippedLocked, SessionDeletionMaintenanceLongRunning, SessionDeletionMaintenanceLongRunningSevere, SessionDeletionMaintenanceFailed, SessionDeletionStrandedQueued, SessionDeletionPoisoned, SessionDeletionMaintenanceCompleted, SessionDeletionMaintenanceFanoutSkipped, CriticalTableBackupCompleted, CriticalTableBackupPartial, CriticalTableBackupFailed, CriticalTableBackupSkippedLocked, BackupRowRestored, VerdictCalibrationDrift, + DeviceBlocked, VersionBlocked, SessionTenantConflict, SessionOwnerMismatch, KillSignalDelivered, EmbeddedCertExpiringSoon, EmbeddedCertExpiringUrgent, EmbeddedCertExpired, EmbeddedCertBundleEmpty, SignalRConnectionsHigh, SignalRConnectionsCritical, SignalRMessagesHigh, SignalRMessagesCritical, PoisonQueueBacklogHigh, PoisonQueueBacklogCritical, ExcessiveSessionEventsAutoActioned, + OffboardingFeedbackReceived, TenantOffboarded, TenantOffboardingFailed, TenantAutoApproved, WelcomeEmailSent, WelcomeEmailSkipped, WelcomeEmailFailed, TenantTrialStarted, TenantTrialExpiring, TenantTrialExpired, TenantPlanDowngraded, TenantRetentionGraceExpiring, TenantRetentionGraceEnded, RuleFrequencyRegression, AppVersionDurationRegression, CollectLogsQuickConfigEnabled, DiagnosticsUploadEnabled, DiagnosticsUploadDisabled, + SessionActionQueued, SessionTimeouts, AgentEmergencyBreak, AgentBinaryIntegrityMismatch, CmTraceTimeSkewRegression, ExcessiveSessionEvents, NewImeVersionDetected, ImePatternDriftSuspected, BlobStorageMissing, BlobStorageUnreachable, + SlaBreachNotification, SlaConsecutiveFailures, SlaEvaluationCompleted, + AzureMonitorAlert, + }; + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs index 7e45e5a5..025d2117 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs @@ -264,6 +264,92 @@ namespace AutopilotMonitor.Shared.Models /// Slack Incoming Webhook URL for ops alerts. public string OpsAlertSlackWebhookUrl { get; set; } = default!; + /// + /// JSON array of platform notification channels (), + /// the destinations an can target by id. Supersedes the three + /// fixed provider slots above: each channel carries its own provider, destination and name, + /// so an operator can keep e.g. an "IT Engineer" push channel and a "Sales" webhook apart + /// and bind each event type to only the channels that should see it. + /// + /// Null/empty = not migrated yet — then synthesizes + /// the list from the legacy slots, so dispatch behavior is unchanged until an operator + /// saves the section once. + /// + /// + public string OpsNotificationChannelsJson { get; set; } = default!; + + /// Stable id of the channel synthesized from the legacy Telegram slot. + public const string LegacyTelegramChannelId = "legacy-telegram"; + + /// Stable id of the channel synthesized from the legacy Teams slot. + public const string LegacyTeamsChannelId = "legacy-teams"; + + /// Stable id of the channel synthesized from the legacy Slack slot. + public const string LegacySlackChannelId = "legacy-slack"; + + /// + /// Display name for the synthesized Telegram channel — the operator push channel that + /// every ops alert went to before channels existed. + /// + public const string LegacyTelegramChannelName = "Autopilot Monitor IT Engineer"; + + /// + /// Returns the platform notification channels. Prefers ; + /// when that is empty, synthesizes one channel per configured legacy slot (stable ids, so a + /// rule's channel binding survives the later migration to the stored list) — same + /// forward-compatibility trick as TenantConfiguration.GetNotificationChannels. + /// + /// The synthesized channels carry the legacy enabled flags, so a disabled provider stays + /// disabled and the pre-channels dispatch behavior is reproduced exactly. + /// + /// + public IReadOnlyList GetOpsNotificationChannels() + { + var channels = Notifications.NotificationChannel.ParseList(OpsNotificationChannelsJson); + if (channels.Count > 0) + return channels; + + var synthesized = new List(); + + if (!string.IsNullOrWhiteSpace(OpsAlertTelegramChatId)) + { + synthesized.Add(new Notifications.NotificationChannel + { + Id = LegacyTelegramChannelId, + Name = LegacyTelegramChannelName, + ProviderType = (int)Notifications.WebhookProviderType.Telegram, + Url = OpsAlertTelegramChatId, + Enabled = OpsAlertTelegramEnabled, + }); + } + + if (!string.IsNullOrWhiteSpace(OpsAlertTeamsWebhookUrl)) + { + synthesized.Add(new Notifications.NotificationChannel + { + Id = LegacyTeamsChannelId, + Name = "Microsoft Teams", + ProviderType = (int)Notifications.WebhookProviderType.TeamsWorkflowWebhook, + Url = OpsAlertTeamsWebhookUrl, + Enabled = OpsAlertTeamsEnabled, + }); + } + + if (!string.IsNullOrWhiteSpace(OpsAlertSlackWebhookUrl)) + { + synthesized.Add(new Notifications.NotificationChannel + { + Id = LegacySlackChannelId, + Name = "Slack", + ProviderType = (int)Notifications.WebhookProviderType.Slack, + Url = OpsAlertSlackWebhookUrl, + Enabled = OpsAlertSlackEnabled, + }); + } + + return synthesized; + } + // ===== PER-LINE AGENT HASH ORACLE ===== // Per-major-line schema. GetAgentConfigFunction parses X-Agent-Version-Major and // dispatches via GetAgentLine(int major). V2 is the only wired line (the V1 line @@ -544,6 +630,12 @@ namespace AutopilotMonitor.Shared.Models /// SECURITY: this is a deny-list — every new secret string field MUST be added here. /// AdminConfigurationRedactionTests guards against drift. /// + /// + /// Shallow copy for read-model projections that must not mutate the cached instance + /// (the configuration service hands out the same object to every caller). + /// + public AdminConfiguration ShallowCopy() => (AdminConfiguration)MemberwiseClone(); + public AdminConfiguration RedactedCopyForReader() { var copy = (AdminConfiguration)MemberwiseClone(); @@ -551,6 +643,10 @@ namespace AutopilotMonitor.Shared.Models copy.NvdApiKey = Redact(copy.NvdApiKey); copy.OpsAlertTeamsWebhookUrl = Redact(copy.OpsAlertTeamsWebhookUrl); copy.OpsAlertSlackWebhookUrl = Redact(copy.OpsAlertSlackWebhookUrl); + // Per-channel: destinations and secrets are redacted, the list structure (ids, names, + // providers) stays readable so a reader still sees which channels exist. + copy.OpsNotificationChannelsJson = + Notifications.NotificationChannel.RedactList(copy.OpsNotificationChannelsJson); return copy; static string Redact(string? value) diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/OpsAlertRule.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/OpsAlertRule.cs index 00d2e0a4..da7a998d 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/OpsAlertRule.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/OpsAlertRule.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; + namespace AutopilotMonitor.Shared.Models { /// @@ -14,5 +16,33 @@ namespace AutopilotMonitor.Shared.Models /// Whether this rule is active. public bool Enabled { get; set; } = true; + + /// + /// Ids of the platform notification channels + /// (AdminConfiguration.GetOpsNotificationChannels) this rule notifies. + /// + /// Null or empty means EVERY enabled channel — the pre-routing broadcast behavior, so + /// rules written before channels existed keep working untouched. An id that no longer + /// resolves is ignored at dispatch (a deleted channel must not resurrect as "all"). + /// + /// + public List? NotifyChannelIds { get; set; } + + /// + /// Send the event's structured payload (the ops event's Details) to this rule's channels, + /// in addition to the category/event/severity/tenant baseline every alert carries. + /// + /// Default FALSE, deliberately: an ops alert is a "something happened, go look" signal and + /// most payloads are operational noise in a chat. Turn it on for the few events whose + /// consumer needs the values themselves — e.g. a trial-conversion rule feeding a sales + /// webhook that must not require a portal round-trip. + /// + /// + /// SECURITY: a payload can carry data the baseline never does (a tenant's domain name, the + /// administrator contact address). Enabling it on a rule widens what leaves the platform to + /// that rule's destinations — check what the event records before switching it on. + /// + /// + public bool IncludePayload { get; set; } } } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs index 73224551..64ed2026 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs @@ -816,32 +816,10 @@ namespace AutopilotMonitor.Shared.Models static string Redact(string? value) => string.IsNullOrEmpty(value) ? (value ?? string.Empty) : Constants.RedactedSecretPlaceholder; - // Channels: redact the secret fields (url, custom headers) per channel but keep the - // list structure (ids, names, toggles) visible so a reader still sees the channel - // setup. Non-empty but unparseable JSON falls back to whole-string redaction. + // Channels: redact the secret fields per channel but keep the list structure visible. + // Shared with the platform ops channel list — see NotificationChannel.RedactList. static string RedactChannels(string? json) - { - if (string.IsNullOrEmpty(json)) - return json ?? string.Empty; - - var channels = Notifications.NotificationChannel.ParseList(json); - if (channels.Count == 0) - return Constants.RedactedSecretPlaceholder; - - foreach (var channel in channels) - { - channel.Url = RedactNullable(channel.Url); - channel.CustomHeadersJson = RedactNullable(channel.CustomHeadersJson); - channel.SigningSecret = RedactNullable(channel.SigningSecret); - } - - return Notifications.NotificationChannel.SerializeList(channels); - - // Unlike the top-level fields (non-nullable strings), channel secrets are - // string? — an unset field must stay null, not become "". - static string? RedactNullable(string? value) - => string.IsNullOrEmpty(value) ? value : Constants.RedactedSecretPlaceholder; - } + => Notifications.NotificationChannel.RedactList(json); } /// @@ -867,35 +845,8 @@ namespace AutopilotMonitor.Shared.Models /// in place — an unresolvable placeholder URL simply fails SSRF validation at dispatch. /// private void RestoreRedactedChannelsFrom(TenantConfiguration existing) - { - if (NotificationChannelsJson == Constants.RedactedSecretPlaceholder) - { - NotificationChannelsJson = existing.NotificationChannelsJson; - return; - } - - if (string.IsNullOrEmpty(NotificationChannelsJson) - || NotificationChannelsJson.IndexOf(Constants.RedactedSecretPlaceholder, StringComparison.Ordinal) < 0) - return; - - var incoming = Notifications.NotificationChannel.ParseList(NotificationChannelsJson); - if (incoming.Count == 0) - return; - - var existingById = Notifications.NotificationChannel.ParseList(existing.NotificationChannelsJson) - .ToDictionary(c => c.Id, StringComparer.OrdinalIgnoreCase); - - foreach (var channel in incoming) - { - if (!existingById.TryGetValue(channel.Id, out var match)) - continue; - if (channel.Url == Constants.RedactedSecretPlaceholder) channel.Url = match.Url; - if (channel.CustomHeadersJson == Constants.RedactedSecretPlaceholder) channel.CustomHeadersJson = match.CustomHeadersJson; - if (channel.SigningSecret == Constants.RedactedSecretPlaceholder) channel.SigningSecret = match.SigningSecret; - } - - NotificationChannelsJson = Notifications.NotificationChannel.SerializeList(incoming); - } + => NotificationChannelsJson = Notifications.NotificationChannel.RestoreRedactedList( + NotificationChannelsJson, existing.NotificationChannelsJson); /// /// Returns the effective webhook URL and provider type, handling legacy TeamsWebhookUrl migration. diff --git a/src/Shared/AutopilotMonitor.Shared/Models/ConfigApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/ConfigApiModels.cs index 5a86dea8..caf6a704 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/ConfigApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/ConfigApiModels.cs @@ -224,8 +224,10 @@ namespace AutopilotMonitor.Shared.Models } /// - /// Response of POST config/{tenantId}/test-notification: the delivery verdict of the - /// test webhook send (HTTP 200 for both verdicts — Success carries the outcome). + /// Response of POST config/{tenantId}/test-notification AND POST global/config/test-ops-channel: + /// the delivery verdict of a test send (HTTP 200 for both verdicts — Success carries the + /// outcome). One shape for both because the semantics are identical; the platform endpoint + /// only differs in which channel list it resolves the id against. /// // Declaration order == wire order. public class TestWebhookNotificationResponse : IApiResponse diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationAlert.cs b/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationAlert.cs index 9ae697d0..a4c7d5d0 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationAlert.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationAlert.cs @@ -36,5 +36,18 @@ namespace AutopilotMonitor.Shared.Models.Notifications /// Action buttons (e.g. "Open session" deep-link). public List Actions { get; set; } = new(); + + /// + /// Raw structured payload as a JSON object string (an ops event's Details), embedded + /// verbatim under data by the generic JSON renderer and ignored by every other + /// renderer — card formats have no place for arbitrary nesting, which is why the same + /// content is ALSO flattened into for them. + /// + /// Consumers that need machine-readable values (a sales webhook wanting the contact + /// address, not a rendered sentence) read this; it is the reason a channel does not have + /// to parse the summary text. + /// + /// + public string? DataJson { get; set; } } } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationChannel.cs b/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationChannel.cs index f55d7e1c..ca9b176e 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationChannel.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationChannel.cs @@ -43,11 +43,18 @@ namespace AutopilotMonitor.Shared.Models.Notifications /// /// Provider type (int form of ): - /// 1=TeamsLegacyConnector, 2=TeamsWorkflowWebhook, 10=Slack, 20=GenericJson, 30=Discord. + /// 1=TeamsLegacyConnector, 2=TeamsWorkflowWebhook, 10=Slack, 20=GenericJson, 30=Discord, + /// 40=Telegram (Global-Admin only — see the enum member). /// public int ProviderType { get; set; } - /// Webhook destination URL (secret — redacted for read-only viewers). + /// + /// Destination (secret — redacted for read-only viewers). For every webhook provider this + /// is the endpoint URL; for it is the CHAT ID + /// instead, because the endpoint (the bot) is platform-owned. Deliberately one field: the + /// redaction and restore-by-id machinery already covers it, so Telegram needs no new + /// secret plumbing. + /// public string? Url { get; set; } /// @@ -155,5 +162,91 @@ namespace AutopilotMonitor.Shared.Models.Notifications /// Serializes a channel list back to its JSON storage form (camelCase). public static string SerializeList(IEnumerable channels) => JsonSerializer.Serialize(channels, JsonOptions); + + /// + /// Redacts the secret fields (destination, custom headers, signing secret) of every channel + /// while keeping the list structure (ids, names, toggles) readable, so a read-only viewer + /// still sees WHICH channels exist. Non-empty but unparseable JSON falls back to + /// whole-string redaction — never leak a blob we could not inspect. + /// + /// Shared by the tenant channel list (TenantConfiguration) and the platform ops + /// channel list (AdminConfiguration): both are served to read-only roles, and a + /// second copy of this logic is exactly how one of them would grow a leak. + /// + /// + public static string RedactList(string? json) + { + if (string.IsNullOrEmpty(json)) + return json ?? string.Empty; + + var channels = ParseList(json); + if (channels.Count == 0) + return Constants.RedactedSecretPlaceholder; + + foreach (var channel in channels) + { + channel.Url = RedactNullable(channel.Url); + channel.CustomHeadersJson = RedactNullable(channel.CustomHeadersJson); + channel.SigningSecret = RedactNullable(channel.SigningSecret); + } + + return SerializeList(channels); + + // Unlike the top-level config fields (non-nullable strings), channel secrets are + // string? — an unset field must stay null, not become "". + static string? RedactNullable(string? value) + => string.IsNullOrEmpty(value) ? value : Constants.RedactedSecretPlaceholder; + } + + /// + /// Restores channel secrets that came back as the redaction sentinel, matching by + /// against . Defense-in-depth for the + /// case where a redacted read-only view is round-tripped on a save: without this, the + /// literal "***REDACTED***" would be persisted as the destination. + /// + /// Returns the candidate JSON unchanged when it carries no sentinel. A sentinel in place + /// of the WHOLE list (unparseable-blob redaction) means "keep what is stored". Sentinels + /// that cannot be matched to a stored channel are left in place — an unresolvable + /// placeholder destination simply fails validation, which is louder than silently + /// substituting somebody else's endpoint. + /// + /// + public static string RestoreRedactedList(string? candidateJson, string? existingJson) + { + if (candidateJson == null) + return string.Empty; + + if (candidateJson == Constants.RedactedSecretPlaceholder) + return existingJson ?? string.Empty; + + if (candidateJson.Length == 0 + || candidateJson.IndexOf(Constants.RedactedSecretPlaceholder, StringComparison.Ordinal) < 0) + { + return candidateJson; + } + + var incoming = ParseList(candidateJson); + if (incoming.Count == 0) + return candidateJson; + + var existingById = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var stored in ParseList(existingJson)) + existingById[stored.Id] = stored; + + foreach (var channel in incoming) + { + if (!existingById.TryGetValue(channel.Id, out var stored)) + continue; + + if (channel.Url == Constants.RedactedSecretPlaceholder) + channel.Url = stored.Url; + if (channel.CustomHeadersJson == Constants.RedactedSecretPlaceholder) + channel.CustomHeadersJson = stored.CustomHeadersJson; + if (channel.SigningSecret == Constants.RedactedSecretPlaceholder) + channel.SigningSecret = stored.SigningSecret; + } + + return SerializeList(incoming); + } } } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Notifications/WebhookProviderType.cs b/src/Shared/AutopilotMonitor.Shared/Models/Notifications/WebhookProviderType.cs index 4dcc52e3..ec6f237a 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Notifications/WebhookProviderType.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Notifications/WebhookProviderType.cs @@ -29,5 +29,15 @@ namespace AutopilotMonitor.Shared.Models.Notifications /// openUrl actions are rendered as markdown links inside the embed description. /// Discord = 30, + + /// + /// Telegram chat (plain-text message via the platform bot). The odd one out: this is not + /// a webhook. The channel's Url carries the destination CHAT ID, and the bot token + /// belongs to the platform (PreviewConfig WebhookUrl), not to the tenant — a caller + /// configuring one sends through OUR bot. That is why Telegram channels are Global-Admin + /// only (enforced in TenantConfigValidation, not just hidden in the UI), and why they are + /// dispatched by TelegramNotificationService instead of a renderer. + /// + Telegram = 40, } } diff --git a/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx b/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx index 6050c33b..00d1f7bf 100644 --- a/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx @@ -6,10 +6,25 @@ import { api } from "@/lib/api"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import type { AdminConfiguration, OpsAlertRule } from "@/types/adminConfig"; import type { GetPreviewWhitelistResponse } from "@/utils/wire-types.generated"; +import type { NotificationChannel } from "@/app/settings/types"; // Re-export so existing `import { AdminConfiguration } from "../AdminConfigContext"` consumers keep working export type { AdminConfiguration, OpsAlertRule }; +/** + * Everything the Alerts section saves in one write. An object rather than positional + * arguments: the section already carries a dozen fields, and one transposed pair there + * silently writes a webhook URL into the wrong provider slot. + */ +export interface OpsAlertSavePayload { + rules: OpsAlertRule[]; + channels: NotificationChannel[]; + excessiveEventCountThreshold: number; + excessiveEventAutoActionMode: "Off" | "Block" | "Kill"; + excessiveEventAutoActionThreshold: number; + excessiveEventAutoActionDurationHours: number; +} + interface AdminConfigContextValue { // Admin config adminConfig: AdminConfiguration | null; @@ -81,16 +96,12 @@ interface AdminConfigContextValue { excessiveEventAutoActionThreshold: number; excessiveEventAutoActionDurationHours: number; savingOpsAlerts: boolean; - handleSaveOpsAlertConfig: ( - rules: OpsAlertRule[], - telegramEnabled: boolean, telegramChatId: string, - teamsEnabled: boolean, teamsWebhookUrl: string, - slackEnabled: boolean, slackWebhookUrl: string, - excessiveEventCountThreshold: number, - excessiveEventAutoActionMode: "Off" | "Block" | "Kill", - excessiveEventAutoActionThreshold: number, - excessiveEventAutoActionDurationHours: number, - ) => Promise; + handleSaveOpsAlertConfig: (payload: OpsAlertSavePayload) => Promise; + opsNotificationChannels: NotificationChannel[]; + setOpsNotificationChannels: React.Dispatch>; + handleTestOpsChannel: (channelId: string) => Promise; + testingOpsChannelId: string | null; + testOpsChannelResult: { channelId: string; success: boolean; message: string } | null; // Lazy loaders — config/tenants are NOT fetched when the admin layout mounts. // Sections that consume adminConfig/tenants call these once on mount; pages that @@ -169,6 +180,10 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) // Ops Alert state const [opsAlertRules, setOpsAlertRules] = useState([]); + const [opsNotificationChannels, setOpsNotificationChannels] = useState([]); + const [testingOpsChannelId, setTestingOpsChannelId] = useState(null); + const [testOpsChannelResult, setTestOpsChannelResult] = + useState<{ channelId: string; success: boolean; message: string } | null>(null); const [opsAlertTelegramEnabled, setOpsAlertTelegramEnabled] = useState(false); const [opsAlertTelegramChatId, setOpsAlertTelegramChatId] = useState(""); const [opsAlertTeamsEnabled, setOpsAlertTeamsEnabled] = useState(false); @@ -242,6 +257,14 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) } catch { setOpsAlertRules([]); } + // The GET projects the legacy provider slots into this list when it is still empty, + // so an unmigrated platform shows the channels that actually receive alerts today. + try { + setOpsNotificationChannels( + data.opsNotificationChannelsJson ? JSON.parse(data.opsNotificationChannelsJson) : []); + } catch { + setOpsNotificationChannels([]); + } setOpsAlertTelegramEnabled(data.opsAlertTelegramEnabled ?? false); setOpsAlertTelegramChatId(data.opsAlertTelegramChatId ?? ""); setOpsAlertTeamsEnabled(data.opsAlertTeamsEnabled ?? false); @@ -417,6 +440,12 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) } catch { setOpsAlertRules([]); } + try { + setOpsNotificationChannels( + adminConfig.opsNotificationChannelsJson ? JSON.parse(adminConfig.opsNotificationChannelsJson) : []); + } catch { + setOpsNotificationChannels([]); + } setOpsAlertTelegramEnabled(adminConfig.opsAlertTelegramEnabled ?? false); setOpsAlertTelegramChatId(adminConfig.opsAlertTelegramChatId ?? ""); setOpsAlertTeamsEnabled(adminConfig.opsAlertTeamsEnabled ?? false); @@ -469,16 +498,14 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) }, [isGlobalAdmin, adminConfig, getAccessToken]); // Save ops alert config (rules + providers) - const handleSaveOpsAlertConfig = useCallback(async ( - rules: OpsAlertRule[], - telegramEnabled: boolean, telegramChatId: string, - teamsEnabled: boolean, teamsWebhookUrl: string, - slackEnabled: boolean, slackWebhookUrl: string, - newExcessiveThreshold: number, - newAutoActionMode: "Off" | "Block" | "Kill", - newAutoActionThreshold: number, - newAutoActionDurationHours: number, - ) => { + const handleSaveOpsAlertConfig = useCallback(async ({ + rules, + channels, + excessiveEventCountThreshold: newExcessiveThreshold, + excessiveEventAutoActionMode: newAutoActionMode, + excessiveEventAutoActionThreshold: newAutoActionThreshold, + excessiveEventAutoActionDurationHours: newAutoActionDurationHours, + }: OpsAlertSavePayload) => { if (!adminConfig) return; if (!isGlobalAdmin) return; // platform settings are GA-only (also route-gated + backend-enforced) try { @@ -486,15 +513,13 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) setError(null); setSuccessMessage(null); + // The legacy provider slots are deliberately NOT written here any more: channels are the + // destination list now, and the first save materializes the projected legacy channels. + // The stored slots stay untouched as the migration source until they are retired. const updatedConfig: AdminConfiguration = { ...adminConfig, opsAlertRulesJson: JSON.stringify(rules), - opsAlertTelegramEnabled: telegramEnabled, - opsAlertTelegramChatId: telegramChatId.trim(), - opsAlertTeamsEnabled: teamsEnabled, - opsAlertTeamsWebhookUrl: teamsWebhookUrl.trim(), - opsAlertSlackEnabled: slackEnabled, - opsAlertSlackWebhookUrl: slackWebhookUrl.trim(), + opsNotificationChannelsJson: channels.length > 0 ? JSON.stringify(channels) : "", excessiveEventCountThreshold: newExcessiveThreshold, excessiveEventAutoActionMode: newAutoActionMode, excessiveEventAutoActionThreshold: newAutoActionThreshold, @@ -512,12 +537,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) const result = await response.json(); setAdminConfig(result.config); setOpsAlertRules(rules); - setOpsAlertTelegramEnabled(telegramEnabled); - setOpsAlertTelegramChatId(telegramChatId.trim()); - setOpsAlertTeamsEnabled(teamsEnabled); - setOpsAlertTeamsWebhookUrl(teamsWebhookUrl.trim()); - setOpsAlertSlackEnabled(slackEnabled); - setOpsAlertSlackWebhookUrl(slackWebhookUrl.trim()); + setOpsNotificationChannels(channels); setExcessiveEventCountThreshold(newExcessiveThreshold); setExcessiveEventAutoActionMode(newAutoActionMode); setExcessiveEventAutoActionThreshold(newAutoActionThreshold); @@ -534,6 +554,45 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) } }, [isGlobalAdmin, adminConfig, getAccessToken]); + /** + * Sends a test notification to one SAVED ops channel. Deliberately server-side: only the + * backend holds the Telegram bot token and the webhook destinations, and only it applies the + * SSRF gate — the browser must never post to a configured endpoint directly. + */ + const handleTestOpsChannel = useCallback(async (channelId: string) => { + if (!isGlobalAdmin) return; + try { + setTestingOpsChannelId(channelId); + setTestOpsChannelResult(null); + + const response = await authenticatedFetch(api.globalConfig.testOpsChannel(), getAccessToken, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ channelId }), + }); + + if (!response.ok) throw new Error(`Test failed: ${response.statusText}`); + + const result = await response.json(); + setTestOpsChannelResult({ + channelId, + success: Boolean(result.success), + message: result.message ?? (result.success ? "Sent." : "Failed."), + }); + } catch (err) { + if (err instanceof TokenExpiredError) { + console.error("Session expired while testing an ops channel"); + } + setTestOpsChannelResult({ + channelId, + success: false, + message: err instanceof Error ? err.message : "Test failed", + }); + } finally { + setTestingOpsChannelId(null); + } + }, [isGlobalAdmin, getAccessToken]); + return ( {children} diff --git a/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx index cd4c6f42..7e07cf6a 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx @@ -1,7 +1,10 @@ "use client"; import { useState } from "react"; -import { type OpsAlertRule } from "../AdminConfigContext"; +import { type OpsAlertRule, type OpsAlertSavePayload } from "../AdminConfigContext"; +import { ChannelEditor } from "@/components/notifications/ChannelEditor"; +import { MAX_NOTIFICATION_CHANNELS, type NotificationChannel } from "@/app/settings/types"; +import { toggleChannelBinding } from "./opsChannelRouting"; import { AUTO_ACTION_MODES, describeAutoActionWarning, type AutoActionMode } from "./excessiveEventAutoAction"; // All known ops event types grouped by category @@ -133,6 +136,11 @@ const OPS_EVENT_TYPES: Record = { "TenantOffboarded", "TenantOffboardingFailed", "OffboardingFeedbackReceived", + // TenantTrialStarted — the conversion moment, fired by PlanManagementFunction from BOTH + // plan write paths (self-service POST trial and a GA grant via PATCH plan). Info-tier; + // the payload carries domain, contact address and who granted it, so a sales/support + // channel bound to this rule needs nothing from the portal. + "TenantTrialStarted", "TenantTrialExpiring", "TenantTrialExpired", // F3 regression radar: an analyze rule's 7d hit rate rose ≥2× over its 28d baseline @@ -245,26 +253,15 @@ interface OpsAlertRulesSectionProps { savingOpsAlerts: boolean; adminConfigExists: boolean; opsAlertRules: OpsAlertRule[]; - opsAlertTelegramEnabled: boolean; - opsAlertTelegramChatId: string; - opsAlertTeamsEnabled: boolean; - opsAlertTeamsWebhookUrl: string; - opsAlertSlackEnabled: boolean; - opsAlertSlackWebhookUrl: string; + opsNotificationChannels: NotificationChannel[]; excessiveEventCountThreshold: number; excessiveEventAutoActionMode: AutoActionMode; excessiveEventAutoActionThreshold: number; excessiveEventAutoActionDurationHours: number; - onSave: ( - rules: OpsAlertRule[], - telegramEnabled: boolean, telegramChatId: string, - teamsEnabled: boolean, teamsWebhookUrl: string, - slackEnabled: boolean, slackWebhookUrl: string, - excessiveEventCountThreshold: number, - excessiveEventAutoActionMode: AutoActionMode, - excessiveEventAutoActionThreshold: number, - excessiveEventAutoActionDurationHours: number, - ) => Promise; + onSave: (payload: OpsAlertSavePayload) => Promise; + onTestChannel: (channelId: string) => Promise; + testingChannelId: string | null; + testChannelResult: { channelId: string; success: boolean; message: string } | null; } export function OpsAlertRulesSection({ @@ -272,26 +269,19 @@ export function OpsAlertRulesSection({ savingOpsAlerts, adminConfigExists, opsAlertRules, - opsAlertTelegramEnabled, - opsAlertTelegramChatId, - opsAlertTeamsEnabled, - opsAlertTeamsWebhookUrl, - opsAlertSlackEnabled, - opsAlertSlackWebhookUrl, + opsNotificationChannels, excessiveEventCountThreshold: excessiveEventCountThresholdProp, excessiveEventAutoActionMode: autoActionModeProp, excessiveEventAutoActionThreshold: autoActionThresholdProp, excessiveEventAutoActionDurationHours: autoActionDurationProp, onSave, + onTestChannel, + testingChannelId, + testChannelResult, }: OpsAlertRulesSectionProps) { // Local state for editing, seeded from props const [rules, setRules] = useState(() => buildFullRules(opsAlertRules)); - const [telegramEnabled, setTelegramEnabled] = useState(opsAlertTelegramEnabled); - const [telegramChatId, setTelegramChatId] = useState(opsAlertTelegramChatId); - const [teamsEnabled, setTeamsEnabled] = useState(opsAlertTeamsEnabled); - const [teamsWebhookUrl, setTeamsWebhookUrl] = useState(opsAlertTeamsWebhookUrl); - const [slackEnabled, setSlackEnabled] = useState(opsAlertSlackEnabled); - const [slackWebhookUrl, setSlackWebhookUrl] = useState(opsAlertSlackWebhookUrl); + const [channels, setChannels] = useState(opsNotificationChannels); const [excessiveThreshold, setExcessiveThreshold] = useState(excessiveEventCountThresholdProp); const [autoActionMode, setAutoActionMode] = useState(autoActionModeProp); const [autoActionThreshold, setAutoActionThreshold] = useState(autoActionThresholdProp); @@ -300,17 +290,12 @@ export function OpsAlertRulesSection({ // Re-seed the local draft whenever a synced prop changes (config load/save), // replicating the previous sync effect's dependency-array comparison via the // adjust-during-render pattern (react.dev "storing information from previous renders"). - const syncedProps = [opsAlertRules, opsAlertTelegramEnabled, opsAlertTelegramChatId, opsAlertTeamsEnabled, opsAlertTeamsWebhookUrl, opsAlertSlackEnabled, opsAlertSlackWebhookUrl, excessiveEventCountThresholdProp, autoActionModeProp, autoActionThresholdProp, autoActionDurationProp]; + const syncedProps = [opsAlertRules, opsNotificationChannels, excessiveEventCountThresholdProp, autoActionModeProp, autoActionThresholdProp, autoActionDurationProp]; const [prevSyncedProps, setPrevSyncedProps] = useState(syncedProps); if (syncedProps.some((value, i) => !Object.is(value, prevSyncedProps[i]))) { setPrevSyncedProps(syncedProps); setRules(buildFullRules(opsAlertRules)); - setTelegramEnabled(opsAlertTelegramEnabled); - setTelegramChatId(opsAlertTelegramChatId); - setTeamsEnabled(opsAlertTeamsEnabled); - setTeamsWebhookUrl(opsAlertTeamsWebhookUrl); - setSlackEnabled(opsAlertSlackEnabled); - setSlackWebhookUrl(opsAlertSlackWebhookUrl); + setChannels(opsNotificationChannels); setExcessiveThreshold(excessiveEventCountThresholdProp); setAutoActionMode(autoActionModeProp); setAutoActionThreshold(autoActionThresholdProp); @@ -325,16 +310,42 @@ export function OpsAlertRulesSection({ setRules(prev => prev.map(r => r.eventType === eventType ? { ...r, minSeverity } : r)); }; + const togglePayload = (eventType: string) => { + setRules(prev => prev.map(r => r.eventType === eventType ? { ...r, includePayload: !r.includePayload } : r)); + }; + + const toggleRuleChannel = (eventType: string, channelId: string) => { + setRules(prev => prev.map(r => r.eventType === eventType + ? { ...r, notifyChannelIds: toggleChannelBinding(r, channelId, channels) } + : r)); + }; + const handleSave = () => { - onSave( - rules, telegramEnabled, telegramChatId, teamsEnabled, teamsWebhookUrl, slackEnabled, slackWebhookUrl, - excessiveThreshold, autoActionMode, autoActionThreshold, autoActionDuration); + onSave({ + rules, + channels, + excessiveEventCountThreshold: excessiveThreshold, + excessiveEventAutoActionMode: autoActionMode, + excessiveEventAutoActionThreshold: autoActionThreshold, + excessiveEventAutoActionDurationHours: autoActionDuration, + }); + }; + + const addChannel = () => { + if (channels.length >= MAX_NOTIFICATION_CHANNELS) return; + setChannels([...channels, { + id: crypto.randomUUID(), + name: "", + providerType: 2, + url: "", + enabled: true, + }]); }; const autoActionWarning = describeAutoActionWarning(autoActionMode, autoActionThreshold, excessiveThreshold); const enabledRulesCount = rules.filter(r => r.enabled).length; - const enabledProviders = [telegramEnabled, teamsEnabled, slackEnabled].filter(Boolean).length; + const enabledProviders = channels.filter(c => c.enabled).length; return (
@@ -396,6 +407,51 @@ export function OpsAlertRulesSection({ ))}
+ {rule.enabled && channels.length > 0 && ( +
+ Notify: + {(rule.notifyChannelIds ?? []).length === 0 && ( + + all channels — + + )} + {channels.map((channel) => { + const on = (rule.notifyChannelIds ?? []).includes(channel.id); + return ( + + ); + })} + +
+ )} {et === "ExcessiveSessionEvents" && rule.enabled && (
@@ -468,7 +524,7 @@ export function OpsAlertRulesSection({
- {/* Providers */} + {/* Channels */}
@@ -476,104 +532,45 @@ export function OpsAlertRulesSection({
-

Alert Providers

-

Configure where alert notifications are delivered

+

Alert Channels

+

+ Named destinations for platform alerts. Each rule above chooses which of these it notifies. +

-
- {/* Telegram */} -
-
- - Telegram -
- {telegramEnabled && ( -
- - setTelegramChatId(e.target.value)} - placeholder="-1003785642894" - className="block w-full max-w-md px-3 py-2 border border-sky-300 dark:border-sky-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 text-sm font-mono" - /> -

Telegram channel or group chat ID (negative number for groups)

-
- )} -
+
+ {channels.length === 0 && ( +

+ No channels configured yet. Add one, then assign it to the rules that should reach it. +

+ )} -
+ {channels.map((channel) => ( + setChannels(channels.map((c) => (c.id === next.id ? next : c)))} + onRemove={() => setChannels(channels.filter((c) => c.id !== channel.id))} + onTest={() => onTestChannel(channel.id)} + testing={testingChannelId === channel.id} + testResult={testChannelResult?.channelId === channel.id ? testChannelResult : null} + showTelegramProvider + showEventToggles={false} + /> + ))} - {/* Teams */} -
-
- - Microsoft Teams - Workflow Webhook -
- {teamsEnabled && ( -
- - setTeamsWebhookUrl(e.target.value)} - placeholder="https://prod-xx.westeurope.logic.azure.com/..." - className="block w-full px-3 py-2 border border-sky-300 dark:border-sky-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 text-sm" - /> -

Teams Workflow webhook URL (Adaptive Card format)

-
- )} -
- -
- - {/* Slack */} -
-
- - Slack - Incoming Webhook -
- {slackEnabled && ( -
- - setSlackWebhookUrl(e.target.value)} - placeholder="https://hooks.slack.com/services/..." - className="block w-full px-3 py-2 border border-sky-300 dark:border-sky-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 text-sm" - /> -

Slack Incoming Webhook URL

-
- )} -
+
diff --git a/src/Web/autopilot-monitor-web/app/admin/components/__tests__/opsChannelRouting.test.ts b/src/Web/autopilot-monitor-web/app/admin/components/__tests__/opsChannelRouting.test.ts new file mode 100644 index 00000000..ed01f84c --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin/components/__tests__/opsChannelRouting.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { resolveRuleTargets, toggleChannelBinding } from "../opsChannelRouting"; +import type { OpsAlertRule } from "../../AdminConfigContext"; +import type { NotificationChannel } from "@/app/settings/types"; + +const channel = (id: string, enabled = true): NotificationChannel => + ({ id, name: id, providerType: 20, url: `https://x.example/${id}`, enabled }); + +const rule = (notifyChannelIds?: string[]): OpsAlertRule => + ({ eventType: "TenantTrialStarted", minSeverity: "Info", enabled: true, notifyChannelIds }); + +describe("toggleChannelBinding", () => { + const channels = [channel("push"), channel("sales")]; + + it("starts from the empty set so a broadcast rule can be narrowed", () => { + // The rule currently reaches everything (empty = all). Picking "sales" must mean + // ONLY sales, not "everything plus sales". + expect(toggleChannelBinding(rule(), "sales", channels)).toEqual(["sales"]); + }); + + it("adds and removes an explicit binding", () => { + expect(toggleChannelBinding(rule(["sales"]), "push", channels)).toEqual(["sales", "push"]); + expect(toggleChannelBinding(rule(["sales", "push"]), "sales", channels)).toEqual(["push"]); + }); + + it("prunes ids of channels that no longer exist", () => { + expect(toggleChannelBinding(rule(["deleted", "sales"]), "push", channels)).toEqual(["sales", "push"]); + }); + + it("returns to broadcast when the last binding is removed", () => { + expect(toggleChannelBinding(rule(["sales"]), "sales", channels)).toEqual([]); + }); +}); + +describe("resolveRuleTargets", () => { + it("an empty binding reaches every enabled channel", () => { + const channels = [channel("push"), channel("sales", false)]; + expect(resolveRuleTargets(rule(), channels).map((c) => c.id)).toEqual(["push"]); + }); + + it("an explicit binding reaches only those channels", () => { + const channels = [channel("push"), channel("sales")]; + expect(resolveRuleTargets(rule(["sales"]), channels).map((c) => c.id)).toEqual(["sales"]); + }); + + it("a binding to a deleted channel reaches nothing rather than everything", () => { + expect(resolveRuleTargets(rule(["deleted"]), [channel("push")])).toEqual([]); + }); + + it("never reaches a disabled channel", () => { + expect(resolveRuleTargets(rule(["sales"]), [channel("sales", false)])).toEqual([]); + }); +}); diff --git a/src/Web/autopilot-monitor-web/app/admin/components/opsChannelRouting.ts b/src/Web/autopilot-monitor-web/app/admin/components/opsChannelRouting.ts new file mode 100644 index 00000000..2706be56 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin/components/opsChannelRouting.ts @@ -0,0 +1,44 @@ +import type { OpsAlertRule } from "../AdminConfigContext"; +import type { NotificationChannel } from "@/app/settings/types"; + +/** + * Rule → channel binding, kept out of the component because getting it wrong misroutes alerts + * silently: an event reaching a channel it should not (a tenant-facing sales webhook receiving + * security noise) looks exactly like a working configuration from the UI. + * + * Mirror of the backend's OpsAlertDispatchService.ResolveTargets — same "empty means all" + * convention, so what the editor shows is what the dispatcher does. + */ + +/** + * Toggles one channel on a rule. An empty selection means "all channels", so the first explicit + * pick starts from the EMPTY set rather than from everything — otherwise a rule could never be + * narrowed, only widened. Ids of channels that no longer exist are pruned on every edit. + */ +export function toggleChannelBinding( + rule: OpsAlertRule, + channelId: string, + channels: NotificationChannel[], +): string[] { + const current = rule.notifyChannelIds ?? []; + const next = current.includes(channelId) + ? current.filter((id) => id !== channelId) + : [...current, channelId]; + + return next.filter((id) => channels.some((c) => c.id === id)); +} + +/** + * The channels a rule currently reaches, for display. Empty binding = every enabled channel; + * a binding whose ids all vanished reaches nothing (it must NOT fall back to broadcasting — + * that is the backend's rule too). + */ +export function resolveRuleTargets( + rule: OpsAlertRule, + channels: NotificationChannel[], +): NotificationChannel[] { + const enabled = channels.filter((c) => c.enabled); + const ids = rule.notifyChannelIds ?? []; + if (ids.length === 0) return enabled; + return enabled.filter((c) => ids.includes(c.id)); +} diff --git a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionAlerts.tsx b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionAlerts.tsx index aaf2d805..7834085f 100644 --- a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionAlerts.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionAlerts.tsx @@ -12,17 +12,15 @@ export function SectionAlerts() { savingOpsAlerts, adminConfig, opsAlertRules, - opsAlertTelegramEnabled, - opsAlertTelegramChatId, - opsAlertTeamsEnabled, - opsAlertTeamsWebhookUrl, - opsAlertSlackEnabled, - opsAlertSlackWebhookUrl, + opsNotificationChannels, excessiveEventCountThreshold, excessiveEventAutoActionMode, excessiveEventAutoActionThreshold, excessiveEventAutoActionDurationHours, handleSaveOpsAlertConfig, + handleTestOpsChannel, + testingOpsChannelId, + testOpsChannelResult, } = useAdminConfig(); useEffect(() => { ensureAdminConfigLoaded(); }, [ensureAdminConfigLoaded]); @@ -35,17 +33,15 @@ export function SectionAlerts() { savingOpsAlerts={savingOpsAlerts} adminConfigExists={!!adminConfig} opsAlertRules={opsAlertRules} - opsAlertTelegramEnabled={opsAlertTelegramEnabled} - opsAlertTelegramChatId={opsAlertTelegramChatId} - opsAlertTeamsEnabled={opsAlertTeamsEnabled} - opsAlertTeamsWebhookUrl={opsAlertTeamsWebhookUrl} - opsAlertSlackEnabled={opsAlertSlackEnabled} - opsAlertSlackWebhookUrl={opsAlertSlackWebhookUrl} + opsNotificationChannels={opsNotificationChannels} excessiveEventCountThreshold={excessiveEventCountThreshold} excessiveEventAutoActionMode={excessiveEventAutoActionMode} excessiveEventAutoActionThreshold={excessiveEventAutoActionThreshold} excessiveEventAutoActionDurationHours={excessiveEventAutoActionDurationHours} onSave={handleSaveOpsAlertConfig} + onTestChannel={handleTestOpsChannel} + testingChannelId={testingOpsChannelId} + testChannelResult={testOpsChannelResult} /> ); diff --git a/src/Web/autopilot-monitor-web/app/settings/components/NotificationsSection.tsx b/src/Web/autopilot-monitor-web/app/settings/components/NotificationsSection.tsx index 2254c3c2..336f57d7 100644 --- a/src/Web/autopilot-monitor-web/app/settings/components/NotificationsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/components/NotificationsSection.tsx @@ -1,9 +1,9 @@ "use client"; -import { useEffect, useRef, useState } from "react"; import SaveResetBar from "./SaveResetBar"; import ReadOnlyFieldset from "./ReadOnlyFieldset"; import { MAX_NOTIFICATION_CHANNELS, NotificationChannel } from "../types"; +import { ChannelEditor } from "@/components/notifications/ChannelEditor"; interface NotificationsSectionProps { channels: NotificationChannel[]; @@ -16,353 +16,10 @@ interface NotificationsSectionProps { saving: boolean; /** Read-only viewer (Operator): channels visible but inert, no Save/Reset bar. */ readOnly?: boolean; + /** Global Admin: the platform-bot Telegram provider is offered in the provider dropdown. */ + showTelegramProvider?: boolean; } -const GENERIC_PROVIDER = 20; - -const PROVIDERS = [ - { 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.../..." }, - { value: 30, label: "Discord", placeholder: "https://discord.com/api/webhooks/..." }, - { value: GENERIC_PROVIDER, label: "Generic JSON (ticketing / automation)", placeholder: "https://your-system.example.com/api/webhooks/autopilot" }, -]; - -const EVENT_TOGGLES: { key: keyof NotificationChannel; label: string; hint: string }[] = [ - { key: "notifyOnStart", label: "Start", hint: "Enrollment started (one signal per fresh session and per Pre-Provisioning resume)" }, - { key: "notifyOnSuccess", label: "Success", hint: "Enrollment completed successfully" }, - { key: "notifyOnFailure", label: "Failure", hint: "Enrollment failed" }, - { key: "notifyOnHardwareRejection", label: "Hardware rejection", hint: "Device rejected by the hardware whitelist" }, - { key: "notifyOnSlaEvents", label: "SLA", hint: "SLA breach / resolved / consecutive failures (which breaches are evaluated is configured under SLA Targets)" }, -]; - -type HeaderRow = { key: string; value: string }; - -function parseHeaderRows(json: string): HeaderRow[] { - if (!json || !json.trim()) return []; - try { - const obj = JSON.parse(json); - if (obj && typeof obj === "object" && !Array.isArray(obj)) { - return Object.entries(obj).map(([key, value]) => ({ key, value: String(value) })); - } - } catch { - // Malformed JSON (e.g. legacy/hand-edited value) — start from an empty editor. - } - return []; -} - -function serializeHeaderRows(rows: HeaderRow[]): string { - const obj: Record = {}; - for (const { key, value } of rows) { - const k = key.trim(); - if (k) obj[k] = value; - } - return Object.keys(obj).length > 0 ? JSON.stringify(obj) : ""; -} - -/** - * Key→value editor for generic-webhook custom headers. Owns row state locally and serializes - * to the JSON string the backend persists; re-syncs only on external changes (config load/reset). - */ -function WebhookHeaderEditor({ value, onChange }: { value: string; onChange: (v: string) => void }) { - const [rows, setRows] = useState(() => parseHeaderRows(value)); - const lastSerialized = useRef(value); - - useEffect(() => { - if (value !== lastSerialized.current) { - setRows(parseHeaderRows(value)); - lastSerialized.current = value; - } - }, [value]); - - const commit = (next: HeaderRow[]) => { - setRows(next); - const json = serializeHeaderRows(next); - lastSerialized.current = json; - onChange(json); - }; - - return ( -
- Custom Headers -

- Optional HTTP headers sent with every request — e.g. Authorization with an API key - for your ticketing system or SMTP gateway. Framing headers (Host, Content-Type, …) are ignored. -

-
- {rows.map((row, i) => ( -
- commit(rows.map((r, j) => (j === i ? { ...r, key: e.target.value } : r)))} - placeholder="Header name" - className="block w-1/3 px-3 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" - /> - commit(rows.map((r, j) => (j === i ? { ...r, value: e.target.value } : r)))} - placeholder="Value" - className="block flex-1 px-3 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" - /> - -
- ))} -
- -
- ); -} - -/** Single channel card: name, provider, URL, headers (generic only), event subscriptions, test. */ -function ChannelEditor({ - channel, - onChange, - onRemove, - onTest, - testing, - testResult, -}: { - channel: NotificationChannel; - onChange: (next: NotificationChannel) => void; - onRemove: () => void; - onTest: () => void; - testing: boolean; - testResult: { success: boolean; message: string } | null; -}) { - const selectedProvider = PROVIDERS.find((p) => p.value === channel.providerType) ?? PROVIDERS[1]; - const isActive = channel.enabled && (channel.url ?? "").length > 0; - - return ( -
- {/* Header row: name + enable + delete */} -
- onChange({ ...channel, name: e.target.value })} - placeholder="Channel name (e.g. Service Desk)" - className="block flex-1 px-3 py-2 border border-gray-300 rounded-lg text-gray-900 font-medium placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors" - /> - - -
- - {/* Provider */} -
- - {selectedProvider.badge && ( - - {selectedProvider.badge} - - )} -
- - {channel.providerType === 1 && ( -
-

- Note: Office 365 Connectors are deprecated by Microsoft. Consider switching to Microsoft Teams (Workflow Webhook) for continued support. -

-
- )} - - {/* URL */} -
- -
- - {/* Custom Headers (generic provider only) */} - {channel.providerType === GENERIC_PROVIDER && ( - onChange({ ...channel, customHeadersJson: v || undefined })} - /> - )} - - {/* Signing secret (generic provider only) */} - {channel.providerType === GENERIC_PROVIDER && ( -
- -
- )} - - {/* Event subscriptions */} -
- Send to this channel -

- A channel with everything off can still be targeted by Analyze Rules (rule-level notifications). -

-
- {EVENT_TOGGLES.map(({ key, label, hint }) => { - const on = Boolean(channel[key]); - return ( - - ); - })} -
-
- - {/* Test */} -
- - Tests the last saved configuration of this channel. - {testResult && ( - - {testResult.message} - - )} -
-
- ); -} export default function NotificationsSection({ channels, @@ -374,6 +31,7 @@ export default function NotificationsSection({ onReset, saving, readOnly = false, + showTelegramProvider = false, }: NotificationsSectionProps) { const addChannel = () => { if (channels.length >= MAX_NOTIFICATION_CHANNELS) return; @@ -424,6 +82,7 @@ export default function NotificationsSection({ onTest={() => onTestChannel(channel.id)} testing={testingChannelId === channel.id} testResult={testChannelResult?.channelId === channel.id ? testChannelResult : null} + showTelegramProvider={showTelegramProvider} /> ))} diff --git a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionNotifications.tsx b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionNotifications.tsx index 1fdf6795..bd02a329 100644 --- a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionNotifications.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionNotifications.tsx @@ -1,5 +1,6 @@ "use client"; +import { useAuth } from "@/contexts/AuthContext"; import { useTenantConfig } from "../../TenantConfigContext"; import { TenantNotifications } from "../../TenantNotifications"; import NotificationsSection from "../../components/NotificationsSection"; @@ -13,6 +14,12 @@ export function SectionNotifications() { savingSection, } = useTenantConfig(); + // Telegram channels deliver through the platform-owned bot, so only a Global Admin may + // configure one. The server enforces the same rule (TenantConfigValidation) — hiding the + // option here is convenience, not the control. + const { user } = useAuth(); + const showTelegramProvider = user?.isGlobalAdmin === true; + return ( <> @@ -26,6 +33,7 @@ export function SectionNotifications() { onReset={handleResetNotifications} saving={savingSection === "notifications"} readOnly={!canEditConfig} + showTelegramProvider={showTelegramProvider} /> ); diff --git a/src/Web/autopilot-monitor-web/components/notifications/ChannelEditor.tsx b/src/Web/autopilot-monitor-web/components/notifications/ChannelEditor.tsx new file mode 100644 index 00000000..3812afdc --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/notifications/ChannelEditor.tsx @@ -0,0 +1,388 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { NotificationChannel } from "@/app/settings/types"; + +/** + * Shared channel editor card, used by BOTH the tenant Notifications section and the platform + * (ops) channel section in the admin area. One component so the two never drift on provider + * options, secret handling or the test flow — the only difference is that ops channels are + * targeted by alert RULES, not by the per-channel event toggles, so those are opt-out here. + */ + +const GENERIC_PROVIDER = 20; +const TELEGRAM_PROVIDER = 40; + +export const PROVIDERS: { + value: number; + label: string; + placeholder: string; + badge?: string; + badgeColor?: string; + /** Offered only to Global Admins (platform-owned transport). */ + gaOnly?: boolean; +}[] = [ + { 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.../..." }, + { value: 30, label: "Discord", placeholder: "https://discord.com/api/webhooks/..." }, + { value: GENERIC_PROVIDER, label: "Generic JSON (ticketing / automation)", placeholder: "https://your-system.example.com/api/webhooks/autopilot" }, + // Telegram sends through the PLATFORM bot, so only a Global Admin may configure one. The + // dropdown hides it for everyone else; the server enforces the same rule (a hidden option is + // not a control). An already-configured Telegram channel stays visible and readable either + // way, so saving unrelated changes cannot silently drop it. + { value: TELEGRAM_PROVIDER, label: "Telegram (mobile push)", placeholder: "-1003785642894", gaOnly: true }, +]; + +const EVENT_TOGGLES: { key: keyof NotificationChannel; label: string; hint: string }[] = [ + { key: "notifyOnStart", label: "Start", hint: "Enrollment started (one signal per fresh session and per Pre-Provisioning resume)" }, + { key: "notifyOnSuccess", label: "Success", hint: "Enrollment completed successfully" }, + { key: "notifyOnFailure", label: "Failure", hint: "Enrollment failed" }, + { key: "notifyOnHardwareRejection", label: "Hardware rejection", hint: "Device rejected by the hardware whitelist" }, + { key: "notifyOnSlaEvents", label: "SLA", hint: "SLA breach / resolved / consecutive failures (which breaches are evaluated is configured under SLA Targets)" }, +]; + +type HeaderRow = { key: string; value: string }; + +function parseHeaderRows(json: string): HeaderRow[] { + if (!json || !json.trim()) return []; + try { + const obj = JSON.parse(json); + if (obj && typeof obj === "object" && !Array.isArray(obj)) { + return Object.entries(obj).map(([key, value]) => ({ key, value: String(value) })); + } + } catch { + // Malformed JSON (e.g. legacy/hand-edited value) — start from an empty editor. + } + return []; +} + +function serializeHeaderRows(rows: HeaderRow[]): string { + const obj: Record = {}; + for (const { key, value } of rows) { + const k = key.trim(); + if (k) obj[k] = value; + } + return Object.keys(obj).length > 0 ? JSON.stringify(obj) : ""; +} + +/** + * Key→value editor for generic-webhook custom headers. Owns row state locally and serializes + * to the JSON string the backend persists; re-syncs only on external changes (config load/reset). + */ +function WebhookHeaderEditor({ value, onChange }: { value: string; onChange: (v: string) => void }) { + const [rows, setRows] = useState(() => parseHeaderRows(value)); + const lastSerialized = useRef(value); + + useEffect(() => { + if (value !== lastSerialized.current) { + setRows(parseHeaderRows(value)); + lastSerialized.current = value; + } + }, [value]); + + const commit = (next: HeaderRow[]) => { + setRows(next); + const json = serializeHeaderRows(next); + lastSerialized.current = json; + onChange(json); + }; + + return ( +
+ Custom Headers +

+ Optional HTTP headers sent with every request — e.g. Authorization with an API key + for your ticketing system or SMTP gateway. Framing headers (Host, Content-Type, …) are ignored. +

+
+ {rows.map((row, i) => ( +
+ commit(rows.map((r, j) => (j === i ? { ...r, key: e.target.value } : r)))} + placeholder="Header name" + className="block w-1/3 px-3 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" + /> + commit(rows.map((r, j) => (j === i ? { ...r, value: e.target.value } : r)))} + placeholder="Value" + className="block flex-1 px-3 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" + /> + +
+ ))} +
+ +
+ ); +} + +/** Single channel card: name, provider, destination, headers (generic only), optional event + * subscriptions, test. */ +export function ChannelEditor({ + channel, + onChange, + onRemove, + onTest, + testing, + testResult, + showTelegramProvider = false, + showEventToggles = true, +}: { + channel: NotificationChannel; + onChange: (next: NotificationChannel) => void; + onRemove: () => void; + onTest: () => void; + testing: boolean; + testResult: { success: boolean; message: string } | null; + /** Global Admin: may pick the platform-bot Telegram provider. */ + showTelegramProvider?: boolean; + /** Tenant channels subscribe to event kinds here; ops channels are targeted by rules instead. */ + showEventToggles?: boolean; +}) { + const selectedProvider = PROVIDERS.find((p) => p.value === channel.providerType) ?? PROVIDERS[1]; + const isActive = channel.enabled && (channel.url ?? "").length > 0; + const isTelegram = channel.providerType === TELEGRAM_PROVIDER; + // Keep the channel's own provider listed even when gated, so a GA-created Telegram channel + // does not silently render as a different provider for a tenant admin. + const providerOptions = PROVIDERS.filter( + (p) => !p.gaOnly || showTelegramProvider || p.value === channel.providerType, + ); + + return ( +
+ {/* Header row: name + enable + delete */} +
+ onChange({ ...channel, name: e.target.value })} + placeholder="Channel name (e.g. Service Desk)" + className="block flex-1 px-3 py-2 border border-gray-300 rounded-lg text-gray-900 font-medium placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors" + /> + + +
+ + {/* Provider */} +
+ + {selectedProvider.badge && ( + + {selectedProvider.badge} + + )} +
+ + {channel.providerType === 1 && ( +
+

+ Note: Office 365 Connectors are deprecated by Microsoft. Consider switching to Microsoft Teams (Workflow Webhook) for continued support. +

+
+ )} + + {/* URL */} +
+ +
+ + {/* Custom Headers (generic provider only) */} + {channel.providerType === GENERIC_PROVIDER && ( + onChange({ ...channel, customHeadersJson: v || undefined })} + /> + )} + + {/* Signing secret (generic provider only) */} + {channel.providerType === GENERIC_PROVIDER && ( +
+ +
+ )} + + {/* Event subscriptions — tenant channels only; ops channels are targeted by alert rules. */} + {showEventToggles && ( +
+ Send to this channel +

+ A channel with everything off can still be targeted by Analyze Rules (rule-level notifications). +

+
+ {EVENT_TOGGLES.map(({ key, label, hint }) => { + const on = Boolean(channel[key]); + return ( + + ); + })} +
+
+ )} + + {/* Test */} +
+ + Tests the last saved configuration of this channel. + {testResult && ( + + {testResult.message} + + )} +
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index 658e645b..436d6d9f 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -193,6 +193,8 @@ export const api = { // ── Global Config (global admin) ────────────────────────────────────────── globalConfig: { get: () => `${API_BASE_URL}/api/global/config`, + /** Sends a test notification to one saved platform (ops) channel. */ + testOpsChannel: () => `${API_BASE_URL}/api/global/config/test-ops-channel`, // NOTE: there is deliberately no per-tenant variant — /api/global/config/{tenantId} does // not exist in the backend. A tenant's member-readable flags (incl. unrestrictedMode) // come from config.featureFlags, which a GA may call cross-tenant (MemberRead bypass). diff --git a/src/Web/autopilot-monitor-web/scripts/generate-shared-manifest-types.js b/src/Web/autopilot-monitor-web/scripts/generate-shared-manifest-types.js index d3bb1b9f..888f975a 100644 --- a/src/Web/autopilot-monitor-web/scripts/generate-shared-manifest-types.js +++ b/src/Web/autopilot-monitor-web/scripts/generate-shared-manifest-types.js @@ -13,6 +13,10 @@ * - a second copy of the wire types into the MCP server * (src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts), * whose tools read the same backend responses. + * - wire-vocabularies.generated.ts for the MCP: the vocabulary sections as runtime + * ARRAYS, so tool input schemas derive their z.enum() from backend truth instead + * of retyping it (types alone are erased at runtime — that gap is what let the + * MCP enums drift). * * Run: node scripts/generate-shared-manifest-types.js (npm run generate:manifests) * Freshness of the web outputs is pinned by utils/__tests__/sharedManifestParity.test.ts, @@ -29,6 +33,8 @@ const DEST_TYPES = path.join(WEB_ROOT, "utils", "wire-types.generated.ts"); // Second copy for the MCP server (same repo) — its tools read the same backend wire. const MCP_ROOT = path.resolve(WEB_ROOT, "..", "..", "McpServer", "autopilot-monitor-mcp"); const DEST_MCP_TYPES = path.join(MCP_ROOT, "src", "generated", "wire-types.generated.ts"); +// Values (not types) for the MCP tool schemas — see MCP_VOCABULARIES. +const DEST_MCP_VOCAB = path.join(MCP_ROOT, "src", "generated", "wire-vocabularies.generated.ts"); const HEADER = "// GENERATED from shared-manifests.json — do not edit by hand.\n" + @@ -60,6 +66,75 @@ function buildMcpWireTypesSource(manifestJsonText) { return buildWireTypesSourceWithHeader(manifestJsonText, MCP_HEADER); } +/** + * Manifest section → exported const name, for the MCP's VALUE copy of the vocabularies. + * + * The web reads vocabularies straight off SHARED_MANIFEST (an `as const` literal module), + * but the MCP only ever received the TYPES file — and types are erased at runtime, while a + * `z.enum([...])` needs values. That asymmetry is why every MCP tool had to retype its + * vocabularies by hand, with nothing to compare against. Sections listed here are emitted as + * runtime arrays so the tools can derive their enums instead. + * + * Add a section here only when the MCP actually consumes it — an unused export is one more + * thing to keep honest for no gain. + */ +const MCP_VOCABULARIES = [ + { section: "sessionStatuses", constName: "SESSION_STATUSES", typeName: "SessionStatusName", + doc: "Enrollment session statuses (C# SessionStatus)." }, + { section: "eventSeverities", constName: "EVENT_SEVERITIES", typeName: "EventSeverityName", + doc: "Telemetry event severities (C# EventSeverity) — includes Debug and Trace." }, + { section: "opsEventCategories", constName: "OPS_EVENT_CATEGORIES", typeName: "OpsEventCategoryName", + doc: "Ops event categories = OpsEvents partition keys (C# OpsEventCategory)." }, + { section: "opsEventSeverities", constName: "OPS_EVENT_SEVERITIES", typeName: "OpsEventSeverityName", + doc: "Ops event severities, ascending (C# OpsEventSeverity) — the order IS the ladder." }, + { section: "opsEventTypes", constName: "OPS_EVENT_TYPES", typeName: "OpsEventTypeName", + doc: "Every ops event type the backend can write (C# OpsEventTypes)." }, + { section: "annotationLanes", constName: "ANNOTATION_LANES", typeName: "AnnotationLane", + doc: "Session-annotation lanes (C# AnnotationLanes)." }, + { section: "annotationVerdicts", constName: "ANNOTATION_VERDICTS", typeName: "AnnotationVerdict", + doc: "Session-annotation verdicts (C# AnnotationVerdicts)." }, +]; + +/** + * Pure builder for the MCP server's src/generated/wire-vocabularies.generated.ts. + * Same freshness contract as the wire types: byte-equal to a fresh run or the MCP suite fails. + */ +function buildMcpVocabulariesSource(manifestJsonText) { + const manifest = JSON.parse(manifestJsonText); + if (manifest.schemaVersion !== 2) { + throw new Error("shared-manifests.json must be schemaVersion 2"); + } + + const parts = [ + MCP_HEADER + + "//\n" + + "// Vocabularies reflected from AutopilotMonitor.Shared. These are VALUES (not just types)\n" + + "// so tool input schemas can derive their z.enum() from the backend truth instead of\n" + + "// retyping it — a hand-typed list drifts silently and the tool then advertises a\n" + + "// vocabulary the backend no longer has (or omits one it gained).\n", + ]; + + for (const { section, constName, typeName, doc } of MCP_VOCABULARIES) { + const value = manifest[section]; + if (value === undefined) { + throw new Error(`shared-manifests.json is missing the "${section}" section (MCP vocabulary)`); + } + // Enum sections are name→ordinal maps; the MCP only ever filters by NAME. + const values = Array.isArray(value) ? value : Object.keys(value); + if (values.length === 0) { + throw new Error(`Manifest section "${section}" is empty — the reflection source is likely broken`); + } + parts.push(""); + parts.push(jsdoc(doc, "")); + parts.push(`export const ${constName} = [`); + for (const v of values) parts.push(` ${JSON.stringify(v)},`); + parts.push(`] as const;`); + parts.push(`export type ${typeName} = (typeof ${constName})[number];`); + } + + return parts.join("\n") + "\n"; +} + function buildWireTypesSourceWithHeader(manifestJsonText, header) { const manifest = JSON.parse(manifestJsonText); const types = manifest.types; @@ -140,10 +215,12 @@ function main() { fs.mkdirSync(path.dirname(DEST_MCP_TYPES), { recursive: true }); fs.writeFileSync(DEST_MCP_TYPES, buildMcpWireTypesSource(json), "utf8"); console.log(`[shared-manifests] Wrote ${path.relative(WEB_ROOT, DEST_MCP_TYPES)}`); + fs.writeFileSync(DEST_MCP_VOCAB, buildMcpVocabulariesSource(json), "utf8"); + console.log(`[shared-manifests] Wrote ${path.relative(WEB_ROOT, DEST_MCP_VOCAB)}`); } if (require.main === module) { main(); } -module.exports = { buildGeneratedSource, buildWireTypesSource, buildMcpWireTypesSource }; +module.exports = { buildGeneratedSource, buildWireTypesSource, buildMcpWireTypesSource, buildMcpVocabulariesSource }; diff --git a/src/Web/autopilot-monitor-web/types/adminConfig.ts b/src/Web/autopilot-monitor-web/types/adminConfig.ts index ff4faa44..d422a264 100644 --- a/src/Web/autopilot-monitor-web/types/adminConfig.ts +++ b/src/Web/autopilot-monitor-web/types/adminConfig.ts @@ -22,4 +22,17 @@ export interface OpsAlertRule { eventType: string; minSeverity: string; enabled: boolean; + /** + * Ids of the ops notification channels this rule notifies. Absent or empty means every + * enabled channel — the pre-routing broadcast behaviour every rule written before channels + * existed still carries, so an untouched rule keeps reaching the same destinations. + */ + notifyChannelIds?: string[]; + /** + * Send the event's structured payload to this rule's channels on top of the + * category/event/severity/tenant baseline. Off by default — most payloads are operational + * noise in a chat, and some carry data the baseline never does (tenant domain, contact + * address), so widening what leaves the platform stays a deliberate per-rule decision. + */ + includePayload?: boolean; } diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts index 308273bc..1e164c09 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts @@ -39,6 +39,7 @@ export const SHARED_MANIFEST = { "opsAlertTeamsWebhookUrl", "opsAlertSlackEnabled", "opsAlertSlackWebhookUrl", + "opsNotificationChannelsJson", "latestAgentV2Version", "latestAgentV2Sha256", "latestAgentV2ExeSha256", @@ -196,7 +197,8 @@ export const SHARED_MANIFEST = { "TeamsWorkflowWebhook": 2, "Slack": 10, "GenericJson": 20, - "Discord": 30 + "Discord": 30, + "Telegram": 40 }, "annotationLanes": [ "operator", @@ -209,6 +211,100 @@ export const SHARED_MANIFEST = { "different_problem", "inconclusive" ], + "opsEventCategories": [ + "Consent", + "Maintenance", + "Security", + "Tenant", + "Agent", + "SLA", + "Platform" + ], + "opsEventSeverities": [ + "Info", + "Warning", + "Error", + "Critical" + ], + "opsEventTypes": [ + "ConsentFlowStarted", + "ConsentFlowSuccess", + "ConsentFlowFailed", + "ConsentRedirectUriMismatch", + "AppHomingFlipped", + "AppHomingFlippedWithEntraRoles", + "MaintenanceCompleted", + "MaintenanceFailed", + "MaintenanceLongRunning", + "SessionSweepCompleted", + "SessionSweepFailed", + "OpsEventCleanup", + "OrphanEventsCleaned", + "SessionDeletionMaintenanceStarted", + "SessionDeletionMaintenanceBudgetExceeded", + "SessionDeletionMaintenanceSkippedLocked", + "SessionDeletionMaintenanceLongRunning", + "SessionDeletionMaintenanceLongRunningSevere", + "SessionDeletionMaintenanceFailed", + "SessionDeletionStrandedQueued", + "SessionDeletionPoisoned", + "SessionDeletionMaintenanceCompleted", + "SessionDeletionMaintenanceFanoutSkipped", + "CriticalTableBackupCompleted", + "CriticalTableBackupPartial", + "CriticalTableBackupFailed", + "CriticalTableBackupSkippedLocked", + "BackupRowRestored", + "VerdictCalibrationDrift", + "DeviceBlocked", + "VersionBlocked", + "SessionTenantConflict", + "SessionOwnerMismatch", + "KillSignalDelivered", + "EmbeddedCertExpiringSoon", + "EmbeddedCertExpiringUrgent", + "EmbeddedCertExpired", + "EmbeddedCertBundleEmpty", + "SignalRConnectionsHigh", + "SignalRConnectionsCritical", + "SignalRMessagesHigh", + "SignalRMessagesCritical", + "PoisonQueueBacklogHigh", + "PoisonQueueBacklogCritical", + "ExcessiveSessionEventsAutoActioned", + "OffboardingFeedbackReceived", + "TenantOffboarded", + "TenantOffboardingFailed", + "TenantAutoApproved", + "WelcomeEmailSent", + "WelcomeEmailSkipped", + "WelcomeEmailFailed", + "TenantTrialStarted", + "TenantTrialExpiring", + "TenantTrialExpired", + "TenantPlanDowngraded", + "TenantRetentionGraceExpiring", + "TenantRetentionGraceEnded", + "RuleFrequencyRegression", + "AppVersionDurationRegression", + "CollectLogsQuickConfigEnabled", + "DiagnosticsUploadEnabled", + "DiagnosticsUploadDisabled", + "SessionActionQueued", + "SessionTimeouts", + "AgentEmergencyBreak", + "AgentBinaryIntegrityMismatch", + "CmTraceTimeSkewRegression", + "ExcessiveSessionEvents", + "NewImeVersionDetected", + "ImePatternDriftSuspected", + "BlobStorageMissing", + "BlobStorageUnreachable", + "SlaBreachNotification", + "SlaConsecutiveFailures", + "SlaEvaluationCompleted", + "AzureMonitorAlert" + ], "tenantRoles": [ "Admin", "Operator", diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json index 6b4b06ee..f0074b12 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -35,6 +35,7 @@ "opsAlertTeamsWebhookUrl", "opsAlertSlackEnabled", "opsAlertSlackWebhookUrl", + "opsNotificationChannelsJson", "latestAgentV2Version", "latestAgentV2Sha256", "latestAgentV2ExeSha256", @@ -192,7 +193,8 @@ "TeamsWorkflowWebhook": 2, "Slack": 10, "GenericJson": 20, - "Discord": 30 + "Discord": 30, + "Telegram": 40 }, "annotationLanes": [ "operator", @@ -205,6 +207,100 @@ "different_problem", "inconclusive" ], + "opsEventCategories": [ + "Consent", + "Maintenance", + "Security", + "Tenant", + "Agent", + "SLA", + "Platform" + ], + "opsEventSeverities": [ + "Info", + "Warning", + "Error", + "Critical" + ], + "opsEventTypes": [ + "ConsentFlowStarted", + "ConsentFlowSuccess", + "ConsentFlowFailed", + "ConsentRedirectUriMismatch", + "AppHomingFlipped", + "AppHomingFlippedWithEntraRoles", + "MaintenanceCompleted", + "MaintenanceFailed", + "MaintenanceLongRunning", + "SessionSweepCompleted", + "SessionSweepFailed", + "OpsEventCleanup", + "OrphanEventsCleaned", + "SessionDeletionMaintenanceStarted", + "SessionDeletionMaintenanceBudgetExceeded", + "SessionDeletionMaintenanceSkippedLocked", + "SessionDeletionMaintenanceLongRunning", + "SessionDeletionMaintenanceLongRunningSevere", + "SessionDeletionMaintenanceFailed", + "SessionDeletionStrandedQueued", + "SessionDeletionPoisoned", + "SessionDeletionMaintenanceCompleted", + "SessionDeletionMaintenanceFanoutSkipped", + "CriticalTableBackupCompleted", + "CriticalTableBackupPartial", + "CriticalTableBackupFailed", + "CriticalTableBackupSkippedLocked", + "BackupRowRestored", + "VerdictCalibrationDrift", + "DeviceBlocked", + "VersionBlocked", + "SessionTenantConflict", + "SessionOwnerMismatch", + "KillSignalDelivered", + "EmbeddedCertExpiringSoon", + "EmbeddedCertExpiringUrgent", + "EmbeddedCertExpired", + "EmbeddedCertBundleEmpty", + "SignalRConnectionsHigh", + "SignalRConnectionsCritical", + "SignalRMessagesHigh", + "SignalRMessagesCritical", + "PoisonQueueBacklogHigh", + "PoisonQueueBacklogCritical", + "ExcessiveSessionEventsAutoActioned", + "OffboardingFeedbackReceived", + "TenantOffboarded", + "TenantOffboardingFailed", + "TenantAutoApproved", + "WelcomeEmailSent", + "WelcomeEmailSkipped", + "WelcomeEmailFailed", + "TenantTrialStarted", + "TenantTrialExpiring", + "TenantTrialExpired", + "TenantPlanDowngraded", + "TenantRetentionGraceExpiring", + "TenantRetentionGraceEnded", + "RuleFrequencyRegression", + "AppVersionDurationRegression", + "CollectLogsQuickConfigEnabled", + "DiagnosticsUploadEnabled", + "DiagnosticsUploadDisabled", + "SessionActionQueued", + "SessionTimeouts", + "AgentEmergencyBreak", + "AgentBinaryIntegrityMismatch", + "CmTraceTimeSkewRegression", + "ExcessiveSessionEvents", + "NewImeVersionDetected", + "ImePatternDriftSuspected", + "BlobStorageMissing", + "BlobStorageUnreachable", + "SlaBreachNotification", + "SlaConsecutiveFailures", + "SlaEvaluationCompleted", + "AzureMonitorAlert" + ], "tenantRoles": [ "Admin", "Operator", @@ -799,6 +895,14 @@ }, "doc": "Slack Incoming Webhook URL for ops alerts." }, + { + "name": "opsNotificationChannelsJson", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "JSON array of platform notification channels (NotificationChannel), the destinations an OpsAlertRule can target by id. Supersedes the three fixed provider slots above: each channel carries its own provider, destination and name, so an operator can keep e.g. an \"IT Engineer\" push channel and a \"Sales\" webhook apart and bind each event type to only the channels that should see it. Null/empty = not migrated yet — then synthesizes the list from the legacy slots, so dispatch behavior is unchanged until an operator saves the section once." + }, { "name": "latestAgentV2Version", "type": { @@ -17337,7 +17441,7 @@ }, "TestWebhookNotificationResponse": { "kind": "object", - "doc": "Response of POST config/{tenantId}/test-notification: the delivery verdict of the test webhook send (HTTP 200 for both verdicts — Success carries the outcome).", + "doc": "Response of POST config/{tenantId}/test-notification AND POST global/config/test-ops-channel: the delivery verdict of a test send (HTTP 200 for both verdicts — Success carries the outcome). One shape for both because the semantics are identical; the platform endpoint only differs in which channel list it resolves the id against.", "fields": [ { "name": "success", diff --git a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts index 7dc79dd0..c173b091 100644 --- a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts @@ -99,6 +99,8 @@ export interface AdminConfiguration { opsAlertSlackEnabled: boolean; /** Slack Incoming Webhook URL for ops alerts. */ opsAlertSlackWebhookUrl: string; + /** JSON array of platform notification channels (NotificationChannel), the destinations an OpsAlertRule can target by id. Supersedes the three fixed provider slots above: each channel carries its own provider, destination and name, so an operator can keep e.g. an "IT Engineer" push channel and a "Sales" webhook apart and bind each event type to only the channels that should see it. Null/empty = not migrated yet — then synthesizes the list from the legacy slots, so dispatch behavior is unchanged until an operator saves the section once. */ + opsNotificationChannelsJson: string; /** Version string of the latest published V2 agent (e.g. "2.0.647"). */ latestAgentV2Version: string; /** SHA-256 (lowercase hex) of the latest published V2 agent ZIP. */ @@ -3779,7 +3781,7 @@ export interface TestLogPatternResponse { result: LogPatternTestResult; } -/** Response of POST config/{tenantId}/test-notification: the delivery verdict of the test webhook send (HTTP 200 for both verdicts — Success carries the outcome). */ +/** Response of POST config/{tenantId}/test-notification AND POST global/config/test-ops-channel: the delivery verdict of a test send (HTTP 200 for both verdicts — Success carries the outcome). One shape for both because the semantics are identical; the platform endpoint only differs in which channel list it resolves the id against. */ export interface TestWebhookNotificationResponse { success: boolean; /** HTTP status returned by the webhook endpoint, or null when the send never got a response — the key is omitted when null. */