@homeflare/alchemy 0.32.0 → 0.34.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.
Files changed (83) hide show
  1. package/dist/grafana/alerting-provenance.d.ts +80 -0
  2. package/dist/grafana/alerting-provenance.d.ts.map +1 -0
  3. package/dist/grafana/contact-point-fixtures.d.ts +28 -0
  4. package/dist/grafana/contact-point-fixtures.d.ts.map +1 -0
  5. package/dist/grafana/contact-point.d.ts +99 -0
  6. package/dist/grafana/contact-point.d.ts.map +1 -0
  7. package/dist/grafana/dashboard-model.d.ts +47 -0
  8. package/dist/grafana/dashboard-model.d.ts.map +1 -0
  9. package/dist/grafana/dashboard.d.ts +98 -0
  10. package/dist/grafana/dashboard.d.ts.map +1 -0
  11. package/dist/grafana/datasource.d.ts +2 -9
  12. package/dist/grafana/datasource.d.ts.map +1 -1
  13. package/dist/grafana/folder.d.ts +100 -0
  14. package/dist/grafana/folder.d.ts.map +1 -0
  15. package/dist/grafana/index.d.ts +16 -9
  16. package/dist/grafana/index.d.ts.map +1 -1
  17. package/dist/grafana/index.js +325 -16
  18. package/dist/grafana/index.js.map +15 -5
  19. package/dist/grafana/message-template.d.ts +70 -0
  20. package/dist/grafana/message-template.d.ts.map +1 -0
  21. package/dist/grafana/mute-timing.d.ts +112 -0
  22. package/dist/grafana/mute-timing.d.ts.map +1 -0
  23. package/dist/grafana/providers.d.ts +1 -1
  24. package/dist/grafana/providers.d.ts.map +1 -1
  25. package/dist/grafana/provisioned.d.ts +11 -0
  26. package/dist/grafana/provisioned.d.ts.map +1 -0
  27. package/dist/grafana/secret-refs.d.ts +19 -0
  28. package/dist/grafana/secret-refs.d.ts.map +1 -0
  29. package/dist/grafana/subset-match.d.ts +2 -0
  30. package/dist/grafana/subset-match.d.ts.map +1 -0
  31. package/dist/linux/index.d.ts +1 -1
  32. package/dist/linux/index.d.ts.map +1 -1
  33. package/dist/linux/index.js +3 -1
  34. package/dist/linux/index.js.map +1 -1
  35. package/dist/proxmox/index.js +76 -45
  36. package/dist/proxmox/index.js.map +6 -6
  37. package/dist/proxmox/zfs-pool-form.d.ts +124 -0
  38. package/dist/proxmox/zfs-pool-form.d.ts.map +1 -0
  39. package/dist/proxmox/zfs-pool-wire.d.ts +78 -0
  40. package/dist/proxmox/zfs-pool-wire.d.ts.map +1 -0
  41. package/dist/proxmox/zfs-pool.d.ts +27 -52
  42. package/dist/proxmox/zfs-pool.d.ts.map +1 -1
  43. package/docs/grafana-alerting.md +145 -0
  44. package/docs/grafana-folder-dashboard.md +145 -0
  45. package/docs/grafana.md +12 -10
  46. package/docs/upstream-conformance.md +31 -4
  47. package/package.json +1 -1
  48. package/src/grafana/alerting-provenance.ts +109 -0
  49. package/src/grafana/contact-point-fixtures.ts +25 -0
  50. package/src/grafana/contact-point-provenance.test.ts +113 -0
  51. package/src/grafana/contact-point-secrets.test.ts +117 -0
  52. package/src/grafana/contact-point.test.ts +123 -0
  53. package/src/grafana/contact-point.ts +179 -0
  54. package/src/grafana/dashboard-model.test.ts +111 -0
  55. package/src/grafana/dashboard-model.ts +62 -0
  56. package/src/grafana/dashboard.test.ts +232 -0
  57. package/src/grafana/dashboard.ts +161 -0
  58. package/src/grafana/datasource.ts +5 -27
  59. package/src/grafana/folder.test.ts +212 -0
  60. package/src/grafana/folder.ts +161 -0
  61. package/src/grafana/index.ts +42 -9
  62. package/src/grafana/message-template.test.ts +212 -0
  63. package/src/grafana/message-template.ts +114 -0
  64. package/src/grafana/mute-timing.test.ts +227 -0
  65. package/src/grafana/mute-timing.ts +160 -0
  66. package/src/grafana/providers.ts +13 -1
  67. package/src/grafana/provisioned.ts +49 -0
  68. package/src/grafana/secret-refs.ts +48 -0
  69. package/src/grafana/subset-match.ts +71 -0
  70. package/src/linux/index-export.test.ts +25 -0
  71. package/src/linux/index.ts +15 -1
  72. package/src/proxmox/constraints-host-forms.test.ts +1 -1
  73. package/src/proxmox/zfs-pool-adopt.test.ts +26 -0
  74. package/src/proxmox/zfs-pool-form.ts +236 -0
  75. package/src/proxmox/zfs-pool-read-failure.test.ts +118 -0
  76. package/src/proxmox/zfs-pool-wire.ts +132 -0
  77. package/src/proxmox/zfs-pool.ts +106 -76
  78. package/dist/proxmox/zfs-pool-spec.d.ts +0 -27
  79. package/dist/proxmox/zfs-pool-spec.d.ts.map +0 -1
  80. package/dist/proxmox/zfs-pool-write.d.ts +0 -128
  81. package/dist/proxmox/zfs-pool-write.d.ts.map +0 -1
  82. package/src/proxmox/zfs-pool-spec.ts +0 -76
  83. package/src/proxmox/zfs-pool-write.ts +0 -196
