@calebcall/camera-ui-notify 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,112 @@ All notable changes to **Notify** are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.7.0] - 2026-08-02
9
+
10
+ ### Changed
11
+
12
+ - **The Grafana `alerts` mode is renamed `alertmanager` and now posts to an Alertmanager directly**
13
+ ([#33](https://github.com/calebcall/camera-ui-notify/issues/33)). **This mode requires
14
+ reconfiguration.** It never worked in 0.6.0 or 0.6.1 — every send failed with
15
+ `400 bad request data` — so no working setup is disturbed.
16
+
17
+ The mode targeted `{grafanaServer}/api/alertmanager/grafana/api/v2/alerts` on the assumption that
18
+ Grafana's built-in Alertmanager accepts injected alerts. It does not. Grafana's route table
19
+ declares built-in-Alertmanager operations with a literal `grafana` path segment and external ones
20
+ with `{DatasourceUID}`; there is a `GET /alertmanager/grafana/api/v2/alerts` but **no `POST`
21
+ equivalent** — reads are supported, writes are not. The forking handler
22
+ (`pkg/services/ngalert/api/forking_alertmanager.go`) resolves an Alertmanager *datasource* by UID
23
+ and always returns the external proxy, never the built-in service, so a request naming `grafana`
24
+ as the UID matches no datasource and fails. Grafana-managed alerts can only originate from
25
+ Grafana's own rule evaluation; there is no supported path to inject one.
26
+
27
+ The mode now addresses an Alertmanager's own v2 API — `POST {alertmanager}/api/v2/alerts` — with
28
+ optional basic auth. That works with a standalone Prometheus Alertmanager, Mimir/Cortex, and
29
+ Grafana Cloud's hosted Alertmanager (username = instance ID, password = API token). The name
30
+ follows the behaviour: this mode talks to Alertmanager, not to Grafana.
31
+
32
+ Config changes for this mode: `grafana_server` and `grafana_token` are no longer used (they are
33
+ now annotations-only) and are replaced by `grafana_am_url` plus the optional `grafana_am_user` /
34
+ `grafana_am_password` pair. Setting only one of the two credential fields is rejected at parse
35
+ time rather than surfacing as a confusing 401. `grafana_alertname` and `grafana_ttl` are
36
+ unchanged, and the alert payload itself is unchanged — it already matched Alertmanager's
37
+ documented `postableAlert` schema; only the destination was wrong.
38
+
39
+ - **`grafana_server` / `grafana_token` are now gated to annotations mode only**, since it is the
40
+ one mode that addresses a Grafana instance. They no longer use the `in` condition operator.
41
+
42
+ ### Fixed
43
+
44
+ - **Grafana modes now label the camera with its display name instead of its UUID**
45
+ ([#34](https://github.com/calebcall/camera-ui-notify/issues/34)). `Data["cameraId"]` is a UUID for
46
+ the publishers seen so far, so alerts read `camera=07614b1d-d5de-48b7-bbb2-592a64a97ead` and IRM
47
+ grouped under `camera.ui:07614b1d-…`, which is unusable at a glance.
48
+
49
+ The name is resolved cheapest-first: `Data["cameraName"]` if a publisher supplies one, else the
50
+ camera segment of the deep link (camera.ui routes cameras by display name, so this is `Patio`
51
+ while the id is a UUID), else the raw id so a notification with neither still gets a label. No
52
+ RPC and no camera lookup — `DeviceManager.GetCamera` would also resolve a name but builds a full
53
+ camera-device proxy and calls `init()` on it, which is heavy and side-effecting for a Hub plugin
54
+ that owns no cameras.
55
+
56
+ This reaches the annotations `camera:<name>` tag, the alertmanager `camera` label, and the IRM
57
+ `camera` label, `groupLabels` and `groupKey`. Alertmanager and IRM modes additionally keep the
58
+ raw id as `camera_id` (omitted when it would merely repeat `camera`), since names change and ids
59
+ do not — routing rules that must survive a rename can match on that.
60
+
61
+ **IRM alert groups will regroup**: existing groups keyed `camera.ui:<uuid>` are replaced by
62
+ `camera.ui:<name>`.
63
+
64
+ - **`grafana_ttl` no longer sets a `Step`.** With `min=30 step=30`, an HTML5 number input rejects
65
+ legitimate values such as 100 or 450. Nothing about a TTL needs 30-second granularity.
66
+
67
+ ### Added
68
+
69
+ - **`grafana_irm_ttl`** (default `300`, minimum `30`). IRM alerts now carry a future `endsAt`
70
+ (`startsAt + grafana_irm_ttl`) instead of the never-resolves sentinel `0001-01-01T00:00:00Z`.
71
+ That is how Alertmanager expresses "resolves on its own at this time", and IRM's
72
+ `grafana_alerting` templates read the same envelope — but whether IRM actually acts on it is
73
+ **unverified**. If it does not, groups stay open until closed by hand, exactly as before, so this
74
+ is an improvement-or-no-change rather than a risk. Still one stateless POST per event: there is
75
+ no follow-up `state: "ok"` request and no background timer.
76
+
77
+ ## [0.6.1] - 2026-08-02
78
+
79
+ ### Fixed
80
+
81
+ - **Grafana IRM mode now renders correctly on a `grafana_alerting` integration** ([#31](https://github.com/calebcall/camera-ui-notify/issues/31)).
82
+ 0.6.0 sent only Grafana OnCall's formatted-webhook field set. IRM renders each alert group
83
+ through Jinja2 templates chosen by the integration's *type*, and a `grafana_alerting`
84
+ integration's templates read `payload.status` and `payload.alerts[]` — neither of which we sent.
85
+ The result was an alert group that arrived intact but displayed as
86
+ `Status: Unknown ⚠️ (Template Warning: 'dict object' has no attribute 'alerts')`, with
87
+ `numFiring`/`numResolved` showing IRM's zero defaults for the absent `alerts` array.
88
+
89
+ The payload is now a union of both shapes: Grafana Alerting's documented webhook envelope
90
+ (`receiver`, `status`, `alerts[]` carrying labels, annotations, `startsAt`/`endsAt`,
91
+ `generatorURL`, `fingerprint` and `imageURL`, plus `groupLabels`, `commonLabels`,
92
+ `commonAnnotations`, `externalURL`, `version`, `groupKey`, `truncatedAlerts`) alongside the
93
+ formatted-webhook fields 0.6.0 already sent. `title`, `message` and `state` are read by both and
94
+ are unchanged, so a **Webhook**-type integration configured against 0.6.0 keeps working exactly
95
+ as before — this is additive, not a replacement.
96
+
97
+ This was a design-time error rather than an implementation one: the spec specified the wrong
98
+ payload shape, and the unit tests could not catch it because they assert our body against a test
99
+ server that accepts anything. The envelope now follows the schema Grafana documents for its
100
+ webhook contact point.
101
+
102
+ ### Added
103
+
104
+ - **Per-camera grouping for IRM alert groups.** `groupKey` is `camera.ui:<cameraId>`, falling back
105
+ to `camera.ui` when a notification names no camera, so a busy camera cannot bury a quiet one.
106
+ Each event keeps a distinct `fingerprint` within its group. IRM groups still do not auto-resolve
107
+ — that remains deliberate, since a follow-up `state: "ok"` would need a background timer.
108
+ - **The snapshot now reaches IRM through the documented `imageURL` alert field** as well as
109
+ `image_url`, so it renders under either integration type. Still only when the publisher supplied
110
+ a hosted `ImageURL`.
111
+ - **`externalURL`** is derived from the absolute deep link's scheme and host, so it needs no new
112
+ configuration; it is omitted when `base_url` is unset and the deep link is therefore relative.
113
+
8
114
  ## [0.6.0] - 2026-08-01
9
115
 
10
116
  ### Added
package/README.md CHANGED
@@ -105,36 +105,69 @@ Delivery: a rich embed (title, body, severity color — blue/yellow/red) with th
105
105
 
106
106
  ### Grafana
107
107
 
108
- Delivers to a [Grafana](https://grafana.com) instance. Grafana is not one ingest endpoint, so a
109
- **Mode** field selects which surface receives the notification.
110
-
111
- | Field | Required | Mode | Notes |
112
- | ----------------------- | -------- | ----------------------- | --------------------------------------------------------- |
113
- | `grafana_mode` | yes | | `annotations`, `alerts`, or `irm`. Defaults to `annotations`. |
114
- | `grafana_server` | yes | annotations, alerts | Base URL of the Grafana instance. Trailing `/` trimmed. |
115
- | `grafana_token` | yes | annotations, alerts | Service-account token, sent as `Authorization: Bearer <token>`. |
116
- | `grafana_tags` | no | annotations | Comma-separated extra tags. |
117
- | `grafana_alertname` | no | alerts | `alertname` label. Defaults to `CameraUINotification`. |
118
- | `grafana_ttl` | no | alerts | Seconds before Grafana auto-resolves the alert. Default `300`, minimum `30`. |
119
- | `grafana_irm_url` | yes | irm | Inbound webhook URL of an IRM / OnCall integration. The token is in the URL, so it is masked and kept out of every error message. |
108
+ Delivers to the [Grafana](https://grafana.com) ecosystem. This is not one ingest endpoint, so a
109
+ **Mode** field selects which surface receives the notification — and the three modes address three
110
+ different services, so each has its own connection fields.
111
+
112
+ | Field | Required | Mode | Notes |
113
+ | ----------------------- | -------- | ------------- | --------------------------------------------------------- |
114
+ | `grafana_mode` | yes | | `annotations`, `alertmanager`, or `irm`. Defaults to `annotations`. |
115
+ | `grafana_server` | yes | annotations | Base URL of the Grafana instance. Trailing `/` trimmed. |
116
+ | `grafana_token` | yes | annotations | Service-account token, sent as `Authorization: Bearer <token>`. |
117
+ | `grafana_tags` | no | annotations | Comma-separated extra tags. |
118
+ | `grafana_am_url` | yes | alertmanager | Base URL of the **Alertmanager**, not of Grafana. Trailing `/` trimmed. |
119
+ | `grafana_am_user` | no | alertmanager | Basic-auth username. For Grafana Cloud, the Alertmanager instance ID. |
120
+ | `grafana_am_password` | no | alertmanager | Basic-auth password. For Grafana Cloud, an API token. Required if a username is set, and vice versa. |
121
+ | `grafana_alertname` | no | alertmanager | `alertname` label. Defaults to `CameraUINotification`. |
122
+ | `grafana_ttl` | no | alertmanager | Seconds before Alertmanager auto-resolves the alert. Default `300`, minimum `30`. |
123
+ | `grafana_irm_url` | yes | irm | Inbound webhook URL of an IRM / OnCall integration. The token is in the URL, so it is masked and kept out of every error message. |
124
+ | `grafana_irm_ttl` | no | irm | Seconds before the alert is eligible to auto-resolve. Default `300`, minimum `30`. Whether IRM acts on it depends on the integration's templates. |
120
125
 
121
126
  **Annotations** — `POST {server}/api/annotations` with a point-in-time, organization-wide
122
- annotation tagged `camera.ui`, `camera:<id>`, `severity:<level>`, plus your extra tags. Surface it
127
+ annotation tagged `camera.ui`, `camera:<name>`, `severity:<level>`, plus your extra tags. Surface it
123
128
  on a dashboard with an annotation query filtered on the `camera.ui` tag; that survives dashboard
124
129
  renames, which pinning to a dashboard UID would not. The tooltip text carries the title, the body,
125
130
  and — when `base_url` is set — a link back to camera.ui.
126
131
 
127
- **Alerts** — `POST {server}/api/alertmanager/grafana/api/v2/alerts`, so your existing notification
128
- policies route the event. `endsAt` is `startsAt + grafana_ttl`, which lets Grafana auto-resolve the
129
- alert without a second request. Labels are `alertname`, `source=camera.ui`, `severity` (camera.ui's
130
- own four levels, verbatim), `camera`, and a unique `event_id` — the last of these matters, because
132
+ **Alertmanager** — `POST {alertmanager}/api/v2/alerts`, straight to an Alertmanager's own API, with
133
+ optional basic auth. Works with a standalone Prometheus Alertmanager, Mimir/Cortex, or Grafana
134
+ Cloud's hosted Alertmanager (username = instance ID, password = API token).
135
+
136
+ > **This mode does not talk to Grafana**, and it deliberately cannot. Grafana's built-in
137
+ > Alertmanager will not accept posted alerts: its route table exposes `GET` for alerts but declares
138
+ > the `POST` route only as `/alertmanager/{DatasourceUID}/api/v2/alerts`, a proxy to an *external*
139
+ > Alertmanager, with no `grafana` variant. Grafana-managed alerts can only come from Grafana's own
140
+ > rule evaluation. Point this at a real Alertmanager. (Versions 0.6.0–0.6.1 targeted Grafana here
141
+ > and always failed with `400 bad request data`.)
142
+
143
+ `endsAt` is `startsAt + grafana_ttl`, which lets Alertmanager auto-resolve the alert without a
144
+ second request. Labels are `alertname`, `source=camera.ui`, `severity` (camera.ui's own four
145
+ levels, verbatim), `camera`, `camera_id`, and a unique `event_id` — the last of these matters, because
131
146
  Alertmanager deduplicates on the label set and without it two detections on one camera inside the
132
147
  TTL window would collapse into a single alert. The absolute deep link becomes `generatorURL`,
133
- which Grafana shows as **Source**.
134
-
135
- **IRM** — `POST {integration URL}` using Grafana IRM / OnCall's formatted-webhook fields
136
- (`alert_uid`, `title`, `message`, `image_url`, `link_to_upstream_details`, `state=alerting`), one
137
- alert group per event.
148
+ which Alertmanager shows as **Source**.
149
+
150
+ **IRM** — `POST {integration URL}`. IRM renders each alert group through templates chosen by the
151
+ integration's *type*, and the two types people actually create read different bodies, so the
152
+ payload carries both: Grafana Alerting's webhook envelope (`status`, `alerts[]` with
153
+ labels/annotations/`generatorURL`/`imageURL`, `groupKey`, `commonLabels`, `externalURL`) for a
154
+ **Grafana Alerting** integration, and OnCall's formatted-webhook fields (`alert_uid`, `image_url`,
155
+ `link_to_upstream_details`) for a **Webhook** integration. `title`, `message`, and
156
+ `state=alerting` are read by both. One body, correct under either type, nothing to configure.
157
+
158
+ Alert groups are keyed **per camera** — `camera.ui:<camera>`, falling back to `camera.ui` for a
159
+ notification that names no camera — so one busy camera can't bury a quiet one. Within a group each
160
+ event keeps its own `fingerprint`, so detections stay individually visible. Each alert carries a
161
+ future `endsAt` (`startsAt + grafana_irm_ttl`), the same way Alertmanager mode expresses
162
+ "resolves on its own at this time"; whether IRM acts on it depends on the integration's templates,
163
+ and if it does not, groups stay open until you resolve them by hand. There is still no follow-up
164
+ `state: "ok"` request — every mode is one stateless POST per event.
165
+
166
+ > **Camera names:** `camera` carries the camera's display name, taken from `Data["cameraName"]`
167
+ > when a publisher supplies one and otherwise from the deep link, which camera.ui routes by name.
168
+ > `Data["cameraId"]` is a UUID for the publishers seen so far, so it is kept separately as
169
+ > `camera_id` (alertmanager and IRM modes) for routing rules that must survive a rename. With
170
+ > neither a name nor a deep link, `camera` falls back to the id.
138
171
 
139
172
  > **Images:** ntfy, Pushover, Telegram, and Discord all render the detection snapshot. Gotify is
140
173
  > text + link only (it needs a hosted image URL, which this fully-local plugin doesn't provide).
package/bundle.zip CHANGED
Binary file
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "displayName": "Notify",
3
3
  "name": "@calebcall/camera-ui-notify",
4
- "version": "0.6.0",
4
+ "version": "0.7.0",
5
5
  "description": "A fully-local, multi-backend camera.ui notifier plugin: delivers notifications to ntfy, Gotify, or a generic webhook, entirely on your own hardware with no cloud dependency.",
6
6
  "author": "calebcall (https://github.com/calebcall/camera-ui-notify)",
7
7
  "main": "./main.go",
@@ -48,14 +48,14 @@
48
48
  "protocolLevel": 1
49
49
  },
50
50
  "optionalDependencies": {
51
- "@calebcall/camera-ui-notify-darwin-arm64": "0.6.0",
52
- "@calebcall/camera-ui-notify-darwin-amd64": "0.6.0",
53
- "@calebcall/camera-ui-notify-linux-amd64": "0.6.0",
54
- "@calebcall/camera-ui-notify-linux-arm64": "0.6.0",
55
- "@calebcall/camera-ui-notify-windows-amd64": "0.6.0",
56
- "@calebcall/camera-ui-notify-windows-arm64": "0.6.0",
57
- "@calebcall/camera-ui-notify-linux-amd64-musl": "0.6.0",
58
- "@calebcall/camera-ui-notify-linux-arm64-musl": "0.6.0"
51
+ "@calebcall/camera-ui-notify-darwin-arm64": "0.7.0",
52
+ "@calebcall/camera-ui-notify-darwin-amd64": "0.7.0",
53
+ "@calebcall/camera-ui-notify-linux-amd64": "0.7.0",
54
+ "@calebcall/camera-ui-notify-linux-arm64": "0.7.0",
55
+ "@calebcall/camera-ui-notify-windows-amd64": "0.7.0",
56
+ "@calebcall/camera-ui-notify-windows-arm64": "0.7.0",
57
+ "@calebcall/camera-ui-notify-linux-amd64-musl": "0.7.0",
58
+ "@calebcall/camera-ui-notify-linux-arm64-musl": "0.7.0"
59
59
  },
60
60
  "files": [
61
61
  "bundle.zip",