@calebcall/camera-ui-notify 0.6.1 → 0.7.1

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,121 @@ 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.1] - 2026-08-02
9
+
10
+ ### Fixed
11
+
12
+ - **Alertmanager mode: documented the required path prefix.** Mimir and Grafana Cloud serve the
13
+ Alertmanager API under a prefix (`/alertmanager` by default); a standalone Alertmanager serves it
14
+ at the root. 0.7.0's field placeholder and README both showed a bare host, so configuring it the
15
+ documented way produced `404 page not found` on every send. The README now gives both forms side
16
+ by side, and the field description and placeholder show the Grafana Cloud shape. No code was
17
+ wrong here — the documentation was.
18
+ - **A 404 from Alertmanager now explains itself.** Go's default mux answers with a bare
19
+ `404 page not found`, which says nothing about the missing prefix that caused it. The error now
20
+ adds that hint. Other statuses are untouched.
21
+ - **A pasted full endpoint is accepted.** Alertmanager's own docs show the complete
22
+ `.../api/v2/alerts` URL, so copying it into the base-URL field is the obvious mistake; a trailing
23
+ `/api/v2/alerts` is now trimmed rather than producing `/api/v2/alerts/api/v2/alerts`.
24
+ - **Documented where Grafana Cloud Alertmanager credentials come from.** The username is the
25
+ numeric Alertmanager instance ID from the Cloud portal; the password is an Access Policy token
26
+ (`glc_...`) with the `alerts:write` scope — *not* a Grafana service-account token (`glsa_...`),
27
+ which authenticates to Grafana rather than to the Alertmanager.
28
+
29
+ ### Changed
30
+
31
+ - **Alertmanager mode no longer sends `startsAt`**, letting Alertmanager stamp it from its own
32
+ clock. The alert's start time is now correct even on a host whose clock has drifted.
33
+ - **`grafana_ttl` default raised from 300 to 900 seconds.** `endsAt` must be absolute — the
34
+ Alertmanager API has no relative form — so it is still derived from the camera.ui host's clock. A
35
+ host running more than `grafana_ttl` behind the Alertmanager sends an `endsAt` already in the
36
+ past, and the alert is accepted with a `200`, resolved on arrival, and never appears as active —
37
+ a silent no-op that reads as success. 15 minutes gives that failure real margin where 5 did not,
38
+ and the README now names the symptom and the fix (keep the host in NTP sync).
39
+
40
+ ### Removed
41
+
42
+ - **`grafana_irm_ttl` and the IRM `endsAt` experiment, both added in 0.7.0.** They did nothing. IRM
43
+ decides whether a group is resolved from a template on the payload's status — its default is
44
+ `{{ payload.status == "resolved" }}` — and ignores `endsAt` entirely, so no value in a single
45
+ firing request can close a group; only a second request can. IRM alerts once again carry the
46
+ documented never-resolves sentinel `0001-01-01T00:00:00Z`, which states the actual behaviour
47
+ instead of implying an auto-close that never happens, and the README says plainly that groups are
48
+ closed by hand.
49
+
50
+ A delayed resolve was considered and rejected: it would require a background timer and per-event
51
+ state in a plugin that is otherwise one stateless POST per event, and a restart would strand the
52
+ group open regardless.
53
+
54
+ ## [0.7.0] - 2026-08-02
55
+
56
+ ### Changed
57
+
58
+ - **The Grafana `alerts` mode is renamed `alertmanager` and now posts to an Alertmanager directly**
59
+ ([#33](https://github.com/calebcall/camera-ui-notify/issues/33)). **This mode requires
60
+ reconfiguration.** It never worked in 0.6.0 or 0.6.1 — every send failed with
61
+ `400 bad request data` — so no working setup is disturbed.
62
+
63
+ The mode targeted `{grafanaServer}/api/alertmanager/grafana/api/v2/alerts` on the assumption that
64
+ Grafana's built-in Alertmanager accepts injected alerts. It does not. Grafana's route table
65
+ declares built-in-Alertmanager operations with a literal `grafana` path segment and external ones
66
+ with `{DatasourceUID}`; there is a `GET /alertmanager/grafana/api/v2/alerts` but **no `POST`
67
+ equivalent** — reads are supported, writes are not. The forking handler
68
+ (`pkg/services/ngalert/api/forking_alertmanager.go`) resolves an Alertmanager *datasource* by UID
69
+ and always returns the external proxy, never the built-in service, so a request naming `grafana`
70
+ as the UID matches no datasource and fails. Grafana-managed alerts can only originate from
71
+ Grafana's own rule evaluation; there is no supported path to inject one.
72
+
73
+ The mode now addresses an Alertmanager's own v2 API — `POST {alertmanager}/api/v2/alerts` — with
74
+ optional basic auth. That works with a standalone Prometheus Alertmanager, Mimir/Cortex, and
75
+ Grafana Cloud's hosted Alertmanager (username = instance ID, password = API token). The name
76
+ follows the behaviour: this mode talks to Alertmanager, not to Grafana.
77
+
78
+ Config changes for this mode: `grafana_server` and `grafana_token` are no longer used (they are
79
+ now annotations-only) and are replaced by `grafana_am_url` plus the optional `grafana_am_user` /
80
+ `grafana_am_password` pair. Setting only one of the two credential fields is rejected at parse
81
+ time rather than surfacing as a confusing 401. `grafana_alertname` and `grafana_ttl` are
82
+ unchanged, and the alert payload itself is unchanged — it already matched Alertmanager's
83
+ documented `postableAlert` schema; only the destination was wrong.
84
+
85
+ - **`grafana_server` / `grafana_token` are now gated to annotations mode only**, since it is the
86
+ one mode that addresses a Grafana instance. They no longer use the `in` condition operator.
87
+
88
+ ### Fixed
89
+
90
+ - **Grafana modes now label the camera with its display name instead of its UUID**
91
+ ([#34](https://github.com/calebcall/camera-ui-notify/issues/34)). `Data["cameraId"]` is a UUID for
92
+ the publishers seen so far, so alerts read `camera=07614b1d-d5de-48b7-bbb2-592a64a97ead` and IRM
93
+ grouped under `camera.ui:07614b1d-…`, which is unusable at a glance.
94
+
95
+ The name is resolved cheapest-first: `Data["cameraName"]` if a publisher supplies one, else the
96
+ camera segment of the deep link (camera.ui routes cameras by display name, so this is `Patio`
97
+ while the id is a UUID), else the raw id so a notification with neither still gets a label. No
98
+ RPC and no camera lookup — `DeviceManager.GetCamera` would also resolve a name but builds a full
99
+ camera-device proxy and calls `init()` on it, which is heavy and side-effecting for a Hub plugin
100
+ that owns no cameras.
101
+
102
+ This reaches the annotations `camera:<name>` tag, the alertmanager `camera` label, and the IRM
103
+ `camera` label, `groupLabels` and `groupKey`. Alertmanager and IRM modes additionally keep the
104
+ raw id as `camera_id` (omitted when it would merely repeat `camera`), since names change and ids
105
+ do not — routing rules that must survive a rename can match on that.
106
+
107
+ **IRM alert groups will regroup**: existing groups keyed `camera.ui:<uuid>` are replaced by
108
+ `camera.ui:<name>`.
109
+
110
+ - **`grafana_ttl` no longer sets a `Step`.** With `min=30 step=30`, an HTML5 number input rejects
111
+ legitimate values such as 100 or 450. Nothing about a TTL needs 30-second granularity.
112
+
113
+ ### Added
114
+
115
+ - **`grafana_irm_ttl`** (default `300`, minimum `30`). IRM alerts now carry a future `endsAt`
116
+ (`startsAt + grafana_irm_ttl`) instead of the never-resolves sentinel `0001-01-01T00:00:00Z`.
117
+ That is how Alertmanager expresses "resolves on its own at this time", and IRM's
118
+ `grafana_alerting` templates read the same envelope — but whether IRM actually acts on it is
119
+ **unverified**. If it does not, groups stay open until closed by hand, exactly as before, so this
120
+ is an improvement-or-no-change rather than a risk. Still one stateless POST per event: there is
121
+ no follow-up `state: "ok"` request and no background timer.
122
+
8
123
  ## [0.6.1] - 2026-08-02
9
124
 
10
125
  ### Fixed
package/README.md CHANGED
@@ -105,32 +105,74 @@ 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 **including any path prefix** (see below). A pasted `.../api/v2/alerts` endpoint is accepted and trimmed. |
119
+ | `grafana_am_user` | no | alertmanager | Basic-auth username. For Grafana Cloud, the numeric Alertmanager instance ID. |
120
+ | `grafana_am_password` | no | alertmanager | Basic-auth password. For Grafana Cloud, an Access Policy token with the `alerts:write` scope. 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 `900`, 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
+ > **Get the URL right — this is the most common way to misconfigure this mode.** Mimir and Grafana
144
+ > Cloud serve the Alertmanager API under a path prefix, `/alertmanager` by default; a standalone
145
+ > Alertmanager serves it at the root. Omit the prefix and every send fails with a bare
146
+ > `404 page not found`.
147
+ >
148
+ > | Target | Enter | Resulting POST |
149
+ > | --- | --- | --- |
150
+ > | Grafana Cloud / Mimir | `https://alertmanager-prod-xx.grafana.net/alertmanager` | `…/alertmanager/api/v2/alerts` |
151
+ > | Standalone Alertmanager | `http://alertmanager:9093` | `…/api/v2/alerts` |
152
+ >
153
+ > **Grafana Cloud credentials** come from two different places. The **username** is the numeric
154
+ > Alertmanager instance ID, shown with the URL on the Alertmanager details page in the Cloud
155
+ > portal. The **password** is an **Access Policy token** (`glc_…`) carrying the `alerts:write`
156
+ > scope, created under Access Policies — *not* a Grafana service-account token (`glsa_…`), which
157
+ > authenticates to Grafana rather than to the Alertmanager.
158
+
159
+ `endsAt` is `now + grafana_ttl`, which lets Alertmanager auto-resolve the alert without a
160
+ second request. `startsAt` is deliberately not sent — Alertmanager stamps it from its own clock.
161
+
162
+ > **If sends succeed but no alert appears, check the clock.** `endsAt` has to be absolute
163
+ > (Alertmanager's API has no relative form), so it is computed from the camera.ui host's clock. A
164
+ > host running more than `grafana_ttl` *behind* the Alertmanager sends an `endsAt` already in the
165
+ > past: the alert is accepted with a `200`, resolved on arrival, and never shows as active. The
166
+ > symptom is a clean `notify: delivered` in the log and an empty
167
+ > `GET {alertmanager}/api/v2/alerts`. Keep the host in NTP sync. The 900-second default exists to
168
+ > give that failure some margin.
169
+ >
170
+ > Second, gentler trap: alerts self-resolve after `grafana_ttl` and drop off the active list, so
171
+ > when testing, look within the window rather than an hour later. Labels are `alertname`, `source=camera.ui`, `severity` (camera.ui's own four
172
+ levels, verbatim), `camera`, `camera_id`, and a unique `event_id` — the last of these matters, because
131
173
  Alertmanager deduplicates on the label set and without it two detections on one camera inside the
132
174
  TTL window would collapse into a single alert. The absolute deep link becomes `generatorURL`,
133
- which Grafana shows as **Source**.
175
+ which Alertmanager shows as **Source**.
134
176
 
135
177
  **IRM** — `POST {integration URL}`. IRM renders each alert group through templates chosen by the
136
178
  integration's *type*, and the two types people actually create read different bodies, so the
@@ -140,11 +182,22 @@ labels/annotations/`generatorURL`/`imageURL`, `groupKey`, `commonLabels`, `exter
140
182
  `link_to_upstream_details`) for a **Webhook** integration. `title`, `message`, and
141
183
  `state=alerting` are read by both. One body, correct under either type, nothing to configure.
142
184
 
143
- Alert groups are keyed **per camera** — `camera.ui:<cameraId>`, falling back to `camera.ui` for a
185
+ Alert groups are keyed **per camera** — `camera.ui:<camera>`, falling back to `camera.ui` for a
144
186
  notification that names no camera — so one busy camera can't bury a quiet one. Within a group each
145
- event keeps its own `fingerprint`, so detections stay individually visible. Unlike Alerts mode,
146
- IRM groups do **not** auto-resolve: there is no TTL and no follow-up `state: "ok"` request, so they
147
- stay open until you resolve them.
187
+ event keeps its own `fingerprint`, so detections stay individually visible.
188
+
189
+ **IRM groups do not auto-resolve.** IRM decides that from a template on the payload's status — its
190
+ default is `{{ payload.status == "resolved" }}` — so a group closes only when a *second* request
191
+ arrives saying so. `endsAt` is ignored, which is why alerts carry the documented never-resolves
192
+ sentinel rather than a future time that would imply a close that never comes. This plugin sends one
193
+ stateless POST per event and no follow-up, by design: a delayed resolve would mean a background
194
+ timer and per-event state, and a restart would strand the group open anyway. Close them in IRM.
195
+
196
+ > **Camera names:** `camera` carries the camera's display name, taken from `Data["cameraName"]`
197
+ > when a publisher supplies one and otherwise from the deep link, which camera.ui routes by name.
198
+ > `Data["cameraId"]` is a UUID for the publishers seen so far, so it is kept separately as
199
+ > `camera_id` (alertmanager and IRM modes) for routing rules that must survive a rename. With
200
+ > neither a name nor a deep link, `camera` falls back to the id.
148
201
 
149
202
  > **Images:** ntfy, Pushover, Telegram, and Discord all render the detection snapshot. Gotify is
150
203
  > 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.1",
4
+ "version": "0.7.1",
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.1",
52
- "@calebcall/camera-ui-notify-darwin-amd64": "0.6.1",
53
- "@calebcall/camera-ui-notify-linux-amd64": "0.6.1",
54
- "@calebcall/camera-ui-notify-linux-arm64": "0.6.1",
55
- "@calebcall/camera-ui-notify-windows-amd64": "0.6.1",
56
- "@calebcall/camera-ui-notify-windows-arm64": "0.6.1",
57
- "@calebcall/camera-ui-notify-linux-amd64-musl": "0.6.1",
58
- "@calebcall/camera-ui-notify-linux-arm64-musl": "0.6.1"
51
+ "@calebcall/camera-ui-notify-darwin-arm64": "0.7.1",
52
+ "@calebcall/camera-ui-notify-darwin-amd64": "0.7.1",
53
+ "@calebcall/camera-ui-notify-linux-amd64": "0.7.1",
54
+ "@calebcall/camera-ui-notify-linux-arm64": "0.7.1",
55
+ "@calebcall/camera-ui-notify-windows-amd64": "0.7.1",
56
+ "@calebcall/camera-ui-notify-windows-arm64": "0.7.1",
57
+ "@calebcall/camera-ui-notify-linux-amd64-musl": "0.7.1",
58
+ "@calebcall/camera-ui-notify-linux-arm64-musl": "0.7.1"
59
59
  },
60
60
  "files": [
61
61
  "bundle.zip",