@@ -0,0 +1,123 @@
1
+ /**
2
+ * `Grafana.ContactPoint` against a fake Grafana — the list-then-filter-by-uid read (no by-uid GET
3
+ * exists), the observe-before-write "adopt" posture, and subset-match tolerance. Mirrors
4
+ * `datasource.test.ts`. Secure-settings-ref tests: `contact-point-secrets.test.ts`. Provenance
5
+ * refusal tests (foreign and missing): `contact-point-provenance.test.ts` — split out to keep each
6
+ * file under the house's 250-line cap.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import * as Effect from 'effect/Effect';
10
+ import { ITEM_PATH, LIST_PATH, liveJson, props } from './contact-point-fixtures.ts';
11
+ import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
12
+ import { grafanaOperations } from './resource.ts';
13
+ import { spec } from './contact-point.ts';
14
+
15
+ describe('spec.fetchLive', () => {
16
+ test('lists and filters by uid client-side — no by-uid GET route exists', async () => {
17
+ const other = { ...liveJson, name: 'Other', uid: 'other' };
18
+ const fake = fakeGrafana((method, url) =>
19
+ method === 'GET' && url.pathname === LIST_PATH
20
+ ? Response.json([other, liveJson])
21
+ : fakeFailure(404, 'unexpected request'),
22
+ );
23
+ const live = await Effect.runPromise(
24
+ spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
25
+ );
26
+ expect(live !== undefined && spec.attributes(live, props)).toEqual({
27
+ disableResolveMessage: false,
28
+ name: 'On-call Slack',
29
+ provenance: 'api',
30
+ settings: { recipient: '#oncall' },
31
+ type: 'slack',
32
+ uid: 'slack-oncall',
33
+ });
34
+ // ⛔ THE POINT: a GET never carries a body.
35
+ expect(fake.bodies[0]).toBeUndefined();
36
+ });
37
+
38
+ test('a uid absent from the list decodes to undefined, not an error', async () => {
39
+ const fake = fakeGrafana(() => Response.json([]));
40
+ const result = await Effect.runPromise(
41
+ spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
42
+ );
43
+ expect(result).toBeUndefined();
44
+ });
45
+
46
+ test('a 403 propagates — never folded to absent', async () => {
47
+ const fake = fakeGrafana(() => fakeFailure(403, 'forbidden'));
48
+ const failure = await Effect.runPromise(
49
+ Effect.flip(spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch)))),
50
+ );
51
+ expect(failure._tag).toBe('Forbidden');
52
+ });
53
+ });
54
+
55
+ describe('subset match — tolerates a default Grafana injects into settings', () => {
56
+ test('an unchanged declaration is a noop despite an extra live settings field it never declared', async () => {
57
+ const decorated = { ...liveJson, settings: { ...liveJson.settings, severity: 'critical' } };
58
+ const fake = fakeGrafana((method, url) =>
59
+ method === 'GET' && url.pathname === LIST_PATH
60
+ ? Response.json([decorated])
61
+ : fakeFailure(500, 'reconcile should not write when content is unchanged'),
62
+ );
63
+ const after = await Effect.runPromise(
64
+ grafanaOperations(spec)
65
+ .reconcile(props)
66
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
67
+ );
68
+ expect(after.uid).toBe('slack-oncall');
69
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
70
+ });
71
+ });
72
+
73
+ describe('reconcile — adopt semantics', () => {
74
+ test('declaring exactly what is live is a noop: no write is ever sent', async () => {
75
+ const fake = fakeGrafana((method, url) =>
76
+ method === 'GET' && url.pathname === LIST_PATH
77
+ ? Response.json([liveJson])
78
+ : fakeFailure(500, 'reconcile should not write when nothing changed'),
79
+ );
80
+ const after = await Effect.runPromise(
81
+ grafanaOperations(spec)
82
+ .reconcile(props)
83
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
84
+ );
85
+ expect(after.uid).toBe('slack-oncall');
86
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
87
+ });
88
+ });
89
+
90
+ describe('update and destroy — normal, api-provenance writes', () => {
91
+ test("an api-provenance contact point (this family's own writes) updates normally", async () => {
92
+ const fake = fakeGrafana((method, url) => {
93
+ if (method === 'GET' && url.pathname === LIST_PATH) return Response.json([liveJson]);
94
+ if (method === 'PUT' && url.pathname === ITEM_PATH) {
95
+ return Response.json({ ...liveJson, name: 'Renamed' });
96
+ }
97
+ return fakeFailure(500, 'unexpected request');
98
+ });
99
+ await Effect.runPromise(
100
+ grafanaOperations(spec)
101
+ .reconcile({ ...props, name: 'Renamed' })
102
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
103
+ );
104
+ expect(fake.seen.some((s) => s.method === 'PUT' && s.path === ITEM_PATH)).toBe(true);
105
+ });
106
+
107
+ test('deletes a live, api-provenance contact point by uid', async () => {
108
+ const fake = fakeGrafana((method, url) => {
109
+ if (method === 'GET' && url.pathname === LIST_PATH) return Response.json([liveJson]);
110
+ if (method === 'DELETE' && url.pathname === ITEM_PATH) return Response.json({});
111
+ return fakeFailure(500, 'unexpected request');
112
+ });
113
+ await Effect.runPromise(
114
+ grafanaOperations(spec)
115
+ .destroy(props)
116
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
117
+ );
118
+ expect(fake.seen).toEqual([
119
+ { method: 'GET', path: LIST_PATH },
120
+ { method: 'DELETE', path: ITEM_PATH },
121
+ ]);
122
+ });
123
+ });
@@ -0,0 +1,179 @@
1
+ /**
2
+ * `Grafana.ContactPoint` — one Grafana alerting contact point, keyed by `uid`.
3
+ *
4
+ * ⚠️ NO GET-BY-UID ROUTE EXISTS — MEASURED against the SDK's generated operations (grep across
5
+ * `services/grafana.ts` for `routeGet.*[Cc]ontactpoint`: only `routeGetContactpoints` (list,
6
+ * with an optional `name` filter) and `routeGetContactpointsExport`). Unlike
7
+ * `Grafana.Datasource`/`Grafana.Folder`/`Grafana.Dashboard`, `fetchLive` cannot key its read
8
+ * directly on `uid`. Filtering server-side by `name` is NOT a substitute: `name` is a grouping
9
+ * label a declaration can rename (`uid` is the only field this resource keys on, and Grafana's
10
+ * own doc calls `name` "used as grouping key in the UI" — several contact points can share one),
11
+ * so narrowing the GET to the DECLARED name would miss a live object still filed under an OLDER
12
+ * name mid-rename, reporting it absent and attempting a duplicate `create`. `fetchLive` instead
13
+ * lists every contact point (`routeGetContactpoints({})`, no filter) and matches `uid`
14
+ * client-side — the same ambiguity `resource.ts`'s header and netbox's `soleMatch` name, resolved
15
+ * the same way: requiring the one field the API is actually keyed on.
16
+ *
17
+ * ⛔ SECRET SETTINGS NEVER APPEAR AS A LITERAL PROP (S25) — same shape as `Grafana.Datasource`'s
18
+ * `secureJsonDataRefs` (`secret-refs.ts`, shared). Grafana does not use a separate top-level
19
+ * "secure settings" field for contact points the way it does for datasources; a secure key (a
20
+ * webhook URL with an embedded token, an API key, …) lives INSIDE `settings` on write, and comes
21
+ * back inside `settings` REDACTED on read — Grafana's own docs for the contact-points EXPORT
22
+ * route say as much explicitly ("Redacted settings will contain RedactedValue instead"; the
23
+ * plain, non-export GET this resource calls has no `decrypt` option at all, so it is redacted
24
+ * unconditionally). `secureSettingsRefs` therefore names which keys of `settings` are secret —
25
+ * value resolved from an env var NAME fresh inside `create`/`update`'s own effect (S24), merged
26
+ * into `settings` only at that point, never stored and never diffed (see `matches` below).
27
+ *
28
+ * ⛔ A FOREIGN-PROVENANCE CONTACT POINT REFUSES EVERY UPDATE AND DESTROY — `alerting-provenance.ts`,
29
+ * shared with `Grafana.MessageTemplate`/`Grafana.AlertRuleGroup`/`Grafana.NotificationPolicy`.
30
+ * See that file's header for the full reasoning and citations. `create` is never at risk: it only
31
+ * runs when `fetchLive` already found nothing with this `uid`.
32
+ *
33
+ * ⚠️ `X-Disable-Provenance` IS NEVER SET, BY DESIGN. Every create/update through this resource
34
+ * therefore leaves the contact point with `provenance: "api"` (Grafana's own default for an
35
+ * object this API just wrote) — which the docs describe as blocking that same object from being
36
+ * edited in the Grafana UI afterward. That is the intended outcome, not an oversight: an object
37
+ * this family declares is meant to be owned by the declaration, the same "one owner per resource"
38
+ * posture `provisioned.ts` establishes for folders/dashboards — handing it back to UI-editability
39
+ * by default would silently create the two-owner situation this whole file exists to prevent.
40
+ */
41
+ import { Resource } from 'alchemy';
42
+ import * as Provider from 'alchemy/Provider';
43
+ import * as grafana from '@distilled.cloud/grafana';
44
+ import * as Effect from 'effect/Effect';
45
+ import {
46
+ type GrafanaProvisionedObjectError,
47
+ isForeignProvenance,
48
+ refuseIfForeignProvenance,
49
+ } from './alerting-provenance.ts';
50
+ import { type GrafanaSpec, grafanaHandlers } from './resource.ts';
51
+ import { GrafanaSecretRefUnsetError, resolveSecretRefs } from './secret-refs.ts';
52
+ import { declaredContentMatches } from './subset-match.ts';
53
+
54
+ export { GrafanaSecretRefUnsetError };
55
+
56
+ export interface ContactPointProps {
57
+ uid: string;
58
+ name: string;
59
+ /** Grafana integration id — `slack`, `webhook`, `email`, `pagerduty`, `opsgenie`, … */
60
+ type: string;
61
+ /** Non-secret settings — shape depends on `type`. Never put a secret VALUE here — declare it
62
+ * in `secureSettingsRefs` instead; see the file header. */
63
+ settings: Record<string, unknown>;
64
+ /** Secure field name (e.g. `url`, `token`, `password`) -> env var NAME holding its value. */
65
+ secureSettingsRefs?: Record<string, string>;
66
+ disableResolveMessage?: boolean;
67
+ }
68
+
69
+ export interface ContactPointAttributes {
70
+ uid: string;
71
+ name: string;
72
+ type: string;
73
+ /** As Grafana returns it — any secure key is a redaction placeholder, never the real value. */
74
+ settings: Record<string, unknown>;
75
+ disableResolveMessage: boolean;
76
+ /** `""`/`"api"` (this family may write); anything else, INCLUDING `undefined` (Grafana didn't
77
+ * report the field), refuses — see alerting-provenance.ts's "fails open" note. */
78
+ provenance: string | undefined;
79
+ }
80
+
81
+ export interface GrafanaContactPoint extends Resource<
82
+ 'Grafana.ContactPoint',
83
+ ContactPointProps,
84
+ ContactPointAttributes,
85
+ never
86
+ > {}
87
+
88
+ export const GrafanaContactPoint = Resource<GrafanaContactPoint>('Grafana.ContactPoint');
89
+
90
+ /** `settings` minus every key `secureSettingsRefs` names — the only part `matches` ever compares
91
+ * (a secure key's live value is always a redaction placeholder, never comparable). */
92
+ const nonSecretSettings = (props: ContactPointProps): Record<string, unknown> => {
93
+ const refs = props.secureSettingsRefs;
94
+ if (refs === undefined) return props.settings;
95
+ const out: Record<string, unknown> = {};
96
+ for (const [key, value] of Object.entries(props.settings)) {
97
+ if (!(key in refs)) out[key] = value;
98
+ }
99
+ return out;
100
+ };
101
+
102
+ const resolveSettings = (props: ContactPointProps) =>
103
+ Effect.map(
104
+ resolveSecretRefs(props.secureSettingsRefs, (field) => `settings.${field}`),
105
+ (secrets) => (secrets === undefined ? props.settings : { ...props.settings, ...secrets }),
106
+ );
107
+
108
+ export const spec: GrafanaSpec<
109
+ ContactPointProps,
110
+ grafana.EmbeddedContactPoint,
111
+ ContactPointAttributes,
112
+ | grafana.RouteGetContactpointsError
113
+ | grafana.RoutePostContactpointsError
114
+ | grafana.RoutePutContactpointError
115
+ | grafana.RouteDeleteContactpointsError
116
+ | GrafanaProvisionedObjectError
117
+ | GrafanaSecretRefUnsetError
118
+ > = {
119
+ attributes: (live) => ({
120
+ disableResolveMessage: live.disableResolveMessage ?? false,
121
+ name: live.name ?? '',
122
+ // ⛔ NEVER `?? ''` — an absent field must stay distinguishable from an explicit `""`
123
+ // (ProvenanceNone); see alerting-provenance.ts's "fails open" note.
124
+ provenance: live.provenance,
125
+ settings: (live.settings ?? {}) as Record<string, unknown>,
126
+ type: live.type,
127
+ uid: live.uid ?? '',
128
+ }),
129
+ create: (props) =>
130
+ Effect.flatMap(resolveSettings(props), (settings) =>
131
+ grafana.routePostContactpoints({
132
+ name: props.name,
133
+ settings,
134
+ type: props.type,
135
+ uid: props.uid,
136
+ ...(props.disableResolveMessage === undefined
137
+ ? {}
138
+ : { disableResolveMessage: props.disableResolveMessage }),
139
+ }),
140
+ ),
141
+ destroy: (props, live) =>
142
+ Effect.gen(function* () {
143
+ if (isForeignProvenance(live.provenance)) {
144
+ return yield* refuseIfForeignProvenance('Grafana.ContactPoint', props.uid, live.provenance);
145
+ }
146
+ yield* grafana.routeDeleteContactpoints({ UID: props.uid });
147
+ }),
148
+ fetchLive: (props) =>
149
+ grafana
150
+ .routeGetContactpoints({})
151
+ .pipe(Effect.map((points) => points.find((point) => point.uid === props.uid))),
152
+ matches: (attributes, props) =>
153
+ attributes.name === props.name &&
154
+ attributes.type === props.type &&
155
+ attributes.disableResolveMessage === (props.disableResolveMessage ?? false) &&
156
+ declaredContentMatches(nonSecretSettings(props), attributes.settings),
157
+ update: (props, live) =>
158
+ Effect.gen(function* () {
159
+ if (isForeignProvenance(live.provenance)) {
160
+ return yield* refuseIfForeignProvenance('Grafana.ContactPoint', props.uid, live.provenance);
161
+ }
162
+ const settings = yield* resolveSettings(props);
163
+ yield* grafana.routePutContactpoint({
164
+ UID: props.uid,
165
+ name: props.name,
166
+ settings,
167
+ type: props.type,
168
+ uid: props.uid,
169
+ ...(props.disableResolveMessage === undefined
170
+ ? {}
171
+ : { disableResolveMessage: props.disableResolveMessage }),
172
+ });
173
+ }),
174
+ };
175
+
176
+ export const handlers = grafanaHandlers(spec);
177
+
178
+ export const GrafanaContactPointProvider = () =>
179
+ Provider.effect(GrafanaContactPoint, Effect.succeed(GrafanaContactPoint.Provider.of(handlers)));
@@ -0,0 +1,111 @@
1
+ /**
2
+ * `normalizeModel`/`modelTitle`/`declaredContentMatches` in isolation — no SDK, no fake, no
3
+ * Effect. Proves the volatile fields (`id`, `version`, `iteration`) are stripped and `uid` is
4
+ * forced to the canonical value regardless of where — or whether — the source model carried one,
5
+ * and that `declaredContentMatches` tolerates Grafana's own schema-migration auto-decoration
6
+ * without hiding genuine content drift. All of which is what makes a pasted "Export as JSON" and
7
+ * a freshly-read live model compare equal in dashboard.test.ts.
8
+ */
9
+ import { describe, expect, test } from 'bun:test';
10
+ import { declaredContentMatches, modelTitle, normalizeModel } from './dashboard-model.ts';
11
+
12
+ describe('normalizeModel', () => {
13
+ test('strips id, version and iteration', () => {
14
+ const model = { id: 42, iteration: 1700000000, title: 'Fleet', version: 7 };
15
+ expect(normalizeModel(model, 'fleet')).toEqual({ title: 'Fleet', uid: 'fleet' });
16
+ });
17
+
18
+ test('forces uid to the canonical value, overwriting a stale embedded one', () => {
19
+ const exported = { title: 'Fleet', uid: 'some-old-uid-from-an-export' };
20
+ expect(normalizeModel(exported, 'fleet')).toEqual({ title: 'Fleet', uid: 'fleet' });
21
+ });
22
+
23
+ test('injects uid when the source model has none at all — a never-saved dashboard', () => {
24
+ const neverSaved = { title: 'Fleet' };
25
+ expect(normalizeModel(neverSaved, 'fleet')).toEqual({ title: 'Fleet', uid: 'fleet' });
26
+ });
27
+
28
+ test('a declared model and a live model that only disagree on volatile fields normalize equal', () => {
29
+ const declared = { panels: [{ id: 1, title: 'CPU' }], title: 'Fleet' };
30
+ const live = {
31
+ id: 42,
32
+ iteration: 1700000000,
33
+ panels: [{ id: 1, title: 'CPU' }],
34
+ title: 'Fleet',
35
+ uid: 'fleet',
36
+ version: 9,
37
+ };
38
+ expect(normalizeModel(declared, 'fleet')).toEqual(normalizeModel(live, 'fleet'));
39
+ });
40
+
41
+ test('preserves array order — panels and targets are position-significant, not a set', () => {
42
+ const model = { panels: [{ title: 'B' }, { title: 'A' }] };
43
+ expect(normalizeModel(model, 'x').panels).toEqual([{ title: 'B' }, { title: 'A' }]);
44
+ });
45
+
46
+ test('leaves an unrelated field genuinely different, so a real content change still shows', () => {
47
+ const declared = { title: 'Fleet v2' };
48
+ const live = { title: 'Fleet', uid: 'fleet' };
49
+ expect(normalizeModel(declared, 'fleet')).not.toEqual(normalizeModel(live, 'fleet'));
50
+ });
51
+ });
52
+
53
+ describe('modelTitle', () => {
54
+ test('reads the model title', () => {
55
+ expect(modelTitle({ title: 'Fleet' })).toBe('Fleet');
56
+ });
57
+
58
+ test('answers empty for a model with no title', () => {
59
+ expect(modelTitle({})).toBe('');
60
+ });
61
+ });
62
+
63
+ describe('declaredContentMatches', () => {
64
+ // ⛔ THE REGRESSION THIS FUNCTION EXISTS TO FIX — found by an adversarial review of this PR.
65
+ // Grafana's own dashboard-save schema migration decorates a panel/target whose `datasource`
66
+ // is absent with an explicit `{type, uid}` reference. Before this function existed, a
67
+ // declared panel that never set `datasource` disagreed with what it read back FOREVER.
68
+ test('tolerates Grafana auto-decorating a panel/target with a datasource it never declared', () => {
69
+ const declared = {
70
+ panels: [{ targets: [{ refId: 'A' }], title: 'CPU' }],
71
+ title: 'Fleet',
72
+ };
73
+ const live = {
74
+ panels: [
75
+ {
76
+ datasource: { type: 'postgres', uid: 'teslamate' },
77
+ targets: [{ datasource: { type: 'postgres', uid: 'teslamate' }, refId: 'A' }],
78
+ title: 'CPU',
79
+ },
80
+ ],
81
+ title: 'Fleet',
82
+ };
83
+ expect(declaredContentMatches(declared, live)).toBe(true);
84
+ });
85
+
86
+ test('still catches a genuine change to a field the declaration DOES mention', () => {
87
+ const declared = { panels: [{ title: 'CPU' }] };
88
+ const live = { panels: [{ datasource: { uid: 'teslamate' }, title: 'Memory' }] };
89
+ expect(declaredContentMatches(declared, live)).toBe(false);
90
+ });
91
+
92
+ test('still fails when live is missing a field the declaration mentions', () => {
93
+ expect(declaredContentMatches({ title: 'Fleet' }, {})).toBe(false);
94
+ });
95
+
96
+ test('still fails on an array length mismatch — panels are not padded away', () => {
97
+ const declared = { panels: [{ title: 'A' }, { title: 'B' }] };
98
+ const live = { panels: [{ title: 'A' }] };
99
+ expect(declaredContentMatches(declared, live)).toBe(false);
100
+ });
101
+
102
+ test('ignores an extra top-level field live carries that the declaration never mentioned', () => {
103
+ expect(declaredContentMatches({ title: 'Fleet' }, { schemaVersion: 39, title: 'Fleet' })).toBe(
104
+ true,
105
+ );
106
+ });
107
+
108
+ test('null (or undefined) on a declared leaf never mismatches, mirroring stripNullish', () => {
109
+ expect(declaredContentMatches({ description: null }, { description: 'set live' })).toBe(true);
110
+ });
111
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Pure functions over a dashboard JSON model — no SDK call, no Effect. Split out of dashboard.ts
3
+ * so that file stays under the house's 250-line cap, and so `normalizeModel`'s no-change-is-noop
4
+ * behavior is exercised directly in dashboard-model.test.ts without a fake Grafana in the way.
5
+ */
6
+ export type DashboardModel = Record<string, unknown>;
7
+
8
+ /** Grafana injects or increments these on every save; a declared model never sets them meaningfully. */
9
+ const VOLATILE_FIELDS = ['id', 'version', 'iteration'] as const;
10
+
11
+ /**
12
+ * ★ NORMALIZES BOTH SIDES THE SAME WAY, so a no-change plan is a true noop
13
+ * (docs/grafana-folder-dashboard.md). Strips `id`/`version`/`iteration` (Grafana-managed, never
14
+ * declared content) and forces `uid` to this resource's OWN canonical value, wherever — or
15
+ * whether — the source object carried one. That second part is "uid placement": a dashboard
16
+ * pasted from Grafana's own "Export as JSON" always embeds the OLD live uid (or none, for a
17
+ * never-saved dashboard), while the live model this resource reads back always carries the
18
+ * CURRENT one; forcing both to `props.uid` before comparing is what makes those two shapes equal
19
+ * instead of permanently disagreeing on one field neither side is meant to declare independently
20
+ * (`DashboardProps.uid` — see dashboard.ts — is the single source of truth).
21
+ *
22
+ * ⚠️ EVERY OTHER DECLARED FIELD COMPARES STRUCTURALLY, ARRAY ORDER INCLUDED — dashboard.ts's
23
+ * `matches` runs this output through THIS FILE's own `declaredContentMatches` (below), which
24
+ * never reorders an array. That is correct here: `panels` is position-significant (screen layout
25
+ * via its own `gridPos`, but the array order also drives Grafana's internal panel indexing and
26
+ * any `repeat` behavior) and each panel's `targets` is execution/legend order. Nothing in a
27
+ * dashboard model is a genuinely unordered SET the way UniFi's `trustedDhcpServerIpAddresses` is
28
+ * (unifi/network-form.ts's `sortedSet`) — this family has no field that needs one.
29
+ * ⚠️ `schemaVersion` IS DELIBERATELY **NOT** NORMALIZED — NOT MEASURED against a live instance (no
30
+ * live calls in this PR). Community reports say Grafana can rewrite an old dashboard's
31
+ * `schemaVersion` upward on load; IF that also happens on every plain save of an
32
+ * already-current-schema dashboard, every plan against it would show a false `update` forever —
33
+ * but silently normalizing it away on a guess would just as easily hide a genuine schema
34
+ * mismatch. Left comparing until measured: add it to `VOLATILE_FIELDS` with a dated, measured
35
+ * comment the day it is seen live, the same standard every other row in this file meets.
36
+ */
37
+ export const normalizeModel = (model: DashboardModel, uid: string): DashboardModel => {
38
+ const normalized: DashboardModel = {};
39
+ for (const [key, value] of Object.entries(model)) {
40
+ if (key === 'uid') continue;
41
+ if ((VOLATILE_FIELDS as readonly string[]).includes(key)) continue;
42
+ normalized[key] = value;
43
+ }
44
+ normalized.uid = uid;
45
+ return normalized;
46
+ };
47
+
48
+ /** The model's own declared title — reporting only. It is never compared on its own; the whole
49
+ * normalized model is, in dashboard.ts's `matches`, so a title-only mismatch still shows `update`. */
50
+ export const modelTitle = (model: DashboardModel): string =>
51
+ typeof model.title === 'string' ? model.title : '';
52
+
53
+ /**
54
+ * `declared` need only be a subset of `live` — dashboard.ts's `matches` runs `normalizeModel`'s
55
+ * output through this. Moved to `subset-match.ts` once `Grafana.ContactPoint`'s `settings` needed
56
+ * the identical "declaration silent about a field means don't compare it" rule; re-exported here
57
+ * under its original name so this file's own callers and dashboard-model.test.ts are unchanged.
58
+ * See subset-match.ts for the full reasoning, including the regression this exists to fix
59
+ * (Grafana's dashboard-save schema migration auto-decorating a panel's `datasource`) and the
60
+ * trade-off it accepts (an omitted field can no longer force-clear a previously-set one).
61
+ */
62
+ export { declaredContentMatches } from './subset-match.ts';