mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
committed by
GitHub
parent
eedca11214
commit
d763a885e2
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
+1
-7
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+1
-7
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-7
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+1
-5
@@ -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?>()))
|
||||
|
||||
+1
-7
@@ -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,
|
||||
|
||||
+2
@@ -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
-21
@@ -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" < "Error" < "Info" <
|
||||
/// "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;
|
||||
}
|
||||
}
|
||||
+20
-3
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-6
@@ -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
|
||||
|
||||
+20
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
+4
-4
@@ -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)",
|
||||
|
||||
+3
-3
@@ -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;
|
||||
|
||||
+27
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+82
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-25
@@ -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 < Warning < Error < 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>
|
||||
|
||||
|
||||
+53
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user