@camstack/addon-post-analysis 1.2.32 → 1.2.34
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/dist/{dist-BHxT5e6k.js → dist-7ujnICn7.js} +362 -232
- package/dist/{dist-iVD5TYly.mjs → dist-Bjhp9nkX.mjs} +362 -232
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/{node-Dqn7hG39.js → node-B4EfMwxa.js} +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-pm9Zv42X.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Dh0UMdC9.mjs} +2 -2
- package/dist/pipeline-analytics/{_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Y-zFctNC.mjs → _virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DmydN-_W.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-BTNJatYz.mjs → hostInit-tUf8t2IV.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +584 -38
- package/dist/pipeline-analytics/index.mjs +583 -37
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -1,8 +1,211 @@
|
|
|
1
|
-
import { $ as EventCategory, A as notificationRulesCapability, B as createEvent, C as cosineSimilarity, D as faceGalleryCapability, F as videoclipsCapability, G as array, H as isDeviceScopedCap, I as zoneAnalyticsCapability, J as number, K as boolean, L as errMsg, M as plateGalleryCapability, N as readDeviceStateFrom, P as subKindsOf, Q as unknown, R as BaseAddon, S as buildEventKindDescriptor, T as defineCustomActions, U as nodePin, V as hydrateSchema, W as _enum, X as record, Y as object, Z as string, _ as TimelapseRuleInputSchema, a as MACRO_LABELS, b as alarmPanelCapability, c as NcConditionDescriptorSchema, d as NcRuleSchema, f as NcSnoozeInputSchema, g as OpsLogEntrySchema, h as NcTaxonomySchema, i as EVENT_PAD_MS, j as pipelineAnalyticsCapability, k as kebabToCamel, l as NcRuleInputSchema, m as NcSnoozeSuppressedSchema, n as DEFAULT_EVENT_COLOR, o as NC_CONDITION_CATALOG, p as NcSnoozeSchema, q as literal, r as EVENT_KIND_BY_CAP, s as NC_TAXONOMY, t as BaseDevice, u as NcRulePatchSchema, v as TimelapseRuleSchema, w as customAction, x as audioMetricsCapability, y as addonWidgetsSourceCapability, z as DeviceType } from "../dist-
|
|
1
|
+
import { $ as EventCategory, A as notificationRulesCapability, B as createEvent, C as cosineSimilarity, D as faceGalleryCapability, F as videoclipsCapability, G as array, H as isDeviceScopedCap, I as zoneAnalyticsCapability, J as number, K as boolean, L as errMsg, M as plateGalleryCapability, N as readDeviceStateFrom, P as subKindsOf, Q as unknown, R as BaseAddon, S as buildEventKindDescriptor, T as defineCustomActions, U as nodePin, V as hydrateSchema, W as _enum, X as record, Y as object, Z as string, _ as TimelapseRuleInputSchema, a as MACRO_LABELS, b as alarmPanelCapability, c as NcConditionDescriptorSchema, d as NcRuleSchema, f as NcSnoozeInputSchema, g as OpsLogEntrySchema, h as NcTaxonomySchema, i as EVENT_PAD_MS, j as pipelineAnalyticsCapability, k as kebabToCamel, l as NcRuleInputSchema, m as NcSnoozeSuppressedSchema, n as DEFAULT_EVENT_COLOR, o as NC_CONDITION_CATALOG, p as NcSnoozeSchema, q as literal, r as EVENT_KIND_BY_CAP, s as NC_TAXONOMY, t as BaseDevice, u as NcRulePatchSchema, v as TimelapseRuleSchema, w as customAction, x as audioMetricsCapability, y as addonWidgetsSourceCapability, z as DeviceType } from "../dist-Bjhp9nkX.mjs";
|
|
2
2
|
import { promises } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
5
5
|
import sharp from "sharp";
|
|
6
|
+
//#region src/notification-center/action-token.ts
|
|
7
|
+
/**
|
|
8
|
+
* The authority behind a notification button.
|
|
9
|
+
*
|
|
10
|
+
* **Read this before changing anything here.** A button's URL travels through
|
|
11
|
+
* third-party infrastructure — ntfy's server, a push relay, whatever forwarded
|
|
12
|
+
* the message — and is therefore exactly as public as the notification is. The
|
|
13
|
+
* operator decided (2026-08-05) that the token ALONE authorises the action:
|
|
14
|
+
* there is no session check, and a tap does not identify who tapped. Whoever
|
|
15
|
+
* holds the link can run that one action. [D47](../../../../docs/decisions/adr-0047.md).
|
|
16
|
+
*
|
|
17
|
+
* Everything in this file exists to bound that blast radius, since the
|
|
18
|
+
* authorisation model does not:
|
|
19
|
+
*
|
|
20
|
+
* - **One token, one action, one notification.** The record carries the rule,
|
|
21
|
+
* the sequence and the device, so a token cannot be re-pointed at anything
|
|
22
|
+
* else. There is no "run arbitrary sequence" shape to abuse.
|
|
23
|
+
* - **Single use, claimed ATOMICALLY.** `claim()` is the only way to read a
|
|
24
|
+
* record and it marks it spent in the same synchronous step, so two taps
|
|
25
|
+
* arriving together cannot both open a gate. This is why the registry is a
|
|
26
|
+
* plain in-process Map and not the settings store: a read-then-write across
|
|
27
|
+
* an async store is a race that a physical actuator would pay for.
|
|
28
|
+
* - **Short-lived, and the expiry is a COMPARISON, not a sweeper.** A reaper
|
|
29
|
+
* that failed would leave tokens live indefinitely — the same reasoning the
|
|
30
|
+
* snooze policy is built on. The sweep here only reclaims memory.
|
|
31
|
+
* - **In-memory is deliberate.** Tokens do not survive a hub restart, so a
|
|
32
|
+
* link can never outlive the process that minted it. The cost is real and
|
|
33
|
+
* accepted: a deploy kills the buttons on notifications already delivered,
|
|
34
|
+
* which with a short TTL were nearly dead anyway.
|
|
35
|
+
*/
|
|
36
|
+
/** Sign `(id, exp)` — the exact string the verifier recomputes. Same shape as
|
|
37
|
+
* the artifact plane's, deliberately: one signing convention in this addon. */
|
|
38
|
+
function signActionToken(secret, id, expMs) {
|
|
39
|
+
return createHmac("sha256", secret).update(`act:${id}:${expMs}`).digest("hex");
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Verify a callback's `(id, exp, sig)`.
|
|
43
|
+
*
|
|
44
|
+
* Constant-time on the signature so a public route cannot be probed for it byte
|
|
45
|
+
* by byte, and expiry-checked BEFORE the compare so an expired link is refused
|
|
46
|
+
* even when its signature is perfect.
|
|
47
|
+
*/
|
|
48
|
+
function verifyActionSignature(input) {
|
|
49
|
+
if (!Number.isFinite(input.exp) || input.exp <= input.nowMs) return false;
|
|
50
|
+
const expected = signActionToken(input.secret, input.id, input.exp);
|
|
51
|
+
const a = Buffer.from(expected, "utf8");
|
|
52
|
+
const b = Buffer.from(input.sig, "utf8");
|
|
53
|
+
if (a.length !== b.length) return false;
|
|
54
|
+
return timingSafeEqual(a, b);
|
|
55
|
+
}
|
|
56
|
+
/** Build the fully-qualified, signed callback URL for one button. */
|
|
57
|
+
function buildActionUrl(input) {
|
|
58
|
+
const sig = signActionToken(input.secret, input.id, input.expMs);
|
|
59
|
+
return `${input.baseUrl.endsWith("/") ? input.baseUrl.slice(0, -1) : input.baseUrl}${input.routePrefix.startsWith("/") ? input.routePrefix : `/${input.routePrefix}`}/${encodeURIComponent(input.id)}?exp=${input.expMs}&sig=${sig}`;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* How long a button stays live.
|
|
63
|
+
*
|
|
64
|
+
* Fifteen minutes, not the artifact plane's 24 h. An image link is a read; this
|
|
65
|
+
* one moves something physical, and the window is the only thing limiting who
|
|
66
|
+
* can use a forwarded notification. An operator who wakes hours later opens the
|
|
67
|
+
* app — the button was never the path for that.
|
|
68
|
+
*/
|
|
69
|
+
var DEFAULT_ACTION_TTL_MS = 15 * 6e4;
|
|
70
|
+
var NcActionTokenRegistry = class {
|
|
71
|
+
grants = /* @__PURE__ */ new Map();
|
|
72
|
+
now;
|
|
73
|
+
constructor(now) {
|
|
74
|
+
this.now = now ?? (() => Date.now());
|
|
75
|
+
}
|
|
76
|
+
/** Register a freshly minted grant. The caller owns id generation (it needs
|
|
77
|
+
* the id to build the URL before the record exists). */
|
|
78
|
+
put(grant) {
|
|
79
|
+
this.grants.set(grant.id, {
|
|
80
|
+
grant,
|
|
81
|
+
used: false
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Take the grant, once.
|
|
86
|
+
*
|
|
87
|
+
* Synchronous and mutating in one step ON PURPOSE — see the header. Anything
|
|
88
|
+
* that awaited between the read and the mark would let two taps through, and
|
|
89
|
+
* the thing on the other end is a lock or a gate.
|
|
90
|
+
*/
|
|
91
|
+
claim(id) {
|
|
92
|
+
const found = this.grants.get(id);
|
|
93
|
+
if (found === void 0) return {
|
|
94
|
+
ok: false,
|
|
95
|
+
reason: "unknown"
|
|
96
|
+
};
|
|
97
|
+
if (found.used) return {
|
|
98
|
+
ok: false,
|
|
99
|
+
reason: "already-used"
|
|
100
|
+
};
|
|
101
|
+
if (found.grant.expiresAt <= this.now()) return {
|
|
102
|
+
ok: false,
|
|
103
|
+
reason: "expired"
|
|
104
|
+
};
|
|
105
|
+
found.used = true;
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
grant: found.grant
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** Drop records that can no longer be claimed. Memory hygiene only — an
|
|
112
|
+
* expired record is already refused by {@link claim}, so a sweep that never
|
|
113
|
+
* ran would cost bytes, never safety. */
|
|
114
|
+
sweep() {
|
|
115
|
+
const now = this.now();
|
|
116
|
+
let removed = 0;
|
|
117
|
+
for (const [id, entry] of this.grants) if (entry.used || entry.grant.expiresAt <= now) {
|
|
118
|
+
this.grants.delete(id);
|
|
119
|
+
removed += 1;
|
|
120
|
+
}
|
|
121
|
+
return removed;
|
|
122
|
+
}
|
|
123
|
+
/** Live (unclaimed, unexpired) token count — for the log line that tells an
|
|
124
|
+
* operator whether buttons are actually being minted. */
|
|
125
|
+
get liveCount() {
|
|
126
|
+
const now = this.now();
|
|
127
|
+
let n = 0;
|
|
128
|
+
for (const entry of this.grants.values()) if (!entry.used && entry.grant.expiresAt > now) n += 1;
|
|
129
|
+
return n;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
//#endregion
|
|
133
|
+
//#region src/notification-center/action-plane.ts
|
|
134
|
+
/** What the human standing in front of the page is told. */
|
|
135
|
+
var FAILURE_COPY = {
|
|
136
|
+
unknown: "This button is no longer available.",
|
|
137
|
+
expired: "This button has expired. Open CamStack to do it there.",
|
|
138
|
+
"already-used": "Already done — this button had been used."
|
|
139
|
+
};
|
|
140
|
+
function page(title, detail) {
|
|
141
|
+
return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title><style>body{font:16px/1.5 system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#111;color:#eee}main{max-width:24rem;padding:2rem;text-align:center}h1{font-size:1.25rem;margin:0 0 .5rem}p{margin:0;color:#aaa}</style><main><h1>${title}</h1><p>${detail}</p></main>`;
|
|
142
|
+
}
|
|
143
|
+
var NcActionPlane = class {
|
|
144
|
+
deps;
|
|
145
|
+
now;
|
|
146
|
+
constructor(deps) {
|
|
147
|
+
this.deps = deps;
|
|
148
|
+
this.now = deps.now ?? (() => Date.now());
|
|
149
|
+
}
|
|
150
|
+
handler = async (req, res) => {
|
|
151
|
+
const url = new URL(req.url ?? "/", "http://placeholder");
|
|
152
|
+
const id = decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() ?? "");
|
|
153
|
+
if (!verifyActionSignature({
|
|
154
|
+
secret: this.deps.secret,
|
|
155
|
+
id,
|
|
156
|
+
exp: Number(url.searchParams.get("exp")),
|
|
157
|
+
sig: url.searchParams.get("sig") ?? "",
|
|
158
|
+
nowMs: this.now()
|
|
159
|
+
})) {
|
|
160
|
+
this.deps.logger.warn("nc action callback refused — bad or expired signature", { meta: { id } });
|
|
161
|
+
this.send(res, 404, page("Not found", "This link is not valid."));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const claim = this.deps.registry.claim(id);
|
|
165
|
+
if (!claim.ok) {
|
|
166
|
+
this.deps.logger.info("nc action callback not claimable", { meta: {
|
|
167
|
+
id,
|
|
168
|
+
reason: claim.reason
|
|
169
|
+
} });
|
|
170
|
+
this.send(res, 200, page("Nothing to do", FAILURE_COPY[claim.reason]));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const grant = claim.grant;
|
|
174
|
+
try {
|
|
175
|
+
await this.deps.run(grant);
|
|
176
|
+
this.deps.logger.info("nc action ran from a notification button", {
|
|
177
|
+
tags: { deviceId: grant.deviceId },
|
|
178
|
+
meta: {
|
|
179
|
+
ruleId: grant.ruleId,
|
|
180
|
+
rule: grant.ruleName,
|
|
181
|
+
sequence: grant.sequence,
|
|
182
|
+
action: grant.actionId,
|
|
183
|
+
targetId: grant.targetId ?? null
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
this.send(res, 200, page("Done", `${grant.ruleName} — ${grant.sequence}`));
|
|
187
|
+
} catch (err) {
|
|
188
|
+
this.deps.logger.warn("nc action failed after the token was spent", {
|
|
189
|
+
tags: { deviceId: grant.deviceId },
|
|
190
|
+
meta: {
|
|
191
|
+
ruleId: grant.ruleId,
|
|
192
|
+
sequence: grant.sequence,
|
|
193
|
+
action: grant.actionId,
|
|
194
|
+
error: String(err)
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
this.send(res, 200, page("It did not work", "The action failed and this button is now spent. Open CamStack."));
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
send(res, status, html) {
|
|
201
|
+
res.writeHead(status, {
|
|
202
|
+
"content-type": "text/html; charset=utf-8",
|
|
203
|
+
"cache-control": "no-store"
|
|
204
|
+
});
|
|
205
|
+
res.end(html);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
//#endregion
|
|
6
209
|
//#region src/notification-center/artifact-url.ts
|
|
7
210
|
/**
|
|
8
211
|
* Signed, externally-reachable URLs for notification artifacts.
|
|
@@ -5467,7 +5670,7 @@ var NcDispatcher = class {
|
|
|
5467
5670
|
return { ok: true };
|
|
5468
5671
|
}
|
|
5469
5672
|
}
|
|
5470
|
-
const notification = await this.buildNotification(entry);
|
|
5673
|
+
const notification = await this.buildNotification(entry, target);
|
|
5471
5674
|
try {
|
|
5472
5675
|
const result = await this.deps.send({
|
|
5473
5676
|
addonId: target.addonId,
|
|
@@ -5597,7 +5800,37 @@ var NcDispatcher = class {
|
|
|
5597
5800
|
if (this.now() - this.targetCacheAt > this.targetCacheTtlMs) return null;
|
|
5598
5801
|
return this.targetCache.get(targetId) ?? null;
|
|
5599
5802
|
}
|
|
5600
|
-
|
|
5803
|
+
/**
|
|
5804
|
+
* This delivery's buttons, or none.
|
|
5805
|
+
*
|
|
5806
|
+
* Never throws. A notification that arrives without its buttons is a
|
|
5807
|
+
* degraded notification; one that does not arrive because the token registry
|
|
5808
|
+
* hiccupped is a missed event. The failure is logged rather than swallowed —
|
|
5809
|
+
* a button the operator authored and never saw is exactly the silence this
|
|
5810
|
+
* repo keeps paying for.
|
|
5811
|
+
*/
|
|
5812
|
+
async mintActions(entry, target) {
|
|
5813
|
+
if (this.deps.buildActions === void 0) return [];
|
|
5814
|
+
try {
|
|
5815
|
+
return await this.deps.buildActions({
|
|
5816
|
+
ruleId: entry.ruleId,
|
|
5817
|
+
ruleName: entry.payload.ruleName,
|
|
5818
|
+
deviceId: entry.deviceId,
|
|
5819
|
+
targetId: target.id
|
|
5820
|
+
});
|
|
5821
|
+
} catch (err) {
|
|
5822
|
+
this.deps.logger.warn("notification buttons could not be minted — sending without them", {
|
|
5823
|
+
tags: { deviceId: entry.deviceId },
|
|
5824
|
+
meta: {
|
|
5825
|
+
ruleId: entry.ruleId,
|
|
5826
|
+
targetId: target.id,
|
|
5827
|
+
error: String(err)
|
|
5828
|
+
}
|
|
5829
|
+
});
|
|
5830
|
+
return [];
|
|
5831
|
+
}
|
|
5832
|
+
}
|
|
5833
|
+
async buildNotification(entry, target) {
|
|
5601
5834
|
const subject = entry.payload.subject;
|
|
5602
5835
|
const deviceName = await this.deps.getDeviceName(subject.deviceId).catch(() => null) ?? `camera ${subject.deviceId}`;
|
|
5603
5836
|
const zoneLabels = await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones);
|
|
@@ -5606,6 +5839,7 @@ var NcDispatcher = class {
|
|
|
5606
5839
|
const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName, zoneLabels);
|
|
5607
5840
|
const attachments = await this.withArtifactUrls(await this.resolveAttachments(entry));
|
|
5608
5841
|
const params = pickParams(entry.payload.params);
|
|
5842
|
+
const actions = await this.mintActions(entry, target);
|
|
5609
5843
|
return {
|
|
5610
5844
|
body,
|
|
5611
5845
|
title,
|
|
@@ -5614,7 +5848,8 @@ var NcDispatcher = class {
|
|
|
5614
5848
|
tag: entry.ruleId,
|
|
5615
5849
|
deviceId: subject.deviceId,
|
|
5616
5850
|
...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
|
|
5617
|
-
...attachments.length > 0 ? { attachments } : {}
|
|
5851
|
+
...attachments.length > 0 ? { attachments } : {},
|
|
5852
|
+
...actions.length > 0 ? { actions } : {}
|
|
5618
5853
|
};
|
|
5619
5854
|
}
|
|
5620
5855
|
/**
|
|
@@ -6732,6 +6967,60 @@ function rowToEntry$1(id, data) {
|
|
|
6732
6967
|
};
|
|
6733
6968
|
}
|
|
6734
6969
|
//#endregion
|
|
6970
|
+
//#region src/notification-center/action-buttons.ts
|
|
6971
|
+
/** Sequence names the rule actually declares. A button may only name one. */
|
|
6972
|
+
function declaredSequences(actions) {
|
|
6973
|
+
const names = /* @__PURE__ */ new Set();
|
|
6974
|
+
for (const sequence of actions.onTrigger ?? []) names.add(sequence.name);
|
|
6975
|
+
return names;
|
|
6976
|
+
}
|
|
6977
|
+
/**
|
|
6978
|
+
* Build the notification's buttons.
|
|
6979
|
+
*
|
|
6980
|
+
* Returns an empty array rather than `undefined` — the caller decides whether
|
|
6981
|
+
* to set the field, and an empty array is the honest answer to "which buttons
|
|
6982
|
+
* survived", which `undefined` would conflate with "the rule declared none".
|
|
6983
|
+
*/
|
|
6984
|
+
function buildActionButtons(input) {
|
|
6985
|
+
const actions = input.actions;
|
|
6986
|
+
if (actions === void 0) return [];
|
|
6987
|
+
const buttons = actions.buttons ?? [];
|
|
6988
|
+
if (buttons.length === 0) return [];
|
|
6989
|
+
const known = declaredSequences(actions);
|
|
6990
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6991
|
+
const out = [];
|
|
6992
|
+
for (const button of buttons) {
|
|
6993
|
+
if (!known.has(button.sequence)) continue;
|
|
6994
|
+
if (seen.has(button.id)) continue;
|
|
6995
|
+
seen.add(button.id);
|
|
6996
|
+
out.push({
|
|
6997
|
+
id: button.id,
|
|
6998
|
+
label: button.label,
|
|
6999
|
+
url: input.mintUrl({
|
|
7000
|
+
sequence: button.sequence,
|
|
7001
|
+
actionId: button.id
|
|
7002
|
+
}),
|
|
7003
|
+
...button.icon !== void 0 ? { icon: button.icon } : {},
|
|
7004
|
+
...button.destructive !== void 0 ? { destructive: button.destructive } : {}
|
|
7005
|
+
});
|
|
7006
|
+
}
|
|
7007
|
+
return out;
|
|
7008
|
+
}
|
|
7009
|
+
/**
|
|
7010
|
+
* Which of a rule's buttons name a sequence it does not have.
|
|
7011
|
+
*
|
|
7012
|
+
* Exported so the caller can LOG the drop. A button silently missing from a
|
|
7013
|
+
* notification is the exact shape of failure this repo keeps paying for — the
|
|
7014
|
+
* operator authored it, it never appeared, and nothing said why.
|
|
7015
|
+
*/
|
|
7016
|
+
function unresolvableButtons(actions) {
|
|
7017
|
+
if (actions === void 0) return [];
|
|
7018
|
+
const buttons = actions.buttons ?? [];
|
|
7019
|
+
if (buttons.length === 0) return [];
|
|
7020
|
+
const known = declaredSequences(actions);
|
|
7021
|
+
return buttons.filter((b) => !known.has(b.sequence)).map((b) => `${b.id}→${b.sequence}`);
|
|
7022
|
+
}
|
|
7023
|
+
//#endregion
|
|
6735
7024
|
//#region src/notification-center/rule-actions.ts
|
|
6736
7025
|
var NcRuleActionRunner = class {
|
|
6737
7026
|
deps;
|
|
@@ -7810,6 +8099,31 @@ var TimelapseStore = class {
|
|
|
7810
8099
|
//#endregion
|
|
7811
8100
|
//#region src/notification-center/index.ts
|
|
7812
8101
|
/**
|
|
8102
|
+
* NotificationCenter — the P1 core of the Notification Center (spec
|
|
8103
|
+
* `2026-07-22-notification-center-requirements.md`, decisions D-1/D-2/D-3).
|
|
8104
|
+
*
|
|
8105
|
+
* A deps-injected, extractable module (TrackCloser pattern) hosted by
|
|
8106
|
+
* pipeline-analytics. It couples rule evaluation to the DURABLE persist
|
|
8107
|
+
* moments (D-2), never to the telemetry bus:
|
|
8108
|
+
*
|
|
8109
|
+
* object-event insert ─▶ onObjectEventPersisted ─▶ immediate rules
|
|
8110
|
+
* TrackCloser.closeExpired ─▶ onTrackClosed ─▶ track-end rules
|
|
8111
|
+
* │ match + throttle
|
|
8112
|
+
* ▼
|
|
8113
|
+
* NcOutbox (durable rows, unique key rule:track:target)
|
|
8114
|
+
* │ drain loop (backoff / dead-letter)
|
|
8115
|
+
* ▼
|
|
8116
|
+
* NcDispatcher ─▶ ctx.api notification-output.send (RPC, hub-routed)
|
|
8117
|
+
*
|
|
8118
|
+
* Crash gap: on start (evaluation mode) the module scans object events
|
|
8119
|
+
* newer than the persisted watermark and re-evaluates them — idempotent
|
|
8120
|
+
* through the outbox dedup key (D8: reconcile, never trust an event).
|
|
8121
|
+
*
|
|
8122
|
+
* Multi-node: rules live in the centralized settings-store; only the
|
|
8123
|
+
* designated post-processing node runs evaluation + the drain loop, so a
|
|
8124
|
+
* CRUD served elsewhere becomes effective within one rule-reload tick.
|
|
8125
|
+
*/
|
|
8126
|
+
/**
|
|
7813
8127
|
* How often the "matched NO rule" report may fire per device. Long enough that
|
|
7814
8128
|
* a busy camera prints one line rather than one per event, short enough that a
|
|
7815
8129
|
* rule which has stopped matching is visible within minutes rather than by
|
|
@@ -7929,6 +8243,9 @@ var NotificationCenter = class NotificationCenter {
|
|
|
7929
8243
|
deviceStates;
|
|
7930
8244
|
/** Runs a matched rule's action sequences. Null when no actuator is wired. */
|
|
7931
8245
|
actionRunner;
|
|
8246
|
+
/** Grants behind the tap-through buttons. In-process and single-use — read
|
|
8247
|
+
* `action-token.ts` before assuming anything about what a tap proves. */
|
|
8248
|
+
actionTokens = new NcActionTokenRegistry();
|
|
7932
8249
|
/** True when ≥1 enabled `device-event` rule declares an occupancy condition —
|
|
7933
8250
|
* the watcher is idle (zero per-frame cost) otherwise. */
|
|
7934
8251
|
occupancyEnabled = false;
|
|
@@ -7949,6 +8266,109 @@ var NotificationCenter = class NotificationCenter {
|
|
|
7949
8266
|
reloadTimer = null;
|
|
7950
8267
|
drainTicks = 0;
|
|
7951
8268
|
evaluationActive = false;
|
|
8269
|
+
/**
|
|
8270
|
+
* The tap-through buttons for ONE delivery, each with its own single-use
|
|
8271
|
+
* token.
|
|
8272
|
+
*
|
|
8273
|
+
* Per delivery, not per notification: the first tap spends the token, so two
|
|
8274
|
+
* recipients sharing one would mean the second person's button was already
|
|
8275
|
+
* dead when it arrived.
|
|
8276
|
+
*
|
|
8277
|
+
* Returns `[]` for every ordinary reason — the rule declares no buttons, no
|
|
8278
|
+
* actuator is wired, no reachable URL exists. A button is an enhancement; not
|
|
8279
|
+
* being able to mint one must never cost the notification.
|
|
8280
|
+
*/
|
|
8281
|
+
async mintButtons(input) {
|
|
8282
|
+
const mint = this.deps.mintActionUrl;
|
|
8283
|
+
if (mint === void 0 || this.actionRunner === null) return [];
|
|
8284
|
+
const rule = this.rules.get(input.ruleId);
|
|
8285
|
+
if (rule === null) return [];
|
|
8286
|
+
if ((rule.actions?.buttons ?? []).length === 0) return [];
|
|
8287
|
+
const expMs = this.now() + DEFAULT_ACTION_TTL_MS;
|
|
8288
|
+
const pending = [];
|
|
8289
|
+
const out = buildActionButtons({
|
|
8290
|
+
actions: rule.actions,
|
|
8291
|
+
mintUrl: ({ sequence, actionId }) => {
|
|
8292
|
+
const id = randomUUID();
|
|
8293
|
+
pending.push({
|
|
8294
|
+
id,
|
|
8295
|
+
url: "",
|
|
8296
|
+
sequence,
|
|
8297
|
+
actionId
|
|
8298
|
+
});
|
|
8299
|
+
return id;
|
|
8300
|
+
}
|
|
8301
|
+
});
|
|
8302
|
+
if (out.length === 0) return [];
|
|
8303
|
+
const resolved = [];
|
|
8304
|
+
for (const [index, action] of out.entries()) {
|
|
8305
|
+
const slot = pending[index];
|
|
8306
|
+
if (slot === void 0) continue;
|
|
8307
|
+
const url = await mint({
|
|
8308
|
+
id: slot.id,
|
|
8309
|
+
expMs
|
|
8310
|
+
});
|
|
8311
|
+
if (url === null) {
|
|
8312
|
+
this.logger.debug("no reachable base URL — notification buttons omitted", {
|
|
8313
|
+
tags: { deviceId: input.deviceId },
|
|
8314
|
+
meta: { ruleId: input.ruleId }
|
|
8315
|
+
});
|
|
8316
|
+
return [];
|
|
8317
|
+
}
|
|
8318
|
+
this.actionTokens.put({
|
|
8319
|
+
id: slot.id,
|
|
8320
|
+
ruleId: input.ruleId,
|
|
8321
|
+
ruleName: input.ruleName,
|
|
8322
|
+
sequence: slot.sequence,
|
|
8323
|
+
deviceId: input.deviceId,
|
|
8324
|
+
actionId: slot.actionId,
|
|
8325
|
+
targetId: input.targetId,
|
|
8326
|
+
expiresAt: expMs
|
|
8327
|
+
});
|
|
8328
|
+
resolved.push({
|
|
8329
|
+
...action,
|
|
8330
|
+
url
|
|
8331
|
+
});
|
|
8332
|
+
}
|
|
8333
|
+
return resolved;
|
|
8334
|
+
}
|
|
8335
|
+
/** The registry the action plane claims against. */
|
|
8336
|
+
get actionTokenRegistry() {
|
|
8337
|
+
return this.actionTokens;
|
|
8338
|
+
}
|
|
8339
|
+
/**
|
|
8340
|
+
* Run the sequence a spent token granted.
|
|
8341
|
+
*
|
|
8342
|
+
* **The grant overrides `enabled`, deliberately.** A sequence reachable only
|
|
8343
|
+
* by a button is written as `enabled: false` — that is how "do not run this
|
|
8344
|
+
* automatically" is expressed — and the runner skips a disabled sequence. For
|
|
8345
|
+
* a granted run the BUTTON is the enablement, and the operator tapping it is
|
|
8346
|
+
* a more explicit instruction than the flag it overrides. The per-sequence
|
|
8347
|
+
* throttle still applies: `minDelaySec` is about how often a gate may
|
|
8348
|
+
* physically move, and a tap does not change that.
|
|
8349
|
+
*
|
|
8350
|
+
* Throws when the sequence is gone or a step failed — the plane turns that
|
|
8351
|
+
* into the page that says so.
|
|
8352
|
+
*/
|
|
8353
|
+
async runGrantedAction(grant) {
|
|
8354
|
+
if (this.actionRunner === null) throw new Error("no actuator is wired on this node");
|
|
8355
|
+
const rule = this.rules.get(grant.ruleId);
|
|
8356
|
+
if (rule === null) throw new Error(`rule ${grant.ruleId} no longer exists`);
|
|
8357
|
+
const sequence = (rule.actions?.onTrigger ?? []).find((s) => s.name === grant.sequence);
|
|
8358
|
+
if (sequence === void 0) throw new Error(`sequence "${grant.sequence}" no longer exists on rule ${rule.name}`);
|
|
8359
|
+
const [outcome] = await this.actionRunner.run({
|
|
8360
|
+
ruleId: rule.id,
|
|
8361
|
+
ruleName: rule.name,
|
|
8362
|
+
deviceId: grant.deviceId,
|
|
8363
|
+
sequences: [{
|
|
8364
|
+
...sequence,
|
|
8365
|
+
enabled: true
|
|
8366
|
+
}]
|
|
8367
|
+
});
|
|
8368
|
+
if (outcome === void 0) throw new Error("the sequence did not run");
|
|
8369
|
+
if (outcome.failedAt !== void 0) throw new Error(`step ${String(outcome.failedAt)} of "${sequence.name}" failed`);
|
|
8370
|
+
if (!outcome.ran) throw new Error(`"${sequence.name}" did not run (${outcome.skippedReason ?? "unknown"})`);
|
|
8371
|
+
}
|
|
7952
8372
|
constructor(deps) {
|
|
7953
8373
|
this.deps = deps;
|
|
7954
8374
|
this.logger = deps.logger;
|
|
@@ -8349,6 +8769,15 @@ var NotificationCenter = class NotificationCenter {
|
|
|
8349
8769
|
deviceId: subject.deviceId,
|
|
8350
8770
|
sequences
|
|
8351
8771
|
});
|
|
8772
|
+
const unresolvable = unresolvableButtons(rule.actions);
|
|
8773
|
+
if (unresolvable.length > 0) this.logger.warn("rule buttons reference sequences that do not exist — not sent", {
|
|
8774
|
+
tags: { deviceId: subject.deviceId },
|
|
8775
|
+
meta: {
|
|
8776
|
+
ruleId: rule.id,
|
|
8777
|
+
rule: rule.name,
|
|
8778
|
+
buttons: unresolvable.join(", ")
|
|
8779
|
+
}
|
|
8780
|
+
});
|
|
8352
8781
|
const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
|
|
8353
8782
|
if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets)) > 0) this.lastFiredAt.set(key, now);
|
|
8354
8783
|
anyMatched = true;
|
|
@@ -12154,6 +12583,43 @@ var MediaStore = class {
|
|
|
12154
12583
|
return files;
|
|
12155
12584
|
}
|
|
12156
12585
|
/**
|
|
12586
|
+
* The same rows as {@link listByOwner}, WITHOUT reading a single blob.
|
|
12587
|
+
*
|
|
12588
|
+
* This is not `listByOwner` with a field dropped — that would still read
|
|
12589
|
+
* every blob off disk and base64-encode it before discarding it. Measured on
|
|
12590
|
+
* a 140 s track: 23 files, 7.67 MB read and ~10 MB of string allocated, to
|
|
12591
|
+
* answer a question the index rows already contain.
|
|
12592
|
+
*
|
|
12593
|
+
* `kind`, `sizeBytes` and `timestamp` all live on the index row. Nothing is
|
|
12594
|
+
* persisted at track close to make this cheap, deliberately: media is written
|
|
12595
|
+
* throughout a track's life AND after it (late enrichment adds face/plate
|
|
12596
|
+
* crops), and retention deletes it later — a manifest frozen at close would
|
|
12597
|
+
* advertise tiles that 404 and hide ones that exist.
|
|
12598
|
+
*/
|
|
12599
|
+
async listInfoByOwner(ownerKind, ownerId) {
|
|
12600
|
+
return (await this.store.query.query({
|
|
12601
|
+
collection: MEDIA_COLLECTION,
|
|
12602
|
+
filter: {
|
|
12603
|
+
where: {
|
|
12604
|
+
ownerKind,
|
|
12605
|
+
ownerId
|
|
12606
|
+
},
|
|
12607
|
+
orderBy: {
|
|
12608
|
+
field: "timestamp",
|
|
12609
|
+
direction: "asc"
|
|
12610
|
+
}
|
|
12611
|
+
}
|
|
12612
|
+
})).map((row) => {
|
|
12613
|
+
const data = row.data;
|
|
12614
|
+
return {
|
|
12615
|
+
key: row.id,
|
|
12616
|
+
kind: String(data["kind"]),
|
|
12617
|
+
sizeBytes: Number(data["sizeBytes"]),
|
|
12618
|
+
timestamp: Number(data["timestamp"])
|
|
12619
|
+
};
|
|
12620
|
+
});
|
|
12621
|
+
}
|
|
12622
|
+
/**
|
|
12157
12623
|
* Delete all media (blobs + index rows) for the given owners of one kind.
|
|
12158
12624
|
* Best-effort per row; returns the number of index rows removed.
|
|
12159
12625
|
*/
|
|
@@ -16814,6 +17280,18 @@ var AnalyticsQueryFacade = class {
|
|
|
16814
17280
|
const kinds = input.kinds;
|
|
16815
17281
|
return kinds && kinds.length > 0 ? all.filter((m) => kinds.includes(m.kind)) : all;
|
|
16816
17282
|
}
|
|
17283
|
+
/**
|
|
17284
|
+
* A track's media WITHOUT the bytes.
|
|
17285
|
+
*
|
|
17286
|
+
* Reads the INDEX ROWS only — it never touches a blob. Stripping `base64`
|
|
17287
|
+
* off `getTrackMedia` would have been the obvious shape and the wrong one:
|
|
17288
|
+
* that path reads every blob off disk and base64-encodes it first, so the
|
|
17289
|
+
* response would shrink while the work did not. Measured: 7.67 MB read and
|
|
17290
|
+
* ~10 MB of string allocated, to answer a question the index already holds.
|
|
17291
|
+
*/
|
|
17292
|
+
async listTrackMedia(input) {
|
|
17293
|
+
return this.deps.mediaStore()?.listInfoByOwner("track", input.trackId) ?? Promise.resolve([]);
|
|
17294
|
+
}
|
|
16817
17295
|
async pruneEventsBefore(input) {
|
|
16818
17296
|
const eventStore = this.deps.eventStore();
|
|
16819
17297
|
if (!eventStore) return {
|
|
@@ -21997,6 +22475,95 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
21997
22475
|
}
|
|
21998
22476
|
}
|
|
21999
22477
|
/**
|
|
22478
|
+
* The ranked base-URL candidates a notification LINK may use.
|
|
22479
|
+
*
|
|
22480
|
+
* Shared by the artifact plane (media) and the action plane (buttons) — one
|
|
22481
|
+
* resolver, because a second copy would drift and the two would start
|
|
22482
|
+
* minting links on different hosts for the same notification.
|
|
22483
|
+
*/
|
|
22484
|
+
async ncEndpoints() {
|
|
22485
|
+
return collectArtifactEndpoints({
|
|
22486
|
+
markedBaseUrl: await this.markedNotificationEndpoint(),
|
|
22487
|
+
configuredPublicUrl: process.env["CAMSTACK_HUB_PUBLIC_URL"],
|
|
22488
|
+
getConnected: async () => {
|
|
22489
|
+
const status = await this.ctx.api.networkAccess.getStatus.query();
|
|
22490
|
+
this.ctx.logger.debug("artifact base-url: connected ingress", { meta: {
|
|
22491
|
+
connected: status.connected,
|
|
22492
|
+
url: status.endpoint?.url ?? null,
|
|
22493
|
+
protocol: status.endpoint?.protocol ?? null
|
|
22494
|
+
} });
|
|
22495
|
+
return status.connected && status.endpoint !== null ? {
|
|
22496
|
+
url: status.endpoint.url,
|
|
22497
|
+
protocol: status.endpoint.protocol
|
|
22498
|
+
} : null;
|
|
22499
|
+
},
|
|
22500
|
+
listExternal: async () => {
|
|
22501
|
+
return (await this.ctx.api.networkAccess.listEndpoints.query()).map((e) => ({
|
|
22502
|
+
url: e.url,
|
|
22503
|
+
protocol: e.protocol
|
|
22504
|
+
}));
|
|
22505
|
+
},
|
|
22506
|
+
listLan: async (port) => {
|
|
22507
|
+
return (await this.ctx.api.localNetwork.getConnectionEndpoints.query({ port })).endpoints.map((e) => ({
|
|
22508
|
+
baseUrl: e.baseUrl,
|
|
22509
|
+
kind: e.kind,
|
|
22510
|
+
priority: e.priority
|
|
22511
|
+
}));
|
|
22512
|
+
},
|
|
22513
|
+
logger: this.ctx.logger
|
|
22514
|
+
});
|
|
22515
|
+
}
|
|
22516
|
+
/**
|
|
22517
|
+
* Serve the NC ACTION plane: the public route a notification button lands on.
|
|
22518
|
+
*
|
|
22519
|
+
* A sibling of the artifact plane and a strictly more dangerous one — that
|
|
22520
|
+
* route serves bytes, this one MOVES something. Its authority is the token in
|
|
22521
|
+
* the link and nothing else, which is the operator's explicit choice; the
|
|
22522
|
+
* bounds are in `notification-center/action-token.ts` and D47.
|
|
22523
|
+
*
|
|
22524
|
+
* Best-effort like every plane here: no facility ⇒ no route, and the
|
|
22525
|
+
* dispatcher simply mints no buttons, because `mintActionUrl` returns null.
|
|
22526
|
+
*/
|
|
22527
|
+
async serveNcActionPlane(center) {
|
|
22528
|
+
try {
|
|
22529
|
+
const secretState = this.state("ncActionSecret", NcArtifactSecretSchema, "");
|
|
22530
|
+
let secret = await secretState.get();
|
|
22531
|
+
if (secret === "") {
|
|
22532
|
+
secret = randomUUID().replace(/-/g, "");
|
|
22533
|
+
await secretState.set(secret);
|
|
22534
|
+
}
|
|
22535
|
+
const routePrefix = `/addon/${this.ctx.id}/nc-action`;
|
|
22536
|
+
const plane = new NcActionPlane({
|
|
22537
|
+
registry: center.actionTokenRegistry,
|
|
22538
|
+
secret,
|
|
22539
|
+
logger: this.ctx.logger.child("nc-actions"),
|
|
22540
|
+
run: (grant) => center.runGrantedAction(grant)
|
|
22541
|
+
});
|
|
22542
|
+
const served = await this.ctx.dataPlane?.serve({
|
|
22543
|
+
prefix: "nc-action",
|
|
22544
|
+
access: "public",
|
|
22545
|
+
handler: plane.handler
|
|
22546
|
+
}) ?? null;
|
|
22547
|
+
this.ncActionMintUrl = served === null ? null : async ({ id, expMs }) => {
|
|
22548
|
+
const baseUrl = pickArtifactBaseUrl(await this.ncEndpoints());
|
|
22549
|
+
if (baseUrl === null) return null;
|
|
22550
|
+
return buildActionUrl({
|
|
22551
|
+
baseUrl,
|
|
22552
|
+
routePrefix,
|
|
22553
|
+
id,
|
|
22554
|
+
secret,
|
|
22555
|
+
expMs
|
|
22556
|
+
});
|
|
22557
|
+
};
|
|
22558
|
+
this.ctx.logger.info("nc-action data-plane served", { meta: { served: served !== null } });
|
|
22559
|
+
} catch (err) {
|
|
22560
|
+
this.ctx.logger.warn("nc-action data-plane failed to serve", { meta: { error: errMsg(err) } });
|
|
22561
|
+
}
|
|
22562
|
+
}
|
|
22563
|
+
/** Mints a button's callback URL once the action plane is served; null until
|
|
22564
|
+
* then, and null forever on an install with no reachable public address. */
|
|
22565
|
+
ncActionMintUrl = null;
|
|
22566
|
+
/**
|
|
22000
22567
|
* Serve the NC artifact plane: a PUBLIC route whose authority is the HMAC on
|
|
22001
22568
|
* each link (see `notification-center/artifact-url.ts`). It exists because
|
|
22002
22569
|
* attachments used to carry BYTES only, and the degrade engine drops a
|
|
@@ -22024,36 +22591,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
22024
22591
|
secret,
|
|
22025
22592
|
logger: this.ctx.logger.child("nc-artifacts"),
|
|
22026
22593
|
routePrefix: `/addon/${this.ctx.id}/nc-artifact`,
|
|
22027
|
-
listEndpoints:
|
|
22028
|
-
markedBaseUrl: await this.markedNotificationEndpoint(),
|
|
22029
|
-
configuredPublicUrl: process.env["CAMSTACK_HUB_PUBLIC_URL"],
|
|
22030
|
-
getConnected: async () => {
|
|
22031
|
-
const status = await this.ctx.api.networkAccess.getStatus.query();
|
|
22032
|
-
this.ctx.logger.debug("artifact base-url: connected ingress", { meta: {
|
|
22033
|
-
connected: status.connected,
|
|
22034
|
-
url: status.endpoint?.url ?? null,
|
|
22035
|
-
protocol: status.endpoint?.protocol ?? null
|
|
22036
|
-
} });
|
|
22037
|
-
return status.connected && status.endpoint !== null ? {
|
|
22038
|
-
url: status.endpoint.url,
|
|
22039
|
-
protocol: status.endpoint.protocol
|
|
22040
|
-
} : null;
|
|
22041
|
-
},
|
|
22042
|
-
listExternal: async () => {
|
|
22043
|
-
return (await this.ctx.api.networkAccess.listEndpoints.query()).map((e) => ({
|
|
22044
|
-
url: e.url,
|
|
22045
|
-
protocol: e.protocol
|
|
22046
|
-
}));
|
|
22047
|
-
},
|
|
22048
|
-
listLan: async (port) => {
|
|
22049
|
-
return (await this.ctx.api.localNetwork.getConnectionEndpoints.query({ port })).endpoints.map((e) => ({
|
|
22050
|
-
baseUrl: e.baseUrl,
|
|
22051
|
-
kind: e.kind,
|
|
22052
|
-
priority: e.priority
|
|
22053
|
-
}));
|
|
22054
|
-
},
|
|
22055
|
-
logger: this.ctx.logger
|
|
22056
|
-
})
|
|
22594
|
+
listEndpoints: () => this.ncEndpoints()
|
|
22057
22595
|
});
|
|
22058
22596
|
this.ncArtifactDataPlane = await this.ctx.dataPlane?.serve({
|
|
22059
22597
|
prefix: "nc-artifact",
|
|
@@ -22821,6 +23359,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
22821
23359
|
});
|
|
22822
23360
|
},
|
|
22823
23361
|
isCapDeviceScoped: isDeviceScopedCap,
|
|
23362
|
+
mintActionUrl: (input) => this.ncActionMintUrl?.(input) ?? Promise.resolve(null),
|
|
22824
23363
|
readDeviceStates: async (ids) => {
|
|
22825
23364
|
const out = /* @__PURE__ */ new Map();
|
|
22826
23365
|
for (const id of ids) try {
|
|
@@ -22882,18 +23421,20 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
22882
23421
|
});
|
|
22883
23422
|
},
|
|
22884
23423
|
send: async (input) => {
|
|
22885
|
-
const { attachments, ...notification } = input.notification;
|
|
23424
|
+
const { attachments, actions, ...notification } = input.notification;
|
|
22886
23425
|
return api.notificationOutput.send.mutate({
|
|
22887
23426
|
addonId: input.addonId,
|
|
22888
23427
|
targetId: input.targetId,
|
|
22889
23428
|
notification: {
|
|
22890
23429
|
...notification,
|
|
22891
|
-
...attachments !== void 0 ? { attachments: [...attachments] } : {}
|
|
23430
|
+
...attachments !== void 0 ? { attachments: [...attachments] } : {},
|
|
23431
|
+
...actions !== void 0 ? { actions: [...actions] } : {}
|
|
22892
23432
|
}
|
|
22893
23433
|
});
|
|
22894
23434
|
},
|
|
22895
23435
|
getMediaForOwner: (ownerKind, ownerId) => stores.mediaStore.listByOwner(ownerKind, ownerId),
|
|
22896
|
-
getDeviceName
|
|
23436
|
+
getDeviceName,
|
|
23437
|
+
buildActions: (input) => this.notificationCenter?.mintButtons(input) ?? Promise.resolve([])
|
|
22897
23438
|
},
|
|
22898
23439
|
listObjectEventsSince: (since, limit) => stores.eventStore.queryObjectSince({
|
|
22899
23440
|
since,
|
|
@@ -22909,6 +23450,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
22909
23450
|
})).tracks;
|
|
22910
23451
|
}
|
|
22911
23452
|
});
|
|
23453
|
+
await this.serveNcActionPlane(this.notificationCenter);
|
|
22912
23454
|
await this.notificationCenter.start({ evaluation: this.isPostProcessingNode });
|
|
22913
23455
|
}
|
|
22914
23456
|
/** Event-media data-plane: serve thumbnail JPEGs at
|
|
@@ -25437,6 +25979,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
25437
25979
|
async getTrackMedia(input) {
|
|
25438
25980
|
return this.queryFacade.getTrackMedia(input);
|
|
25439
25981
|
}
|
|
25982
|
+
/** What media a track HAS, without any of it — the detail view's manifest. */
|
|
25983
|
+
async listTrackMedia(input) {
|
|
25984
|
+
return this.queryFacade.listTrackMedia(input);
|
|
25985
|
+
}
|
|
25440
25986
|
/**
|
|
25441
25987
|
* Search object events by text using CLIP cosine similarity.
|
|
25442
25988
|
*
|