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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml.cs
index 4dfb5ccfd5..135718eabd 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml.cs
@@ -874,9 +874,20 @@ private void SeedNotificationControlsFrom(NotificationRow r)
SlackWebhookUrlBox.Text = r.SlackUrl;
SlackProxyAddressBox.Text = r.SlackProxy;
+ GenericWebhookEnabledCheckBox.IsChecked = !string.IsNullOrWhiteSpace(r.GenericUrl);
+ GenericWebhookUrlBox.Text = r.GenericUrl;
+ GenericWebhookHeadersBox.Text = r.GenericHeaders;
+ /* Blank means "use the built-in default" — show it, so the operator has something to edit rather
+ than a blank box whose shape they have to guess. */
+ GenericWebhookBodyBox.Text = string.IsNullOrWhiteSpace(r.GenericBodyTemplate)
+ ? WebhookAlertService.DefaultGenericBodyTemplate
+ : r.GenericBodyTemplate;
+ GenericWebhookProxyAddressBox.Text = r.GenericProxy;
+
UpdateSmtpControlStates();
UpdateTeamsControlStates();
UpdateSlackControlStates();
+ UpdateGenericControlStates();
}
/// Builds the from the SMTP + webhook controls. A DISABLED channel
@@ -916,6 +927,26 @@ private NotificationRow BuildNotificationRowFromControls(List errors)
row.SlackProxy = SlackProxyAddressBox.Text?.Trim() ?? "";
}
+ if (GenericWebhookEnabledCheckBox.IsChecked == true)
+ {
+ row.GenericUrl = GenericWebhookUrlBox.Text?.Trim() ?? "";
+ row.GenericHeaders = GenericWebhookHeadersBox.Text?.Trim() ?? "";
+ /* Persist the empty "use built-in default" sentinel unless the operator actually edited the body box
+ (the Settings load pre-fills it with the default), so a future release can still improve it. */
+ row.GenericBodyTemplate = WebhookAlertService.IsDefaultBodyTemplate(GenericWebhookBodyBox.Text)
+ ? ""
+ : GenericWebhookBodyBox.Text?.Trim() ?? "";
+ row.GenericProxy = GenericWebhookProxyAddressBox.Text?.Trim() ?? "";
+
+ /* A malformed headers JSON / body template would let the service accept the channel and then
+ drop every alert with only a log line to show for it — block the Save instead (#1506). */
+ var configError = WebhookAlertService.ValidateGenericConfig(row.GenericHeaders, row.GenericBodyTemplate);
+ if (configError != null)
+ {
+ errors.Add(configError);
+ }
+ }
+
return row;
}
@@ -1023,6 +1054,18 @@ private void UpdateSlackControlStates()
TestSlackButton.IsEnabled = enabled;
}
+ private void GenericWebhookEnabledCheckBox_Changed(object sender, RoutedEventArgs e) => UpdateGenericControlStates();
+
+ private void UpdateGenericControlStates()
+ {
+ var enabled = GenericWebhookEnabledCheckBox.IsChecked == true;
+ GenericWebhookUrlBox.IsEnabled = enabled;
+ GenericWebhookHeadersBox.IsEnabled = enabled;
+ GenericWebhookBodyBox.IsEnabled = enabled;
+ GenericWebhookProxyAddressBox.IsEnabled = enabled;
+ TestGenericButton.IsEnabled = enabled;
+ }
+
private async void TestTeamsButton_Click(object sender, RoutedEventArgs e)
{
TestTeamsButton.IsEnabled = false;
@@ -1085,6 +1128,67 @@ private async void TestSlackButton_Click(object sender, RoutedEventArgs e)
}
}
+ ///
+ /// Non-blocking confirm shown when an http:// generic-webhook URL carries headers — the Authorization
+ /// token would go on the wire in cleartext (#1506). Yes proceeds; No cancels the action. is the verb ("Save" / "Send"). Not blocked outright: a plaintext POST to a trusted LAN
+ /// listener is a legitimate setup.
+ ///
+ private static bool ConfirmCleartextWebhook(string action)
+ {
+ var result = MessageBox.Show(
+ "The webhook URL uses http://, so the Authorization header and any credentials are sent in cleartext " +
+ $"and can be intercepted on the network.\n\n{action} anyway?",
+ "Insecure Webhook URL", MessageBoxButton.YesNo, MessageBoxImage.Warning);
+ return result == MessageBoxResult.Yes;
+ }
+
+ ///
+ /// Tests the generic webhook with the values currently in the boxes (not the stored ones), like the
+ /// Teams/Slack test buttons. A malformed headers JSON or body template comes back as the error message
+ /// rather than an exception, so the operator fixes it here instead of discovering it when a real alert
+ /// silently fails to deliver.
+ ///
+ private async void TestGenericButton_Click(object sender, RoutedEventArgs e)
+ {
+ var url = GenericWebhookUrlBox.Text?.Trim() ?? "";
+ var headers = GenericWebhookHeadersBox.Text?.Trim();
+
+ /* Warn before a cleartext http:// POST would send the Authorization header unencrypted (#1506). */
+ if (WebhookAlertService.IsCleartextHttpWithHeaders(url, headers) && !ConfirmCleartextWebhook("Send"))
+ {
+ return;
+ }
+
+ TestGenericButton.IsEnabled = false;
+ TestGenericButton.Content = "Sending...";
+
+ try
+ {
+ var body = GenericWebhookBodyBox.Text?.Trim();
+ var proxy = GenericWebhookProxyAddressBox.Text?.Trim();
+ var error = await WebhookAlertService.SendTestGenericAsync(url, headers, body, proxy, s_branding);
+
+ if (error == null)
+ {
+ MessageBox.Show("Generic webhook test notification sent successfully!", "Test Webhook", MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+ else
+ {
+ MessageBox.Show($"Failed to send generic webhook test notification:\n\n{error}", "Test Webhook Failed", MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show($"Failed to send generic webhook test notification:\n\n{ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ finally
+ {
+ TestGenericButton.Content = "Send Test Notification";
+ TestGenericButton.IsEnabled = true;
+ }
+ }
+
private void Hyperlink_RequestNavigate(object sender, RequestNavigateEventArgs e)
{
try
@@ -1105,6 +1209,16 @@ private async void SaveButton_Click(object sender, RoutedEventArgs e)
SaveViewerLocalAlertFields(errors);
var notifyRow = BuildNotificationRowFromControls(errors);
+ /* Cleartext warning (#1506): an http:// generic-webhook URL carrying headers sends the Authorization
+ token in the clear. Confirm before persisting anything; No cancels the save and the window stays open
+ to fix the URL. NOT blocked — a plaintext POST to a trusted LAN listener is legitimate. */
+ if (GenericWebhookEnabledCheckBox.IsChecked == true
+ && WebhookAlertService.IsCleartextHttpWithHeaders(GenericWebhookUrlBox.Text?.Trim(), GenericWebhookHeadersBox.Text?.Trim())
+ && !ConfirmCleartextWebhook("Save"))
+ {
+ return;
+ }
+
/* Persist the viewer-LOCAL preferences immediately (valid values applied above), and capture the
edited viewer preferences for MainWindow to save + re-seed tabs from. */
SaveConnectionTimeout();
@@ -1221,6 +1335,12 @@ private sealed class TestAlertSettings : IAlertSettings
public string SlackWebhookUrl { get; private init; } = "";
public string SlackProxyAddress { get; private init; } = "";
+ public bool GenericWebhookEnabled { get; private init; }
+ public string GenericWebhookUrl { get; private init; } = "";
+ public string GenericWebhookHeadersJson { get; private init; } = "";
+ public string GenericWebhookBodyTemplate { get; private init; } = "";
+ public string GenericWebhookProxyAddress { get; private init; } = "";
+
public double AnalysisNotifySeverity { get; private init; }
public int AnalysisNotifyCooldownMinutes { get; private init; }
@@ -1245,6 +1365,11 @@ public static TestAlertSettings FromUi(SettingsWindow w)
SlackWebhookEnabled = w.SlackWebhookEnabledCheckBox.IsChecked == true,
SlackWebhookUrl = w.SlackWebhookUrlBox.Text?.Trim() ?? "",
SlackProxyAddress = w.SlackProxyAddressBox.Text?.Trim() ?? "",
+ GenericWebhookEnabled = w.GenericWebhookEnabledCheckBox.IsChecked == true,
+ GenericWebhookUrl = w.GenericWebhookUrlBox.Text?.Trim() ?? "",
+ GenericWebhookHeadersJson = w.GenericWebhookHeadersBox.Text?.Trim() ?? "",
+ GenericWebhookBodyTemplate = w.GenericWebhookBodyBox.Text?.Trim() ?? "",
+ GenericWebhookProxyAddress = w.GenericWebhookProxyAddressBox.Text?.Trim() ?? "",
AnalysisNotifySeverity = w._appSettings.AnalysisNotifySeverity,
AnalysisNotifyCooldownMinutes = w._appSettings.AnalysisNotifyCooldownMinutes,
};
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Notification.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Notification.cs
index 02229a9909..8187aa5fc4 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Notification.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Notification.cs
@@ -37,24 +37,26 @@ namespace PerformanceMonitor.Darling.Viewer;
///
public sealed partial class ViewerDataService
{
- /* The 12 SmtpConfig + WebhooksConfig columns in the SAME order the service reads them
+ /* The 16 SmtpConfig + WebhooksConfig columns in the SAME order the service reads them
(StoreConfigProvider.ReadNotificationAsync), so the parity test pins one list against both ends. */
private const string NotificationColumns =
"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";
/// The single global notification row (id=1), for the Settings window prefill + migrate-in check.
public const string NotificationSelectSql =
"SELECT " + NotificationColumns + " FROM config_notification WHERE id = 1";
- /* The non-secret subset of NotificationColumns a read-only viewer role CAN read. The four secret
- columns (smtp_encrypted_password, smtp_username, teams_url, slack_url — a webhook URL is a bearer
- secret) are column-REVOKEd from the viewer role (#1262 /
- DarlingManagedRoles.ViewerRestrictedConfigTables), so selecting them raises SQLSTATE 42501 for a
- read-only seat and — in the Settings load — one such throw would blank every later section. */
+ /* The non-secret subset of NotificationColumns a read-only viewer role CAN read. The six secret
+ columns (smtp_encrypted_password, smtp_username, teams_url, slack_url, generic_url — a webhook URL
+ is a bearer secret — and generic_headers, which holds the Authorization token itself) are
+ column-REVOKEd from the viewer role (#1262 / DarlingManagedRoles.ViewerRestrictedConfigTables), so
+ selecting them raises SQLSTATE 42501 for a read-only seat and — in the Settings load — one such
+ throw would blank every later section. */
private const string NotificationNonSecretColumns =
"smtp_host, smtp_port, smtp_use_ssl, smtp_from_address, smtp_recipients, " +
- "email_cooldown_minutes, teams_proxy, slack_proxy";
+ "email_cooldown_minutes, teams_proxy, slack_proxy, generic_body_template, generic_proxy";
/// The secret-free notification projection a read-only viewer seat reads (D7 degradation):
/// the four carved secret columns are omitted, so it never 42501s for a viewer. The secret fields come back
@@ -63,10 +65,10 @@ public sealed partial class ViewerDataService
"SELECT " + NotificationNonSecretColumns + " FROM config_notification WHERE id = 1";
/// Upserts the single global notification row (Settings window Save). ON CONFLICT rewrites every
- /// column and bumps config_version via the V17 trigger. $1..$12 in order.
+ /// column and bumps config_version via the V17 trigger. $1..$16 in order.
public const string NotificationUpsertSql = @"
INSERT INTO config_notification (id, " + NotificationColumns + @", modified_at)
-VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, (now() AT TIME ZONE 'UTC'))
+VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, (now() AT TIME ZONE 'UTC'))
ON CONFLICT (id) DO UPDATE SET
smtp_host = EXCLUDED.smtp_host,
smtp_port = EXCLUDED.smtp_port,
@@ -80,6 +82,10 @@ ON CONFLICT (id) DO UPDATE SET
teams_proxy = EXCLUDED.teams_proxy,
slack_url = EXCLUDED.slack_url,
slack_proxy = EXCLUDED.slack_proxy,
+ generic_url = EXCLUDED.generic_url,
+ generic_headers = EXCLUDED.generic_headers,
+ generic_body_template = EXCLUDED.generic_body_template,
+ generic_proxy = EXCLUDED.generic_proxy,
modified_at = (now() AT TIME ZONE 'UTC')";
/// Reads the single global notification row, or null when the store has not seeded it yet. A
@@ -119,6 +125,10 @@ public async Task UpsertNotificationAsync(NotificationRow row, CancellationToken
command.Parameters.Add(new NpgsqlParameter { TypedValue = row.TeamsProxy }); // $10
command.Parameters.Add(new NpgsqlParameter { TypedValue = row.SlackUrl }); // $11
command.Parameters.Add(new NpgsqlParameter { TypedValue = row.SlackProxy }); // $12
+ command.Parameters.Add(new NpgsqlParameter { TypedValue = row.GenericUrl }); // $13
+ command.Parameters.Add(new NpgsqlParameter { TypedValue = row.GenericHeaders }); // $14
+ command.Parameters.Add(new NpgsqlParameter { TypedValue = row.GenericBodyTemplate }); // $15
+ command.Parameters.Add(new NpgsqlParameter { TypedValue = row.GenericProxy }); // $16
await ExecuteWriteAsync(command, cancellationToken);
}
@@ -136,15 +146,20 @@ public async Task UpsertNotificationAsync(NotificationRow row, CancellationToken
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),
};
///
/// Reads a from the secret-free projection
/// () a read-only viewer seat uses (D7): identical to
- /// but the four carved secret columns (smtp_username,
- /// smtp_encrypted_password, teams_url, slack_url) are not selected, so they stay
- /// null/empty and every column after each shifts ordinal. The viewer can't author those secrets on a
- /// read-only seat anyway, so the empty values are harmless — the non-secret fields still prefill.
+ /// but the six carved secret columns (smtp_username,
+ /// smtp_encrypted_password, teams_url, slack_url, generic_url,
+ /// generic_headers) are not selected, so they stay null/empty and every column after each shifts
+ /// ordinal. The viewer can't author those secrets on a read-only seat anyway, so the empty values are
+ /// harmless — the non-secret fields still prefill.
///
private static NotificationRow ReadNotificationRowNoSecret(NpgsqlDataReader reader) => new()
{
@@ -160,6 +175,10 @@ public async Task UpsertNotificationAsync(NotificationRow row, CancellationToken
TeamsProxy = reader.GetString(6),
SlackUrl = "", /* carved */
SlackProxy = reader.GetString(7),
+ GenericUrl = "", /* carved */
+ GenericHeaders = "", /* carved — holds the Authorization bearer token (#1506) */
+ GenericBodyTemplate = reader.GetString(8),
+ GenericProxy = reader.GetString(9),
};
}
@@ -189,6 +208,13 @@ public sealed class NotificationRow
public string SlackUrl { get; set; } = "";
public string SlackProxy { get; set; } = "";
+ /* Generic webhook (#1506 / V26). GenericUrl and GenericHeaders are secrets — the headers JSON carries
+ the Authorization bearer token — and are column-REVOKEd from the read-only viewer role. */
+ public string GenericUrl { get; set; } = "";
+ public string GenericHeaders { get; set; } = "";
+ public string GenericBodyTemplate { get; set; } = "";
+ public string GenericProxy { get; set; } = "";
+
/// A row equal to the V17 seed defaults — the migrate-in "is the service section still at defaults?" baseline.
public static NotificationRow Defaults() => new();
@@ -208,6 +234,10 @@ public bool ValueEquals(NotificationRow other)
&& string.Equals(TeamsUrl, other.TeamsUrl, StringComparison.Ordinal)
&& string.Equals(TeamsProxy, other.TeamsProxy, StringComparison.Ordinal)
&& string.Equals(SlackUrl, other.SlackUrl, StringComparison.Ordinal)
- && string.Equals(SlackProxy, other.SlackProxy, StringComparison.Ordinal);
+ && string.Equals(SlackProxy, other.SlackProxy, StringComparison.Ordinal)
+ && string.Equals(GenericUrl, other.GenericUrl, StringComparison.Ordinal)
+ && string.Equals(GenericHeaders, other.GenericHeaders, StringComparison.Ordinal)
+ && string.Equals(GenericBodyTemplate, other.GenericBodyTemplate, StringComparison.Ordinal)
+ && string.Equals(GenericProxy, other.GenericProxy, StringComparison.Ordinal);
}
}
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs
index a631e33cba..2a8b1e5d6d 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs
@@ -340,8 +340,9 @@ internal static bool IsConnectionFailure(Exception ex) =>
/// for the one index sentinel) — the version the viewer gates on at connect (finding B1). The authoritative
/// darling_schema_version table is owner-only (a viewer/admin role can't read it, and even
/// MAX(version) can itself throw 42501), so rather than read the version number this checks whether
- /// the store carries the schema OBJECTS the recent migrations added — objects any role can see. Six
- /// sentinels, newest first: V22's idx_index_object_stats_latest index (the FinOps Index Analysis
+ /// the store carries the schema OBJECTS the recent migrations added — objects any role can see. Newest
+ /// first: V26's config_notification.generic_url column (the generic webhook channel, #1506), V25's
+ /// agent_status table, V24's job_history table, V22's idx_index_object_stats_latest index (the FinOps Index Analysis
/// supporting index — indexes are not listed in information_schema, so this one reads the
/// world-readable pg_indexes catalog), V21's default_trace_events table (the shared Default
/// Trace collector), V20's notify_connection_changes column (config_alert_settings), V19's
@@ -382,7 +383,8 @@ internal static bool IsConnectionFailure(Exception ex) =>
OR NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')
),
EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'job_history'),
- EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'agent_status')";
+ EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'agent_status'),
+ EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'config_notification' AND column_name = 'generic_url')";
/// The store schema version this viewer build requires — the highest migration it knows
/// (). The connect-time gate blocks a store below this.
@@ -403,7 +405,7 @@ OR NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (await reader.ReadAsync(cancellationToken))
{
- return MapProbedSchemaVersion(reader.GetBoolean(0), reader.GetBoolean(1), reader.GetBoolean(2), reader.GetBoolean(3), reader.GetBoolean(4), reader.GetBoolean(5), reader.GetBoolean(6), reader.GetBoolean(7), reader.GetBoolean(8));
+ return MapProbedSchemaVersion(reader.GetBoolean(0), reader.GetBoolean(1), reader.GetBoolean(2), reader.GetBoolean(3), reader.GetBoolean(4), reader.GetBoolean(5), reader.GetBoolean(6), reader.GetBoolean(7), reader.GetBoolean(8), reader.GetBoolean(9));
}
return null;
@@ -425,11 +427,18 @@ OR NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')
/// 19, else (V18) → 18, else
/// (V17) → 17, else 16 — the "older than the V17 config control
/// plane" floor (the exact pre-17 version isn't probed, but it is below what the viewer needs). Pure, so it
- /// is unit-tested without a live store; a schema bump past 25 trips the pinning test that keeps this in step
+ /// is unit-tested without a live store; a schema bump past 26 trips the pinning test that keeps this in step
/// with .
///
- internal static int MapProbedSchemaVersion(bool hasConfigControlPlane, bool hasAlertDeliveryOverride, bool hasAnalysisState, bool hasAlertTuningKnobs, bool hasDefaultTraceEvents, bool hasIndexObjectStatsLatestIndex, bool hasCollectionLogHypertableOrPlainPg, bool hasJobHistory, bool hasAgentStatus)
+ internal static int MapProbedSchemaVersion(bool hasConfigControlPlane, bool hasAlertDeliveryOverride, bool hasAnalysisState, bool hasAlertTuningKnobs, bool hasDefaultTraceEvents, bool hasIndexObjectStatsLatestIndex, bool hasCollectionLogHypertableOrPlainPg, bool hasJobHistory, bool hasAgentStatus, bool hasGenericWebhook)
{
+ /* V26 (#1506 generic webhook): another engine-agnostic column-existence sentinel, so it takes the
+ newest-first arm. config_notification.generic_url exists only at V26 or later. */
+ if (hasGenericWebhook)
+ {
+ return 26;
+ }
+
/* V24/V25 (#1433 Job History tab) are engine-agnostic table-existence sentinels, so they ARE
standalone newest-first arms (unlike the V23 hypertable composite): agent_status (V25) present →
25, else job_history (V24) present → 24, else fall through to the V22/V23 composite below. A
diff --git a/Dashboard/Services/DashboardAlertSettings.cs b/Dashboard/Services/DashboardAlertSettings.cs
index 2c210f58e5..d1ce9d1ccf 100644
--- a/Dashboard/Services/DashboardAlertSettings.cs
+++ b/Dashboard/Services/DashboardAlertSettings.cs
@@ -62,6 +62,15 @@ public DashboardAlertSettings(IUserPreferencesService preferencesService)
public string SlackWebhookUrl => DashboardAlertCredentials.GetSlackWebhookUrl();
public string SlackProxyAddress => Prefs.SlackProxyAddress;
+ /* The generic webhook channel (#1506) ships to Lite and Darling only. The Dashboard is deprecated
+ (bug fixes only), so it gains no new settings surface to author these from — the channel stays
+ off, and the shared WebhookAlertService skips it on the disabled flag. */
+ public bool GenericWebhookEnabled => false;
+ public string GenericWebhookUrl => "";
+ public string GenericWebhookHeadersJson => "";
+ public string GenericWebhookBodyTemplate => "";
+ public string GenericWebhookProxyAddress => "";
+
public double AnalysisNotifySeverity => Math.Clamp(Prefs.AnalysisNotifySeverity, 0.0, 2.0);
public int AnalysisNotifyCooldownMinutes => Math.Clamp(Prefs.AnalysisNotifyCooldownMinutes, 30, 10080);
}
diff --git a/Dashboard/Services/UserPreferencesAlertSettings.cs b/Dashboard/Services/UserPreferencesAlertSettings.cs
index 6e5776c86c..68f6198839 100644
--- a/Dashboard/Services/UserPreferencesAlertSettings.cs
+++ b/Dashboard/Services/UserPreferencesAlertSettings.cs
@@ -54,6 +54,14 @@ public UserPreferencesAlertSettings(UserPreferences prefs)
public string SlackWebhookUrl => DashboardAlertCredentials.GetSlackWebhookUrl();
public string SlackProxyAddress => _prefs.SlackProxyAddress;
+ /* Off for the Dashboard — see DashboardAlertSettings: the generic channel (#1506) is a Lite/Darling
+ feature, and the deprecated Dashboard grows no settings surface to author it from. */
+ public bool GenericWebhookEnabled => false;
+ public string GenericWebhookUrl => "";
+ public string GenericWebhookHeadersJson => "";
+ public string GenericWebhookBodyTemplate => "";
+ public string GenericWebhookProxyAddress => "";
+
public double AnalysisNotifySeverity => Math.Clamp(_prefs.AnalysisNotifySeverity, 0.0, 2.0);
public int AnalysisNotifyCooldownMinutes => Math.Clamp(_prefs.AnalysisNotifyCooldownMinutes, 30, 10080);
}
diff --git a/Lite.Tests/ConnectionEdgeDetectorTests.cs b/Lite.Tests/ConnectionEdgeDetectorTests.cs
new file mode 100644
index 0000000000..476cff649b
--- /dev/null
+++ b/Lite.Tests/ConnectionEdgeDetectorTests.cs
@@ -0,0 +1,105 @@
+using System.Collections.Generic;
+using System.Linq;
+using PerformanceMonitorLite.Services;
+using Xunit;
+
+namespace PerformanceMonitorLite.Tests;
+
+///
+/// #1506: Lite's connection-lost / connection-restored alerts are EDGE-triggered. The property that matters
+/// is that a transition alerts exactly once — a server that is unreachable for hours is polled hundreds of
+/// times and must produce ONE "Server Unreachable", not one per poll. These pin that, plus the first-sighting
+/// baseline (an app that starts with a server already down must not alert about a change it never observed —
+/// the Dashboard's skip-first-check and Darling's Unknown-state rule).
+///
+public class ConnectionEdgeDetectorTests
+{
+ [Fact]
+ public void FirstObservation_IsASilentBaseline()
+ {
+ Assert.Equal(ConnectionAlertEdge.None, ConnectionEdgeDetector.Detect(previous: null, current: false));
+ Assert.Equal(ConnectionAlertEdge.None, ConnectionEdgeDetector.Detect(previous: null, current: true));
+ }
+
+ [Fact]
+ public void OnlineToOffline_IsLost()
+ => Assert.Equal(ConnectionAlertEdge.Lost, ConnectionEdgeDetector.Detect(previous: true, current: false));
+
+ [Fact]
+ public void OfflineToOnline_IsRestored()
+ => Assert.Equal(ConnectionAlertEdge.Restored, ConnectionEdgeDetector.Detect(previous: false, current: true));
+
+ [Fact]
+ public void SteadyStates_DoNotReFire()
+ {
+ Assert.Equal(ConnectionAlertEdge.None, ConnectionEdgeDetector.Detect(previous: false, current: false));
+ Assert.Equal(ConnectionAlertEdge.None, ConnectionEdgeDetector.Detect(previous: true, current: true));
+ }
+
+ ///
+ /// The anti-nag guarantee, stated the way the bug would be reported: a server offline across an 8-hour
+ /// outage polled every minute must yield exactly ONE "Server Unreachable" — not 480.
+ ///
+ [Fact]
+ public void EightHourOutage_PolledEveryMinute_FiresExactlyOnce()
+ {
+ var observations = new List { true }; // online at first sighting
+ observations.AddRange(Enumerable.Repeat(false, 8 * 60)); // then offline for 480 polls
+
+ var edges = ReplayPolls(observations);
+
+ Assert.Equal(1, edges.Count(e => e == ConnectionAlertEdge.Lost));
+ Assert.Empty(edges.Where(e => e == ConnectionAlertEdge.Restored));
+ }
+
+ /// An outage that ends: exactly one Lost and exactly one Restored, in that order.
+ [Fact]
+ public void OutageThenRecovery_FiresOneLostAndOneRestored()
+ {
+ var observations = new List { true, true };
+ observations.AddRange(Enumerable.Repeat(false, 100)); // the outage
+ observations.AddRange(Enumerable.Repeat(true, 100)); // recovered, stays up
+
+ var edges = ReplayPolls(observations).Where(e => e != ConnectionAlertEdge.None).ToList();
+
+ Assert.Equal(new[] { ConnectionAlertEdge.Lost, ConnectionAlertEdge.Restored }, edges);
+ }
+
+ ///
+ /// The reporter's actual pattern (#1506): an Azure SQL firewall rule that stops matching when their public
+ /// IP rotates, breaking the connection 1-2x/day and recovering when re-authorized. Each break/recover cycle
+ /// is one alert and one restore — the webhook that re-runs their GitHub Action fires once per genuine break.
+ ///
+ [Fact]
+ public void RepeatedDailyBreaks_EachCycleAlertsOnce()
+ {
+ var observations = new List { true };
+
+ for (int day = 0; day < 5; day++)
+ {
+ observations.AddRange(Enumerable.Repeat(false, 30)); // connection broken, polled 30x
+ observations.AddRange(Enumerable.Repeat(true, 30)); // access restored
+ }
+
+ var edges = ReplayPolls(observations);
+
+ Assert.Equal(5, edges.Count(e => e == ConnectionAlertEdge.Lost));
+ Assert.Equal(5, edges.Count(e => e == ConnectionAlertEdge.Restored));
+ }
+
+ /// Drives the detector exactly as MainWindow.CheckConnectionsAndNotify does: carry the previous
+ /// state forward across polls, starting from "never observed".
+ private static List ReplayPolls(IEnumerable observations)
+ {
+ var edges = new List();
+ bool? previous = null;
+
+ foreach (var isOnline in observations)
+ {
+ edges.Add(ConnectionEdgeDetector.Detect(previous, isOnline));
+ previous = isOnline;
+ }
+
+ return edges;
+ }
+}
diff --git a/Lite.Tests/GenericWebhookTests.cs b/Lite.Tests/GenericWebhookTests.cs
new file mode 100644
index 0000000000..f905d0ee70
--- /dev/null
+++ b/Lite.Tests/GenericWebhookTests.cs
@@ -0,0 +1,269 @@
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Text.Json;
+using PerformanceMonitor.Notifications;
+using Xunit;
+
+namespace PerformanceMonitorLite.Tests;
+
+///
+/// #1506: the generic webhook channel. The channel exists so an alert can drive automation we ship no
+/// adapter for (PagerDuty, Opsgenie, n8n, a GitHub repository_dispatch that re-runs a workflow)
+/// WITHOUT executing a script or an exe — so the two things that must hold are that the operator's JSON
+/// body template cannot be broken out of by alert data, and that a malformed config degrades to a clear
+/// error instead of an exception in the alert loop.
+///
+public class GenericWebhookTests
+{
+ private static readonly AlertBranding Branding = new("Performance Monitor Lite", null);
+
+ private static string Build(string metric, string server, string value = "0", string threshold = "0", string? template = null)
+ => WebhookAlertService.BuildGenericPayload(metric, server, value, threshold, Branding, bodyTemplate: template);
+
+ /* ---------------- JSON escaping (the injection guard) ---------------- */
+
+ [Fact]
+ public void ServerNameWithQuote_DoesNotBreakOutOfTheJsonStringLiteral()
+ {
+ /* A server name carrying a double quote is the classic break-out: naive substitution into
+ "server": "{{server}}" would terminate the literal and corrupt — or inject into — the payload. */
+ var payload = Build("High CPU", "SRV\"; DROP TABLE x; --");
+
+ var root = JsonDocument.Parse(payload).RootElement; // must still be valid JSON
+ Assert.Equal("SRV\"; DROP TABLE x; --", root.GetProperty("server").GetString());
+ }
+
+ [Fact]
+ public void ServerNameWithBackslash_IsEscapedAndRoundTrips()
+ {
+ /* A named instance (HOST\INSTANCE) is the everyday case: an unescaped backslash makes "\I" an
+ invalid JSON escape and the whole body unparseable. */
+ var payload = Build("High CPU", @"HOST\SQLEXPRESS");
+
+ var root = JsonDocument.Parse(payload).RootElement;
+ Assert.Equal(@"HOST\SQLEXPRESS", root.GetProperty("server").GetString());
+ }
+
+ [Fact]
+ public void LoneSurrogate_DoesNotThrow_AndProducesValidJson()
+ {
+ /* SQL Server nvarchar/sysname can hold an unpaired surrogate (NCHAR(0xD800)). JsonEncodedText.Encode
+ THROWS on that — which would let a low-privileged user drop this channel for the incident they cause
+ while email/Teams/Slack still deliver. The escape must relax it (U+FFFD), never throw. */
+ var payload = Build("High CPU", "SRV\uD800X"); // lone high surrogate
+
+ var root = JsonDocument.Parse(payload).RootElement; // valid JSON, no throw
+ Assert.StartsWith("SRV", root.GetProperty("server").GetString());
+ }
+
+ [Fact]
+ public void ValueContainingAnotherPlaceholder_IsNotReExpanded()
+ {
+ /* Single-pass substitution: a server name that literally contains "{{timestamp}}" must render verbatim,
+ not pull the timestamp field into itself (chained .Replace would). One field can't bleed into another. */
+ var payload = Build("High CPU", "SRV {{timestamp}} {{value}}", value: "42");
+
+ var root = JsonDocument.Parse(payload).RootElement;
+ Assert.Equal("SRV {{timestamp}} {{value}}", root.GetProperty("server").GetString());
+ Assert.Equal("42", root.GetProperty("value").GetString());
+ }
+
+ [Fact]
+ public void InjectedJsonStructure_StaysDataNotStructure()
+ {
+ /* The value must land as a STRING, not as new JSON structure: an attacker-ish value that closes the
+ literal and opens a new key must round-trip verbatim inside "value", adding no properties. */
+ var payload = Build("High CPU", "SRV", value: "99\", \"injected\": \"pwned");
+
+ var root = JsonDocument.Parse(payload).RootElement;
+ Assert.Equal("99\", \"injected\": \"pwned", root.GetProperty("value").GetString());
+ Assert.False(root.TryGetProperty("injected", out _));
+ }
+
+ [Fact]
+ public void EveryPlaceholderIsSubstituted_AndNoneRemain()
+ {
+ var payload = Build("Server Unreachable", "SRV", value: "Login timeout", threshold: "Online");
+
+ Assert.DoesNotContain("{{", payload, System.StringComparison.Ordinal);
+
+ var root = JsonDocument.Parse(payload).RootElement;
+ Assert.Equal("Server Unreachable", root.GetProperty("metric").GetString());
+ Assert.Equal("SRV", root.GetProperty("server").GetString());
+ Assert.Equal("Login timeout", root.GetProperty("value").GetString());
+ Assert.Equal("Online", root.GetProperty("threshold").GetString());
+ /* Severity comes from the shared AlertSeverity map, which already knows this metric. */
+ Assert.Equal("CRITICAL", root.GetProperty("severity").GetString());
+ Assert.False(string.IsNullOrWhiteSpace(root.GetProperty("timestamp").GetString()));
+ }
+
+ [Fact]
+ public void OperatorTemplate_IsHonored_AndEscapedTheSameWay()
+ {
+ /* The motivating use case: a GitHub repository_dispatch body. Placeholders sit inside nested string
+ literals, and a hostile server name must not escape them. */
+ const string template =
+ """{"event_type": "sql-monitor-alert", "client_payload": {"metric": "{{metric}}", "server": "{{server}}"}}""";
+
+ var payload = Build("Server Unreachable", "SRV\"evil", template: template);
+
+ var root = JsonDocument.Parse(payload).RootElement;
+ Assert.Equal("sql-monitor-alert", root.GetProperty("event_type").GetString());
+ Assert.Equal("SRV\"evil", root.GetProperty("client_payload").GetProperty("server").GetString());
+ }
+
+ [Fact]
+ public void EmptyTemplate_FallsBackToTheDefault()
+ {
+ var payload = Build("High CPU", "SRV", template: " ");
+
+ var root = JsonDocument.Parse(payload).RootElement;
+ Assert.Equal("SRV", root.GetProperty("server").GetString());
+ }
+
+ [Fact]
+ public void DefaultTemplate_IsItselfValidJson()
+ {
+ /* Guards the shipped default: a typo there would break every un-customized deployment. */
+ var payload = Build("High CPU", "SRV", template: WebhookAlertService.DefaultGenericBodyTemplate);
+ JsonDocument.Parse(payload);
+ }
+
+ /* ---------------- headers parsing (never throw into the alert loop) ---------------- */
+
+ [Fact]
+ public void Headers_ParseIntoRequestHeaders()
+ {
+ var ok = WebhookAlertService.TryParseHeaders(
+ """{"Authorization": "Bearer ghp_token", "Accept": "application/vnd.github+json"}""",
+ out var headers, out var error);
+
+ Assert.True(ok);
+ Assert.Null(error);
+ Assert.Equal("Bearer ghp_token", headers["Authorization"]);
+ Assert.Equal("application/vnd.github+json", headers["Accept"]);
+ }
+
+ [Fact]
+ public void Headers_EmptyIsValid_MeaningNoCustomHeaders()
+ {
+ Assert.True(WebhookAlertService.TryParseHeaders("", out var headers, out var error));
+ Assert.Empty(headers);
+ Assert.Null(error);
+ }
+
+ [Fact]
+ public void Headers_MalformedJson_FailsGracefullyWithAMessage()
+ {
+ /* A truncated paste is the realistic operator error. It must come back as an error, NOT an exception. */
+ var ok = WebhookAlertService.TryParseHeaders("""{"Authorization": "Bearer x" """, out var headers, out var error);
+
+ Assert.False(ok);
+ Assert.Empty(headers);
+ Assert.NotNull(error);
+ Assert.Contains("not valid JSON", error, System.StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Headers_NonObjectRoot_IsRejected()
+ {
+ Assert.False(WebhookAlertService.TryParseHeaders("""["Authorization", "Bearer x"]""", out _, out var error));
+ Assert.NotNull(error);
+ Assert.Contains("JSON object", error, System.StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Headers_NonStringValue_IsRejectedByName()
+ {
+ Assert.False(WebhookAlertService.TryParseHeaders("""{"Retry-After": 30}""", out _, out var error));
+ Assert.NotNull(error);
+ Assert.Contains("Retry-After", error, System.StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Headers_ValueWithCrlf_IsRejected_NoHeaderSmuggling()
+ {
+ /* .NET's TryAddWithoutValidation lets a CR/LF in a header VALUE onto the wire, splitting it into a
+ second header. Reject it at parse time so a token pasted with a stray newline can't smuggle one. */
+ var ok = WebhookAlertService.TryParseHeaders(
+ "{\"Authorization\": \"Bearer good\\r\\nX-Smuggled: injected\"}", out _, out var error);
+
+ Assert.False(ok);
+ Assert.NotNull(error);
+ Assert.Contains("control character", error, System.StringComparison.OrdinalIgnoreCase);
+ }
+
+ /* ---------------- the settings-window validator ---------------- */
+
+ [Fact]
+ public void ValidateGenericConfig_AcceptsAGoodConfig()
+ {
+ Assert.Null(WebhookAlertService.ValidateGenericConfig(
+ """{"Authorization": "Bearer x"}""",
+ """{"metric": "{{metric}}"}"""));
+ }
+
+ [Fact]
+ public void ValidateGenericConfig_RejectsMalformedHeaders()
+ {
+ Assert.NotNull(WebhookAlertService.ValidateGenericConfig("{not json", null));
+ }
+
+ [Fact]
+ public void ValidateGenericConfig_RejectsATemplateThatCannotProduceValidJson()
+ {
+ /* An unquoted placeholder: the substituted text lands outside a string literal, so the body is not
+ JSON. Caught at Save, rather than silently dropping every alert afterwards. */
+ Assert.NotNull(WebhookAlertService.ValidateGenericConfig(null, """{"metric": {{metric}}}"""));
+ }
+
+ /* ---------------- ApplyHeaders (request/content header routing) ---------------- */
+
+ private static (HttpRequestMessage Request, HttpContent Content) ApplyHeaders(Dictionary headers)
+ {
+ var content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json");
+ var request = new HttpRequestMessage(HttpMethod.Post, "https://example.test/hook") { Content = content };
+ WebhookAlertService.ApplyHeaders(request, content, headers);
+ return (request, content);
+ }
+
+ [Fact]
+ public void ApplyHeaders_DefaultsUserAgent_WhenOperatorSetsNone()
+ {
+ /* GitHub's REST API 403s a request with no User-Agent, and repository_dispatch is the motivating
+ case — so one is defaulted rather than handing the operator a 403 to diagnose. */
+ var (request, _) = ApplyHeaders(new Dictionary { ["Accept"] = "application/vnd.github+json" });
+
+ Assert.True(request.Headers.Contains("User-Agent"));
+ Assert.Equal("PerformanceMonitor", string.Join("", request.Headers.GetValues("User-Agent")));
+ }
+
+ [Fact]
+ public void ApplyHeaders_DoesNotOverrideAnOperatorUserAgent()
+ {
+ var (request, _) = ApplyHeaders(new Dictionary { ["User-Agent"] = "my-agent/2.0" });
+
+ Assert.Equal("my-agent/2.0", string.Join("", request.Headers.GetValues("User-Agent")));
+ }
+
+ [Fact]
+ public void ApplyHeaders_RoutesContentTypeOntoTheContent_NotDroppedAsARequestHeader()
+ {
+ /* Content-Type is a CONTENT header — TryAddWithoutValidation on request.Headers rejects it, so a naive
+ add would silently drop the override. ApplyHeaders must move it onto the content, replacing the
+ default application/json StringContent set. (The framework won't even let you QUERY Content-Type on
+ request.Headers — it throws "misused header name" — which is the same rule that makes routing it onto
+ the content necessary; so the content-side assertion is the meaningful proof.) */
+ var (_, content) = ApplyHeaders(new Dictionary { ["Content-Type"] = "application/vnd.github+json" });
+
+ Assert.Equal("application/vnd.github+json", content.Headers.ContentType?.ToString());
+ }
+
+ [Fact]
+ public void ApplyHeaders_AddsAnOrdinaryOperatorHeader()
+ {
+ var (request, _) = ApplyHeaders(new Dictionary { ["Authorization"] = "Bearer ghp_token" });
+
+ Assert.Equal("Bearer ghp_token", string.Join("", request.Headers.GetValues("Authorization")));
+ }
+}
diff --git a/Lite.Tests/WebhookCooldownSeedTests.cs b/Lite.Tests/WebhookCooldownSeedTests.cs
index 34aebf472b..4529aae750 100644
--- a/Lite.Tests/WebhookCooldownSeedTests.cs
+++ b/Lite.Tests/WebhookCooldownSeedTests.cs
@@ -89,6 +89,11 @@ private sealed class FakeWebhookSettings : IAlertSettings
public bool SlackWebhookEnabled { get; set; }
public string SlackWebhookUrl { get; set; } = "";
public string SlackProxyAddress => "";
+ public bool GenericWebhookEnabled { get; set; }
+ public string GenericWebhookUrl { get; set; } = "";
+ public string GenericWebhookHeadersJson { get; set; } = "";
+ public string GenericWebhookBodyTemplate { get; set; } = "";
+ public string GenericWebhookProxyAddress => "";
public double AnalysisNotifySeverity => 1.5;
public int AnalysisNotifyCooldownMinutes => 360;
}
diff --git a/Lite/App.xaml.cs b/Lite/App.xaml.cs
index f7f01ee2cd..42937e7f23 100644
--- a/Lite/App.xaml.cs
+++ b/Lite/App.xaml.cs
@@ -172,8 +172,21 @@ private static string GetDefaultCsvSeparator()
public static string SlackWebhookUrl { get; set; } = "";
public static string SlackProxyAddress { get; set; } = "";
+ /* Generic webhook settings (#1506) — 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). The URL and the headers JSON both carry bearer
+ tokens, so — like the Teams/Slack URLs — they live in Credential Manager, never in settings.json;
+ only the enable flag, the proxy, and the body template are plain prefs. */
+ public static bool GenericWebhookEnabled { get; set; } = false;
+ public static string GenericWebhookUrl { get; set; } = "";
+ public static string GenericWebhookHeadersJson { get; set; } = "";
+ public static string GenericWebhookBodyTemplate { get; set; } = "";
+ public static string GenericWebhookProxyAddress { get; set; } = "";
+
private const string TeamsWebhookCredentialKey = "TeamsWebhook";
private const string SlackWebhookCredentialKey = "SlackWebhook";
+ private const string GenericWebhookCredentialKey = "GenericWebhook";
+ private const string GenericWebhookHeadersCredentialKey = "GenericWebhookHeaders";
///
/// Gets a webhook URL from Windows Credential Manager.
@@ -590,6 +603,12 @@ public static void LoadAlertSettings()
if (root.TryGetProperty("slack_webhook_enabled", out v)) SlackWebhookEnabled = v.GetBoolean();
if (root.TryGetProperty("slack_proxy_address", out v)) SlackProxyAddress = v.GetString() ?? "";
+ /* Generic webhook settings (#1506). The URL + headers JSON are secrets and load from Credential
+ Manager below; only these three are plain prefs. */
+ if (root.TryGetProperty("generic_webhook_enabled", out v)) GenericWebhookEnabled = v.GetBoolean();
+ if (root.TryGetProperty("generic_proxy_address", out v)) GenericWebhookProxyAddress = v.GetString() ?? "";
+ if (root.TryGetProperty("generic_body_template", out v)) GenericWebhookBodyTemplate = v.GetString() ?? "";
+
/* Migrate webhook URLs from plaintext settings.json to Credential Manager */
if (root.TryGetProperty("teams_webhook_url", out v))
{
@@ -608,9 +627,12 @@ public static void LoadAlertSettings()
}
}
- /* Load webhook URLs from Credential Manager */
+ /* Load webhook URLs from Credential Manager. The generic channel's headers JSON rides the same
+ secure store as a URL — it carries the Authorization bearer token (#1506). */
TeamsWebhookUrl = GetWebhookUrl(TeamsWebhookCredentialKey);
SlackWebhookUrl = GetWebhookUrl(SlackWebhookCredentialKey);
+ GenericWebhookUrl = GetWebhookUrl(GenericWebhookCredentialKey);
+ GenericWebhookHeadersJson = GetWebhookUrl(GenericWebhookHeadersCredentialKey);
/* SMTP settings */
if (root.TryGetProperty("smtp_enabled", out v)) SmtpEnabled = v.GetBoolean();
diff --git a/Lite/MainWindow.AlertEngine.cs b/Lite/MainWindow.AlertEngine.cs
index 44994a7db2..65e10c67d1 100644
--- a/Lite/MainWindow.AlertEngine.cs
+++ b/Lite/MainWindow.AlertEngine.cs
@@ -13,6 +13,7 @@
using System.Threading.Tasks;
using System.Windows;
using PerformanceMonitor.Notifications;
+using PerformanceMonitorLite.Models;
using PerformanceMonitorLite.Services;
/* The whole per-check loop (CPU → blocking → deadlocks → poison waits → long-running queries →
tempdb → volume free space → anomalous jobs → failed jobs) lives in the shared
@@ -109,6 +110,71 @@ clears a previously-lit badge instead of leaving it stale. A false->true transit
}
}
+ ///
+ /// Delivers a connection-edge alert — email + webhook + the config_alert_log row — for one server
+ /// (#1506). Called from 's existing edge-triggered transitions,
+ /// which keep showing their tray balloon; this is purely ADDITIVE, and closes the gap where Lite was the
+ /// only app of the three that could not tell you your server had gone down unless you were watching the
+ /// tray (the Dashboard emails on the same edge, and Darling's DarlingSelfAlertEvaluator emails and
+ /// webhooks it).
+ ///
+ /// The metric names — "Server Unreachable" / "Server Restored" — are the ones the shared severity
+ /// map and already understand, so the alert
+ /// renders CRITICAL-red / RESOLVED-green and the history grid classifies the restore as a resolution
+ /// without any new naming.
+ ///
+ /// Fires once per edge. The caller's _previousConnectionStates dictionary is the edge
+ /// trigger: a server that stays offline is offline→offline and does not re-enter this method, so an
+ /// 8-hour outage produces ONE "Server Unreachable", not one per poll. There is deliberately no cooldown
+ /// re-fire here — an edge is a single event.
+ ///
+ /// Suppression matches Lite's other alerts: a mute rule flags the row muted and skips the channels
+ /// ('s own contract — the history row is still
+ /// written), and a silenced server delivers nothing at all, the same "Silence All Alerts covers the
+ /// connection edge too" rule the Dashboard applies. Never throws: the send is fire-and-forget (SMTP/HTTP
+ /// off a UI-timer tick) and TrySendAlertEmailAsync absorbs its own failures.
+ ///
+ private void SendConnectionAlert(ServerConnection server, string metricName, string currentValue, string detailText)
+ {
+ try
+ {
+ /* The name every other Lite alert keys on: the sweep's serverName is summary.DisplayName, which
+ MainWindow builds from DisplayNameWithIntent — so mute rules and history rows written here
+ match the ones the engine writes for the same server. */
+ var serverName = server.DisplayNameWithIntent;
+ var serverId = RemoteCollectorService.GetDeterministicHashCode(
+ RemoteCollectorService.GetServerNameForStorage(server));
+
+ /* A silenced/acknowledged server suppresses the new channels, exactly as it suppresses the sweep's
+ (ShouldShowAlerts is the engine's Suppressed input). The tray balloon in the caller is left
+ alone — it never consulted this, and this change must not regress it. */
+ if (!_alertStateService.ShouldShowAlerts(serverId.ToString()))
+ {
+ return;
+ }
+
+ bool isMuted = _muteRuleService.IsAlertMuted(new AlertMuteContext
+ {
+ ServerName = serverName,
+ MetricName = metricName
+ });
+
+ _ = _emailAlertService.TrySendAlertEmailAsync(
+ metricName,
+ serverName,
+ currentValue,
+ "Online",
+ serverId,
+ context: null,
+ muted: isMuted,
+ detailText: detailText);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error("ConnectionAlerts", $"Connection alert delivery failed for {metricName}: {ex.Message}");
+ }
+ }
+
///
/// The engine's live-msdb failed-jobs fetcher. Failure outcomes aren't part of the collected
/// running_jobs snapshot, so this queries the monitored server directly at alert-check time via
diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs
index 578c334f57..4fbe344d9e 100644
--- a/Lite/MainWindow.xaml.cs
+++ b/Lite/MainWindow.xaml.cs
@@ -1480,34 +1480,41 @@ private void CheckConnectionsAndNotify()
bool hasErrors = healthSummary?.ErroringCollectors > 0;
server.HasCollectorErrors = hasErrors;
- if (_previousConnectionStates.TryGetValue(server.Id, out var wasOnline))
+ /* null = never observed: a silent baseline (no edge, but a refresh). */
+ bool? previouslyOnline = _previousConnectionStates.TryGetValue(server.Id, out var prev) ? prev : null;
+ var connectionEdge = ConnectionEdgeDetector.Detect(previouslyOnline, isOnline);
+
+ if (App.AlertsEnabled && App.NotifyConnectionChanges)
{
- if (App.AlertsEnabled && App.NotifyConnectionChanges)
+ /* Each edge now ALSO routes through Lite's alert path — email + webhook + a
+ config_alert_log row (#1506) — alongside the tray balloon it always showed. The
+ detector is the edge trigger: a server that stays down is offline→offline, so an
+ outage produces ONE "Server Unreachable", not one per poll. */
+ if (connectionEdge == ConnectionAlertEdge.Lost)
{
- if (wasOnline && !isOnline)
- {
- _trayService?.ShowNotification(
- "Server Offline",
- $"{server.DisplayNameWithIntent} is unreachable: {status.ErrorMessage ?? "unknown error"}",
- Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Error);
- }
- else if (!wasOnline && isOnline)
- {
- _trayService?.ShowNotification(
- "Server Online",
- $"{server.DisplayNameWithIntent} is back online",
- Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Info);
- }
- }
+ var reason = status.ErrorMessage ?? "unknown error";
- if (wasOnline != isOnline)
+ _trayService?.ShowNotification(
+ "Server Offline",
+ $"{server.DisplayNameWithIntent} is unreachable: {reason}",
+ Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Error);
+
+ SendConnectionAlert(server, "Server Unreachable", reason, reason);
+ }
+ else if (connectionEdge == ConnectionAlertEdge.Restored)
{
- needsRefresh = true;
+ _trayService?.ShowNotification(
+ "Server Online",
+ $"{server.DisplayNameWithIntent} is back online",
+ Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Info);
+
+ SendConnectionAlert(server, "Server Restored", "Online", "Connection restored");
}
}
- else
+
+ /* Refresh on a genuine change, and on the first sighting of this server. */
+ if (previouslyOnline is null || previouslyOnline != isOnline)
{
- /* First time seeing this server's status — need to refresh */
needsRefresh = true;
}
diff --git a/Lite/Services/AppAlertSettings.cs b/Lite/Services/AppAlertSettings.cs
index b75ccaba72..f95c76ad8e 100644
--- a/Lite/Services/AppAlertSettings.cs
+++ b/Lite/Services/AppAlertSettings.cs
@@ -38,6 +38,12 @@ public sealed class AppAlertSettings : IAlertSettings
public string SlackWebhookUrl => App.SlackWebhookUrl;
public string SlackProxyAddress => App.SlackProxyAddress;
+ public bool GenericWebhookEnabled => App.GenericWebhookEnabled;
+ public string GenericWebhookUrl => App.GenericWebhookUrl;
+ public string GenericWebhookHeadersJson => App.GenericWebhookHeadersJson;
+ public string GenericWebhookBodyTemplate => App.GenericWebhookBodyTemplate;
+ public string GenericWebhookProxyAddress => App.GenericWebhookProxyAddress;
+
public double AnalysisNotifySeverity => App.AnalysisNotifySeverity;
public int AnalysisNotifyCooldownMinutes => App.AnalysisNotifyCooldownMinutes;
}
diff --git a/Lite/Services/ConnectionEdgeDetector.cs b/Lite/Services/ConnectionEdgeDetector.cs
new file mode 100644
index 0000000000..6f026924fa
--- /dev/null
+++ b/Lite/Services/ConnectionEdgeDetector.cs
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+namespace PerformanceMonitorLite.Services;
+
+/// The connection transition one poll observed for one server.
+public enum ConnectionAlertEdge
+{
+ /// No transition to announce: the steady states (online→online, offline→offline) and the
+ /// first-ever observation, which is a silent baseline.
+ None,
+
+ /// online → offline.
+ Lost,
+
+ /// offline → online.
+ Restored
+}
+
+///
+/// The connection-alert edge trigger (#1506), lifted out of MainWindow.CheckConnectionsAndNotify's
+/// inline condition so the property that actually matters is testable: an alert fires ONCE per transition,
+/// never once per poll. A server that is down for eight hours is observed offline hundreds of times, but
+/// every observation after the first is offline→offline — — so it
+/// produces one "Server Unreachable", not one per cycle.
+///
+/// Semantics are the pre-existing ones (previous-state dictionary, skip the first observation) and
+/// match the Dashboard's skip-first-check and Darling's Unknown/Online/Offline machine: a first
+/// sighting only establishes the baseline, so an app that starts up with a server already down does not
+/// immediately alert about a state it never saw change.
+///
+public static class ConnectionEdgeDetector
+{
+ /// The last observed state, or null when this server has never been observed.
+ /// The state this poll observed.
+ public static ConnectionAlertEdge Detect(bool? previous, bool current) => previous switch
+ {
+ null => ConnectionAlertEdge.None,
+ true when !current => ConnectionAlertEdge.Lost,
+ false when current => ConnectionAlertEdge.Restored,
+ _ => ConnectionAlertEdge.None
+ };
+}
diff --git a/Lite/Windows/SettingsWindow.xaml b/Lite/Windows/SettingsWindow.xaml
index 5002deb9e7..b94d06b5d3 100644
--- a/Lite/Windows/SettingsWindow.xaml
+++ b/Lite/Windows/SettingsWindow.xaml
@@ -484,6 +484,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Lite/Windows/SettingsWindow.xaml.cs b/Lite/Windows/SettingsWindow.xaml.cs
index 9d2f4e223d..73d4035a89 100644
--- a/Lite/Windows/SettingsWindow.xaml.cs
+++ b/Lite/Windows/SettingsWindow.xaml.cs
@@ -217,12 +217,12 @@ private async void SaveButton_Click(object sender, RoutedEventArgs e)
SaveTimeDisplayMode();
bool alertsValid = SaveAlertSettings();
SaveSmtpSettings();
- SaveWebhookSettings();
+ bool webhooksValid = SaveWebhookSettings();
_saved = true;
if (mcpChanged) McpSettingsChanged = true;
- if (!alertsValid || !mcpValid) return;
+ if (!alertsValid || !mcpValid || !webhooksValid) return;
var message = mcpChanged
? "Settings saved. MCP changes take effect after restarting the application."
@@ -949,7 +949,7 @@ private async void TestEmailButton_Click(object sender, RoutedEventArgs e)
}
// ============================================
- // Webhooks (Teams / Slack)
+ // Webhooks (Teams / Slack / Generic)
// ============================================
private void LoadWebhookSettings()
@@ -960,22 +960,61 @@ private void LoadWebhookSettings()
SlackWebhookEnabledCheckBox.IsChecked = App.SlackWebhookEnabled;
SlackWebhookUrlBox.Text = App.SlackWebhookUrl;
SlackProxyAddressBox.Text = App.SlackProxyAddress;
+ GenericWebhookEnabledCheckBox.IsChecked = App.GenericWebhookEnabled;
+ GenericWebhookUrlBox.Text = App.GenericWebhookUrl;
+ GenericWebhookHeadersBox.Text = App.GenericWebhookHeadersJson;
+ /* Blank means "use the built-in default" — show it, so the operator has something to edit
+ rather than a blank box they have to guess the shape of. */
+ GenericWebhookBodyBox.Text = string.IsNullOrWhiteSpace(App.GenericWebhookBodyTemplate)
+ ? WebhookAlertService.DefaultGenericBodyTemplate
+ : App.GenericWebhookBodyTemplate;
+ GenericWebhookProxyAddressBox.Text = App.GenericWebhookProxyAddress;
UpdateTeamsControlStates();
UpdateSlackControlStates();
+ UpdateGenericControlStates();
}
- private void SaveWebhookSettings()
+ ///
+ /// Persists the webhook settings. Returns false when the generic channel is enabled with a headers JSON
+ /// or body template that cannot produce a valid request — the values are still saved (so the operator
+ /// doesn't lose their typing), but they're told, because a broken template silently drops every alert.
+ /// Follows 's bool contract: the caller suppresses the "Settings saved" toast.
+ ///
+ private bool SaveWebhookSettings()
{
+ /* Cleartext warning (#1506): an http:// generic-webhook URL carrying headers sends the Authorization
+ token in the clear. Confirm before persisting a config that will transmit credentials unencrypted;
+ Yes proceeds, No cancels the webhook save (the typed values stay in the still-open dialog to fix).
+ NOT blocked — a plaintext POST to a trusted LAN listener is legitimate. */
+ if (GenericWebhookEnabledCheckBox.IsChecked == true
+ && WebhookAlertService.IsCleartextHttpWithHeaders(GenericWebhookUrlBox.Text?.Trim(), GenericWebhookHeadersBox.Text?.Trim())
+ && !ConfirmCleartextWebhook("Save"))
+ {
+ return false;
+ }
+
App.TeamsWebhookEnabled = TeamsWebhookEnabledCheckBox.IsChecked == true;
App.TeamsWebhookUrl = TeamsWebhookUrlBox.Text?.Trim() ?? "";
App.TeamsProxyAddress = TeamsProxyAddressBox.Text?.Trim() ?? "";
App.SlackWebhookEnabled = SlackWebhookEnabledCheckBox.IsChecked == true;
App.SlackWebhookUrl = SlackWebhookUrlBox.Text?.Trim() ?? "";
App.SlackProxyAddress = SlackProxyAddressBox.Text?.Trim() ?? "";
-
- /* Save webhook URLs to Credential Manager instead of settings.json */
+ App.GenericWebhookEnabled = GenericWebhookEnabledCheckBox.IsChecked == true;
+ App.GenericWebhookUrl = GenericWebhookUrlBox.Text?.Trim() ?? "";
+ App.GenericWebhookHeadersJson = GenericWebhookHeadersBox.Text?.Trim() ?? "";
+ /* Persist the empty "use built-in default" sentinel unless the operator actually edited the body box
+ (LoadWebhookSettings pre-fills it with the default), so a future release can still improve it. */
+ App.GenericWebhookBodyTemplate = WebhookAlertService.IsDefaultBodyTemplate(GenericWebhookBodyBox.Text)
+ ? ""
+ : GenericWebhookBodyBox.Text?.Trim() ?? "";
+ App.GenericWebhookProxyAddress = GenericWebhookProxyAddressBox.Text?.Trim() ?? "";
+
+ /* Save webhook URLs to Credential Manager instead of settings.json. The generic channel's headers
+ JSON goes there too — it carries the Authorization bearer token (#1506). */
App.SaveWebhookUrl("TeamsWebhook", App.TeamsWebhookUrl);
App.SaveWebhookUrl("SlackWebhook", App.SlackWebhookUrl);
+ App.SaveWebhookUrl("GenericWebhook", App.GenericWebhookUrl);
+ App.SaveWebhookUrl("GenericWebhookHeaders", App.GenericWebhookHeadersJson);
var settingsPath = Path.Combine(App.ConfigDirectory, "settings.json");
try
@@ -996,6 +1035,12 @@ private void SaveWebhookSettings()
root["slack_webhook_enabled"] = App.SlackWebhookEnabled;
root["slack_proxy_address"] = App.SlackProxyAddress;
+ /* The generic channel's URL + headers are secrets and live in Credential Manager; only these
+ three are safe to persist in settings.json (#1506). */
+ root["generic_webhook_enabled"] = App.GenericWebhookEnabled;
+ root["generic_proxy_address"] = App.GenericWebhookProxyAddress;
+ root["generic_body_template"] = App.GenericWebhookBodyTemplate;
+
/* Remove legacy plaintext webhook URLs from settings.json */
if (root is JsonObject obj)
{
@@ -1010,6 +1055,22 @@ private void SaveWebhookSettings()
{
AppLogger.Error("Settings", $"Failed to save webhook settings: {ex.Message}");
}
+
+ if (App.GenericWebhookEnabled)
+ {
+ var configError = WebhookAlertService.ValidateGenericConfig(
+ App.GenericWebhookHeadersJson, App.GenericWebhookBodyTemplate);
+
+ if (configError != null)
+ {
+ MessageBox.Show(
+ $"The generic webhook is enabled but its configuration is not valid, so it will not deliver alerts:\n\n{configError}",
+ "Generic Webhook", MessageBoxButton.OK, MessageBoxImage.Warning);
+ return false;
+ }
+ }
+
+ return true;
}
private void TeamsWebhookEnabledCheckBox_Changed(object sender, RoutedEventArgs e)
@@ -1030,6 +1091,21 @@ private void UpdateTeamsControlStates()
TestTeamsButton.IsEnabled = enabled;
}
+ private void GenericWebhookEnabledCheckBox_Changed(object sender, RoutedEventArgs e)
+ {
+ UpdateGenericControlStates();
+ }
+
+ private void UpdateGenericControlStates()
+ {
+ bool enabled = GenericWebhookEnabledCheckBox.IsChecked == true;
+ GenericWebhookUrlBox.IsEnabled = enabled;
+ GenericWebhookHeadersBox.IsEnabled = enabled;
+ GenericWebhookBodyBox.IsEnabled = enabled;
+ GenericWebhookProxyAddressBox.IsEnabled = enabled;
+ TestGenericButton.IsEnabled = enabled;
+ }
+
private void UpdateSlackControlStates()
{
bool enabled = SlackWebhookEnabledCheckBox.IsChecked == true;
@@ -1100,6 +1176,67 @@ private async void TestSlackButton_Click(object sender, RoutedEventArgs e)
}
}
+ ///
+ /// Non-blocking confirm shown when an http:// generic-webhook URL carries headers — the Authorization
+ /// token would go on the wire in cleartext (#1506). Yes proceeds; No cancels the action. is the verb ("Save" / "Send"). Not blocked outright: a plaintext POST to a trusted LAN
+ /// listener is a legitimate setup.
+ ///
+ private bool ConfirmCleartextWebhook(string action)
+ {
+ var result = MessageBox.Show(
+ "The webhook URL uses http://, so the Authorization header and any credentials are sent in cleartext " +
+ $"and can be intercepted on the network.\n\n{action} anyway?",
+ "Insecure Webhook URL", MessageBoxButton.YesNo, MessageBoxImage.Warning);
+ return result == MessageBoxResult.Yes;
+ }
+
+ ///
+ /// Tests the generic webhook with the values currently in the boxes (not the saved ones), like the
+ /// Teams/Slack test buttons. A malformed headers JSON or body template comes back as the error message
+ /// rather than an exception, so the operator fixes it here instead of discovering it when a real alert
+ /// silently fails to deliver.
+ ///
+ private async void TestGenericButton_Click(object sender, RoutedEventArgs e)
+ {
+ var url = GenericWebhookUrlBox.Text?.Trim() ?? "";
+ var headers = GenericWebhookHeadersBox.Text?.Trim();
+
+ /* Warn before a cleartext http:// POST would send the Authorization header unencrypted (#1506). */
+ if (WebhookAlertService.IsCleartextHttpWithHeaders(url, headers) && !ConfirmCleartextWebhook("Send"))
+ {
+ return;
+ }
+
+ TestGenericButton.IsEnabled = false;
+ TestGenericButton.Content = "Sending...";
+
+ try
+ {
+ var body = GenericWebhookBodyBox.Text?.Trim();
+ var proxy = GenericWebhookProxyAddressBox.Text?.Trim();
+ var error = await WebhookAlertService.SendTestGenericAsync(url, headers, body, proxy, EmailAlertService.Branding);
+
+ if (error == null)
+ {
+ MessageBox.Show("Generic webhook test notification sent successfully!", "Test Webhook", MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+ else
+ {
+ MessageBox.Show($"Failed to send generic webhook test notification:\n\n{error}", "Test Webhook Failed", MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show($"Failed to send generic webhook test notification:\n\n{ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ finally
+ {
+ TestGenericButton.Content = "Send Test Notification";
+ TestGenericButton.IsEnabled = true;
+ }
+ }
+
private void Hyperlink_RequestNavigate(object sender, RequestNavigateEventArgs e)
{
try
diff --git a/PerformanceMonitor.Notifications/IAlertSettings.cs b/PerformanceMonitor.Notifications/IAlertSettings.cs
index f1931a62d8..da9fdecedd 100644
--- a/PerformanceMonitor.Notifications/IAlertSettings.cs
+++ b/PerformanceMonitor.Notifications/IAlertSettings.cs
@@ -43,6 +43,31 @@ public interface IAlertSettings
string SlackWebhookUrl { get; }
string SlackProxyAddress { get; }
+ /* Generic webhook (#1506): POSTs an operator-authored JSON body to any endpoint, so an alert can
+ drive a system we ship no adapter for — PagerDuty, Opsgenie, n8n, or a GitHub repository_dispatch
+ that re-runs a workflow. Deliberately the answer to "run a script/exe on alert": it covers the
+ same automation need with no process-execution surface in a signed binary. */
+ bool GenericWebhookEnabled { get; }
+ string GenericWebhookUrl { get; }
+
+ ///
+ /// A JSON object of request headers, e.g. {"Authorization":"Bearer ghp_...","Accept":"application/vnd.github+json"}.
+ /// Carries bearer tokens, so every app stores this alongside the URL as a SECRET (Lite/Dashboard:
+ /// Credential Manager; Darling: a column-REVOKEd control-plane column). Malformed JSON fails the
+ /// send with a logged error rather than throwing into the alert loop.
+ ///
+ string GenericWebhookHeadersJson { get; }
+
+ ///
+ /// The JSON request body, with {{metric}}, {{server}}, {{value}},
+ /// {{threshold}}, {{severity}}, {{context}} and {{timestamp}}
+ /// placeholders substituted per alert. Empty falls back to
+ /// .
+ ///
+ string GenericWebhookBodyTemplate { get; }
+
+ string GenericWebhookProxyAddress { get; }
+
/* Scheduled-analysis notifications */
double AnalysisNotifySeverity { get; }
int AnalysisNotifyCooldownMinutes { get; }
diff --git a/PerformanceMonitor.Notifications/WebhookAlertService.cs b/PerformanceMonitor.Notifications/WebhookAlertService.cs
index 5e74123f1a..f70af624f8 100644
--- a/PerformanceMonitor.Notifications/WebhookAlertService.cs
+++ b/PerformanceMonitor.Notifications/WebhookAlertService.cs
@@ -9,6 +9,7 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
+using System.Globalization;
using System.Net;
using System.Net.Http;
using System.Text;
@@ -19,7 +20,7 @@
namespace PerformanceMonitor.Notifications;
///
-/// Sends alert notifications to Microsoft Teams and/or Slack via incoming webhooks.
+/// Sends alert notifications to Microsoft Teams, Slack, and/or a generic JSON endpoint via webhooks.
/// Color-coded accent bars match the existing email alert severity mapping.
///
/// Shared between Lite and Dashboard (Plan E E3c). Per-app branding (edition label +
@@ -29,6 +30,12 @@ namespace PerformanceMonitor.Notifications;
/// MCP/health surfaces stay app-side: Dashboard keeps a reference to the injected
/// instance.
///
+///
+/// The generic channel (#1506) is the deliberate answer to "run a script/exe when an alert fires":
+/// it drives arbitrary automation (PagerDuty, Opsgenie, n8n, a GitHub repository_dispatch that
+/// re-runs a workflow) through an operator-authored JSON body + headers, with no process-execution
+/// surface in a signed binary.
+///
///
public class WebhookAlertService
{
@@ -37,6 +44,28 @@ at the email / in-app dialog instead. */
private const string TsqlWebhookHint = "See email or in-app Alert Details for the copy-paste T-SQL.";
private static readonly JsonSerializerOptions s_jsonOptions = new() { PropertyNamingPolicy = null };
+ ///
+ /// The generic channel's body template when the operator has not authored one — a flat JSON object
+ /// exercising every placeholder, which most endpoints (n8n, Zapier, a bare HTTP listener) accept as-is.
+ ///
+ public const string DefaultGenericBodyTemplate =
+ """
+ {
+ "severity": "{{severity}}",
+ "metric": "{{metric}}",
+ "server": "{{server}}",
+ "value": "{{value}}",
+ "threshold": "{{threshold}}",
+ "context": "{{context}}",
+ "timestamp": "{{timestamp}}"
+ }
+ """;
+
+ /* GitHub's REST API 403s any request without a User-Agent, and repository_dispatch is the motivating
+ use case (#1506), so the generic channel defaults one instead of handing the operator a 403 they
+ have to diagnose. An explicit User-Agent in the headers JSON overrides it. */
+ private const string DefaultUserAgent = "PerformanceMonitor";
+
/* #1154: per-incident-fingerprint cooldown (was a per-(serverId, metricName)
ConcurrentDictionary). Keyed per #1140 dedup fingerprint so a distinct incident in the
window is delivered; falls back to the metric-level key when an alert carries no
@@ -50,6 +79,8 @@ fingerprintable incident. */
private string? _lastTeamsError;
private int _consecutiveSlackFailures;
private string? _lastSlackError;
+ private int _consecutiveGenericFailures;
+ private string? _lastGenericError;
///
/// Optional alert-history store used to seed the per-fingerprint webhook cooldown across an app
@@ -114,6 +145,11 @@ the alert log on first touch per key (#1145), unless the store is null (no seedi
sent |= await TrySendSlackAlertAsync(metricName, serverName, currentValue, thresholdValue, context);
}
+ if (_settings.GenericWebhookEnabled && !string.IsNullOrWhiteSpace(_settings.GenericWebhookUrl))
+ {
+ sent |= await TrySendGenericAlertAsync(metricName, serverName, currentValue, thresholdValue, context);
+ }
+
if (sent)
{
_cooldown.Stamp(decision);
@@ -166,12 +202,48 @@ the alert log on first touch per key (#1145), unless the store is null (no seedi
}
}
+ ///
+ /// Sends a test notification to the generic endpoint. Returns null on success, error message on failure.
+ /// Surfaces a bad template / bad headers JSON as that error message, so the operator sees the problem in
+ /// the settings window instead of only in a log after the next real alert.
+ ///
+ public static async Task SendTestGenericAsync(
+ string webhookUrl,
+ string? headersJson,
+ string? bodyTemplate,
+ string? proxyAddress,
+ AlertBranding branding)
+ {
+ try
+ {
+ if (string.IsNullOrWhiteSpace(webhookUrl))
+ return "Webhook URL is not configured.";
+
+ if (!TryParseHeaders(headersJson, out var headers, out var headerError))
+ return headerError;
+
+ var payload = BuildGenericPayload("Test Notification", "", "Webhook configuration verified", "", branding, isTest: true, bodyTemplate: bodyTemplate);
+
+ if (!IsWellFormedJson(payload, out var bodyError))
+ return bodyError;
+
+ return await PostWebhookAsync(webhookUrl, payload, proxyAddress, headers);
+ }
+ catch (Exception ex)
+ {
+ return ex.Message;
+ }
+ }
+
public (int ConsecutiveFailures, string? LastError) GetTeamsHealth() =>
(_consecutiveTeamsFailures, _lastTeamsError);
public (int ConsecutiveFailures, string? LastError) GetSlackHealth() =>
(_consecutiveSlackFailures, _lastSlackError);
+ public (int ConsecutiveFailures, string? LastError) GetGenericHealth() =>
+ (_consecutiveGenericFailures, _lastGenericError);
+
#region Teams
private async Task TrySendTeamsAlertAsync(
@@ -472,6 +544,336 @@ internal static string BuildSlackPayload(
#endregion
+ #region Generic
+
+ private async Task TrySendGenericAlertAsync(
+ string metricName,
+ string serverName,
+ string currentValue,
+ string thresholdValue,
+ AlertContext? context)
+ {
+ try
+ {
+ /* A malformed headers JSON / body template is an operator config error, not a transport
+ failure — but it still counts as a failure so the health surface and the log throttle
+ report a channel that is delivering nothing, and it must never throw into the alert loop. */
+ if (!TryParseHeaders(_settings.GenericWebhookHeadersJson, out var headers, out var headerError))
+ {
+ RecordGenericFailure(headerError!);
+ return false;
+ }
+
+ var payload = BuildGenericPayload(
+ metricName, serverName, currentValue, thresholdValue, _branding,
+ context: context, bodyTemplate: _settings.GenericWebhookBodyTemplate);
+
+ if (!IsWellFormedJson(payload, out var bodyError))
+ {
+ RecordGenericFailure(bodyError!);
+ return false;
+ }
+
+ var error = await PostWebhookAsync(
+ _settings.GenericWebhookUrl, payload, _settings.GenericWebhookProxyAddress, headers);
+
+ if (error != null)
+ {
+ RecordGenericFailure(error);
+ return false;
+ }
+
+ if (_consecutiveGenericFailures > 0)
+ _logger.LogInformation($"Generic webhook recovered after {_consecutiveGenericFailures} failure(s)");
+
+ _consecutiveGenericFailures = 0;
+ _lastGenericError = null;
+ _logger.LogInformation($"Generic webhook sent for {metricName} on {serverName}");
+ return true;
+ }
+ catch (Exception ex)
+ {
+ _consecutiveGenericFailures++;
+ _lastGenericError = ex.Message;
+ _logger.LogError($"Generic webhook error: {ex.Message}");
+ return false;
+ }
+ }
+
+ /* The Teams/Slack log-throttle shape (loud for the first 3, then every 50th) — factored out only
+ because the generic channel fails from three places (headers, body, transport). */
+ private void RecordGenericFailure(string error)
+ {
+ _consecutiveGenericFailures++;
+ _lastGenericError = error;
+
+ if (_consecutiveGenericFailures <= 3)
+ _logger.LogError($"GENERIC WEBHOOK FAILED ({_consecutiveGenericFailures}x): {error}");
+ else if (_consecutiveGenericFailures % 50 == 0)
+ _logger.LogError($"GENERIC WEBHOOK STILL FAILING: {_consecutiveGenericFailures} failures. Last: {error}");
+ }
+
+ ///
+ /// Substitutes the alert's values into the operator's JSON body template. Every placeholder expands to
+ /// JSON-ESCAPED text: the tokens sit inside JSON string literals in the template
+ /// ("server": "{{server}}"), so a server name, wait type, or error message containing a quote or
+ /// backslash would otherwise terminate the literal early and corrupt — or inject into — the posted JSON.
+ /// The escaping goes through (see ) — NOT
+ /// JsonEncodedText.Encode, which throws on the lone surrogates SQL Server names can carry — and the
+ /// two surrounding quotes are stripped to leave exactly the escaped INNER text a token inside a literal needs.
+ ///
+ internal static string BuildGenericPayload(
+ string metricName,
+ string serverName,
+ string currentValue,
+ string thresholdValue,
+ AlertBranding branding,
+ bool isTest = false,
+ AlertContext? context = null,
+ string? bodyTemplate = null)
+ {
+ var (_, badgeText, _) = AlertSeverity.ForMetric(metricName, context?.SeverityOverride);
+ var template = string.IsNullOrWhiteSpace(bodyTemplate) ? DefaultGenericBodyTemplate : bodyTemplate!;
+
+ var contextText = isTest
+ ? $"Webhook configuration is working correctly. Sent by {branding.EditionName}."
+ : RenderContextForTemplate(context, branding);
+
+ var values = new Dictionary(StringComparer.Ordinal)
+ {
+ ["metric"] = EscapeForJson(isTest ? $"TEST — {metricName}" : metricName),
+ ["server"] = EscapeForJson(serverName),
+ ["value"] = EscapeForJson(currentValue),
+ ["threshold"] = EscapeForJson(thresholdValue),
+ ["severity"] = EscapeForJson(isTest ? "TEST" : badgeText),
+ ["context"] = EscapeForJson(contextText),
+ ["timestamp"] = EscapeForJson(DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)),
+ };
+
+ /* Single pass: a MatchEvaluator's output is NOT re-scanned, so a value that itself contains the
+ literal text of another token (e.g. a server name "{{timestamp}}") is left verbatim rather than
+ re-expanded — which chained .Replace() calls would do, letting one field pull another's contents
+ into itself. An unknown "{{foo}}" isn't in the alternation, so it stays literal (today's behavior). */
+ return s_genericPlaceholders.Replace(template, m => values[m.Groups[1].Value]);
+ }
+
+ private static readonly System.Text.RegularExpressions.Regex s_genericPlaceholders =
+ new(@"\{\{(metric|server|value|threshold|severity|context|timestamp)\}\}",
+ System.Text.RegularExpressions.RegexOptions.Compiled);
+
+ ///
+ /// Flattens the structured alert context into one plain-text line for {{context}} — the generic
+ /// endpoint has no card schema to render into. Follows the Teams/Slack rule: remediation T-SQL is never
+ /// inlined, it points at the email / in-app dialog.
+ ///
+ private static string RenderContextForTemplate(AlertContext? context, AlertBranding branding)
+ {
+ if (context?.Details is not { Count: > 0 })
+ {
+ return $"Sent by {branding.EditionName}";
+ }
+
+ var parts = new List();
+
+ foreach (var detail in context.Details)
+ {
+ if (detail.IsCodeBlock)
+ {
+ parts.Add($"{detail.Heading}: {TsqlWebhookHint}");
+ continue;
+ }
+
+ if (!string.IsNullOrEmpty(detail.Body))
+ {
+ parts.Add($"{detail.Heading}: {detail.Body.Replace("\n\n", " ", StringComparison.Ordinal).Replace("\n", " ", StringComparison.Ordinal)}");
+ continue;
+ }
+
+ foreach (var (label, value) in detail.Fields)
+ {
+ parts.Add($"{detail.Heading} — {label}: {value}");
+ }
+ }
+
+ return parts.Count == 0 ? $"Sent by {branding.EditionName}" : string.Join(" | ", parts);
+ }
+
+ ///
+ /// Escapes a value for interpolation INSIDE a JSON string literal — the escaped inner text, without the
+ /// surrounding quotes. HTML-sensitive and non-ASCII characters escape to \uXXXX; over-escaping is
+ /// still valid JSON and keeps the payload ASCII-safe.
+ ///
+ /// Uses rather than deliberately:
+ /// SQL Server nvarchar/sysname can hold a LONE SURROGATE (e.g. an object or login name containing
+ /// NCHAR(0xD800), or a RAISERROR message), and JsonEncodedText.Encode THROWS
+ /// on invalid UTF-16 — which would let a low-privileged user drop the
+ /// generic webhook for the very incident they cause while email/Teams/Slack (which use
+ /// JsonSerializer) still deliver. The serializer relaxes invalid UTF-16 to U+FFFD and never throws,
+ /// matching the sibling channels. The result is a quoted JSON string; strip the two surrounding quotes to
+ /// get the inner text a token inside a literal needs.
+ ///
+ ///
+ private static string EscapeForJson(string? value)
+ {
+ var quoted = JsonSerializer.Serialize(value ?? "");
+ return quoted.Substring(1, quoted.Length - 2);
+ }
+
+ ///
+ /// Validates the generic channel's headers JSON + body template without sending anything — for the
+ /// settings windows' Save path, so a typo is caught where the operator can fix it rather than silently
+ /// dropping every future alert. Returns null when the config is usable, else the error to show.
+ /// Both inputs are optional: empty headers mean "no custom headers", an empty template means
+ /// .
+ ///
+ public static string? ValidateGenericConfig(string? headersJson, string? bodyTemplate)
+ {
+ if (!TryParseHeaders(headersJson, out _, out var headerError))
+ {
+ return headerError;
+ }
+
+ /* Render with placeholder-shaped stand-ins: substitution is what can break the JSON, so validating
+ the raw template would miss a token sitting outside a string literal. */
+ var rendered = BuildGenericPayload(
+ "Test Notification", "Test Server", "0", "0",
+ new AlertBranding("Performance Monitor", null), isTest: true, bodyTemplate: bodyTemplate);
+
+ return IsWellFormedJson(rendered, out var bodyError) ? null : bodyError;
+ }
+
+ ///
+ /// True when the text is the built-in default body template (newline-insensitive), or empty. The settings
+ /// windows pre-fill the body box with when nothing is stored, so
+ /// Save uses this to persist the empty "use the default" sentinel instead of freezing a copy of today's
+ /// default — which would otherwise lock that operator out of future default improvements.
+ ///
+ public static bool IsDefaultBodyTemplate(string? text)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ return true;
+ }
+
+ static string Normalize(string s) => s.Replace("\r\n", "\n", StringComparison.Ordinal).Replace("\r", "\n", StringComparison.Ordinal).Trim();
+ return string.Equals(Normalize(text), Normalize(DefaultGenericBodyTemplate), StringComparison.Ordinal);
+ }
+
+ ///
+ /// True when the generic webhook would send credentials in the clear: an http:// (not https) URL
+ /// carrying at least one header — the headers hold the Authorization bearer token, which a plaintext
+ /// POST exposes to anyone on the path. The settings windows use this to prompt a non-blocking confirm at
+ /// Save/Test (it is NOT blocked: a plaintext POST to a trusted LAN listener is legitimate). Returns false
+ /// for https, for a headerless http URL, and for a URL that does not parse (the send surfaces its own error).
+ ///
+ public static bool IsCleartextHttpWithHeaders(string? url, string? headersJson)
+ {
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)
+ || !string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ /* Only warn when a header is actually present; a malformed headers JSON is caught by its own
+ validation path, so treat "doesn't parse" as "no header to expose here". */
+ return TryParseHeaders(headersJson, out var headers, out _) && headers.Count > 0;
+ }
+
+ ///
+ /// Parses the operator's headers JSON object into request headers. An empty/whitespace value is valid
+ /// (no custom headers). Malformed JSON, a non-object root, or a non-string value returns false with a
+ /// clear message the caller reports — the alert loop never sees an exception.
+ ///
+ internal static bool TryParseHeaders(
+ string? headersJson,
+ out Dictionary headers,
+ out string? error)
+ {
+ headers = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ error = null;
+
+ if (string.IsNullOrWhiteSpace(headersJson))
+ {
+ return true;
+ }
+
+ try
+ {
+ using var document = JsonDocument.Parse(headersJson);
+
+ if (document.RootElement.ValueKind != JsonValueKind.Object)
+ {
+ error = "Webhook headers must be a JSON object, e.g. {\"Authorization\": \"Bearer \"}.";
+ return false;
+ }
+
+ foreach (var property in document.RootElement.EnumerateObject())
+ {
+ if (property.Value.ValueKind != JsonValueKind.String)
+ {
+ error = $"Webhook header '{property.Name}' must have a string value.";
+ return false;
+ }
+
+ var value = property.Value.GetString() ?? "";
+
+ /* Reject CR/LF in the name or value. HttpRequestHeaders.TryAddWithoutValidation lets a CR/LF
+ in a VALUE through onto the wire, splitting one header into two — so a bearer token pasted
+ with a stray newline would silently smuggle a garbage header. Caught here (which runs at
+ Save/Test via ValidateGenericConfig) the operator sees it immediately. */
+ if (HasControlChar(property.Name) || HasControlChar(value))
+ {
+ error = $"Webhook header '{property.Name}' may not contain control characters (e.g. CR or LF).";
+ return false;
+ }
+
+ headers[property.Name] = value;
+ }
+
+ return true;
+ }
+ catch (JsonException ex)
+ {
+ error = $"Webhook headers are not valid JSON: {ex.Message}";
+ return false;
+ }
+ }
+
+ /// True when the string contains any C0/C1 control character (CR, LF, tab, NUL, …).
+ private static bool HasControlChar(string s)
+ {
+ foreach (var c in s)
+ {
+ if (char.IsControl(c))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ ///
+ /// Validates the substituted body before it goes on the wire, so a typo in the operator's template is
+ /// reported as a config error (in the settings test, or the channel's health) rather than as an opaque
+ /// 400 from the endpoint.
+ ///
+ private static bool IsWellFormedJson(string payload, out string? error)
+ {
+ try
+ {
+ using var _ = JsonDocument.Parse(payload);
+ error = null;
+ return true;
+ }
+ catch (JsonException ex)
+ {
+ error = $"Webhook body template did not produce valid JSON: {ex.Message}";
+ return false;
+ }
+ }
+
+ #endregion
+
#region Shared
///
@@ -519,19 +921,65 @@ private static HttpClient GetHttpClient(string? proxyAddress)
///
/// Posts a JSON payload to a webhook URL. Returns null on success, error message on failure.
///
- private static async Task PostWebhookAsync(string webhookUrl, string jsonPayload, string? proxyAddress)
+ ///
+ /// The generic channel's operator-authored request headers. null — what Teams/Slack pass — sends
+ /// exactly today's request (no custom headers, no User-Agent); those two endpoints need neither.
+ ///
+ private static async Task PostWebhookAsync(
+ string webhookUrl,
+ string jsonPayload,
+ string? proxyAddress,
+ IReadOnlyDictionary? headers = null)
{
var client = GetHttpClient(proxyAddress);
using var content = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
+ using var request = new HttpRequestMessage(HttpMethod.Post, webhookUrl) { Content = content };
+
+ if (headers != null)
+ {
+ ApplyHeaders(request, content, headers);
+ }
- var response = await client.PostAsync(webhookUrl, content);
+ using var response = await client.SendAsync(request);
if (response.IsSuccessStatusCode)
return null;
+ /* Cap the destination's error body: it goes into the log + the health getter, and an unbounded read
+ lets a hostile/misconfigured endpoint bloat both (and, if it echoes request headers, spill more of
+ them). The first 2 KB is plenty to diagnose a 4xx/5xx. */
var body = await response.Content.ReadAsStringAsync();
+ if (body.Length > 2048)
+ body = string.Concat(body.AsSpan(0, 2048), "…(truncated)");
+
return $"HTTP {(int)response.StatusCode}: {body}";
}
+ ///
+ /// Applies the operator's headers to the request, routing content headers (a Content-Type override) onto
+ /// the content — setting one on is rejected, so a naive add would
+ /// silently drop it. Values are added without validation: they are the operator's own, and a strict parse
+ /// would reject perfectly good tokens (CR/LF is already rejected upstream in ).
+ ///
+ internal static void ApplyHeaders(HttpRequestMessage request, HttpContent content, IReadOnlyDictionary headers)
+ {
+ foreach (var (name, value) in headers)
+ {
+ if (request.Headers.TryAddWithoutValidation(name, value))
+ {
+ continue;
+ }
+
+ /* A content header (e.g. Content-Type) — StringContent already set one, so replace it. */
+ content.Headers.Remove(name);
+ content.Headers.TryAddWithoutValidation(name, value);
+ }
+
+ if (!request.Headers.Contains("User-Agent"))
+ {
+ request.Headers.TryAddWithoutValidation("User-Agent", DefaultUserAgent);
+ }
+ }
+
#endregion
}