Ops alert channels, ops-events server-side filters, and generated MCP tool vocabularies (#140)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-01 16:57:41 +02:00
committed by GitHub
parent eedca11214
commit d763a885e2
88 changed files with 4456 additions and 979 deletions
+4 -2
View File
@@ -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
+134
View File
@@ -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
+151
View File
@@ -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`
+43 -2
View File
@@ -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
+3 -1
View File
@@ -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.
+12
View File
@@ -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.
@@ -79,13 +79,7 @@ public class AppHomingServiceTests
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(e => { lock (_savedOpsEvents) _savedOpsEvents.Add(e); })
.Returns(Task.CompletedTask);
var alertDispatch = new OpsAlertDispatchService(
_adminConfigMock.Object,
new TelegramNotificationService(new HttpClient(), configRepo,
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(),
NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(_adminConfigMock.Object);
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
_sut = new AppHomingService(
@@ -30,12 +30,7 @@ public class CollectLogsQuickConfigOpsEventTests
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
var alertDispatch = new OpsAlertDispatchService(
adminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
return (new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch), saved);
}
@@ -181,13 +181,7 @@ public class KillSwitchEvaluatorTests
var memCache = new MemoryCache(new MemoryCacheOptions());
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, memCache);
var alertDispatch = new OpsAlertDispatchService(
adminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(),
NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
var evaluator = new KillSwitchEvaluator(
@@ -585,13 +585,7 @@ public class TenantOffboardingHandlerDrainContractTests
var memCache = new MemoryCache(new MemoryCacheOptions());
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, memCache);
var alertDispatch = new OpsAlertDispatchService(
adminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(),
NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
return new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
}
@@ -451,13 +451,7 @@ public class TenantOffboardingHandlerOrderingTests
var memCache = new MemoryCache(new MemoryCacheOptions());
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, memCache);
var alertDispatch = new OpsAlertDispatchService(
adminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(),
NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
return new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
}
}
@@ -277,13 +277,7 @@ public class TenantOffboardingWorkerPoisonTests
var memCache = new MemoryCache(new MemoryCacheOptions());
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, memCache);
return new OpsAlertDispatchService(
adminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(),
NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
return TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<string>());
Assert.True(payload["data"]!["selfService"]!.Value<bool>());
Assert.Equal("TenantTrialStarted", payload["eventType"]!.Value<string>());
}
[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<string>());
// ── 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);
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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);
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<KeyValuePair<string, string?>>
{
new KeyValuePair<string, string?>("category", "Agent"),
};
var withEmptyFilters = new List<KeyValuePair<string, string?>>(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));
}
}
@@ -1,13 +1,17 @@
using System.Reflection;
using System.Text.RegularExpressions;
using AutopilotMonitor.Shared.DataAccess;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Every ops event type the backend can write must be selectable in the portal's alert-rule
/// editor (<c>OpsAlertRulesSection.tsx</c> <c>OPS_EVENT_TYPES</c>) — 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
/// <see cref="OpsEventTypes"/> vocabulary and (b) selectable in the portal's alert-rule editor
/// (<c>OpsAlertRulesSection.tsx</c> <c>OPS_EVENT_TYPES</c>). 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.
/// </summary>
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]+)""")
/// <summary>The vocabulary as the assembly declares it — reflection, not a source scan.</summary>
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));
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>Channels that receive the baseline alert (no payload) - the default for every rule.</summary>
private static IEnumerable<string> Plain(IReadOnlyList<OpsAlertRule> rules, IReadOnlyList<NotificationChannel> channels)
=> OpsAlertDispatchService.ResolveTargets(rules, channels).Plain.Select(c => c.Id);
/// <summary>Channels that receive the event structured payload (opted in per rule).</summary>
private static IEnumerable<string> WithPayload(IReadOnlyList<OpsAlertRule> rules, IReadOnlyList<NotificationChannel> 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, ""));
}
}
@@ -121,13 +121,7 @@ public class ReportAgentErrorFunctionTests
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions()));
var alertDispatch = new OpsAlertDispatchService(
adminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(),
NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
OpsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
}
@@ -397,11 +397,7 @@ public class SessionDeletionMaintenanceFunctionTests
}
return Task.CompletedTask;
});
var alertDispatch = new OpsAlertDispatchService(
AdminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
new AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService(new HttpClient(), NullLogger<AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(AdminConfig.Object);
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
var maintRepo = new Mock<IMaintenanceRepository>();
@@ -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<IOpsEventRepository>();
opsRepoStub.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>())).Returns(Task.CompletedTask);
var alertDispatchStub = new OpsAlertDispatchService(
AdminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
new AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService(new HttpClient(), NullLogger<AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var alertDispatchStub = TestNotifications.InertOpsAlertDispatch(AdminConfig.Object);
var opsServiceStub = new OpsEventService(opsRepoStub.Object, NullLogger<OpsEventService>.Instance, alertDispatchStub);
// PR-B Codex F4 follow-up: worker now reads DeletionProgress on the poison path.
@@ -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<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
new AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService(new HttpClient(), NullLogger<AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(AdminConfig.Object);
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
Sut = new SessionDeletionWorker(
@@ -46,12 +46,7 @@ public class SessionOwnerBindingObserverTests
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
var alertDispatch = new OpsAlertDispatchService(
adminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
Sut = new SessionOwnerBindingObserver(
@@ -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<WebhookProviderType>(),
["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)),
@@ -168,11 +168,8 @@ public class SlaTenantStatusLifecycleTests
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>())).Returns(Task.CompletedTask);
var webhook = new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance);
var alertDispatch = new OpsAlertDispatchService(
_adminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
webhook,
NullLogger<OpsAlertDispatchService>.Instance);
var channelDispatcher = new NotificationChannelDispatcher(webhook, new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance));
var alertDispatch = TestNotifications.InertOpsAlertDispatch(_adminConfig.Object);
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.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<SessionSummary>(recentPage, null));
var webhook = new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance);
var alertDispatch = new OpsAlertDispatchService(adminCfg.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
webhook, NullLogger<OpsAlertDispatchService>.Instance);
var channelDispatcher = new NotificationChannelDispatcher(webhook, new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance));
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminCfg.Object);
var opsRepo = new Mock<IOpsEventRepository>();
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>())).Returns(Task.CompletedTask);
var opsSvc = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.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<SlaBreachEvaluationService>.Instance);
@@ -850,14 +846,13 @@ public class SlaTenantStatusLifecycleTests
.Returns(Task.CompletedTask);
var webhook = new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance);
var alertDispatch = new OpsAlertDispatchService(adminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
webhook, NullLogger<OpsAlertDispatchService>.Instance);
var channelDispatcher = new NotificationChannelDispatcher(webhook, new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance));
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.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<SlaBreachEvaluationService>.Instance);
_sessionRepo.Setup(r => r.GetSessionsPageAsync(It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>()))
@@ -283,13 +283,7 @@ public sealed class SubmitOffboardingFeedbackFunctionTests
Mock.Of<IConfigRepository>(),
NullLogger<AdminConfigurationService>.Instance,
memCache);
var alertDispatch = new OpsAlertDispatchService(
adminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(),
NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
return (
new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch),
@@ -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;
/// <summary>
/// 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.
/// </summary>
public class TelegramChannelProviderTests
{
private static Mock<WebhookNotificationService> WebhookMock()
=> new(new HttpClient(), NullLogger<WebhookNotificationService>.Instance) { CallBase = false };
private static Mock<TelegramNotificationService> TelegramMock()
=> new(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.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<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), 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<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Once);
telegram.Verify(t => t.SendOpsAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), 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<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), 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<string>(), It.IsAny<NotificationAlert>()), Times.Never);
webhook.Verify(w => w.SendNotificationAsync(
It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task SendWithResult_RoutesTelegramToBot()
{
var webhook = WebhookMock();
var telegram = TelegramMock();
telegram.Setup(t => t.SendAlertWithResultAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()))
.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<NotificationAlert>()), 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));
}
}
@@ -51,13 +51,7 @@ public class TenantApprovalServiceTests
.Returns(Task.CompletedTask);
var adminConfig = new Mock<AdminConfigurationService>(
configRepo, NullLogger<AdminConfigurationService>.Instance, cache);
var alertDispatch = new OpsAlertDispatchService(
adminConfig.Object,
new TelegramNotificationService(new HttpClient(), configRepo,
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(),
NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
_sut = new TenantApprovalService(
NullLogger<TenantApprovalService>.Instance,
@@ -48,13 +48,7 @@ public class TenantAutoApproveHandlerTests
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(e => { lock (_savedOpsEvents) _savedOpsEvents.Add(e); })
.Returns(Task.CompletedTask);
var alertDispatch = new OpsAlertDispatchService(
_adminConfigMock.Object,
new TelegramNotificationService(new HttpClient(), configRepo,
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(),
NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(_adminConfigMock.Object);
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
_approvalMock = new Mock<TenantApprovalService>(
@@ -99,13 +99,7 @@ public class TenantConfigPatchServiceTests
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
var alertDispatch = new OpsAlertDispatchService(
adminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(),
NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
var opsService = new OpsEventService(OpsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
Sut = new TenantConfigPatchService(
@@ -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;
/// <summary>
/// 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.
/// <para>
/// 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.
/// </para>
/// </summary>
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<OpsEventEntry> Events) NewService()
{
var events = new List<OpsEventEntry>();
var repo = new Mock<IOpsEventRepository>();
repo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(events.Add)
.Returns(Task.CompletedTask);
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
adminConfig.Setup(a => a.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration { UpdatedBy = "test" });
return (new OpsEventService(repo.Object, NullLogger<OpsEventService>.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<string, string> Changes, TenantConfiguration Config) Apply(
TenantConfiguration config, string? planTier = null, bool trialProvided = false, DateTime? trialExpiresUtc = null)
{
var changes = new Dictionary<string, string>();
PlanManagementFunction.ApplyPlanChanges(config, planTier, trialProvided, trialExpiresUtc, Caller, Now, changes);
return (changes, config);
}
private static bool WouldFire((Dictionary<string, string> 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)));
}
}
@@ -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;
/// <summary>
/// Inert notification plumbing for tests that need an <see cref="OpsEventService"/> 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.
/// <para>
/// Tests that DO assert on dispatch should mock the transports directly — see
/// <c>TelegramChannelProviderTests</c> — rather than extending this helper.
/// </para>
/// </summary>
internal static class TestNotifications
{
internal static NotificationChannelDispatcher InertDispatcher()
=> new(
new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance),
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
NullLogger<TelegramNotificationService>.Instance));
internal static OpsAlertDispatchService InertOpsAlertDispatch(AdminConfigurationService adminConfigService)
=> new(adminConfigService, InertDispatcher(), NullLogger<OpsAlertDispatchService>.Instance);
}
@@ -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<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
var sut = new TrialExpirySweepFunction(
@@ -248,11 +244,7 @@ public class TrialExpirySweepFunctionTests
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.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<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
var sut = new TrialExpirySweepFunction(
configRepo.Object,
@@ -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,
@@ -53,10 +53,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
public async Task<List<OpsEventEntry>> 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<OpsEventEntry>();
var filter = BuildFilter(category, dateFrom, dateTo);
var filter = BuildFilter(category, dateFrom, dateTo, filters);
var query = string.IsNullOrEmpty(filter)
? _table.QueryAsync<TableEntity>()
: _table.QueryAsync<TableEntity>(filter: filter);
@@ -71,7 +72,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
public async Task<RawPage<OpsEventEntry>> 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<TableEntity>(
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<RawPage<OpsEventEntry>> 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<TableEntity>(filter: filter, maxPerPage: pageSize))
@@ -162,7 +159,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
return new RawPage<OpsEventEntry>(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<string>
{
@@ -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);
}
/// <summary>
/// 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" &lt; "Error" &lt; "Info" &lt;
/// "Warning"), which has nothing to do with severity order.
/// </summary>
private static void AppendFieldFilters(List<string> 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<string>();
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);
}
@@ -14,7 +14,8 @@ namespace AutopilotMonitor.Functions.Functions.Admin
{
/// <summary>
/// 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<KeyValuePair<string, string?>>();
if (!string.IsNullOrEmpty(category))
extrasList.Add(new KeyValuePair<string, string?>("category", category));
if (!string.IsNullOrEmpty(filterTenantId))
extrasList.Add(new KeyValuePair<string, string?>("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();
@@ -0,0 +1,82 @@
using System.Collections.Generic;
using System.Collections.Specialized;
using AutopilotMonitor.Shared.DataAccess;
namespace AutopilotMonitor.Functions.Functions.Admin
{
/// <summary>
/// Parses the optional ops-event field filters (<c>eventType</c> / <c>severity</c> /
/// <c>minSeverity</c>) from the request query and projects them into both the storage
/// <see cref="OpsEventQueryFilters"/> and the pagination extras (key/value pairs folded into
/// the continuation fingerprint and echoed on <c>nextLink</c>). Mirrors
/// <see cref="AuditLogFilterRequest"/> — same mechanism, same guarantees.
/// </summary>
internal static class OpsEventFilterRequest
{
/// <summary>Parse outcome: either <see cref="Filters"/> (possibly empty) or an <see cref="Error"/> for a 400.</summary>
public sealed class Result
{
public OpsEventQueryFilters Filters { get; init; } = new OpsEventQueryFilters();
public string? Error { get; init; }
}
/// <summary>
/// Severity values are normalised onto the canonical vocabulary and REJECTED when unknown.
/// Table Storage <c>eq</c> 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.
/// </summary>
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 };
}
/// <summary>
/// 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.
/// </summary>
public static List<KeyValuePair<string, string?>> ToExtras(OpsEventQueryFilters filters)
{
var extras = new List<KeyValuePair<string, string?>>();
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<KeyValuePair<string, string?>> extras, string key, string? value)
{
if (!string.IsNullOrEmpty(value))
extras.Add(new KeyValuePair<string, string?>(key, value));
}
private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value;
}
}
@@ -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();
@@ -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
{
@@ -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
{
/// <summary>
/// Sends a test notification to one PLATFORM ops channel — the operator-side counterpart of
/// <see cref="TestWebhookNotificationFunction"/>, which tests a tenant's channels. Same
/// response contract; the only difference is the channel list it resolves against.
/// </summary>
public class TestOpsChannelFunction
{
private readonly ILogger<TestOpsChannelFunction> _logger;
private readonly AdminConfigurationService _adminConfigService;
private readonly NotificationChannelDispatcher _channelDispatcher;
public TestOpsChannelFunction(
ILogger<TestOpsChannelFunction> logger,
AdminConfigurationService adminConfigService,
NotificationChannelDispatcher channelDispatcher)
{
_logger = logger;
_adminConfigService = adminConfigService;
_channelDispatcher = channelDispatcher;
}
[Function("TestOpsChannel")]
public async Task<HttpResponseData> 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;
}
}
}
}
@@ -18,16 +18,16 @@ namespace AutopilotMonitor.Functions.Functions.Config
{
private readonly ILogger<TestWebhookNotificationFunction> _logger;
private readonly TenantConfigurationService _configService;
private readonly WebhookNotificationService _webhookNotificationService;
private readonly NotificationChannelDispatcher _channelDispatcher;
public TestWebhookNotificationFunction(
ILogger<TestWebhookNotificationFunction> 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
@@ -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
@@ -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);
}
}
}
@@ -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)
{
@@ -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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// <para>
/// 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.
/// </para>
/// </summary>
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;
}
/// <summary>
/// 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
@@ -447,6 +447,9 @@ builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Notifications
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().Notification);
builder.Services.AddHttpClient<TelegramNotificationService>()
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().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<AutopilotMonitor.Functions.Services.Notifications.NotificationChannelDispatcher>();
// 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<EmailTemplateService>();
@@ -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
@@ -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<AnalyzeOnEnrollmentEndHandler> _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<AnalyzeOnEnrollmentEndHandler> 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)",
@@ -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);
}
@@ -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;
@@ -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[].
/// </summary>
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,
});
}
/// <summary>
/// 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 <c>data</c> that consumers parse against.
/// </summary>
private static JObject? ParseData(string? dataJson)
{
if (string.IsNullOrWhiteSpace(dataJson))
return null;
try
{
return JsonConvert.DeserializeObject(dataJson) as JObject;
}
catch (JsonException)
{
return null;
}
}
}
}
@@ -0,0 +1,82 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using AutopilotMonitor.Shared.Models.Notifications;
namespace AutopilotMonitor.Functions.Services.Notifications
{
/// <summary>
/// The channel-level send API: takes <see cref="NotificationChannel"/> 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.
/// <para>
/// Two transports today: <see cref="WebhookProviderType.Telegram"/> goes to
/// <see cref="TelegramNotificationService"/> (plain text through the platform bot, the channel's
/// Url field carrying the chat ID); everything else is a rendered webhook POST via
/// <see cref="WebhookNotificationService"/>. Telegram is deliberately NOT a renderer — it has no
/// endpoint URL of its own and must never reach the SSRF-guarded webhook path.
/// </para>
/// </summary>
public class NotificationChannelDispatcher
{
private readonly WebhookNotificationService _webhook;
private readonly TelegramNotificationService _telegram;
public NotificationChannelDispatcher(
WebhookNotificationService webhook,
TelegramNotificationService telegram)
{
_webhook = webhook;
_telegram = telegram;
}
/// <summary>
/// Sends a notification to every channel in <paramref name="channels"/> (callers pre-filter
/// by <see cref="NotificationChannel.Enabled"/> 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.
/// </summary>
public virtual async Task SendToChannelsAsync(IEnumerable<NotificationChannel> 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());
}
}
/// <summary>
/// Sends to a single channel and REPORTS the outcome — the "send test notification"
/// endpoints. Not fire-and-forget; never throws.
/// </summary>
public virtual async Task<WebhookTestResult> 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());
}
}
}
@@ -40,7 +40,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
/// <paramref name="signingSecret"/> (generic webhooks only) adds HMAC signature headers
/// (see <see cref="WebhookSignatureCalculator"/>).
/// </summary>
public async Task SendNotificationAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert,
public virtual async Task SendNotificationAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert,
IReadOnlyDictionary<string, string>? customHeaders = null, string? signingSecret = null)
{
if (string.IsNullOrEmpty(webhookUrl) || providerType == WebhookProviderType.None)
@@ -73,35 +73,12 @@ namespace AutopilotMonitor.Functions.Services.Notifications
}
}
/// <summary>
/// Sends a notification to every channel in <paramref name="channels"/> (callers pre-filter
/// by <see cref="NotificationChannel.Enabled"/> and the relevant NotifyOn* toggle).
/// Channels are dispatched sequentially and independently — a failing endpoint only logs
/// a warning (via <see cref="SendNotificationAsync"/>) and never blocks the remaining
/// channels or the caller's pipeline.
/// </summary>
public async Task SendToChannelsAsync(IEnumerable<NotificationChannel> 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());
}
}
/// <summary>
/// Sends a notification and returns the result (for test endpoint). Not fire-and-forget.
/// <paramref name="customHeaders"/> and <paramref name="signingSecret"/> (generic
/// webhooks only) are applied exactly like in <see cref="SendNotificationAsync"/>.
/// </summary>
public async Task<WebhookTestResult> SendNotificationWithResultAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert,
public virtual async Task<WebhookTestResult> SendNotificationWithResultAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert,
IReadOnlyDictionary<string, string>? customHeaders = null, string? signingSecret = null)
{
if (string.IsNullOrEmpty(webhookUrl))
@@ -181,6 +158,11 @@ namespace AutopilotMonitor.Functions.Services.Notifications
}
}
/// <summary>
/// Outcome of a single "send a test notification" attempt. Shared by every channel provider —
/// <see cref="NotificationChannelDispatcher"/> returns it for Telegram channels too, which is
/// why the type is not webhook-specific despite the name.
/// </summary>
public class WebhookTestResult
{
public bool Success { get; set; }
@@ -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<OpsAlertDispatchService> _logger;
public OpsAlertDispatchService(
AdminConfigurationService adminConfigService,
TelegramNotificationService telegram,
WebhookNotificationService webhook,
NotificationChannelDispatcher channelDispatcher,
ILogger<OpsAlertDispatchService> logger)
{
_adminConfigService = adminConfigService;
_telegram = telegram;
_webhook = webhook;
_channelDispatcher = channelDispatcher;
_logger = logger;
}
/// <summary>
/// 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.
/// </summary>
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<Task>();
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
}
}
/// <summary>
/// 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.
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </summary>
internal static (List<NotificationChannel> WithPayload, List<NotificationChannel> Plain) ResolveTargets(
IReadOnlyList<OpsAlertRule> matchingRules,
IReadOnlyList<NotificationChannel> channels)
{
var enabled = channels.Where(c => c.Enabled).ToList();
if (enabled.Count == 0)
return (new List<NotificationChannel>(), new List<NotificationChannel>());
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());
}
/// <summary>Channel ids one group of rules targets; empty binding = every enabled channel.</summary>
private static HashSet<string> TargetedIds(
IEnumerable<OpsAlertRule> rules, IReadOnlyList<NotificationChannel> enabled)
{
var ids = new HashSet<string>(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;
}
/// <summary>Caps on the flattened detail facts — a card must stay readable.</summary>
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
/// <summary>
/// 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 <c>data</c>. Reached only for rules that opted in via
/// <see cref="OpsAlertRule.IncludePayload"/> — the caller passes null otherwise, so the
/// default alert stays the category/event/severity/tenant baseline.
/// <para>
/// 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.
/// </para>
/// </summary>
internal static List<NotificationFact> FlattenDetails(string? detailsJson)
{
OpsEventSeverity.Info => 0,
OpsEventSeverity.Warning => 1,
OpsEventSeverity.Error => 2,
OpsEventSeverity.Critical => 3,
_ => -1
};
var facts = new List<NotificationFact>();
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;
}
/// <summary>"trialExpiresUtc" → "Trial Expires Utc" — the payload uses camelCase keys, the
/// surrounding facts use title case, and a card mixing both reads like a bug.</summary>
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);
}
}
@@ -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).
/// </summary>
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.
/// </summary>
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.
/// </summary>
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 <c>abortedByBudget=true</c>.
/// </summary>
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 <c>RecordCriticalTableBackupSkippedLockedAsync</c>.
/// </summary>
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 });
/// <summary>Watchdog: maintenance run still in flight 30 minutes after start. Warning-level early signal.</summary>
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 });
/// <summary>Watchdog: maintenance run still in flight 60 minutes after start. Error-level escalation in case the operator missed the 30min warning.</summary>
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 });
/// <summary>Unhandled exception path. Re-thrown after this audit so the Azure Functions runtime records the failure.</summary>
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 });
/// <summary>Stale Queued state detected (no worker pickup) — operator must inspect the manifest + progress blobs. No auto-clear.</summary>
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
/// </summary>
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
/// </summary>
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
/// <summary>Backup run finished with all tables successfully captured. Info-level — visible in the timeline, not alertable by default.</summary>
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 });
/// <summary>Backup run wrote a manifest but at least one table Failed or Skipped. Warning-level — operator should inspect manifest perTableFailures.</summary>
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 });
/// <summary>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.</summary>
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 });
/// <summary>Backup or restore was skipped because the maintenance lease was already held by another job. Info-level — not a failure.</summary>
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
/// </summary>
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.
/// <summary>
/// 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
/// (<paramref name="selfService"/> 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.
/// </summary>
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 });
}
/// <summary>Heads-up: a Pro trial ends within the next few days. Info-tier visibility signal.</summary>
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.
/// </summary>
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.
/// </summary>
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.
/// </summary>
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.
/// </summary>
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).
/// </summary>
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)
{
@@ -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(
@@ -218,37 +218,19 @@ namespace AutopilotMonitor.Functions.Services
}
/// <summary>
/// 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.
/// <para>
/// Virtual: <see cref="Notifications.NotificationChannelDispatcher"/> routes Telegram
/// channels here, and its routing tests need a seam.
/// </para>
/// </summary>
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
}
}
/// <summary>
/// Same message as <see cref="SendOpsAlertAsync"/>, but REPORTS the outcome instead of
/// swallowing it — for the channel "send test" endpoints. Never throws.
/// </summary>
public virtual async Task<Notifications.WebhookTestResult> 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}" };
}
}
/// <summary>
/// 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.
/// </summary>
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<string?> GetWebhookUrlAsync()
{
try
@@ -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<typeof import('../client.js')>();
return { ...actual, apiFetch: apiFetchMock };
});
import { registerAdminTools } from '../tools/admin.js';
type Handler = (args: Record<string, unknown>) => Promise<{ content: Array<{ text: string }>; isError?: boolean }>;
function opsEventsHandler(): Handler {
const handlers: Record<string, Handler> = {};
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<string, unknown>): Promise<string> {
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<string, Handler> = {};
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();
});
});
@@ -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<string, string> = {
"'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);
});
});
@@ -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));
});
});
@@ -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. */
@@ -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];
@@ -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':
@@ -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',
@@ -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,
@@ -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.'),
@@ -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 <see cref="GetOpsEventsPageAsync"/>.
/// <paramref name="filters"/> narrows on non-key columns server-side.
/// </summary>
Task<List<OpsEventEntry>> GetOpsEventsAsync(
string? category = null, DateTime? dateFrom = null, DateTime? dateTo = null);
string? category = null, DateTime? dateFrom = null, DateTime? dateTo = null,
OpsEventQueryFilters? filters = null);
/// <summary>
/// Reads a single page of ops events. The returned <see cref="RawPage{T}"/>
@@ -29,7 +31,8 @@ namespace AutopilotMonitor.Shared.DataAccess
/// newest-first.
/// </summary>
Task<RawPage<OpsEventEntry>> 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<int> DeleteOpsEventsOlderThanAsync(DateTime cutoff);
}
@@ -47,6 +50,15 @@ namespace AutopilotMonitor.Shared.DataAccess
public const string Sla = "SLA";
/// <summary>Platform infrastructure alerts relayed from Azure Monitor (ops alert webhook).</summary>
public const string Platform = "Platform";
/// <summary>
/// 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.
/// </summary>
public static readonly string[] All = { Consent, Maintenance, Security, Tenant, Agent, Sla, Platform };
}
/// <summary>
@@ -58,6 +70,81 @@ namespace AutopilotMonitor.Shared.DataAccess
public const string Warning = "Warning";
public const string Error = "Error";
public const string Critical = "Critical";
/// <summary>Canonical severities, ascending by <see cref="Rank"/>.</summary>
public static readonly string[] All = { Info, Warning, Error, Critical };
/// <summary>
/// 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
/// <see cref="TryNormalize"/> first.
/// </summary>
public static int Rank(string? severity) => severity switch
{
Info => 0,
Warning => 1,
Error => 2,
Critical => 3,
_ => -1,
};
/// <summary>
/// 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 <c>eq</c> is case-sensitive, so an unnormalized "warning"
/// would match nothing at all.
/// </summary>
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;
}
/// <summary>Canonical severities at or above <paramref name="minSeverity"/> (empty for an unknown value).</summary>
public static IReadOnlyList<string> AtOrAbove(string minSeverity)
{
var min = Rank(minSeverity);
if (min < 0) return new string[0];
var result = new List<string>();
foreach (var s in All)
{
if (Rank(s) >= min) result.Add(s);
}
return result;
}
}
/// <summary>
/// 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 <c>AuditLogQueryFilters</c>.
/// </summary>
public class OpsEventQueryFilters
{
/// <summary>Exact match on the <c>EventType</c> column (e.g. "AgentEmergencyBreak"). Case-sensitive — Table Storage cannot fold case.</summary>
public string? EventType { get; set; }
/// <summary>Exact match on the <c>Severity</c> column. Callers should pass a canonical <see cref="OpsEventSeverity"/> value.</summary>
public string? Severity { get; set; }
/// <summary>Threshold match: every severity at or above this one (Info &lt; Warning &lt; Error &lt; Critical). Same vocabulary the ops-alert rules use.</summary>
public string? MinSeverity { get; set; }
/// <summary>True when no filter field is set — callers can skip the filter plumbing entirely.</summary>
public bool IsEmpty =>
string.IsNullOrEmpty(EventType) &&
string.IsNullOrEmpty(Severity) &&
string.IsNullOrEmpty(MinSeverity);
}
public class OpsEventEntry
@@ -0,0 +1,126 @@
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.DataAccess
{
/// <summary>
/// Every ops event type the backend can write — the canonical vocabulary.
/// <para>
/// These used to be bare string literals at the call sites in <c>OpsEventService</c>, 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.
/// </para>
/// <para>
/// Adding a type: declare it here, use the constant at the call site (a raw literal fails
/// <c>OpsEventTypeDualRegisterTests</c>), and list it in <c>OPS_EVENT_TYPES</c> in
/// <c>OpsAlertRulesSection.tsx</c> so an operator can route an alert on it. Constant name
/// == wire value.
/// </para>
/// </summary>
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";
/// <summary>Every declared type, declaration order (grouped by category).</summary>
public static readonly IReadOnlyList<string> 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,
};
}
}
@@ -264,6 +264,92 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>Slack Incoming Webhook URL for ops alerts.</summary>
public string OpsAlertSlackWebhookUrl { get; set; } = default!;
/// <summary>
/// JSON array of platform notification channels (<see cref="Notifications.NotificationChannel"/>),
/// the destinations an <see cref="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.
/// <para>
/// Null/empty = not migrated yet — <see cref="GetOpsNotificationChannels"/> then synthesizes
/// the list from the legacy slots, so dispatch behavior is unchanged until an operator
/// saves the section once.
/// </para>
/// </summary>
public string OpsNotificationChannelsJson { get; set; } = default!;
/// <summary>Stable id of the channel synthesized from the legacy Telegram slot.</summary>
public const string LegacyTelegramChannelId = "legacy-telegram";
/// <summary>Stable id of the channel synthesized from the legacy Teams slot.</summary>
public const string LegacyTeamsChannelId = "legacy-teams";
/// <summary>Stable id of the channel synthesized from the legacy Slack slot.</summary>
public const string LegacySlackChannelId = "legacy-slack";
/// <summary>
/// Display name for the synthesized Telegram channel — the operator push channel that
/// every ops alert went to before channels existed.
/// </summary>
public const string LegacyTelegramChannelName = "Autopilot Monitor IT Engineer";
/// <summary>
/// Returns the platform notification channels. Prefers <see cref="OpsNotificationChannelsJson"/>;
/// 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 <c>TenantConfiguration.GetNotificationChannels</c>.
/// <para>
/// The synthesized channels carry the legacy enabled flags, so a disabled provider stays
/// disabled and the pre-channels dispatch behavior is reproduced exactly.
/// </para>
/// </summary>
public IReadOnlyList<Notifications.NotificationChannel> GetOpsNotificationChannels()
{
var channels = Notifications.NotificationChannel.ParseList(OpsNotificationChannelsJson);
if (channels.Count > 0)
return channels;
var synthesized = new List<Notifications.NotificationChannel>();
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.
/// <c>AdminConfigurationRedactionTests</c> guards against drift.
/// </summary>
/// <summary>
/// Shallow copy for read-model projections that must not mutate the cached instance
/// (the configuration service hands out the same object to every caller).
/// </summary>
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)
@@ -1,3 +1,5 @@
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
@@ -14,5 +16,33 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>Whether this rule is active.</summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Ids of the platform notification channels
/// (<c>AdminConfiguration.GetOpsNotificationChannels</c>) this rule notifies.
/// <para>
/// 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").
/// </para>
/// </summary>
public List<string>? NotifyChannelIds { get; set; }
/// <summary>
/// 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.
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </summary>
public bool IncludePayload { get; set; }
}
}
@@ -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);
}
/// <summary>
@@ -867,35 +845,8 @@ namespace AutopilotMonitor.Shared.Models
/// in place — an unresolvable placeholder URL simply fails SSRF validation at dispatch.
/// </summary>
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);
/// <summary>
/// Returns the effective webhook URL and provider type, handling legacy TeamsWebhookUrl migration.
@@ -224,8 +224,10 @@ namespace AutopilotMonitor.Shared.Models
}
/// <summary>
/// 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.
/// </summary>
// Declaration order == wire order.
public class TestWebhookNotificationResponse : IApiResponse
@@ -36,5 +36,18 @@ namespace AutopilotMonitor.Shared.Models.Notifications
/// <summary>Action buttons (e.g. "Open session" deep-link).</summary>
public List<NotificationAction> Actions { get; set; } = new();
/// <summary>
/// Raw structured payload as a JSON object string (an ops event's Details), embedded
/// verbatim under <c>data</c> 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 <see cref="Facts"/> for them.
/// <para>
/// 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.
/// </para>
/// </summary>
public string? DataJson { get; set; }
}
}
@@ -43,11 +43,18 @@ namespace AutopilotMonitor.Shared.Models.Notifications
/// <summary>
/// Provider type (int form of <see cref="WebhookProviderType"/>):
/// 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).
/// </summary>
public int ProviderType { get; set; }
/// <summary>Webhook destination URL (secret — redacted for read-only viewers).</summary>
/// <summary>
/// Destination (secret — redacted for read-only viewers). For every webhook provider this
/// is the endpoint URL; for <see cref="WebhookProviderType.Telegram"/> 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.
/// </summary>
public string? Url { get; set; }
/// <summary>
@@ -155,5 +162,91 @@ namespace AutopilotMonitor.Shared.Models.Notifications
/// <summary>Serializes a channel list back to its JSON storage form (camelCase).</summary>
public static string SerializeList(IEnumerable<NotificationChannel> channels)
=> JsonSerializer.Serialize(channels, JsonOptions);
/// <summary>
/// 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.
/// <para>
/// Shared by the tenant channel list (<c>TenantConfiguration</c>) and the platform ops
/// channel list (<c>AdminConfiguration</c>): both are served to read-only roles, and a
/// second copy of this logic is exactly how one of them would grow a leak.
/// </para>
/// </summary>
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;
}
/// <summary>
/// Restores channel secrets that came back as the redaction sentinel, matching by
/// <see cref="Id"/> against <paramref name="existingJson"/>. 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.
/// <para>
/// 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.
/// </para>
/// </summary>
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<string, NotificationChannel>(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);
}
}
}
@@ -29,5 +29,15 @@ namespace AutopilotMonitor.Shared.Models.Notifications
/// openUrl actions are rendered as markdown links inside the embed description.
/// </summary>
Discord = 30,
/// <summary>
/// Telegram chat (plain-text message via the platform bot). The odd one out: this is not
/// a webhook. The channel's <c>Url</c> carries the destination CHAT ID, and the bot token
/// belongs to the platform (PreviewConfig <c>WebhookUrl</c>), 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.
/// </summary>
Telegram = 40,
}
}
@@ -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<void>;
handleSaveOpsAlertConfig: (payload: OpsAlertSavePayload) => Promise<void>;
opsNotificationChannels: NotificationChannel[];
setOpsNotificationChannels: React.Dispatch<React.SetStateAction<NotificationChannel[]>>;
handleTestOpsChannel: (channelId: string) => Promise<void>;
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<OpsAlertRule[]>([]);
const [opsNotificationChannels, setOpsNotificationChannels] = useState<NotificationChannel[]>([]);
const [testingOpsChannelId, setTestingOpsChannelId] = useState<string | null>(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 (
<AdminConfigContext.Provider value={{
adminConfig, setAdminConfig, loadingConfig, savingConfig, setSavingConfig,
@@ -575,6 +634,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
error, setError, successMessage, setSuccessMessage,
getAccessToken,
handleSaveAdminConfig, handleResetAdminConfig, handleSaveDiagPaths, handleSaveOpsAlertConfig,
opsNotificationChannels, setOpsNotificationChannels,
handleTestOpsChannel, testingOpsChannelId, testOpsChannelResult,
}}>
{children}
</AdminConfigContext.Provider>
@@ -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<string, string[]> = {
"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<void>;
onSave: (payload: OpsAlertSavePayload) => Promise<void>;
onTestChannel: (channelId: string) => Promise<void>;
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<OpsAlertRule[]>(() => 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<NotificationChannel[]>(opsNotificationChannels);
const [excessiveThreshold, setExcessiveThreshold] = useState(excessiveEventCountThresholdProp);
const [autoActionMode, setAutoActionMode] = useState<AutoActionMode>(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 (
<div className="space-y-6">
@@ -396,6 +407,51 @@ export function OpsAlertRulesSection({
))}
</select>
</div>
{rule.enabled && channels.length > 0 && (
<div className="ml-12 mt-1 mb-2 flex flex-wrap items-center gap-1.5">
<span className="text-xs text-gray-500 dark:text-gray-400 mr-1">Notify:</span>
{(rule.notifyChannelIds ?? []).length === 0 && (
<span className="text-xs text-gray-500 dark:text-gray-400 italic mr-1">
all channels —
</span>
)}
{channels.map((channel) => {
const on = (rule.notifyChannelIds ?? []).includes(channel.id);
return (
<button
key={channel.id}
type="button"
onClick={() => toggleRuleChannel(et, channel.id)}
title={channel.enabled ? undefined : "This channel is disabled and receives nothing."}
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border transition-colors ${
on
? "bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-900/40 dark:text-amber-200 dark:border-amber-700"
: "bg-white text-gray-500 border-gray-300 hover:border-amber-300 dark:bg-gray-700 dark:text-gray-400 dark:border-gray-600"
} ${channel.enabled ? "" : "opacity-50"}`}
>
{channel.name || "Unnamed channel"}
</button>
);
})}
<button
type="button"
onClick={() => togglePayload(et)}
title={
rule.includePayload
? "This rule sends the event's structured fields (e.g. tenant domain, contact address) to its channels."
: "Baseline only: category, event, severity and tenant id. Turn on to include the event's structured fields."
}
className={`ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border transition-colors ${
rule.includePayload
? "bg-sky-100 text-sky-800 border-sky-300 dark:bg-sky-900/40 dark:text-sky-200 dark:border-sky-700"
: "bg-white text-gray-500 border-gray-300 hover:border-sky-300 dark:bg-gray-700 dark:text-gray-400 dark:border-gray-600"
}`}
>
<span className={`mr-1.5 inline-block h-2 w-2 rounded-full ${rule.includePayload ? "bg-sky-500" : "bg-gray-300"}`} />
Payload
</button>
</div>
)}
{et === "ExcessiveSessionEvents" && rule.enabled && (
<div className="ml-12 mt-1 mb-2 flex items-center gap-2">
<label className="text-xs text-amber-700 dark:text-amber-300 whitespace-nowrap">Threshold:</label>
@@ -468,7 +524,7 @@ export function OpsAlertRulesSection({
</div>
</div>
{/* Providers */}
{/* Channels */}
<div className="bg-gradient-to-br from-sky-50 to-cyan-50 dark:from-gray-800 dark:to-gray-800 border-2 border-sky-300 dark:border-sky-700 rounded-lg shadow-lg">
<div className="p-6 border-b border-sky-200 dark:border-sky-700 bg-gradient-to-r from-sky-100 to-cyan-100 dark:from-sky-900/40 dark:to-cyan-900/40">
<div className="flex items-center space-x-2">
@@ -476,104 +532,45 @@ export function OpsAlertRulesSection({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-sky-900 dark:text-sky-100">Alert Providers</h2>
<p className="text-sm text-sky-600 dark:text-sky-300 mt-1">Configure where alert notifications are delivered</p>
<h2 className="text-xl font-semibold text-sky-900 dark:text-sky-100">Alert Channels</h2>
<p className="text-sm text-sky-600 dark:text-sky-300 mt-1">
Named destinations for platform alerts. Each rule above chooses which of these it notifies.
</p>
</div>
</div>
</div>
<div className="p-6 space-y-6">
{/* Telegram */}
<div className="space-y-3">
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={telegramEnabled}
onChange={(e) => setTelegramEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-sky-500 dark:peer-checked:bg-sky-500 transition-colors after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full"></div>
</label>
<span className="font-medium text-sky-900 dark:text-sky-100">Telegram</span>
</div>
{telegramEnabled && (
<div className="ml-12">
<label className="block text-sm text-sky-800 dark:text-sky-200 mb-1">Chat ID</label>
<input
type="text"
value={telegramChatId}
onChange={(e) => 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"
/>
<p className="text-xs text-sky-600 dark:text-sky-400 mt-1">Telegram channel or group chat ID (negative number for groups)</p>
</div>
)}
</div>
<div className="p-6 space-y-4">
{channels.length === 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400">
No channels configured yet. Add one, then assign it to the rules that should reach it.
</p>
)}
<hr className="border-sky-200 dark:border-sky-700" />
{channels.map((channel) => (
<ChannelEditor
key={channel.id}
channel={channel}
onChange={(next) => 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 */}
<div className="space-y-3">
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={teamsEnabled}
onChange={(e) => setTeamsEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-sky-500 dark:peer-checked:bg-sky-500 transition-colors after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full"></div>
</label>
<span className="font-medium text-sky-900 dark:text-sky-100">Microsoft Teams</span>
<span className="text-xs px-2 py-0.5 rounded bg-sky-200 dark:bg-sky-800 text-sky-700 dark:text-sky-300">Workflow Webhook</span>
</div>
{teamsEnabled && (
<div className="ml-12">
<label className="block text-sm text-sky-800 dark:text-sky-200 mb-1">Webhook URL</label>
<input
type="url"
value={teamsWebhookUrl}
onChange={(e) => 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"
/>
<p className="text-xs text-sky-600 dark:text-sky-400 mt-1">Teams Workflow webhook URL (Adaptive Card format)</p>
</div>
)}
</div>
<hr className="border-sky-200 dark:border-sky-700" />
{/* Slack */}
<div className="space-y-3">
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={slackEnabled}
onChange={(e) => setSlackEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-sky-500 dark:peer-checked:bg-sky-500 transition-colors after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full"></div>
</label>
<span className="font-medium text-sky-900 dark:text-sky-100">Slack</span>
<span className="text-xs px-2 py-0.5 rounded bg-sky-200 dark:bg-sky-800 text-sky-700 dark:text-sky-300">Incoming Webhook</span>
</div>
{slackEnabled && (
<div className="ml-12">
<label className="block text-sm text-sky-800 dark:text-sky-200 mb-1">Webhook URL</label>
<input
type="url"
value={slackWebhookUrl}
onChange={(e) => 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"
/>
<p className="text-xs text-sky-600 dark:text-sky-400 mt-1">Slack Incoming Webhook URL</p>
</div>
)}
</div>
<button
type="button"
onClick={addChannel}
disabled={channels.length >= MAX_NOTIFICATION_CHANNELS}
className="inline-flex items-center text-sm font-medium text-sky-600 hover:text-sky-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add channel{channels.length >= MAX_NOTIFICATION_CHANNELS ? ` (max ${MAX_NOTIFICATION_CHANNELS})` : ""}
</button>
</div>
</div>
@@ -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([]);
});
});
@@ -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));
}
@@ -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}
/>
</>
);
@@ -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<string, string> = {};
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<HeaderRow[]>(() => 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 (
<div>
<span className="text-gray-700 font-medium text-sm">Custom Headers</span>
<p className="text-xs text-gray-500 mb-2">
Optional HTTP headers sent with every request — e.g. <code className="font-mono">Authorization</code> with an API key
for your ticketing system or SMTP gateway. Framing headers (Host, Content-Type, …) are ignored.
</p>
<div className="space-y-2">
{rows.map((row, i) => (
<div key={i} className="flex items-center gap-2">
<input
type="text"
value={row.key}
onChange={(e) => 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"
/>
<input
type="text"
value={row.value}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => commit(rows.filter((_, j) => j !== i))}
className="flex-shrink-0 p-2 text-gray-400 hover:text-red-600 transition-colors"
aria-label="Remove header"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
<button
type="button"
onClick={() => commit([...rows, { key: "", value: "" }])}
className="mt-2 inline-flex items-center text-sm font-medium text-sky-600 hover:text-sky-700 transition-colors"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add header
</button>
</div>
);
}
/** 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 (
<div className={`rounded-lg border ${channel.enabled ? "border-gray-200" : "border-gray-100 bg-gray-50"} p-4 space-y-4`}>
{/* Header row: name + enable + delete */}
<div className="flex items-center gap-3">
<input
type="text"
value={channel.name}
onChange={(e) => 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"
/>
<label className="flex items-center gap-2 text-sm text-gray-600 whitespace-nowrap">
Enabled
<button
type="button"
onClick={() => onChange({ ...channel, enabled: !channel.enabled })}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${channel.enabled ? "bg-sky-500" : "bg-gray-300"}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${channel.enabled ? "translate-x-6" : "translate-x-1"}`} />
</button>
</label>
<button
type="button"
onClick={onRemove}
className="flex-shrink-0 p-2 text-gray-400 hover:text-red-600 transition-colors"
aria-label="Remove channel"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
{/* Provider */}
<div className="flex items-center gap-2">
<select
value={channel.providerType}
onChange={(e) => {
const next = Number(e.target.value);
// Custom headers and the signing secret are generic-only; clear them when leaving the
// generic provider so a later switch back can't revive stale secrets and persist them
// to a new endpoint.
onChange({
...channel,
providerType: next,
customHeadersJson: next === GENERIC_PROVIDER ? channel.customHeadersJson : undefined,
signingSecret: next === GENERIC_PROVIDER ? channel.signingSecret : undefined,
});
}}
className="block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors"
>
{PROVIDERS.map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
))}
</select>
{selectedProvider.badge && (
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium whitespace-nowrap ${selectedProvider.badgeColor}`}>
{selectedProvider.badge}
</span>
)}
</div>
{channel.providerType === 1 && (
<div className="p-3 rounded-lg bg-amber-50 border border-amber-200">
<p className="text-sm text-amber-800">
<strong>Note:</strong> Office 365 Connectors are deprecated by Microsoft. Consider switching to <strong>Microsoft Teams (Workflow Webhook)</strong> for continued support.
</p>
</div>
)}
{/* URL */}
<div>
<label className="block">
<span className="text-gray-700 font-medium text-sm">Webhook URL</span>
<p className="text-xs text-gray-500 mb-1">
{channel.providerType === 2
? "Create a Workflow in Teams (Channel → Manage channel → Workflows → \"Post to a channel when a webhook request is received\") and paste the URL here."
: channel.providerType === 10
? "Create an Incoming Webhook in Slack (Apps → Incoming Webhooks → Add to channel) and paste the URL here."
: channel.providerType === GENERIC_PROVIDER
? "Any HTTPS endpoint that accepts a JSON POST. Receives a stable payload (schemaVersion + eventType, e.g. \"enrollment_succeeded\") for ticketing, automation, or an SMTP gateway like Postal."
: channel.providerType === 30
? "Create a webhook in your Discord channel (Channel settings → Integrations → Webhooks) and paste the URL here."
: "Create an Incoming Webhook in your Teams channel (Channel → Connectors → Incoming Webhook) and paste the URL here."}
</p>
<div className="flex items-center gap-2">
<input
type="url"
value={channel.url ?? ""}
onChange={(e) => onChange({ ...channel, url: e.target.value })}
placeholder={selectedProvider.placeholder}
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors font-mono text-sm"
/>
{isActive && (
<span className="mt-1 inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 whitespace-nowrap">
Active
</span>
)}
</div>
</label>
</div>
{/* Custom Headers (generic provider only) */}
{channel.providerType === GENERIC_PROVIDER && (
<WebhookHeaderEditor
value={channel.customHeadersJson ?? ""}
onChange={(v) => onChange({ ...channel, customHeadersJson: v || undefined })}
/>
)}
{/* Signing secret (generic provider only) */}
{channel.providerType === GENERIC_PROVIDER && (
<div>
<label className="block">
<span className="text-gray-700 font-medium text-sm">Signing Secret (optional)</span>
<p className="text-xs text-gray-500 mb-1">
When set, every request carries <code className="font-mono">X-AutopilotMonitor-Signature</code> (HMAC-SHA256)
so your endpoint can verify it came from Autopilot Monitor — see the docs for the verification scheme.
</p>
<div className="flex items-center gap-2">
<input
type="password"
value={channel.signingSecret ?? ""}
onChange={(e) => onChange({ ...channel, signingSecret: e.target.value || undefined })}
placeholder="16-128 characters"
autoComplete="off"
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors font-mono text-sm"
/>
<button
type="button"
onClick={() => {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
const secret = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
onChange({ ...channel, signingSecret: secret });
}}
className="mt-1 flex-shrink-0 px-3 py-2 text-sm font-medium text-sky-600 hover:text-sky-700 border border-sky-200 hover:border-sky-300 rounded-lg transition-colors"
>
Generate
</button>
</div>
</label>
</div>
)}
{/* Event subscriptions */}
<div>
<span className="text-gray-700 font-medium text-sm">Send to this channel</span>
<p className="text-xs text-gray-500 mb-2">
A channel with everything off can still be targeted by Analyze Rules (rule-level notifications).
</p>
<div className="flex flex-wrap gap-2">
{EVENT_TOGGLES.map(({ key, label, hint }) => {
const on = Boolean(channel[key]);
return (
<button
key={key}
type="button"
title={hint}
onClick={() => onChange({ ...channel, [key]: !on })}
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ${
on
? "bg-sky-100 text-sky-800 border-sky-300"
: "bg-white text-gray-500 border-gray-300 hover:border-sky-300"
}`}
>
<span className={`mr-1.5 inline-block h-2 w-2 rounded-full ${on ? "bg-sky-500" : "bg-gray-300"}`} />
{label}
</button>
);
})}
</div>
</div>
{/* Test */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={onTest}
disabled={!isActive || testing}
className="inline-flex items-center px-3 py-1.5 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{testing ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Sending...
</>
) : (
<>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
Send Test
</>
)}
</button>
<span className="text-xs text-gray-400">Tests the last saved configuration of this channel.</span>
{testResult && (
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${testResult.success ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"}`}>
{testResult.message}
</span>
)}
</div>
</div>
);
}
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}
/>
))}
@@ -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 (
<>
<TenantNotifications />
@@ -26,6 +33,7 @@ export function SectionNotifications() {
onReset={handleResetNotifications}
saving={savingSection === "notifications"}
readOnly={!canEditConfig}
showTelegramProvider={showTelegramProvider}
/>
</>
);
@@ -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<string, string> = {};
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<HeaderRow[]>(() => 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 (
<div>
<span className="text-gray-700 font-medium text-sm">Custom Headers</span>
<p className="text-xs text-gray-500 mb-2">
Optional HTTP headers sent with every request — e.g. <code className="font-mono">Authorization</code> with an API key
for your ticketing system or SMTP gateway. Framing headers (Host, Content-Type, …) are ignored.
</p>
<div className="space-y-2">
{rows.map((row, i) => (
<div key={i} className="flex items-center gap-2">
<input
type="text"
value={row.key}
onChange={(e) => 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"
/>
<input
type="text"
value={row.value}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => commit(rows.filter((_, j) => j !== i))}
className="flex-shrink-0 p-2 text-gray-400 hover:text-red-600 transition-colors"
aria-label="Remove header"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
<button
type="button"
onClick={() => commit([...rows, { key: "", value: "" }])}
className="mt-2 inline-flex items-center text-sm font-medium text-sky-600 hover:text-sky-700 transition-colors"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add header
</button>
</div>
);
}
/** 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 (
<div className={`rounded-lg border ${channel.enabled ? "border-gray-200" : "border-gray-100 bg-gray-50"} p-4 space-y-4`}>
{/* Header row: name + enable + delete */}
<div className="flex items-center gap-3">
<input
type="text"
value={channel.name}
onChange={(e) => 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"
/>
<label className="flex items-center gap-2 text-sm text-gray-600 whitespace-nowrap">
Enabled
<button
type="button"
onClick={() => onChange({ ...channel, enabled: !channel.enabled })}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${channel.enabled ? "bg-sky-500" : "bg-gray-300"}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${channel.enabled ? "translate-x-6" : "translate-x-1"}`} />
</button>
</label>
<button
type="button"
onClick={onRemove}
className="flex-shrink-0 p-2 text-gray-400 hover:text-red-600 transition-colors"
aria-label="Remove channel"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
{/* Provider */}
<div className="flex items-center gap-2">
<select
value={channel.providerType}
onChange={(e) => {
const next = Number(e.target.value);
// Custom headers and the signing secret are generic-only; clear them when leaving the
// generic provider so a later switch back can't revive stale secrets and persist them
// to a new endpoint.
onChange({
...channel,
providerType: next,
customHeadersJson: next === GENERIC_PROVIDER ? channel.customHeadersJson : undefined,
signingSecret: next === GENERIC_PROVIDER ? channel.signingSecret : undefined,
});
}}
className="block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors"
>
{providerOptions.map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
))}
</select>
{selectedProvider.badge && (
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium whitespace-nowrap ${selectedProvider.badgeColor}`}>
{selectedProvider.badge}
</span>
)}
</div>
{channel.providerType === 1 && (
<div className="p-3 rounded-lg bg-amber-50 border border-amber-200">
<p className="text-sm text-amber-800">
<strong>Note:</strong> Office 365 Connectors are deprecated by Microsoft. Consider switching to <strong>Microsoft Teams (Workflow Webhook)</strong> for continued support.
</p>
</div>
)}
{/* URL */}
<div>
<label className="block">
<span className="text-gray-700 font-medium text-sm">{isTelegram ? "Chat ID" : "Webhook URL"}</span>
<p className="text-xs text-gray-500 mb-1">
{channel.providerType === 2
? "Create a Workflow in Teams (Channel → Manage channel → Workflows → \"Post to a channel when a webhook request is received\") and paste the URL here."
: channel.providerType === 10
? "Create an Incoming Webhook in Slack (Apps → Incoming Webhooks → Add to channel) and paste the URL here."
: channel.providerType === GENERIC_PROVIDER
? "Any HTTPS endpoint that accepts a JSON POST. Receives a stable payload (schemaVersion + eventType, e.g. \"enrollment_succeeded\") for ticketing, automation, or an SMTP gateway like Postal."
: channel.providerType === 30
? "Create a webhook in your Discord channel (Channel settings → Integrations → Webhooks) and paste the URL here."
: isTelegram
? "Telegram chat or channel ID (negative number for groups), or an @username. Messages are delivered by the platform bot — add it to the chat first."
: "Create an Incoming Webhook in your Teams channel (Channel → Connectors → Incoming Webhook) and paste the URL here."}
</p>
<div className="flex items-center gap-2">
<input
type={isTelegram ? "text" : "url"}
value={channel.url ?? ""}
onChange={(e) => onChange({ ...channel, url: e.target.value })}
placeholder={selectedProvider.placeholder}
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors font-mono text-sm"
/>
{isActive && (
<span className="mt-1 inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 whitespace-nowrap">
Active
</span>
)}
</div>
</label>
</div>
{/* Custom Headers (generic provider only) */}
{channel.providerType === GENERIC_PROVIDER && (
<WebhookHeaderEditor
value={channel.customHeadersJson ?? ""}
onChange={(v) => onChange({ ...channel, customHeadersJson: v || undefined })}
/>
)}
{/* Signing secret (generic provider only) */}
{channel.providerType === GENERIC_PROVIDER && (
<div>
<label className="block">
<span className="text-gray-700 font-medium text-sm">Signing Secret (optional)</span>
<p className="text-xs text-gray-500 mb-1">
When set, every request carries <code className="font-mono">X-AutopilotMonitor-Signature</code> (HMAC-SHA256)
so your endpoint can verify it came from Autopilot Monitor — see the docs for the verification scheme.
</p>
<div className="flex items-center gap-2">
<input
type="password"
value={channel.signingSecret ?? ""}
onChange={(e) => onChange({ ...channel, signingSecret: e.target.value || undefined })}
placeholder="16-128 characters"
autoComplete="off"
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors font-mono text-sm"
/>
<button
type="button"
onClick={() => {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
const secret = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
onChange({ ...channel, signingSecret: secret });
}}
className="mt-1 flex-shrink-0 px-3 py-2 text-sm font-medium text-sky-600 hover:text-sky-700 border border-sky-200 hover:border-sky-300 rounded-lg transition-colors"
>
Generate
</button>
</div>
</label>
</div>
)}
{/* Event subscriptions — tenant channels only; ops channels are targeted by alert rules. */}
{showEventToggles && (
<div>
<span className="text-gray-700 font-medium text-sm">Send to this channel</span>
<p className="text-xs text-gray-500 mb-2">
A channel with everything off can still be targeted by Analyze Rules (rule-level notifications).
</p>
<div className="flex flex-wrap gap-2">
{EVENT_TOGGLES.map(({ key, label, hint }) => {
const on = Boolean(channel[key]);
return (
<button
key={key}
type="button"
title={hint}
onClick={() => onChange({ ...channel, [key]: !on })}
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ${
on
? "bg-sky-100 text-sky-800 border-sky-300"
: "bg-white text-gray-500 border-gray-300 hover:border-sky-300"
}`}
>
<span className={`mr-1.5 inline-block h-2 w-2 rounded-full ${on ? "bg-sky-500" : "bg-gray-300"}`} />
{label}
</button>
);
})}
</div>
</div>
)}
{/* Test */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={onTest}
disabled={!isActive || testing}
className="inline-flex items-center px-3 py-1.5 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{testing ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Sending...
</>
) : (
<>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
Send Test
</>
)}
</button>
<span className="text-xs text-gray-400">Tests the last saved configuration of this channel.</span>
{testResult && (
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${testResult.success ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"}`}>
{testResult.message}
</span>
)}
</div>
</div>
);
}
+2
View File
@@ -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).
@@ -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 };
@@ -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;
}
@@ -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",
@@ -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",
@@ -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. */