diff --git a/CHANGELOG.md b/CHANGELOG.md index df0c4f5953..32d06d6f25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Lite: connection-lost / connection-restored alerts, and a generic webhook channel for both apps** ([#1506]) — Lite was the only one of the three apps that could not tell you your server had gone down unless you happened to be watching the tray: the Dashboard emails on the connection edge and Darling's `DarlingSelfAlertEvaluator` emails *and* webhooks it, but Lite's edge fired a tray balloon and nothing else. Both edges in `MainWindow.CheckConnectionsAndNotify` now also route through Lite's alert path — email, webhook, and a `config_alert_log` row — as **"Server Unreachable"** / **"Server Restored"**, the metric names the shared `AlertSeverity` map and `AlertMetricClassifier` already understood (CRITICAL-red, and the restore classified as a green resolution), so no new naming was invented. The tray balloon is unchanged; this is purely additive. Delivery stays **edge-triggered** — the transition logic moved out of the inline WPF condition into a pure, unit-tested `ConnectionEdgeDetector`, so an eight-hour outage polled every minute produces ONE "Server Unreachable", not 480 — and it respects mute rules and per-server silencing exactly as Lite's other alerts do. Gated on the existing `App.NotifyConnectionChanges` preference (default on, already in Settings), which is also the name Darling uses; no second preference was added. +- Alongside it, a third webhook channel in the shared `WebhookAlertService` — **generic HTTP POST** — beside Teams and Slack, for **Lite and Darling**. It POSTs an operator-authored JSON body with `{{metric}}`, `{{server}}`, `{{value}}`, `{{threshold}}`, `{{severity}}`, `{{context}}` and `{{timestamp}}` placeholders and an arbitrary JSON headers object, which covers PagerDuty / Opsgenie / n8n and — the motivating use case — a GitHub `repository_dispatch` that re-runs a workflow when an alert fires. **This is deliberately not a script/exe runner:** arbitrary command execution in a signed binary is an EDR-flagged attack path, and a webhook reaches the same automation with no process-execution surface. Every substituted placeholder is **JSON-escaped** (via `JsonSerializer`, which — unlike `JsonEncodedText.Encode` — tolerates the lone surrogates SQL Server names can carry instead of throwing), so a server name carrying a quote or backslash — `HOST\INSTANCE`, or something hostile — cannot break out of the template and corrupt or inject into the posted JSON; the substituted body is validated as JSON before it goes on the wire, malformed headers JSON degrades to a clear logged error instead of throwing into the alert loop, and a `User-Agent` is defaulted (GitHub's API 403s requests without one). Settings live beside Teams/Slack in both apps' Settings windows with a "Send Test" button and a `repository_dispatch` example; the URL and the headers JSON are treated as **secrets** — Windows Credential Manager in Lite, and in Darling's Postgres control plane (schema **V26**) `generic_url` + `generic_headers` are column-`REVOKE`d from the read-only viewer role like the existing webhook URLs, since the headers carry the `Authorization` bearer token. The deprecated Dashboard satisfies the shared settings interface with the channel off and gains no new UI. + - **Lite viewer: System Events tab (Tier-1 parity BUILD — drains the coverage pin to ZERO)** ([#1509]) — the Lite port of the Darling viewer's **System Events** tab, the LAST unbuilt-UI pair on the parity board. Lite already collected `system_health_events` (raw `system_health` XE) and `default_trace_events` (built-in Default Trace) but had **no viewer surface** (the coverage pin from #1499 allow-listed both); this adds one, so `CollectorViewerCoverageTests.KnownStoreOnlyOrUnbuiltTables` is now **empty** — every collector table has a Lite reader and the ratchet is fully drained. New top-level tab (index 18, the new LAST tab, after Collection Health — nothing renumbered, so switch `case 18` still equals its 0-based XAML index) with 11 inner sub-tabs in Darling's order. The two lead sub-tabs — **Corruption Events** + **Contention Events** — are the SYSTEM-component counter CHARTS (bad pages / dump requests / access violations / write-AV; non-yielding tasks / latch warnings / sick spinlocks by type / SQL-vs-system CPU), all eight fed by ONE `sp_server_diagnostics` SYSTEM read with **no** significance filter (every snapshot plotted over time), in `ServerTab.SystemHealthCharts.cs` using Lite's chart idioms (shared `ChartStyle`/`ChartHoverHelper`, `SeriesColors`/`ChartPalette`, `UtcOffsetMinutes` display conversion, Y-floor-at-0). The remaining nine are parse-on-read GRIDS — Scheduler Issues, Severe Errors, Memory Conditions, Memory Broker, Memory Node OOM, Significant Waits, CPU Tasks, I/O Issues, Default Trace — with **every Darling grid column** reproduced (headers, bindings, formats). The reader (`LocalDataService.SystemEvents.cs`, mirroring Darling's `ViewerDataService.SystemEvents` reads adapted to DuckDB) fetches the raw `event_xml` for each category over the window from the `v_system_health_events` archive view, shreds it with the Common `SystemHealthParser`, and keeps the significant rows by calling the shared `PerformanceMonitor.Common.SystemHealthSignificance` **directly** (zero duplication — Lite and Darling share the one sp_HealthParser predicate source hoisted in #1507). The shred runs off the UI thread (the loaders wrap the reads in `Task.Run`, mirroring the deadlock-graph parse). Severe Errors resolves each event's `database_id` to a name from the latest `v_database_size_stats` snapshot (DuckDB `QUALIFY ROW_NUMBER() OVER (PARTITION BY database_id ORDER BY collection_time DESC)`, the equivalent of Darling's Postgres `DISTINCT ON`) and honors the #1319 database filter client-side on the resolved name; Default Trace honors it in SQL on `database_name`. The **Default Trace lane** reads `v_default_trace_events`, gates via the shared `DefaultTraceEventSignificance`, and — because the trace `StartTime` is the monitored server's LOCAL wall clock (not UTC like the XE `@timestamp`) — windows on the server-local bounds (`GetTimeRangeServerLocal`) and de-skews each row's `event_time` to naive-UTC (subtracting the connected server's `UtcOffsetMinutes`) so it shares the system_health rows' UTC frame and renders/sorts consistently through `ServerTimeHelper.FormatServerTime`. Wired into the TabControl + `RefreshVisibleTabAsync` switch + `MainTabControl_SelectionChanged` sub-tab-reload path + `ServerTab.xaml.cs` init/dispose, with a `DataGridFilterManager` on each of the nine grids. Real-DuckDB round-trip reader tests (`SystemEventsReaderTests`) plant fixture rows into the store and assert each category's significant set, the column projection, the DB-name resolution + #1319 filter, the event_time window bounding, and the Default Trace de-skew. - **Lite viewer: CPU Scheduler, Plan Cache & Session Stats tabs (Tier-1 parity BUILD, batch of 3)** ([#1505]) — the Lite ports of three more Darling viewer per-server tabs whose data Lite already collected but never surfaced (the coverage pin from #1499 allow-listed `cpu_scheduler_stats` / `plan_cache_stats` / `session_summary_stats` as unbuilt UI). Placed together in the diagnostics tail right after Latches & Spinlocks — **CPU Scheduler** (index 14), **Plan Cache** (15), **Session Stats** (16), shifting Collection Health 14→17 with the `RefreshVisibleTabAsync` switch re-aligned so every top-level tab's 0-based index still equals its case. All three are **point-in-time snapshot** collectors (one aggregate row per collection, not cumulative-counter deltas), so the readers (`LocalDataService.CpuScheduler.cs` / `.PlanCache.cs` / `.SessionStats.cs`, mirroring Darling's `ViewerDataService` reads adapted to DuckDB against the `v_cpu_scheduler_stats` / `v_plan_cache_stats` / `v_session_summary_stats` archive views) plot the counts directly — no `LAG`/delta math. **CPU Scheduler**: a runnable / blocked / queued task-count trend over the window plus a latest-snapshot metric/value grid (schedulers, workers, NUMA, OS-memory pressure) whose warning rows highlight from the collector's own CASE-computed pressure flags, with the rolled-up pressure level + recommendation headline rows (`ClassifyCpuPressure`, install/47 `report.cpu_scheduler_pressure` parity). **Plan Cache**: a single-use vs multi-use plan-cache size (MB) trend (the single-use bloat signal, on the shared `SinglePagePlans`/`MultiPagePlans` palette keys), a summary strip with the TRUE total plans (uncapped SUM, independent of the top-30 grid), oldest-plan age, and a derived single-use bloat badge + forced-parameterization hint (`ClassifyPlanCacheBloat`, `report.plan_cache_bloat` parity), plus a filterable latest-snapshot composition grid per (cacheobjtype, objtype). **Session Stats**: a server-wide session-status trend (Total / Running / Sleeping / Background / Dormant / Idle >30m / Waiting for Memory, each on its fixed `Session*` palette key, a series only drawn when non-zero in the window so all-zero states stay out of the legend) plus a summary strip for the non-chartable attribution (top application / top host by connections + distinct databases). All three tabs mirror Darling's rendering with Lite's chart idioms (shared `ChartStyle`/`ChartHoverHelper`, `SeriesColors`/`ChartPalette`, Y-floor-at-0, `UtcOffsetMinutes` display conversion, `SetLimitsX` window-pinning), wired into the TabControl + switch + `ServerTab.xaml.cs` init/dispose, with the Plan Cache composition grid on a `DataGridFilterManager`. Removes the three tables from the coverage-pin allow-list (`CollectorViewerCoverageTests.KnownStoreOnlyOrUnbuiltTables`, now **2** — only `system_health_events` / `default_trace_events` remain), so the ratchet now REQUIRES their readers. - **Lite viewer: Latches & Spinlocks tab (Tier-1 parity BUILD)** ([#1504]) — the Lite port of the Darling viewer's consolidated **Latches & Spinlocks** per-server tab. Lite already collected `latch_stats` (`sys.dm_os_latch_stats`) and `spinlock_stats` (`sys.dm_os_spinlock_stats`) but had **no viewer surface** (the coverage pin from #1499 allow-listed them as unbuilt UI); this adds one, closing a parity-board Tier-1 gap. New top-level tab (index 13, between Daily Summary and Collection Health, mirroring Darling's diagnostics-tail placement): the latch **wait-time trend** (ms/sec) and spinlock **collisions trend** (collisions/sec) for the TOP 5 contenders stack vertically, each above a collapsed Expander holding its per-class latest-snapshot grid. The reader (`LocalDataService.LatchSpinlock.cs`) mirrors the Darling Postgres reads adapted to DuckDB against the `v_latch_stats` / `v_spinlock_stats` archive views — per-second rates computed in SQL from the per-contender `LAG` interval, the same `date_trunc`/`extract(epoch)`/`LAG` idiom the Wait Stats trend uses (the delta tables carry no stored `sample_interval_seconds`). The tab (`ServerTab.LatchSpinlock.cs`) mirrors Darling's rendering with Lite's chart idioms (shared `ChartStyle`/`ChartHoverHelper`, cycling `SeriesColors`, Y-floor-at-0, `UtcOffsetMinutes` display conversion, zero-line-when-empty), wired into the TabControl + `RefreshVisibleTabAsync` switch + `DataGridFilterManager`s for both grids. Removes `latch_stats` / `spinlock_stats` from the coverage-pin allow-list (`CollectorViewerCoverageTests.KnownStoreOnlyOrUnbuiltTables`, now 5), so the ratchet REQUIRES the reader. diff --git a/Darling/Darling.Tests/DarlingAnalysisPipelineTests.cs b/Darling/Darling.Tests/DarlingAnalysisPipelineTests.cs index c6d2191589..2ccec6e6d0 100644 --- a/Darling/Darling.Tests/DarlingAnalysisPipelineTests.cs +++ b/Darling/Darling.Tests/DarlingAnalysisPipelineTests.cs @@ -516,6 +516,11 @@ private sealed class TestAlertSettings : IAlertSettings public bool SlackWebhookEnabled => false; public string SlackWebhookUrl => ""; public string SlackProxyAddress => ""; + public bool GenericWebhookEnabled => false; + public string GenericWebhookUrl => ""; + public string GenericWebhookHeadersJson => ""; + public string GenericWebhookBodyTemplate => ""; + public string GenericWebhookProxyAddress => ""; public double AnalysisNotifySeverity { get; init; } = 1.5; public int AnalysisNotifyCooldownMinutes { get; init; } = 360; } diff --git a/Darling/Darling.Tests/DarlingManagedRolesTests.cs b/Darling/Darling.Tests/DarlingManagedRolesTests.cs index d20e9b4280..b7902a15ff 100644 --- a/Darling/Darling.Tests/DarlingManagedRolesTests.cs +++ b/Darling/Darling.Tests/DarlingManagedRolesTests.cs @@ -129,7 +129,13 @@ public void ViewerRestrictedConfigTables_SecretAndNonSecretColumns_AreDisjointAn { ["config_monitored_servers"] = new[] { "encrypted_password" }, ["config_command"] = new[] { "args_json" }, - ["config_notification"] = new[] { "smtp_encrypted_password", "smtp_username", "teams_url", "slack_url" }, + /* generic_url is a bearer secret like the sibling webhook URLs, and generic_headers holds the + Authorization token itself (#1506 / V26) — both must stay column-REVOKEd from the viewer role. */ + ["config_notification"] = new[] + { + "smtp_encrypted_password", "smtp_username", "teams_url", "slack_url", + "generic_url", "generic_headers", + }, }; /* Exactly the three known secret-bearing tables, with the expected secret columns. */ diff --git a/Darling/Darling.Tests/DarlingObservabilityTests.cs b/Darling/Darling.Tests/DarlingObservabilityTests.cs index 66a4faf87d..a72edacd1e 100644 --- a/Darling/Darling.Tests/DarlingObservabilityTests.cs +++ b/Darling/Darling.Tests/DarlingObservabilityTests.cs @@ -32,9 +32,9 @@ public sealed class DarlingObservabilityTests private const int TestServerId = -424242; [Fact] - public void MigrationScripts_TwentyFiveVersions_V23CollectionLogHypertable_V24JobHistory_V25AgentStatus() + public void MigrationScripts_TwentySixVersions_V24JobHistory_V25AgentStatus_V26GenericWebhook() { - Assert.Equal(25, PgMigrations.Scripts.Count); + Assert.Equal(26, PgMigrations.Scripts.Count); Assert.Equal(1, PgMigrations.Scripts[0].Version); Assert.Equal(2, PgMigrations.Scripts[1].Version); Assert.Equal(3, PgMigrations.Scripts[2].Version); @@ -60,7 +60,16 @@ public void MigrationScripts_TwentyFiveVersions_V23CollectionLogHypertable_V24Jo Assert.Equal(23, PgMigrations.Scripts[22].Version); Assert.Equal(24, PgMigrations.Scripts[23].Version); Assert.Equal(25, PgMigrations.Scripts[24].Version); - Assert.Equal(25, StorageVersion.SchemaVersion); + Assert.Equal(26, PgMigrations.Scripts[25].Version); + Assert.Equal(26, StorageVersion.SchemaVersion); + + /* V26 (#1506) adds the generic webhook channel's four columns to the V17 control-plane table. + Schema-qualified config.* and IF NOT EXISTS, per the file's additive-ALTER idiom. */ + var v26 = PgMigrations.Scripts[25].Sql; + Assert.Contains("ALTER TABLE config.config_notification ADD COLUMN IF NOT EXISTS generic_url", v26, StringComparison.Ordinal); + Assert.Contains("ALTER TABLE config.config_notification ADD COLUMN IF NOT EXISTS generic_headers", v26, StringComparison.Ordinal); + Assert.Contains("ALTER TABLE config.config_notification ADD COLUMN IF NOT EXISTS generic_body_template", v26, StringComparison.Ordinal); + Assert.Contains("ALTER TABLE config.config_notification ADD COLUMN IF NOT EXISTS generic_proxy", v26, StringComparison.Ordinal); /* V5 completes the v_* twin of Lite's DuckDB view layer -- the copy-parity tail tabs (Running Jobs, Configuration, Daily Summary, Collection Health) read these five, so diff --git a/Darling/Darling.Tests/ViewerControlPlaneStage3bTests.cs b/Darling/Darling.Tests/ViewerControlPlaneStage3bTests.cs index 9950484934..5ab03a9927 100644 --- a/Darling/Darling.Tests/ViewerControlPlaneStage3bTests.cs +++ b/Darling/Darling.Tests/ViewerControlPlaneStage3bTests.cs @@ -109,6 +109,7 @@ public sealed class ViewerNotificationSqlTests { "smtp_host", "smtp_port", "smtp_use_ssl", "smtp_username", "smtp_encrypted_password", "smtp_from_address", "smtp_recipients", "email_cooldown_minutes", "teams_url", "teams_proxy", "slack_url", "slack_proxy", + "generic_url", "generic_headers", "generic_body_template", "generic_proxy", }; [Fact] @@ -122,7 +123,7 @@ public void UpsertSql_WritesEveryColumn_AsBoundParameters_SingleRowOnConflictUpd Assert.Contains(column, sql, StringComparison.Ordinal); } - for (var i = 1; i <= 12; i++) + for (var i = 1; i <= 16; i++) { Assert.Contains("$" + i.ToString(System.Globalization.CultureInfo.InvariantCulture), sql, StringComparison.Ordinal); } diff --git a/Darling/Darling.Tests/ViewerDataServiceTests.cs b/Darling/Darling.Tests/ViewerDataServiceTests.cs index 3c59728cce..332c3b36db 100644 --- a/Darling/Darling.Tests/ViewerDataServiceTests.cs +++ b/Darling/Darling.Tests/ViewerDataServiceTests.cs @@ -383,24 +383,28 @@ public void StoreSchemaProbeSql_ProbesInformationSchema_ForTheV17ToV25Sentinels( /* V24/V25 (#1433): job_history + agent_status table-existence sentinels (engine-agnostic). */ Assert.Contains("job_history", sql, StringComparison.Ordinal); Assert.Contains("agent_status", sql, StringComparison.Ordinal); + + /* V26 (#1506): the generic webhook's generic_url column on config_notification. */ + Assert.Contains("generic_url", sql, StringComparison.Ordinal); } [Theory] - [InlineData(true, true, true, true, true, true, true, true, true, 25)] // fully migrated V25 (job_history + agent_status, #1433) - [InlineData(true, true, true, true, true, true, true, true, false, 24)] // V24: job_history present, agent_status not yet - [InlineData(true, true, true, true, true, true, true, false, false, 23)] // V23 (collection_log hypertable, or plain-PG at V23) - [InlineData(true, true, true, true, true, true, false, false, false, 22)] // Timescale store still at V22 (index present, collection_log not yet a hypertable) - [InlineData(true, true, true, true, true, false, false, false, false, 21)] // pre-V22: has default_trace_events, no V22 index - [InlineData(true, true, true, true, false, false, false, false, false, 20)] // pre-V21: no default_trace_events - [InlineData(true, true, true, false, false, false, false, false, false, 19)]// pre-V20: no alert-tuning knobs - [InlineData(true, true, false, false, false, false, false, false, false, 18)]// pre-V19: no analysis_state - [InlineData(true, false, false, false, false, false, false, false, false, 17)]// pre-V18: no delivery-override column - [InlineData(false, false, false, false, false, false, false, false, false, 16)]// pre-V17: no config control plane at all + [InlineData(true, true, true, true, true, true, true, true, true, true, 26)] // fully migrated V26 (generic webhook, #1506) + [InlineData(true, true, true, true, true, true, true, true, true, false, 25)] // V25: agent_status present, generic webhook not yet + [InlineData(true, true, true, true, true, true, true, true, false, false, 24)] // V24: job_history present, agent_status not yet + [InlineData(true, true, true, true, true, true, true, false, false, false, 23)] // V23 (collection_log hypertable, or plain-PG at V23) + [InlineData(true, true, true, true, true, true, false, false, false, false, 22)] // Timescale store still at V22 (index present, collection_log not yet a hypertable) + [InlineData(true, true, true, true, true, false, false, false, false, false, 21)] // pre-V22: has default_trace_events, no V22 index + [InlineData(true, true, true, true, false, false, false, false, false, false, 20)] // pre-V21: no default_trace_events + [InlineData(true, true, true, false, false, false, false, false, false, false, 19)]// pre-V20: no alert-tuning knobs + [InlineData(true, true, false, false, false, false, false, false, false, false, 18)]// pre-V19: no analysis_state + [InlineData(true, false, false, false, false, false, false, false, false, false, 17)]// pre-V18: no delivery-override column + [InlineData(false, false, false, false, false, false, false, false, false, false, 16)]// pre-V17: no config control plane at all public void MapProbedSchemaVersion_TakesTheHighestSatisfiedSentinel( - bool hasConfigControlPlane, bool hasAlertDeliveryOverride, bool hasAnalysisState, bool hasAlertTuningKnobs, bool hasDefaultTraceEvents, bool hasIndexObjectStatsLatestIndex, bool hasCollectionLogHypertableOrPlainPg, bool hasJobHistory, bool hasAgentStatus, int expected) + bool hasConfigControlPlane, bool hasAlertDeliveryOverride, bool hasAnalysisState, bool hasAlertTuningKnobs, bool hasDefaultTraceEvents, bool hasIndexObjectStatsLatestIndex, bool hasCollectionLogHypertableOrPlainPg, bool hasJobHistory, bool hasAgentStatus, bool hasGenericWebhook, int expected) { Assert.Equal(expected, ViewerDataService.MapProbedSchemaVersion( - hasConfigControlPlane, hasAlertDeliveryOverride, hasAnalysisState, hasAlertTuningKnobs, hasDefaultTraceEvents, hasIndexObjectStatsLatestIndex, hasCollectionLogHypertableOrPlainPg, hasJobHistory, hasAgentStatus)); + hasConfigControlPlane, hasAlertDeliveryOverride, hasAnalysisState, hasAlertTuningKnobs, hasDefaultTraceEvents, hasIndexObjectStatsLatestIndex, hasCollectionLogHypertableOrPlainPg, hasJobHistory, hasAgentStatus, hasGenericWebhook)); } [Fact] @@ -410,7 +414,7 @@ public void MapProbedSchemaVersion_V23CompositeIsGatedBehindV22_NotAStandaloneTo but must NOT be reported as 23 — the composite only counts once the engine-agnostic V22 index is present. Here: pre-V22 (no index) with the composite true still maps to 21 (its real V21 sentinel), proving the composite is gated behind V22 rather than treated as a newest-first arm. */ - Assert.Equal(21, ViewerDataService.MapProbedSchemaVersion(true, true, true, true, true, false, true, false, false)); + Assert.Equal(21, ViewerDataService.MapProbedSchemaVersion(true, true, true, true, true, false, true, false, false, false)); } [Fact] @@ -420,11 +424,11 @@ public void RequiredStoreSchemaVersion_TracksTheBuildSchemaVersion_AndTheProbeCo Assert.Equal(StorageVersion.SchemaVersion, ViewerDataService.RequiredStoreSchemaVersion); /* Pin: a fully-migrated store (all sentinels present) must map to exactly the required version. If a - future migration bumps StorageVersion past 23, this fails until a matching sentinel + map arm is + future migration bumps StorageVersion past 26, this fails until a matching sentinel + map arm is added — the guard against the probe silently under-reporting a newer store as skewed. */ Assert.Equal( ViewerDataService.RequiredStoreSchemaVersion, - ViewerDataService.MapProbedSchemaVersion(true, true, true, true, true, true, true, true, true)); + ViewerDataService.MapProbedSchemaVersion(true, true, true, true, true, true, true, true, true, true)); } } diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingAlertSettings.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingAlertSettings.cs index 50fa0803a1..04b9d5be92 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingAlertSettings.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingAlertSettings.cs @@ -143,6 +143,14 @@ unchanged from the previously-hardcoded behavior. */ public string SlackWebhookUrl => _config.Webhooks.SlackUrl; public string SlackProxyAddress => _config.Webhooks.SlackProxy; + /* Generic webhook (#1506) — enabled by a non-empty URL, the same no-speculative-enable-flag derivation + the sibling channels use. */ + public bool GenericWebhookEnabled => !string.IsNullOrWhiteSpace(_config.Webhooks.GenericUrl); + public string GenericWebhookUrl => _config.Webhooks.GenericUrl; + public string GenericWebhookHeadersJson => _config.Webhooks.GenericHeaders; + public string GenericWebhookBodyTemplate => _config.Webhooks.GenericBodyTemplate; + public string GenericWebhookProxyAddress => _config.Webhooks.GenericProxy; + /* Scheduled-analysis notifications (AN3): the shared AnalysisNotificationService's severity floor + per-finding re-notify cooldown. The severity floor is now a control-plane knob (config Stage 1) read through the by-reference config seam — a store reload reflects it immediately; clamped diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs index f144ec3277..9eba4245a8 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs @@ -713,6 +713,28 @@ public sealed class WebhooksConfig [JsonPropertyName("slackProxy")] public string SlackProxy { get; set; } = ""; + + /* Generic webhook (#1506): POSTs an operator-authored JSON body to any endpoint, so an alert can drive + automation we ship no adapter for (PagerDuty, Opsgenie, n8n, a GitHub repository_dispatch that re-runs + a workflow). Enabled by a non-empty URL, like the sibling channels. */ + + [JsonPropertyName("genericUrl")] + public string GenericUrl { get; set; } = ""; + + /// + /// A JSON object of request headers, e.g. {"Authorization":"Bearer ghp_..."}. A bearer secret — + /// carved out of the read-only viewer role's column grants alongside generic_url + /// (DarlingManagedRoles.ViewerRestrictedConfigTables). + /// + [JsonPropertyName("genericHeaders")] + public string GenericHeaders { get; set; } = ""; + + /// The JSON body, with {{metric}}/{{server}}/… placeholders; empty uses the shared default. + [JsonPropertyName("genericBodyTemplate")] + public string GenericBodyTemplate { get; set; } = ""; + + [JsonPropertyName("genericProxy")] + public string GenericProxy { get; set; } = ""; } public sealed class MonitoredServer diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs index 10b90dc818..87d1dd12e2 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs @@ -121,8 +121,15 @@ public static class DarlingManagedRoles { "id", "smtp_host", "smtp_port", "smtp_use_ssl", "smtp_from_address", "smtp_recipients", "email_cooldown_minutes", "teams_proxy", "slack_proxy", "modified_at", + "generic_body_template", "generic_proxy", }, - SecretColumns: new[] { "smtp_encrypted_password", "smtp_username", "teams_url", "slack_url" }), + /* generic_headers carries the Authorization bearer token itself, and generic_url is a bearer + secret like the sibling webhook URLs (#1506 / V26). */ + SecretColumns: new[] + { + "smtp_encrypted_password", "smtp_username", "teams_url", "slack_url", + "generic_url", "generic_headers", + }), }; /// diff --git a/Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs b/Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs index d07d4763d4..d8daa44ce5 100644 --- a/Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs +++ b/Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs @@ -405,7 +405,8 @@ store row can't reach here without the columns present. */ { using var command = new NpgsqlCommand(@" SELECT smtp_host, smtp_port, smtp_use_ssl, smtp_username, smtp_encrypted_password, smtp_from_address, - smtp_recipients, email_cooldown_minutes, teams_url, teams_proxy, slack_url, slack_proxy + smtp_recipients, email_cooldown_minutes, teams_url, teams_proxy, slack_url, slack_proxy, + generic_url, generic_headers, generic_body_template, generic_proxy FROM config_notification WHERE id = 1", connection); using var reader = await command.ExecuteReaderAsync(ct); if (!await reader.ReadAsync(ct)) @@ -430,6 +431,10 @@ store row can't reach here without the columns present. */ TeamsProxy = reader.GetString(9), SlackUrl = reader.GetString(10), SlackProxy = reader.GetString(11), + GenericUrl = reader.GetString(12), + GenericHeaders = reader.GetString(13), + GenericBodyTemplate = reader.GetString(14), + GenericProxy = reader.GetString(15), }; return (smtp, webhooks); } diff --git a/Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs b/Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs index f4ec850885..bd3eaefbc9 100644 --- a/Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs +++ b/Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs @@ -69,6 +69,7 @@ public Migration(int version, string name, string sql) new Migration(23, "collection-log-hypertable", V23Sql), new Migration(24, "job-history-collector", V24Sql), new Migration(25, "agent-status-collector", V25Sql), + new Migration(26, "generic-webhook-channel", V26Sql), }; /// @@ -848,6 +849,29 @@ next_scheduled_run timestamp CREATE INDEX IF NOT EXISTS idx_agent_status_time ON collect.agent_status(server_id, collection_time);"; + /// + /// V26 — the generic webhook channel (#1506): a third delivery channel beside Teams/Slack that POSTs an + /// operator-authored JSON body to any endpoint, so an alert can drive automation we ship no adapter for + /// (PagerDuty, Opsgenie, n8n, or a GitHub repository_dispatch that re-runs a workflow). It is the + /// deliberate alternative to executing a script/exe on alert — same automation reach, no process-execution + /// surface. Additive ALTERs on the V17 control-plane table, schema-qualified config.* and + /// IF NOT EXISTS per the file's ALTER idiom (V7/V9/V15/V16/V18), so a re-run is a harmless no-op. + /// The channel is enabled by a non-empty generic_url — the same derivation Teams/Slack use — so the + /// empty-string defaults leave an upgraded store with the channel off. + /// + /// Two of these columns are secrets. generic_url is a bearer secret exactly like + /// teams_url/slack_url, and generic_headers holds the Authorization token + /// itself — both are carved out of the read-only viewer role's column grants in + /// DarlingManagedRoles.ViewerRestrictedConfigTables. That list's union-equals-the-table invariant is + /// gated live by the build, so adding these columns without carving them there FAILS the gate rather than + /// silently exposing a token to a viewer seat. + /// + private const string V26Sql = @" +ALTER TABLE config.config_notification ADD COLUMN IF NOT EXISTS generic_url text NOT NULL DEFAULT ''; +ALTER TABLE config.config_notification ADD COLUMN IF NOT EXISTS generic_headers text NOT NULL DEFAULT ''; +ALTER TABLE config.config_notification ADD COLUMN IF NOT EXISTS generic_body_template text NOT NULL DEFAULT ''; +ALTER TABLE config.config_notification ADD COLUMN IF NOT EXISTS generic_proxy text NOT NULL DEFAULT '';"; + private const string VersionTableSql = @" CREATE TABLE IF NOT EXISTS darling_schema_version ( version integer NOT NULL PRIMARY KEY, diff --git a/Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs b/Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs index 65f98e672a..a21082666a 100644 --- a/Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs +++ b/Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs @@ -16,5 +16,5 @@ namespace PerformanceMonitor.Darling.Storage; /// public static class StorageVersion { - public const int SchemaVersion = 25; + public const int SchemaVersion = 26; } diff --git a/Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml b/Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml index b345c6f7ff..20a2fa9478 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml +++ b/Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml @@ -535,6 +535,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +