@camstack/addon-post-analysis 1.2.153 → 1.2.154
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-DDIu0Q1W.mjs → dist-D6iC--Gh.mjs} +260 -1
- package/dist/{dist-DeMgxNFC.js → dist-Ds6x9Agm.js} +260 -1
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Cil5ur25.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CnB4y-xY.mjs} +3 -3
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DdANt7Ia.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-I37PqSoI.mjs → hostInit-BqnSOQO7.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +1018 -145
- package/dist/pipeline-analytics/index.mjs +1018 -145
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CG8y8a6j.mjs +0 -26
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as audioMetricsCapability, A as NcSnoozeInputSchema, At as errMsg, B as SCENE_DEFAULT_ANCHOR_THRESHOLD, Bt as boolean, C as NC_TAXONOMY, D as NcRuleSchema, Dt as vectorDimFromBase64, E as NcRulePatchSchema, Et as systemEventFilterApplies, F as OpsLogEntrySchema, Ft as isDeviceScopedCap, G as TimelapseRuleInputSchema, Gt as partialRecord, H as SCENE_DIVERGED, Ht as literal, It as nodePin, J as TrackSourceSchema, Jt as unknown, K as TimelapseRulePatchSchema, Kt as record, L as RECORDING_EXPORT_MAX_READ_BYTES, Lt as sleep, M as NcSnoozeSuppressedSchema, Mt as DeviceType, N as NcSystemEventKindSchema, Nt as createEvent, O as NcRuleTargetSchema, Ot as videoclipsCapability, P as NcTaxonomySchema, Pt as hydrateSchema, Q as assertTimelapseCadences, R as RetrainStatusSchema, Rt as _enum, St as readTimelapseGeneratedAt, T as NcRuleInputSchema, Tt as subKindsOf, U as SceneMonitorSchema, Ut as number, V as SCENE_DEFAULT_UNCOVERED_POLICY, Vt as discriminatedUnion, W as TIMELAPSE_DENSE_FLOOR_SEC, Wt as object, X as addonWidgetsSourceCapability, Y as VISIT_MERGE_GAP_MS, Yt as EventCategory, Z as alarmPanelCapability, _ as MACRO_LABELS, a as COCO_TO_MACRO, at as deriveRecordingMode, b as NC_CONDITION_CATALOG, bt as plateGalleryCapability, ct as evaluateSensorEdge, d as DeclaredDevices, et as audioModeOf, f as EVENT_KIND_BY_CAP, ft as isDetectionMacroClass, g as LabelAttributionSchema, gt as notificationRulesCapability, h as FailureCounters, ht as kebabToCamel, i as CLUSTER_MODEL_SCOPED_STEPS, it as defineCustomActions, j as NcSnoozeSchema, jt as BaseAddon, k as NcScheduleSchema, kt as zoneAnalyticsCapability, l as DETECTION_MACRO_CLASSES, lt as faceGalleryCapability, m as FULL_IMAGE_BBOX, mt as isSourceCap, n as AnalyticsGroupRecordSchema, nt as cosineSimilarity$1, o as DEFAULT_EVENT_COLOR, p as EVENT_PAD_MS, pt as isScheduleActive, q as TimelapseRuleSchema, qt as string, r as BaseDevice, rt as customAction, st as encodeVectorBase64, t as AnalyticsGroupDetailSchema, tt as buildEventKindDescriptor, ut as failureContributionCapability, v as MediaFileKindEnum, vt as pickClusterStepModels, w as NcConditionDescriptorSchema, wt as sceneMonitorCapability, x as NC_DEFAULT_SNOOZE_MINUTES, xt as readDeviceStateFrom, y as NC_ALARM_SYSTEM_EVENT_KINDS, yt as pipelineAnalyticsCapability, zt as array } from "../dist-
|
|
1
|
+
import { $ as audioMetricsCapability, A as NcSnoozeInputSchema, At as errMsg, B as SCENE_DEFAULT_ANCHOR_THRESHOLD, Bt as boolean, C as NC_TAXONOMY, D as NcRuleSchema, Dt as vectorDimFromBase64, E as NcRulePatchSchema, Et as systemEventFilterApplies, F as OpsLogEntrySchema, Ft as isDeviceScopedCap, G as TimelapseRuleInputSchema, Gt as partialRecord, H as SCENE_DIVERGED, Ht as literal, It as nodePin, J as TrackSourceSchema, Jt as unknown, K as TimelapseRulePatchSchema, Kt as record, L as RECORDING_EXPORT_MAX_READ_BYTES, Lt as sleep, M as NcSnoozeSuppressedSchema, Mt as DeviceType, N as NcSystemEventKindSchema, Nt as createEvent, O as NcRuleTargetSchema, Ot as videoclipsCapability, P as NcTaxonomySchema, Pt as hydrateSchema, Q as assertTimelapseCadences, R as RetrainStatusSchema, Rt as _enum, St as readTimelapseGeneratedAt, T as NcRuleInputSchema, Tt as subKindsOf, U as SceneMonitorSchema, Ut as number, V as SCENE_DEFAULT_UNCOVERED_POLICY, Vt as discriminatedUnion, W as TIMELAPSE_DENSE_FLOOR_SEC, Wt as object, X as addonWidgetsSourceCapability, Y as VISIT_MERGE_GAP_MS, Yt as EventCategory, Z as alarmPanelCapability, _ as MACRO_LABELS, a as COCO_TO_MACRO, at as deriveRecordingMode, b as NC_CONDITION_CATALOG, bt as plateGalleryCapability, ct as evaluateSensorEdge, d as DeclaredDevices, et as audioModeOf, f as EVENT_KIND_BY_CAP, ft as isDetectionMacroClass, g as LabelAttributionSchema, gt as notificationRulesCapability, h as FailureCounters, ht as kebabToCamel, i as CLUSTER_MODEL_SCOPED_STEPS, it as defineCustomActions, j as NcSnoozeSchema, jt as BaseAddon, k as NcScheduleSchema, kt as zoneAnalyticsCapability, l as DETECTION_MACRO_CLASSES, lt as faceGalleryCapability, m as FULL_IMAGE_BBOX, mt as isSourceCap, n as AnalyticsGroupRecordSchema, nt as cosineSimilarity$1, o as DEFAULT_EVENT_COLOR, p as EVENT_PAD_MS, pt as isScheduleActive, q as TimelapseRuleSchema, qt as string, r as BaseDevice, rt as customAction, st as encodeVectorBase64, t as AnalyticsGroupDetailSchema, tt as buildEventKindDescriptor, ut as failureContributionCapability, v as MediaFileKindEnum, vt as pickClusterStepModels, w as NcConditionDescriptorSchema, wt as sceneMonitorCapability, x as NC_DEFAULT_SNOOZE_MINUTES, xt as readDeviceStateFrom, y as NC_ALARM_SYSTEM_EVENT_KINDS, yt as pipelineAnalyticsCapability, zt as array } from "../dist-D6iC--Gh.mjs";
|
|
2
2
|
import { t as __exportAll } from "../embedding-encoder/index.mjs";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import { promises } from "node:fs";
|
|
@@ -106,6 +106,7 @@ function actionTtlMs(subject) {
|
|
|
106
106
|
case "snooze": return SNOOZE_ACTION_TTL_MS;
|
|
107
107
|
case "alarm": return subject.action === "disarm" ? ALARM_DISARM_TTL_MS : DEFAULT_ACTION_TTL_MS;
|
|
108
108
|
case "sequence": return DEFAULT_ACTION_TTL_MS;
|
|
109
|
+
case "addon-update": return DEFAULT_ACTION_TTL_MS;
|
|
109
110
|
}
|
|
110
111
|
}
|
|
111
112
|
var NcActionTokenRegistry = class {
|
|
@@ -185,6 +186,7 @@ function describeGrant(grant) {
|
|
|
185
186
|
case "sequence": return `${grant.ruleName} — ${grant.sequence}`;
|
|
186
187
|
case "snooze": return grant.classes === void 0 || grant.classes.length === 0 ? `Silenced for ${grant.minutes} min` : `Silenced for ${grant.minutes} min — ${grant.classes.join(", ")}`;
|
|
187
188
|
case "alarm": return grant.action === "disarm" ? "Alarm disarmed" : `Alarm armed${grant.mode !== void 0 ? ` (${grant.mode})` : ""}`;
|
|
189
|
+
case "addon-update": return `${grant.packages.length} addon update(s) on ${grant.nodeId}`;
|
|
188
190
|
}
|
|
189
191
|
}
|
|
190
192
|
/** The audit line's `meta`, per family. A snooze that logged a `sequence` it
|
|
@@ -215,6 +217,11 @@ function grantMeta(grant) {
|
|
|
215
217
|
mode: grant.mode ?? null,
|
|
216
218
|
panelDeviceId: grant.panelDeviceId
|
|
217
219
|
};
|
|
220
|
+
case "addon-update": return {
|
|
221
|
+
...common,
|
|
222
|
+
nodeId: grant.nodeId,
|
|
223
|
+
packages: grant.packages
|
|
224
|
+
};
|
|
218
225
|
}
|
|
219
226
|
}
|
|
220
227
|
/** What the human standing in front of the page is told. */
|
|
@@ -4724,6 +4731,40 @@ var SceneMonitorProvider = class {
|
|
|
4724
4731
|
async listScenes(input) {
|
|
4725
4732
|
return this.statusFor(input.deviceId);
|
|
4726
4733
|
}
|
|
4734
|
+
/**
|
|
4735
|
+
* `listScenes` for a SET, in one round trip.
|
|
4736
|
+
*
|
|
4737
|
+
* `/scenes` renders every camera and re-reads them behind the push slice on a
|
|
4738
|
+
* 30 s safety net. Fanned out client-side that was one request per camera to
|
|
4739
|
+
* read `statusFor`, which is an in-memory merge this provider already holds —
|
|
4740
|
+
* all cost, no work. The work here is identical; only the transport
|
|
4741
|
+
* collapses.
|
|
4742
|
+
*
|
|
4743
|
+
* **Every requested id gets a row.** A camera `statusFor` cannot answer for
|
|
4744
|
+
* is `status: null`, which is NOT `monitors: []`: this feature's whole
|
|
4745
|
+
* failure mode is silence reading as "nothing configured", and a batch that
|
|
4746
|
+
* dropped the row would have made an unreachable camera invisible instead of
|
|
4747
|
+
* reported.
|
|
4748
|
+
*/
|
|
4749
|
+
async listScenesBatch(input) {
|
|
4750
|
+
return input.deviceIds.map((deviceId) => {
|
|
4751
|
+
try {
|
|
4752
|
+
return {
|
|
4753
|
+
deviceId,
|
|
4754
|
+
status: this.statusFor(deviceId)
|
|
4755
|
+
};
|
|
4756
|
+
} catch (err) {
|
|
4757
|
+
this.deps.logger.warn("scene status could not be read", {
|
|
4758
|
+
tags: { deviceId },
|
|
4759
|
+
meta: { error: String(err) }
|
|
4760
|
+
});
|
|
4761
|
+
return {
|
|
4762
|
+
deviceId,
|
|
4763
|
+
status: null
|
|
4764
|
+
};
|
|
4765
|
+
}
|
|
4766
|
+
});
|
|
4767
|
+
}
|
|
4727
4768
|
async getStatus(input) {
|
|
4728
4769
|
return this.statusFor(input.deviceId);
|
|
4729
4770
|
}
|
|
@@ -5406,6 +5447,67 @@ function buildAlarmButtons(input) {
|
|
|
5406
5447
|
}];
|
|
5407
5448
|
}
|
|
5408
5449
|
//#endregion
|
|
5450
|
+
//#region src/notification-center/addon-update-apply.ts
|
|
5451
|
+
/**
|
|
5452
|
+
* The frozen packages that STILL have an update, in the order the row listed
|
|
5453
|
+
* them.
|
|
5454
|
+
*
|
|
5455
|
+
* Order matters: the notification's body listed them in that order, and an
|
|
5456
|
+
* operator watching addons restart should see them go in the order they read.
|
|
5457
|
+
*/
|
|
5458
|
+
function pendingPackages(frozen, updatable) {
|
|
5459
|
+
const available = new Set(updatable);
|
|
5460
|
+
return frozen.filter((name) => available.has(name));
|
|
5461
|
+
}
|
|
5462
|
+
/**
|
|
5463
|
+
* `done` only when every package the button promised was applied and nothing
|
|
5464
|
+
* was skipped or failed — the ONE case where a single number is the whole
|
|
5465
|
+
* truth. Anything else states all three counts, because a sentence that hides
|
|
5466
|
+
* one of them is a sentence that reports work it did not do.
|
|
5467
|
+
*/
|
|
5468
|
+
function addonUpdateSentence(report) {
|
|
5469
|
+
if (report.applied.length === 0 && report.failed.length === 0) return "uptodate";
|
|
5470
|
+
if (report.applied.length === report.requested && report.failed.length === 0) return "done";
|
|
5471
|
+
return "partial";
|
|
5472
|
+
}
|
|
5473
|
+
//#endregion
|
|
5474
|
+
//#region src/notification-center/addon-update-buttons.ts
|
|
5475
|
+
/** The id this button travels under — in the callback and in the audit line. */
|
|
5476
|
+
var ADDON_UPDATE_BUTTON_ID = "addon-update-apply";
|
|
5477
|
+
function addonUpdateButtonRefusal(subject, recipient) {
|
|
5478
|
+
if (subject.kind !== "addon-update-available") return "not-an-addon-update";
|
|
5479
|
+
if ((subject.packageNames ?? []).length === 0) return "no-packages";
|
|
5480
|
+
if (subject.nodeId === void 0) return "several-nodes";
|
|
5481
|
+
if (recipient.ownerUserId === void 0) return "unowned-target";
|
|
5482
|
+
return null;
|
|
5483
|
+
}
|
|
5484
|
+
/**
|
|
5485
|
+
* The button, or none.
|
|
5486
|
+
*
|
|
5487
|
+
* Returns an array rather than a nullable, so the caller composes it with the
|
|
5488
|
+
* other families by spreading — and so "which buttons survived" has one shape.
|
|
5489
|
+
*/
|
|
5490
|
+
function buildAddonUpdateButtons(input) {
|
|
5491
|
+
const subject = input.subject;
|
|
5492
|
+
if (addonUpdateButtonRefusal(subject, input.recipient) !== null) return [];
|
|
5493
|
+
const packages = subject.packageNames ?? [];
|
|
5494
|
+
const nodeId = subject.nodeId ?? "";
|
|
5495
|
+
return [{
|
|
5496
|
+
id: ADDON_UPDATE_BUTTON_ID,
|
|
5497
|
+
label: input.label({
|
|
5498
|
+
count: packages.length,
|
|
5499
|
+
nodeId
|
|
5500
|
+
}),
|
|
5501
|
+
url: input.mintUrl({
|
|
5502
|
+
actionId: ADDON_UPDATE_BUTTON_ID,
|
|
5503
|
+
packages,
|
|
5504
|
+
nodeId
|
|
5505
|
+
}),
|
|
5506
|
+
destructive: true,
|
|
5507
|
+
mode: "background"
|
|
5508
|
+
}];
|
|
5509
|
+
}
|
|
5510
|
+
//#endregion
|
|
5409
5511
|
//#region src/notification-center/audio-rule-matcher.ts
|
|
5410
5512
|
/**
|
|
5411
5513
|
* AudioWatcher — the Notification Center's matcher for audio rules
|
|
@@ -8471,6 +8573,22 @@ var en_default = {
|
|
|
8471
8573
|
"button.alarm.arm.night": "Arm night",
|
|
8472
8574
|
"button.alarm.arm.vacation": "Arm vacation",
|
|
8473
8575
|
"button.alarm.arm.custom_bypass": "Arm custom",
|
|
8576
|
+
"button.addon-update.apply": {
|
|
8577
|
+
"one": "Update {{count}} addon on {{nodeId}}",
|
|
8578
|
+
"other": "Update {{count}} addons on {{nodeId}}"
|
|
8579
|
+
},
|
|
8580
|
+
"action.addon-update.title": "Addon update",
|
|
8581
|
+
"action.addon-update.started": {
|
|
8582
|
+
"one": "Updating {{count}} addon on {{nodeId}}…",
|
|
8583
|
+
"other": "Updating {{count}} addons on {{nodeId}}…"
|
|
8584
|
+
},
|
|
8585
|
+
"action.addon-update.done": {
|
|
8586
|
+
"one": "{{applied}} addon updated on {{nodeId}}",
|
|
8587
|
+
"other": "{{applied}} addons updated on {{nodeId}}"
|
|
8588
|
+
},
|
|
8589
|
+
"action.addon-update.partial": "{{nodeId}}: {{applied}} updated, {{skipped}} already current, {{failed}} failed",
|
|
8590
|
+
"action.addon-update.uptodate": "Already up to date on {{nodeId}} — nothing to do",
|
|
8591
|
+
"action.addon-update.unknown": "Could not read what needs updating on {{nodeId}} — nothing was changed. Open CamStack.",
|
|
8474
8592
|
"summary.title": "Summary — {{rule}}",
|
|
8475
8593
|
"summary.body.window": "{{from}}→{{to}}",
|
|
8476
8594
|
"summary.body.events": {
|
|
@@ -8693,6 +8811,22 @@ var it_default = {
|
|
|
8693
8811
|
"button.alarm.arm.night": "Arma notte",
|
|
8694
8812
|
"button.alarm.arm.vacation": "Arma vacanza",
|
|
8695
8813
|
"button.alarm.arm.custom_bypass": "Arma personalizzato",
|
|
8814
|
+
"button.addon-update.apply": {
|
|
8815
|
+
"one": "Aggiorna {{count}} addon su {{nodeId}}",
|
|
8816
|
+
"other": "Aggiorna {{count}} addon su {{nodeId}}"
|
|
8817
|
+
},
|
|
8818
|
+
"action.addon-update.title": "Aggiornamento addon",
|
|
8819
|
+
"action.addon-update.started": {
|
|
8820
|
+
"one": "Aggiornamento di {{count}} addon su {{nodeId}}…",
|
|
8821
|
+
"other": "Aggiornamento di {{count}} addon su {{nodeId}}…"
|
|
8822
|
+
},
|
|
8823
|
+
"action.addon-update.done": {
|
|
8824
|
+
"one": "{{applied}} addon aggiornato su {{nodeId}}",
|
|
8825
|
+
"other": "{{applied}} addon aggiornati su {{nodeId}}"
|
|
8826
|
+
},
|
|
8827
|
+
"action.addon-update.partial": "{{nodeId}}: {{applied}} aggiornati, {{skipped}} già aggiornati, {{failed}} falliti",
|
|
8828
|
+
"action.addon-update.uptodate": "Già aggiornato su {{nodeId}} — niente da fare",
|
|
8829
|
+
"action.addon-update.unknown": "Non è stato possibile leggere cosa aggiornare su {{nodeId}} — non è stato cambiato nulla. Apri CamStack.",
|
|
8696
8830
|
"summary.title": "Riepilogo — {{rule}}",
|
|
8697
8831
|
"summary.body.window": "{{from}}→{{to}}",
|
|
8698
8832
|
"summary.body.events": {
|
|
@@ -9420,7 +9554,7 @@ var NcDispatcher = class {
|
|
|
9420
9554
|
targetId: target.id,
|
|
9421
9555
|
...target.ownerUserId !== void 0 ? { ownerUserId: target.ownerUserId } : {},
|
|
9422
9556
|
...entry.payload.subject.className !== void 0 ? { className: entry.payload.subject.className } : {},
|
|
9423
|
-
...entry.payload.subject.systemEvent !== void 0 ? {
|
|
9557
|
+
...entry.payload.subject.systemEvent !== void 0 ? { systemEvent: entry.payload.subject.systemEvent } : {}
|
|
9424
9558
|
});
|
|
9425
9559
|
} catch (err) {
|
|
9426
9560
|
this.deps.logger.warn("notification buttons could not be minted — sending without them", {
|
|
@@ -20154,7 +20288,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
20154
20288
|
return id;
|
|
20155
20289
|
}
|
|
20156
20290
|
});
|
|
20157
|
-
const alarmKind = alarmButtonKind(input.
|
|
20291
|
+
const alarmKind = alarmButtonKind(input.systemEvent?.kind);
|
|
20158
20292
|
const panel = this.alarmPanel;
|
|
20159
20293
|
const alarmButtons = alarmKind === null ? [] : buildAlarmButtons({
|
|
20160
20294
|
kind: alarmKind,
|
|
@@ -20190,6 +20324,49 @@ var NotificationCenter = class NotificationCenter {
|
|
|
20190
20324
|
return id;
|
|
20191
20325
|
}
|
|
20192
20326
|
});
|
|
20327
|
+
const systemEvent = input.systemEvent;
|
|
20328
|
+
const recipient = input.ownerUserId !== void 0 ? { ownerUserId: input.ownerUserId } : {};
|
|
20329
|
+
const updateRefusal = systemEvent === void 0 ? "not-an-addon-update" : addonUpdateButtonRefusal(systemEvent, recipient);
|
|
20330
|
+
const updateButtons = systemEvent === void 0 ? [] : buildAddonUpdateButtons({
|
|
20331
|
+
subject: systemEvent,
|
|
20332
|
+
recipient,
|
|
20333
|
+
label: ({ count, nodeId }) => this.texts.text({
|
|
20334
|
+
key: "button.addon-update.apply",
|
|
20335
|
+
vars: {
|
|
20336
|
+
count: String(count),
|
|
20337
|
+
nodeId
|
|
20338
|
+
},
|
|
20339
|
+
count
|
|
20340
|
+
}),
|
|
20341
|
+
mintUrl: ({ actionId, packages, nodeId }) => {
|
|
20342
|
+
const id = randomUUID();
|
|
20343
|
+
drafts.push({
|
|
20344
|
+
id,
|
|
20345
|
+
ttlMs: actionTtlMs({ kind: "addon-update" }),
|
|
20346
|
+
grant: (expiresAt) => ({
|
|
20347
|
+
kind: "addon-update",
|
|
20348
|
+
id,
|
|
20349
|
+
packages,
|
|
20350
|
+
nodeId,
|
|
20351
|
+
deviceId: input.deviceId,
|
|
20352
|
+
actionId,
|
|
20353
|
+
targetId: input.targetId,
|
|
20354
|
+
expiresAt
|
|
20355
|
+
})
|
|
20356
|
+
});
|
|
20357
|
+
return id;
|
|
20358
|
+
}
|
|
20359
|
+
});
|
|
20360
|
+
if (updateRefusal === "several-nodes" || updateRefusal === "unowned-target") this.logger.info("addon-update notification carries no apply button", {
|
|
20361
|
+
tags: { deviceId: input.deviceId },
|
|
20362
|
+
meta: {
|
|
20363
|
+
ruleId: input.ruleId,
|
|
20364
|
+
reason: updateRefusal,
|
|
20365
|
+
targetId: input.targetId,
|
|
20366
|
+
nodeIds: systemEvent?.nodeIds ?? [],
|
|
20367
|
+
packages: systemEvent?.packageNames ?? []
|
|
20368
|
+
}
|
|
20369
|
+
});
|
|
20193
20370
|
if (alarmKind !== null && panel === null) this.logger.info("alarm notification carries no buttons — this node owns no panel", {
|
|
20194
20371
|
tags: { deviceId: input.deviceId },
|
|
20195
20372
|
meta: {
|
|
@@ -20200,7 +20377,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
20200
20377
|
const out = [
|
|
20201
20378
|
...sequenceButtons,
|
|
20202
20379
|
...snoozeButtons,
|
|
20203
|
-
...alarmButtons
|
|
20380
|
+
...alarmButtons,
|
|
20381
|
+
...updateButtons
|
|
20204
20382
|
];
|
|
20205
20383
|
if (out.length === 0) return [];
|
|
20206
20384
|
const now = this.now();
|
|
@@ -20321,8 +20499,139 @@ var NotificationCenter = class NotificationCenter {
|
|
|
20321
20499
|
case "snooze": return this.applyGrantedSnooze(grant);
|
|
20322
20500
|
case "alarm": return this.applyGrantedAlarm(grant);
|
|
20323
20501
|
case "sequence": return this.runGrantedSequence(grant);
|
|
20502
|
+
case "addon-update": return this.applyGrantedAddonUpdate(grant);
|
|
20324
20503
|
}
|
|
20325
20504
|
}
|
|
20505
|
+
/**
|
|
20506
|
+
* Apply the addon updates one notification was about.
|
|
20507
|
+
*
|
|
20508
|
+
* The whole shape of this method is the answer to "a button that reports
|
|
20509
|
+
* success for work it did not do":
|
|
20510
|
+
*
|
|
20511
|
+
* 1. **The cheap question first.** Ask the registry what is still out of
|
|
20512
|
+
* date on that node. A read that FAILS changes nothing and says so — it
|
|
20513
|
+
* is never read as "everything is current" and never as "apply the frozen
|
|
20514
|
+
* list anyway".
|
|
20515
|
+
* 2. **Nothing to do is an outcome, not a success.** A frozen list the
|
|
20516
|
+
* operator already applied (from the Addons page, from auto-update, or by
|
|
20517
|
+
* tapping the copy of this notification that went to their other phone)
|
|
20518
|
+
* produces one announcement and zero `updatePackage` calls.
|
|
20519
|
+
* 3. **The operator hears twice.** A background POST returns while the
|
|
20520
|
+
* updates run for minutes, so the tap is acknowledged BEFORE the first
|
|
20521
|
+
* apply and the verdict follows it. Tapping into silence is worse than no
|
|
20522
|
+
* button.
|
|
20523
|
+
*
|
|
20524
|
+
* The announcements go back to `grant.targetId` — the delivery this button
|
|
20525
|
+
* was minted for, which is the only addressee a tap can honestly name (a tap
|
|
20526
|
+
* identifies nobody; same reasoning as the snooze grant's frozen `userId`).
|
|
20527
|
+
*
|
|
20528
|
+
* Throws only when NOTHING was applied and something went wrong, so the
|
|
20529
|
+
* plane's line says the run failed rather than reporting a spent token as a
|
|
20530
|
+
* success.
|
|
20531
|
+
*/
|
|
20532
|
+
async applyGrantedAddonUpdate(grant) {
|
|
20533
|
+
const apply = this.deps.applyAddonUpdate;
|
|
20534
|
+
const list = this.deps.listAddonUpdates;
|
|
20535
|
+
if (apply === void 0 || list === void 0) throw new Error("no addon updater is wired on this node");
|
|
20536
|
+
let updatable;
|
|
20537
|
+
try {
|
|
20538
|
+
updatable = await list(grant.nodeId);
|
|
20539
|
+
} catch (err) {
|
|
20540
|
+
this.logger.warn("addon update button changed nothing — could not read what needs updating", { meta: {
|
|
20541
|
+
nodeId: grant.nodeId,
|
|
20542
|
+
packages: grant.packages,
|
|
20543
|
+
error: String(err)
|
|
20544
|
+
} });
|
|
20545
|
+
await this.announceAddonUpdate(grant, "unknown", { nodeId: grant.nodeId });
|
|
20546
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
20547
|
+
}
|
|
20548
|
+
const pending = pendingPackages(grant.packages, updatable);
|
|
20549
|
+
const skipped = grant.packages.filter((name) => !pending.includes(name));
|
|
20550
|
+
if (pending.length === 0) {
|
|
20551
|
+
this.logger.info("addon update button applied nothing — every package is already current", { meta: {
|
|
20552
|
+
nodeId: grant.nodeId,
|
|
20553
|
+
packages: grant.packages
|
|
20554
|
+
} });
|
|
20555
|
+
await this.announceAddonUpdate(grant, "uptodate", { nodeId: grant.nodeId }, {
|
|
20556
|
+
requested: grant.packages.length,
|
|
20557
|
+
applied: [],
|
|
20558
|
+
skipped,
|
|
20559
|
+
failed: []
|
|
20560
|
+
});
|
|
20561
|
+
return;
|
|
20562
|
+
}
|
|
20563
|
+
await this.announceAddonUpdate(grant, "started", {
|
|
20564
|
+
count: String(pending.length),
|
|
20565
|
+
nodeId: grant.nodeId
|
|
20566
|
+
}, void 0, pending.length);
|
|
20567
|
+
const applied = [];
|
|
20568
|
+
const failed = [];
|
|
20569
|
+
for (const packageName of pending) try {
|
|
20570
|
+
const result = await apply({
|
|
20571
|
+
packageName,
|
|
20572
|
+
nodeId: grant.nodeId
|
|
20573
|
+
});
|
|
20574
|
+
if (result.ok) applied.push(packageName);
|
|
20575
|
+
else {
|
|
20576
|
+
failed.push(packageName);
|
|
20577
|
+
this.logger.warn("addon update from a notification button failed", { meta: {
|
|
20578
|
+
nodeId: grant.nodeId,
|
|
20579
|
+
packageName,
|
|
20580
|
+
error: result.error
|
|
20581
|
+
} });
|
|
20582
|
+
}
|
|
20583
|
+
} catch (err) {
|
|
20584
|
+
failed.push(packageName);
|
|
20585
|
+
this.logger.warn("addon update from a notification button threw", { meta: {
|
|
20586
|
+
nodeId: grant.nodeId,
|
|
20587
|
+
packageName,
|
|
20588
|
+
error: String(err)
|
|
20589
|
+
} });
|
|
20590
|
+
}
|
|
20591
|
+
const report = {
|
|
20592
|
+
requested: grant.packages.length,
|
|
20593
|
+
applied,
|
|
20594
|
+
skipped,
|
|
20595
|
+
failed
|
|
20596
|
+
};
|
|
20597
|
+
this.logger.info("addon updates applied from a notification button", { meta: {
|
|
20598
|
+
nodeId: grant.nodeId,
|
|
20599
|
+
requested: report.requested,
|
|
20600
|
+
applied: applied.length,
|
|
20601
|
+
skipped: skipped.length,
|
|
20602
|
+
failed
|
|
20603
|
+
} });
|
|
20604
|
+
await this.announceAddonUpdate(grant, addonUpdateSentence(report), {
|
|
20605
|
+
nodeId: grant.nodeId,
|
|
20606
|
+
applied: String(applied.length),
|
|
20607
|
+
skipped: String(skipped.length),
|
|
20608
|
+
failed: String(failed.length)
|
|
20609
|
+
}, report, applied.length);
|
|
20610
|
+
if (applied.length === 0 && failed.length > 0) throw new Error(`no package updated on ${grant.nodeId} (${failed.join(", ")})`);
|
|
20611
|
+
}
|
|
20612
|
+
/**
|
|
20613
|
+
* One sentence about an update tap, to the target the notification went to.
|
|
20614
|
+
*
|
|
20615
|
+
* Best-effort by construction — `deliverAnnouncement` never throws and never
|
|
20616
|
+
* retries — because an announcement that failed must not turn an update that
|
|
20617
|
+
* HAPPENED into a run the plane reports as failed.
|
|
20618
|
+
*/
|
|
20619
|
+
async announceAddonUpdate(grant, sentence, vars, report, count) {
|
|
20620
|
+
const targetId = grant.targetId;
|
|
20621
|
+
if (targetId === void 0) return;
|
|
20622
|
+
const body = this.texts.text({
|
|
20623
|
+
key: `action.addon-update.${sentence}`,
|
|
20624
|
+
vars,
|
|
20625
|
+
...count !== void 0 ? { count } : {}
|
|
20626
|
+
});
|
|
20627
|
+
const failedNames = report?.failed ?? [];
|
|
20628
|
+
await this.dispatcher.deliverAnnouncement({
|
|
20629
|
+
reason: `addon-update-${sentence}`,
|
|
20630
|
+
targetId,
|
|
20631
|
+
title: this.texts.text({ key: "action.addon-update.title" }),
|
|
20632
|
+
body: failedNames.length > 0 ? `${body}\n${failedNames.join(", ")}` : body
|
|
20633
|
+
});
|
|
20634
|
+
}
|
|
20326
20635
|
async runGrantedSequence(grant) {
|
|
20327
20636
|
if (this.actionRunner === null) throw new Error("no actuator is wired on this node");
|
|
20328
20637
|
const rule = this.rules.get(grant.ruleId);
|
|
@@ -29037,30 +29346,57 @@ var LEGACY_FACE_MODEL_ID = "arcface-r100";
|
|
|
29037
29346
|
*/
|
|
29038
29347
|
var FACES_COLLECTION = "pipeline-analytics:faces";
|
|
29039
29348
|
/**
|
|
29040
|
-
* Rows per page for the
|
|
29041
|
-
*
|
|
29042
|
-
* A face row is one of the two WIDEST row shapes in the hub's database: it
|
|
29043
|
-
* carries a JSON `embedding` on every single row, measured at **10.8 KB
|
|
29044
|
-
* average** where a motion event is 78 bytes. Both sweeps used to read the
|
|
29045
|
-
* collection in ONE statement with no `limit`.
|
|
29349
|
+
* Rows per page for `pruneCapOverflow`, the ONE remaining walk of this
|
|
29350
|
+
* collection.
|
|
29046
29351
|
*
|
|
29047
|
-
*
|
|
29048
|
-
*
|
|
29049
|
-
*
|
|
29050
|
-
*
|
|
29051
|
-
* grows with a table nothing else bounds, which is the same defect one incident
|
|
29052
|
-
* earlier. `pruneCapOverflow` is what keeps the row count small — and it was
|
|
29053
|
-
* itself one of the two unbounded readers, so the bound and the thing enforcing
|
|
29054
|
-
* it were the same loop.
|
|
29352
|
+
* It is a walk that DOES WORK: the capacity bound has to consider every row to
|
|
29353
|
+
* decide which ones leave, and it runs on the maintenance pass, not on a
|
|
29354
|
+
* request. That is the shape D320 permits. What it must not do is drag the
|
|
29355
|
+
* whole row across for four scalars — see the projection there.
|
|
29055
29356
|
*
|
|
29056
|
-
* The
|
|
29057
|
-
*
|
|
29058
|
-
*
|
|
29357
|
+
* The two READS that used to page here do not any more. They asked a question
|
|
29358
|
+
* ("the newest N faces that are still unassigned"), the question is a
|
|
29359
|
+
* predicate, and a predicate answers it in one statement whose cost is the
|
|
29360
|
+
* ANSWER rather than the table.
|
|
29059
29361
|
*
|
|
29060
|
-
*
|
|
29061
|
-
*
|
|
29362
|
+
* The engine refuses an unbounded read (`query-bounds.ts`) and truncates a
|
|
29363
|
+
* limitless one at `SETTINGS_QUERY_DEFAULT_ROW_CAP` with a WARN nobody can act
|
|
29364
|
+
* on, so every read below carries an explicit `limit`.
|
|
29062
29365
|
*/
|
|
29063
29366
|
var FACE_SWEEP_PAGE = 1e3;
|
|
29367
|
+
/**
|
|
29368
|
+
* The widest window a single gallery read may materialise before it REFUSES.
|
|
29369
|
+
*
|
|
29370
|
+
* It bounds only the reads whose size the CALLER did not name: an unbounded
|
|
29371
|
+
* `listAllRecentFaces`, and the `suggestionConfidence` order, which cannot stop
|
|
29372
|
+
* early because the most certain suggestion may be the oldest row (`face-sort.ts`).
|
|
29373
|
+
* A caller's own `limit` is honoured verbatim at any table size — asking for
|
|
29374
|
+
* 500 and getting 500 is being served, not being truncated.
|
|
29375
|
+
*
|
|
29376
|
+
* 10 000 because that is above what `pruneCapOverflow` actually permits to
|
|
29377
|
+
* exist: 29 cameras × (50 ordinary + 200 suggested) = 7 250 unassigned rows,
|
|
29378
|
+
* plus whatever the operator has enrolled. It sits under the engine's
|
|
29379
|
+
* `SETTINGS_QUERY_HARD_ROW_CAP` (20 000) so the engine never truncates first —
|
|
29380
|
+
* this bound is the one that speaks, and it speaks by throwing.
|
|
29381
|
+
*
|
|
29382
|
+
* The alternative was to answer with the rows it managed to read. A short list
|
|
29383
|
+
* and a complete one are the same array, and a gallery that quietly stops
|
|
29384
|
+
* showing a face is exactly the failure this whole change exists to avoid.
|
|
29385
|
+
*/
|
|
29386
|
+
var FACE_WINDOW_MAX_ROWS = 1e4;
|
|
29387
|
+
/**
|
|
29388
|
+
* A gallery read whose window is wider than {@link FACE_WINDOW_MAX_ROWS}.
|
|
29389
|
+
*
|
|
29390
|
+
* Distinct type so a router can map it to a 400 and an operator can be told
|
|
29391
|
+
* *"the buffer is too large to rank in one pass — narrow the window"* instead
|
|
29392
|
+
* of being shown a page that silently lost its tail.
|
|
29393
|
+
*/
|
|
29394
|
+
var FaceWindowTooWideError = class extends Error {
|
|
29395
|
+
constructor(rows) {
|
|
29396
|
+
super(`face window exceeds ${String(FACE_WINDOW_MAX_ROWS)} rows (read ${String(rows)}) — refusing to answer with the part that fit; narrow with deviceIds / since / until, or pass an explicit limit`);
|
|
29397
|
+
this.name = "FaceWindowTooWideError";
|
|
29398
|
+
}
|
|
29399
|
+
};
|
|
29064
29400
|
/** How many evicted face ids one per-camera eviction line names before it
|
|
29065
29401
|
* stops — enough to find the rows, not enough to turn a log line into a page. */
|
|
29066
29402
|
var SUGGESTED_EVICTION_IDS_LOGGED = 10;
|
|
@@ -29162,13 +29498,128 @@ var FACE_COLUMNS = [
|
|
|
29162
29498
|
type: "REAL"
|
|
29163
29499
|
}
|
|
29164
29500
|
];
|
|
29165
|
-
|
|
29166
|
-
|
|
29167
|
-
|
|
29168
|
-
|
|
29169
|
-
|
|
29170
|
-
|
|
29171
|
-
|
|
29501
|
+
/**
|
|
29502
|
+
* What every gallery query orders by, and what the three of them filter on.
|
|
29503
|
+
*
|
|
29504
|
+
* Before these existed, EVERY read of this collection was
|
|
29505
|
+
* `SCAN … | USE TEMP B-TREE FOR ORDER BY` — a full table scan plus a sort of
|
|
29506
|
+
* the whole table, to hand back the newest 500 rows. Measured with
|
|
29507
|
+
* `EXPLAIN QUERY PLAN` over a 20 000-row copy of this exact schema:
|
|
29508
|
+
*
|
|
29509
|
+
* | query | before | after |
|
|
29510
|
+
* | --- | --- | --- |
|
|
29511
|
+
* | newest N | `SCAN + TEMP B-TREE` | `SCAN USING idx_faces_ts` |
|
|
29512
|
+
* | newest N unassigned | `SCAN + TEMP B-TREE` | `SEARCH USING idx_faces_assigned_ts (assigned=?)` |
|
|
29513
|
+
* | newest N recognized | `SCAN + TEMP B-TREE` | `SEARCH USING idx_faces_assigned_ts (assigned=?)` |
|
|
29514
|
+
* | faces of one identity | `SCAN + TEMP B-TREE` | `SEARCH USING idx_faces_identity_ts (recognizedIdentityId=?)` |
|
|
29515
|
+
*
|
|
29516
|
+
* The sort disappearing is the point: with an ordered index the statement stops
|
|
29517
|
+
* at the `LIMIT`, so the cost is the ANSWER. Without it the cost is the table,
|
|
29518
|
+
* and this table is bounded only by `pruneCapOverflow` — it went from 372 rows
|
|
29519
|
+
* (2026-08-25) to 1 612 (2026-08-31) while a UI polled it every 30 seconds.
|
|
29520
|
+
*
|
|
29521
|
+
* `idx_faces_ts` is NOT redundant beside `idx_faces_assigned_ts`: a composite
|
|
29522
|
+
* leading on `assigned` cannot serve an unfiltered `ORDER BY timestamp`.
|
|
29523
|
+
* Three b-trees over a few thousand narrow rows is tens of KB, against one
|
|
29524
|
+
* UPSERT per track that produces a face — the write rate this pays at.
|
|
29525
|
+
*/
|
|
29526
|
+
var FACE_INDEXES = [
|
|
29527
|
+
{
|
|
29528
|
+
name: "idx_faces_device_ts",
|
|
29529
|
+
columns: ["deviceId", "timestamp"]
|
|
29530
|
+
},
|
|
29531
|
+
{
|
|
29532
|
+
name: "idx_faces_track",
|
|
29533
|
+
columns: ["trackId"]
|
|
29534
|
+
},
|
|
29535
|
+
{
|
|
29536
|
+
name: "idx_faces_ts",
|
|
29537
|
+
columns: ["timestamp"]
|
|
29538
|
+
},
|
|
29539
|
+
{
|
|
29540
|
+
name: "idx_faces_assigned_ts",
|
|
29541
|
+
columns: ["assigned", "timestamp"]
|
|
29542
|
+
},
|
|
29543
|
+
{
|
|
29544
|
+
name: "idx_faces_identity_ts",
|
|
29545
|
+
columns: ["recognizedIdentityId", "timestamp"]
|
|
29546
|
+
}
|
|
29547
|
+
];
|
|
29548
|
+
/**
|
|
29549
|
+
* The columns a {@link Face} is built from — every declared column EXCEPT the
|
|
29550
|
+
* drained legacy `embedding`, derived mechanically so a column added tomorrow
|
|
29551
|
+
* arrives without anyone remembering this list.
|
|
29552
|
+
*
|
|
29553
|
+
* Mechanical on purpose. A hand-written projection is how a field silently
|
|
29554
|
+
* stops arriving: the row comes back missing the key, `rowToFace` reads
|
|
29555
|
+
* `undefined`, and "the database says nothing" is indistinguishable from "I did
|
|
29556
|
+
* not ask". The engine REFUSES a column it cannot serve
|
|
29557
|
+
* (`resolveProjection`) — the projection is the one part of a query that is not
|
|
29558
|
+
* allowed to be forgiving — so a stale name here is a throw, not a wrong value.
|
|
29559
|
+
*
|
|
29560
|
+
* `embedding` is the only exclusion, and it is the whole reason to project:
|
|
29561
|
+
* it is a `notNull` JSON column on the widest row shape in the database, it is
|
|
29562
|
+
* dropped by `rowToFace` on arrival, and {@link Face} has no field for it. A
|
|
29563
|
+
* caller that wants floats asks by name ({@link FaceStore.getWithEmbedding},
|
|
29564
|
+
* {@link FaceStore.loadEmbeddings}). Since D304 drained it to `[]` the saving
|
|
29565
|
+
* is small — 23 B/row, measured on the live hub — but the column is still
|
|
29566
|
+
* declared, still `notNull`, and an unconverted row still carries ~10.8 KB of
|
|
29567
|
+
* it. Projecting is what keeps that a fact about ONE row rather than about
|
|
29568
|
+
* every gallery poll.
|
|
29569
|
+
*/
|
|
29570
|
+
var FACE_LIST_COLUMNS = FACE_COLUMNS.map((c) => c.name).filter((name) => name !== "embedding" && name !== "id");
|
|
29571
|
+
/**
|
|
29572
|
+
* The four scalars `pruneCapOverflow` decides on.
|
|
29573
|
+
*
|
|
29574
|
+
* Its docblock has always said *"only four scalars per row survive the page"*.
|
|
29575
|
+
* They survived the PAGE; the query still dragged every column — the embedding
|
|
29576
|
+
* included — across the RPC boundary and through V8 first. A leftover that
|
|
29577
|
+
* describes the right design reads as verification, so the projection now makes
|
|
29578
|
+
* the sentence true.
|
|
29579
|
+
*/
|
|
29580
|
+
var FACE_CAP_COLUMNS = [
|
|
29581
|
+
"deviceId",
|
|
29582
|
+
"timestamp",
|
|
29583
|
+
"assigned",
|
|
29584
|
+
"suggestedIdentityId"
|
|
29585
|
+
];
|
|
29586
|
+
/** One camera reference per identity — see {@link FaceStore.listCameraRefsForIdentity}. */
|
|
29587
|
+
var FACE_REF_COLUMNS = ["deviceId"];
|
|
29588
|
+
/**
|
|
29589
|
+
* A gallery view as a STORE PREDICATE.
|
|
29590
|
+
*
|
|
29591
|
+
* Every one of the four is expressible, and that is the whole finding behind
|
|
29592
|
+
* this change. They were applied in JS, over pages the store had already
|
|
29593
|
+
* chosen, which is why `limit` counted rows that PASSED and the read had to
|
|
29594
|
+
* sweep until enough had: with `filter: 'unassigned'` the newest N rows may be
|
|
29595
|
+
* entirely assigned while matches wait just beyond them. Pushed down, `limit`
|
|
29596
|
+
* IS the store's limit, and the sweep has nothing left to do.
|
|
29597
|
+
*
|
|
29598
|
+
* **Two of them name null, and null is the trap.** `recognizedIdentityId` is a
|
|
29599
|
+
* nullable TEXT column, so "has an identity" is `IS NOT NULL` — which
|
|
29600
|
+
* `whereNot: { field: null }` compiles to since D312, and which before D312
|
|
29601
|
+
* compiled to `(expr IS NULL OR expr != NULL)`: TRUE for a NULL row and NULL
|
|
29602
|
+
* for every other, i.e. precisely the rows it was asked to exclude. A predicate
|
|
29603
|
+
* that silently matched nothing would report "no recognized faces" forever and
|
|
29604
|
+
* every assertion about the rows returned would agree with it. Verified against
|
|
29605
|
+
* the live hub before shipping: 1 612 rows, `where {assigned:false}` → 1 444,
|
|
29606
|
+
* `whereNot {recognizedIdentityId:null}` → 247.
|
|
29607
|
+
*
|
|
29608
|
+
* `assigned` is `BOOLEAN NOT NULL`; the backend serialises `false` to `0` at
|
|
29609
|
+
* the bind (`serializeColumnValue`) and decodes `0` back to `false` on read, so
|
|
29610
|
+
* plain equality is correct and no null semantics are involved.
|
|
29611
|
+
*/
|
|
29612
|
+
function faceFilterPredicate(filter) {
|
|
29613
|
+
switch (filter) {
|
|
29614
|
+
case "unassigned": return { where: { assigned: false } };
|
|
29615
|
+
case "recognized": return {
|
|
29616
|
+
where: { assigned: false },
|
|
29617
|
+
whereNot: { recognizedIdentityId: null }
|
|
29618
|
+
};
|
|
29619
|
+
case "identified": return { whereNot: { recognizedIdentityId: null } };
|
|
29620
|
+
case "all": return {};
|
|
29621
|
+
}
|
|
29622
|
+
}
|
|
29172
29623
|
/**
|
|
29173
29624
|
* `since` / `until` as a store predicate.
|
|
29174
29625
|
*
|
|
@@ -29203,6 +29654,7 @@ function rowToFace(id, raw) {
|
|
|
29203
29654
|
return {
|
|
29204
29655
|
...rest,
|
|
29205
29656
|
id,
|
|
29657
|
+
embeddingModelId: data.embeddingModelId ?? void 0,
|
|
29206
29658
|
assigned: Boolean(data.assigned),
|
|
29207
29659
|
mediaKey: data.mediaKey ?? void 0,
|
|
29208
29660
|
recognizedIdentityId: data.recognizedIdentityId ?? void 0,
|
|
@@ -29431,16 +29883,21 @@ var FaceStore = class {
|
|
|
29431
29883
|
*/
|
|
29432
29884
|
async pruneCapOverflow(input) {
|
|
29433
29885
|
/**
|
|
29434
|
-
* Paged, not one statement
|
|
29435
|
-
*
|
|
29436
|
-
*
|
|
29437
|
-
*
|
|
29438
|
-
*
|
|
29886
|
+
* Paged, not one statement, and PROJECTED to the four scalars the decision
|
|
29887
|
+
* needs — this sentence used to be true only of what survived the page,
|
|
29888
|
+
* while the query still dragged every column, `embedding` included, across
|
|
29889
|
+
* the RPC boundary and through V8 first.
|
|
29890
|
+
*
|
|
29891
|
+
* This is the one walk of the collection that remains, and D320 permits it:
|
|
29892
|
+
* it must touch every row to ACT on it, and it runs on the maintenance
|
|
29893
|
+
* pass, not on a request. The prune still considers EVERY row, which is
|
|
29894
|
+
* what makes it a capacity bound rather than a bound on the newest page.
|
|
29439
29895
|
*/
|
|
29440
29896
|
const byDevice = /* @__PURE__ */ new Map();
|
|
29441
29897
|
for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
|
|
29442
29898
|
const rows = await this.store.query.query({
|
|
29443
29899
|
collection: FACES_COLLECTION,
|
|
29900
|
+
columns: FACE_CAP_COLUMNS,
|
|
29444
29901
|
filter: {
|
|
29445
29902
|
orderBy: {
|
|
29446
29903
|
field: "timestamp",
|
|
@@ -29734,29 +30191,38 @@ var FaceStore = class {
|
|
|
29734
30191
|
* - 'recognized' → !assigned && recognizedIdentityId != null
|
|
29735
30192
|
* - 'identified' → recognizedIdentityId != null, including manual assignments
|
|
29736
30193
|
*
|
|
29737
|
-
*
|
|
29738
|
-
*
|
|
29739
|
-
*
|
|
29740
|
-
*
|
|
29741
|
-
*
|
|
29742
|
-
*
|
|
29743
|
-
*
|
|
29744
|
-
*
|
|
29745
|
-
*
|
|
29746
|
-
*
|
|
29747
|
-
*
|
|
29748
|
-
*
|
|
29749
|
-
*
|
|
29750
|
-
*
|
|
29751
|
-
*
|
|
29752
|
-
*
|
|
29753
|
-
*
|
|
29754
|
-
*
|
|
29755
|
-
*
|
|
29756
|
-
*
|
|
29757
|
-
*
|
|
29758
|
-
* `
|
|
29759
|
-
*
|
|
30194
|
+
* **Every part of the question is now a store predicate** — `deviceIds`,
|
|
30195
|
+
* `since` / `until` AND `filter` ({@link faceFilterPredicate}) — so `limit`
|
|
30196
|
+
* is the STORE's limit and one statement is the whole answer.
|
|
30197
|
+
*
|
|
30198
|
+
* That is the change. This used to page: `filter` was a JS predicate applied
|
|
30199
|
+
* to rows the store had already chosen, so `limit` counted rows that PASSED
|
|
30200
|
+
* and the read had to sweep until enough had, up to `FACE_SWEEP_MAX_PAGES`
|
|
30201
|
+
* (1 000) × `FACE_SWEEP_PAGE` (1 000) — a million rows, `offset`-paged,
|
|
30202
|
+
* behind a UI polling every 30 seconds. On the live hub that meant reading
|
|
30203
|
+
* all 1 612 rows over 2 round trips to produce a 60-row `recognized` page,
|
|
30204
|
+
* every 30 s, per open tab, and growing with a table only `pruneCapOverflow`
|
|
30205
|
+
* bounds. It is the same family as `MediaRelocateEngine.pageRows`, which put
|
|
30206
|
+
* a ~16 s standing queue in front of every settings-store call in the cluster
|
|
30207
|
+
* (D320) — smaller, on a longer timer, and the same shape.
|
|
30208
|
+
*
|
|
30209
|
+
* `offset` is gone with it, not merely unnecessary: the recognizer UPSERTs
|
|
30210
|
+
* into this collection continuously, so page two at `offset: N` starts N rows
|
|
30211
|
+
* past a set that may have gained or lost its first N (D295 rule 1, D320 R3).
|
|
30212
|
+
*
|
|
30213
|
+
* ## What still cannot be pushed, and what that costs
|
|
30214
|
+
*
|
|
30215
|
+
* The `suggestionConfidence` ORDER stays here. `ORDER BY suggestedMatchScore`
|
|
30216
|
+
* would let the backend decide where a scoreless row goes — SQLite collates
|
|
30217
|
+
* NULL first on ASC and last on DESC — and the contract is that it sorts LAST
|
|
30218
|
+
* in BOTH directions (`face-sort.ts`). So that order reads the whole FILTERED
|
|
30219
|
+
* window and ranks it. Filtered, which is the improvement: the window is the
|
|
30220
|
+
* unassigned buffer, not the table.
|
|
30221
|
+
*
|
|
30222
|
+
* A window it cannot read whole REFUSES ({@link FaceWindowTooWideError}).
|
|
30223
|
+
* Ranking the part that fit is exactly "most certain among the newest N",
|
|
30224
|
+
* which is the defect that order was added to remove, and it is invisible in
|
|
30225
|
+
* the rows returned.
|
|
29760
30226
|
*
|
|
29761
30227
|
* An **empty `deviceIds` reads nothing at all**: `[]` is a request for no
|
|
29762
30228
|
* cameras, not an omission (the `deviceManager.listFleet` contract).
|
|
@@ -29765,74 +30231,86 @@ var FaceStore = class {
|
|
|
29765
30231
|
if (input.deviceIds !== void 0 && input.deviceIds.length === 0) return [];
|
|
29766
30232
|
const order = resolveFaceSort(input);
|
|
29767
30233
|
const pagingServesTheOrder = sortIsServedByTimestampPaging(order);
|
|
29768
|
-
|
|
29769
|
-
|
|
29770
|
-
|
|
29771
|
-
|
|
29772
|
-
|
|
29773
|
-
|
|
29774
|
-
|
|
29775
|
-
const
|
|
29776
|
-
|
|
29777
|
-
|
|
29778
|
-
|
|
29779
|
-
|
|
29780
|
-
|
|
30234
|
+
/**
|
|
30235
|
+
* The caller's `limit` bounds the READ only when the store's ordering is
|
|
30236
|
+
* already the answer's. Under `suggestionConfidence` the most certain
|
|
30237
|
+
* suggestion may be the oldest row, so the read is the window and `limit`
|
|
30238
|
+
* bounds only what is RETAINED (`rankAndCap`).
|
|
30239
|
+
*/
|
|
30240
|
+
const callerBounded = pagingServesTheOrder && input.limit !== void 0;
|
|
30241
|
+
const readLimit = callerBounded ? input.limit ?? 0 : 10001;
|
|
30242
|
+
const rows = await this.store.query.query({
|
|
30243
|
+
collection: FACES_COLLECTION,
|
|
30244
|
+
columns: FACE_LIST_COLUMNS,
|
|
30245
|
+
filter: {
|
|
30246
|
+
...input.deviceIds !== void 0 ? { whereIn: { deviceId: [...new Set(input.deviceIds)] } } : {},
|
|
30247
|
+
...buildTimestampWindow(input),
|
|
30248
|
+
...faceFilterPredicate(input.filter ?? "all"),
|
|
30249
|
+
orderBy: {
|
|
30250
|
+
field: "timestamp",
|
|
30251
|
+
direction: pagingServesTheOrder ? order.direction : "desc"
|
|
30252
|
+
},
|
|
30253
|
+
limit: readLimit
|
|
29781
30254
|
}
|
|
29782
|
-
};
|
|
29783
|
-
|
|
29784
|
-
|
|
29785
|
-
|
|
29786
|
-
collection: FACES_COLLECTION,
|
|
29787
|
-
filter: {
|
|
29788
|
-
...storeFilter,
|
|
29789
|
-
limit: FACE_SWEEP_PAGE,
|
|
29790
|
-
offset: page * FACE_SWEEP_PAGE
|
|
29791
|
-
}
|
|
29792
|
-
});
|
|
29793
|
-
if (rows.length === 0) break;
|
|
29794
|
-
const matched = [];
|
|
29795
|
-
for (const r of rows) {
|
|
29796
|
-
const face = rowToFace(r.id, r.data);
|
|
29797
|
-
if (matches(face)) matched.push(face);
|
|
29798
|
-
}
|
|
29799
|
-
if (pagingServesTheOrder) {
|
|
29800
|
-
const merged = [...result, ...matched];
|
|
29801
|
-
if (input.limit !== void 0 && merged.length >= input.limit) return merged.slice(0, input.limit);
|
|
29802
|
-
result = merged;
|
|
29803
|
-
} else result = rankAndCap([...result, ...matched], order, input.limit);
|
|
29804
|
-
if (rows.length < 1e3) break;
|
|
29805
|
-
}
|
|
29806
|
-
return pagingServesTheOrder ? result : rankAndCap(result, order, input.limit);
|
|
30255
|
+
});
|
|
30256
|
+
if (!callerBounded && rows.length > 1e4) throw new FaceWindowTooWideError(rows.length);
|
|
30257
|
+
const faces = rows.map((r) => rowToFace(r.id, r.data));
|
|
30258
|
+
return pagingServesTheOrder ? faces : rankAndCap(faces, order, input.limit);
|
|
29807
30259
|
}
|
|
29808
|
-
/**
|
|
30260
|
+
/**
|
|
30261
|
+
* One real face row per camera currently attributed to an identity.
|
|
30262
|
+
*
|
|
30263
|
+
* `recognizedIdentityId` is indexed (`idx_faces_identity_ts`), so this is a
|
|
30264
|
+
* bounded index walk over the identity's own rows rather than the
|
|
30265
|
+
* `offset`-paged sweep of the whole collection it used to be. It projects
|
|
30266
|
+
* `deviceId` alone — the answer is a device id and a row id, and the row id
|
|
30267
|
+
* is the record key.
|
|
30268
|
+
*/
|
|
29809
30269
|
async listCameraRefsForIdentity(identityId) {
|
|
30270
|
+
const rows = await this.identityRows(identityId, FACE_REF_COLUMNS);
|
|
29810
30271
|
const byDevice = /* @__PURE__ */ new Map();
|
|
29811
|
-
for (
|
|
29812
|
-
const
|
|
29813
|
-
|
|
29814
|
-
filter: {
|
|
29815
|
-
where: { recognizedIdentityId: identityId },
|
|
29816
|
-
orderBy: {
|
|
29817
|
-
field: "timestamp",
|
|
29818
|
-
direction: "desc"
|
|
29819
|
-
},
|
|
29820
|
-
limit: FACE_SWEEP_PAGE,
|
|
29821
|
-
offset: page * FACE_SWEEP_PAGE
|
|
29822
|
-
}
|
|
29823
|
-
});
|
|
29824
|
-
if (rows.length === 0) break;
|
|
29825
|
-
for (const row of rows) {
|
|
29826
|
-
const deviceId = Number(row.data["deviceId"]);
|
|
29827
|
-
if (Number.isInteger(deviceId) && !byDevice.has(deviceId)) byDevice.set(deviceId, row.id);
|
|
29828
|
-
}
|
|
29829
|
-
if (rows.length < 1e3) break;
|
|
30272
|
+
for (const row of rows) {
|
|
30273
|
+
const deviceId = Number(row.data["deviceId"]);
|
|
30274
|
+
if (Number.isInteger(deviceId) && !byDevice.has(deviceId)) byDevice.set(deviceId, row.id);
|
|
29830
30275
|
}
|
|
29831
30276
|
return [...byDevice].map(([deviceId, faceId]) => ({
|
|
29832
30277
|
deviceId,
|
|
29833
30278
|
faceId
|
|
29834
30279
|
}));
|
|
29835
30280
|
}
|
|
30281
|
+
/**
|
|
30282
|
+
* Every face row currently attributed to an identity, id only.
|
|
30283
|
+
*
|
|
30284
|
+
* `deleteIdentity` detaches these before the identity goes. It used to get
|
|
30285
|
+
* them by listing the WHOLE collection with `filter: 'all'` and no limit and
|
|
30286
|
+
* comparing in JS — which past `SETTINGS_QUERY_DEFAULT_ROW_CAP` would have
|
|
30287
|
+
* detached the rows that fit and left the rest pointing at a deleted
|
|
30288
|
+
* identity, silently, with a WARN in the engine that no caller can see.
|
|
30289
|
+
*/
|
|
30290
|
+
async listFaceIdsForIdentity(identityId) {
|
|
30291
|
+
return (await this.identityRows(identityId, FACE_REF_COLUMNS)).map((r) => r.id);
|
|
30292
|
+
}
|
|
30293
|
+
/**
|
|
30294
|
+
* The rows of one identity, newest first, bounded and REFUSING rather than
|
|
30295
|
+
* narrowing. Shared by the two callers above so they cannot disagree about
|
|
30296
|
+
* what "this identity's faces" means.
|
|
30297
|
+
*/
|
|
30298
|
+
async identityRows(identityId, columns) {
|
|
30299
|
+
const rows = await this.store.query.query({
|
|
30300
|
+
collection: FACES_COLLECTION,
|
|
30301
|
+
columns,
|
|
30302
|
+
filter: {
|
|
30303
|
+
where: { recognizedIdentityId: identityId },
|
|
30304
|
+
orderBy: {
|
|
30305
|
+
field: "timestamp",
|
|
30306
|
+
direction: "desc"
|
|
30307
|
+
},
|
|
30308
|
+
limit: 10001
|
|
30309
|
+
}
|
|
30310
|
+
});
|
|
30311
|
+
if (rows.length > 1e4) throw new FaceWindowTooWideError(rows.length);
|
|
30312
|
+
return rows;
|
|
30313
|
+
}
|
|
29836
30314
|
};
|
|
29837
30315
|
//#endregion
|
|
29838
30316
|
//#region src/pipeline-analytics/face-gallery-provider.ts
|
|
@@ -29949,8 +30427,7 @@ var FaceGalleryProvider = class {
|
|
|
29949
30427
|
});
|
|
29950
30428
|
}
|
|
29951
30429
|
async deleteIdentity(input) {
|
|
29952
|
-
const
|
|
29953
|
-
for (const f of faces) if (f.recognizedIdentityId === input.id) await this.unassignFace({ faceId: f.id });
|
|
30430
|
+
for (const faceId of await this.faceStore.listFaceIdsForIdentity(input.id)) await this.unassignFace({ faceId });
|
|
29954
30431
|
await this.mediaStore.deleteForOwner("identity", [input.id]);
|
|
29955
30432
|
await this.identityStore.deleteIdentity(input.id);
|
|
29956
30433
|
this.refreshGallery();
|
|
@@ -30023,8 +30500,7 @@ var FaceGalleryProvider = class {
|
|
|
30023
30500
|
sortBy: input?.sortBy,
|
|
30024
30501
|
sortDirection: input?.sortDirection
|
|
30025
30502
|
});
|
|
30026
|
-
const
|
|
30027
|
-
const nameMap = new Map(identities.map((i) => [i.id, i.name]));
|
|
30503
|
+
const nameMap = await this.identityStore.namesFor(faces.flatMap((f) => f.recognizedIdentityId != null ? [f.recognizedIdentityId] : []));
|
|
30028
30504
|
const includeCrops = resolveIncludeCrops(input);
|
|
30029
30505
|
return mapWithConcurrency(faces, FACE_CROP_FETCH_CONCURRENCY, async (face) => {
|
|
30030
30506
|
const base64 = includeCrops ? await this.faceCropBase64(face) : void 0;
|
|
@@ -30060,8 +30536,7 @@ var FaceGalleryProvider = class {
|
|
|
30060
30536
|
const face = await this.faceStore.get("face-" + input.trackId);
|
|
30061
30537
|
if (!face) return null;
|
|
30062
30538
|
if (face.deviceId !== input.deviceId) return null;
|
|
30063
|
-
const
|
|
30064
|
-
const nameMap = new Map(identities.map((i) => [i.id, i.name]));
|
|
30539
|
+
const nameMap = await this.identityStore.namesFor(face.recognizedIdentityId != null ? [face.recognizedIdentityId] : []);
|
|
30065
30540
|
const identityName = face.recognizedIdentityId ? nameMap.get(face.recognizedIdentityId) : void 0;
|
|
30066
30541
|
const cropUrl = this.cropUrlFor(face);
|
|
30067
30542
|
return {
|
|
@@ -34878,9 +35353,14 @@ var PlateGalleryProvider = class {
|
|
|
34878
35353
|
...cropUrl !== void 0 ? { cropUrl } : {}
|
|
34879
35354
|
};
|
|
34880
35355
|
}
|
|
34881
|
-
|
|
34882
|
-
|
|
34883
|
-
|
|
35356
|
+
/**
|
|
35357
|
+
* Names for the vehicles THESE PLATES reference — not the collection.
|
|
35358
|
+
*
|
|
35359
|
+
* A page of unassigned reads references none, and then this costs no round
|
|
35360
|
+
* trip at all. See `VehicleStore.namesFor`.
|
|
35361
|
+
*/
|
|
35362
|
+
async vehicleNameMap(plates) {
|
|
35363
|
+
return this.vehicleStore.namesFor(plates.flatMap((p) => p.recognizedVehicleId != null ? [p.recognizedVehicleId] : []));
|
|
34884
35364
|
}
|
|
34885
35365
|
/**
|
|
34886
35366
|
* `includeCrops` defaults to `true` — the pre-existing behaviour, kept so no
|
|
@@ -34895,14 +35375,14 @@ var PlateGalleryProvider = class {
|
|
|
34895
35375
|
...input?.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
|
|
34896
35376
|
...input?.limit !== void 0 ? { limit: input.limit } : {}
|
|
34897
35377
|
});
|
|
34898
|
-
const nameMap = await this.vehicleNameMap();
|
|
35378
|
+
const nameMap = await this.vehicleNameMap(rows);
|
|
34899
35379
|
const includeCrops = resolveIncludeCrops(input);
|
|
34900
35380
|
return Promise.all(rows.map((p) => this.toPlateInfo(p, nameMap, { includeCrops })));
|
|
34901
35381
|
}
|
|
34902
35382
|
async getPlateByTrack(input) {
|
|
34903
35383
|
const plate = await this.plateStore.get(`plate-${input.trackId}`);
|
|
34904
35384
|
if (!plate || plate.deviceId !== input.deviceId) return null;
|
|
34905
|
-
const nameMap = await this.vehicleNameMap();
|
|
35385
|
+
const nameMap = await this.vehicleNameMap([plate]);
|
|
34906
35386
|
return this.toPlateInfo(plate, nameMap, { includeCrops: false });
|
|
34907
35387
|
}
|
|
34908
35388
|
async getPlateMedia(input) {
|
|
@@ -34915,7 +35395,7 @@ var PlateGalleryProvider = class {
|
|
|
34915
35395
|
dist: plateDistance(input.text, p.text)
|
|
34916
35396
|
})).filter((x) => x.dist <= maxDistance).toSorted((a, b) => a.dist - b.dist || b.p.timestamp - a.p.timestamp);
|
|
34917
35397
|
const limited = input.limit !== void 0 ? scored.slice(0, input.limit) : scored;
|
|
34918
|
-
const nameMap = await this.vehicleNameMap();
|
|
35398
|
+
const nameMap = await this.vehicleNameMap(limited.map((x) => x.p));
|
|
34919
35399
|
const includeCrops = input.includeCrops ?? true;
|
|
34920
35400
|
return Promise.all(limited.map((x) => this.toPlateInfo(x.p, nameMap, { includeCrops })));
|
|
34921
35401
|
}
|
|
@@ -34990,8 +35470,7 @@ var PlateGalleryProvider = class {
|
|
|
34990
35470
|
await this.vehicleStore.renameVehicle(input.id, input.name);
|
|
34991
35471
|
}
|
|
34992
35472
|
async deleteVehicle(input) {
|
|
34993
|
-
const
|
|
34994
|
-
for (const p of plates) if (p.recognizedVehicleId === input.id) await this.unassignPlate({ plateId: p.id });
|
|
35473
|
+
for (const plateId of await this.plateStore.listPlateIdsForVehicle(input.id)) await this.unassignPlate({ plateId });
|
|
34995
35474
|
await this.mediaStore.deleteForOwner("vehicle", [input.id]);
|
|
34996
35475
|
await this.vehicleStore.deleteVehicle(input.id);
|
|
34997
35476
|
this.refreshGallery();
|
|
@@ -44845,6 +45324,45 @@ function pickWritableMediaLocation(locations, currentDefaultId) {
|
|
|
44845
45324
|
}
|
|
44846
45325
|
//#endregion
|
|
44847
45326
|
//#region src/pipeline-analytics/media-relocate-engine.ts
|
|
45327
|
+
/**
|
|
45328
|
+
* Event-media relocation engine (entity-routing spec, Phase 4) — the
|
|
45329
|
+
* analytics sibling of the recorder's footage mover. Each addon moves ONLY
|
|
45330
|
+
* what it owns (addons never touch each other's files).
|
|
45331
|
+
*
|
|
45332
|
+
* Per row: read blob from its CURRENT location → write to the target →
|
|
45333
|
+
* stamp `row.locationId` → delete the old blob. The row update lands between
|
|
45334
|
+
* the two blob operations, so a crash leaves the row pointing at a location
|
|
45335
|
+
* that still holds valid bytes (the old blob is only deleted after the row
|
|
45336
|
+
* points away from it). Rows already at the target are skipped — a re-run
|
|
45337
|
+
* RESUMES.
|
|
45338
|
+
*
|
|
45339
|
+
* Paging: timestamp-cursor over the media collection, with a seen-id set
|
|
45340
|
+
* bridging equal-timestamp boundaries. The PASS pages because it must visit
|
|
45341
|
+
* every row to act on it; that is not true of every question asked about this
|
|
45342
|
+
* collection, and `countUnstamped` no longer pages — see its docblock, and the
|
|
45343
|
+
* 60 s timeout that shape cost.
|
|
45344
|
+
*
|
|
45345
|
+
* (This header used to claim `WHERE locationId IS NULL` was inexpressible
|
|
45346
|
+
* through the store's filter. It was expressible, and that was the problem:
|
|
45347
|
+
* `where: { locationId: null }` compiled to `= NULL`, which matches nothing
|
|
45348
|
+
* and reports it as `0`. The filter layer now compiles it to `IS NULL`.)
|
|
45349
|
+
*
|
|
45350
|
+
* ── Three passes, one engine (see `MediaRelocateModeSchema`) ──────────────
|
|
45351
|
+
*
|
|
45352
|
+
* `move` is the historical pass. `seal` and `gallery` are the SAME walk with a
|
|
45353
|
+
* different row predicate and a different per-row action, deliberately not a
|
|
45354
|
+
* second mover: the walk, the cursor, the tie-bridging and the failure
|
|
45355
|
+
* accounting are the hard parts and they are already right here.
|
|
45356
|
+
*
|
|
45357
|
+
* - `seal` exists because a row with `locationId: NULL` means "wherever
|
|
45358
|
+
* `eventMedia` points NOW" (`media-store.ts:396`). A repoint moves that
|
|
45359
|
+
* pointer and every NULL row starts reading from the new disk while its
|
|
45360
|
+
* bytes are on the old one. `seal` is a ROW-ONLY pass — no blob is read,
|
|
45361
|
+
* written or deleted — that nails those rows to the location they are
|
|
45362
|
+
* actually on before anything repoints.
|
|
45363
|
+
* - `gallery` is the inverse selection of `move`: only the retention-exempt
|
|
45364
|
+
* enrolled-gallery rows, which `move` skips on purpose (D197).
|
|
45365
|
+
*/
|
|
44848
45366
|
var PAGE_SIZE = 200;
|
|
44849
45367
|
var DEFAULT_THROTTLE_MBPS = 40;
|
|
44850
45368
|
/** What a pass touches, reported on the job so an ops-log row says which of the
|
|
@@ -44889,6 +45407,59 @@ var LOCATION_ID_FIELD = "locationId";
|
|
|
44889
45407
|
*/
|
|
44890
45408
|
var SEAL_BATCH_SIZE = 500;
|
|
44891
45409
|
/**
|
|
45410
|
+
* Rows one CENSUS may visit before it answers `null`.
|
|
45411
|
+
*
|
|
45412
|
+
* A census is the read-only question behind `storageMigration.residue` — "how
|
|
45413
|
+
* much would a pass still act on". It is NOT the pass: a pass walks the whole
|
|
45414
|
+
* collection because it has to touch every row, and pays for that walk once,
|
|
45415
|
+
* under an operator's explicit start.
|
|
45416
|
+
*
|
|
45417
|
+
* 20 000 rows is 100 round trips at {@link PAGE_SIZE} — ~26 ms of engine time
|
|
45418
|
+
* on a healthy store, and a ceiling that cannot become the cluster's queue. A
|
|
45419
|
+
* collection larger than this is one a WALK cannot answer questions about; the
|
|
45420
|
+
* honest reply is "I could not answer" (D295), and the structural fix is an
|
|
45421
|
+
* indexed `COUNT(*)`, which needs `locationId` sealed first and is deliberately
|
|
45422
|
+
* not smuggled in behind a bound.
|
|
45423
|
+
*/
|
|
45424
|
+
var MEDIA_CENSUS_ROW_BUDGET = 2e4;
|
|
45425
|
+
/**
|
|
45426
|
+
* Wall clock one CENSUS may spend before it answers `null`.
|
|
45427
|
+
*
|
|
45428
|
+
* The row budget bounds a healthy store; this one bounds a sick store, where
|
|
45429
|
+
* 100 round trips at a 16 s queue depth is 27 minutes. Read through
|
|
45430
|
+
* `deps.now`, so it is deterministic under test and does not need a timer.
|
|
45431
|
+
*/
|
|
45432
|
+
var MEDIA_CENSUS_DEADLINE_MS = 2e4;
|
|
45433
|
+
/**
|
|
45434
|
+
* What one census may spend, and what it has spent.
|
|
45435
|
+
*
|
|
45436
|
+
* Explicit rather than folded into the `stop` callback: `stop` means "someone
|
|
45437
|
+
* asked me to stop" and the budget means "I am not allowed to continue", and a
|
|
45438
|
+
* pass passes `null` here because a pass is allowed to walk the whole
|
|
45439
|
+
* collection. Two concepts, two parameters — the walk that ran unbounded for
|
|
45440
|
+
* a day did so because ONE `stop: () => false` stood for both.
|
|
45441
|
+
*/
|
|
45442
|
+
var CensusBudget = class {
|
|
45443
|
+
now;
|
|
45444
|
+
maxRows;
|
|
45445
|
+
rows = 0;
|
|
45446
|
+
deadlineAt;
|
|
45447
|
+
constructor(now, maxRows, maxMs) {
|
|
45448
|
+
this.now = now;
|
|
45449
|
+
this.maxRows = maxRows;
|
|
45450
|
+
this.deadlineAt = now() + maxMs;
|
|
45451
|
+
}
|
|
45452
|
+
get rowsVisited() {
|
|
45453
|
+
return this.rows;
|
|
45454
|
+
}
|
|
45455
|
+
/** Visit one row. `true` means the budget is spent and the census must stop. */
|
|
45456
|
+
spend() {
|
|
45457
|
+
this.rows += 1;
|
|
45458
|
+
if (this.rows > this.maxRows) return true;
|
|
45459
|
+
return this.rows % PAGE_SIZE === 0 && this.now() >= this.deadlineAt;
|
|
45460
|
+
}
|
|
45461
|
+
};
|
|
45462
|
+
/**
|
|
44892
45463
|
* True when the row names NO location — the population `seal` exists for.
|
|
44893
45464
|
*
|
|
44894
45465
|
* Deliberately NOT `mediaRowLocation(data) === DEFAULT_MEDIA_LOCATION`: that
|
|
@@ -44945,9 +45516,26 @@ function selectsRow(mode, toLocationId, data, isRetrain) {
|
|
|
44945
45516
|
var MediaRelocateEngine = class {
|
|
44946
45517
|
deps;
|
|
44947
45518
|
jobs = /* @__PURE__ */ new Map();
|
|
45519
|
+
/** Censuses in flight, keyed by the QUESTION — see {@link countSelectable}. */
|
|
45520
|
+
censuses = /* @__PURE__ */ new Map();
|
|
44948
45521
|
constructor(deps) {
|
|
44949
45522
|
this.deps = deps;
|
|
44950
45523
|
}
|
|
45524
|
+
/**
|
|
45525
|
+
* Retire every census in flight. They answer `null`, and stop reading.
|
|
45526
|
+
*
|
|
45527
|
+
* @returns how many were retired.
|
|
45528
|
+
*/
|
|
45529
|
+
cancelCensuses(reason) {
|
|
45530
|
+
const retired = this.censuses.size;
|
|
45531
|
+
if (retired === 0) return 0;
|
|
45532
|
+
for (const run of this.censuses.values()) run.abort();
|
|
45533
|
+
this.deps.logger.info("media relocate: standing censuses retired", { meta: {
|
|
45534
|
+
retired,
|
|
45535
|
+
reason
|
|
45536
|
+
} });
|
|
45537
|
+
return retired;
|
|
45538
|
+
}
|
|
44951
45539
|
list() {
|
|
44952
45540
|
return [...this.jobs.values()].sort((a, b) => b.startedAt - a.startedAt).map(snapshot);
|
|
44953
45541
|
}
|
|
@@ -45046,6 +45634,7 @@ var MediaRelocateEngine = class {
|
|
|
45046
45634
|
}
|
|
45047
45635
|
start(input) {
|
|
45048
45636
|
for (const j of this.jobs.values()) if (j.state === "running") throw new Error(`a media relocation is already running (${j.jobId})`);
|
|
45637
|
+
this.cancelCensuses("a relocate pass started");
|
|
45049
45638
|
const job = {
|
|
45050
45639
|
jobId: this.deps.newId(),
|
|
45051
45640
|
state: "running",
|
|
@@ -45116,9 +45705,31 @@ var MediaRelocateEngine = class {
|
|
|
45116
45705
|
* must not be allowed to make (D295).
|
|
45117
45706
|
*/
|
|
45118
45707
|
async countSelectable(input) {
|
|
45119
|
-
|
|
45708
|
+
const mode = input.mode ?? "move";
|
|
45709
|
+
const key = `${mode}|${input.toLocationId}`;
|
|
45710
|
+
const running = this.censuses.get(key);
|
|
45711
|
+
if (running !== void 0) return running.promise;
|
|
45712
|
+
let aborted = false;
|
|
45713
|
+
const budget = new CensusBudget(this.deps.now, MEDIA_CENSUS_ROW_BUDGET, MEDIA_CENSUS_DEADLINE_MS);
|
|
45714
|
+
const promise = this.countSelected(mode, input.toLocationId, () => aborted, budget).finally(() => {
|
|
45715
|
+
this.censuses.delete(key);
|
|
45716
|
+
});
|
|
45717
|
+
this.censuses.set(key, {
|
|
45718
|
+
promise,
|
|
45719
|
+
abort: () => {
|
|
45720
|
+
aborted = true;
|
|
45721
|
+
}
|
|
45722
|
+
});
|
|
45723
|
+
return promise;
|
|
45120
45724
|
}
|
|
45121
|
-
|
|
45725
|
+
/**
|
|
45726
|
+
* @param stop someone asked this walk to stop (an operator cancel, or
|
|
45727
|
+
* {@link cancelCensuses}). Answers `null`.
|
|
45728
|
+
* @param budget what this walk is allowed to spend, or `null` for a PASS —
|
|
45729
|
+
* a pass walks the whole collection because it must touch every row, and
|
|
45730
|
+
* an operator started it explicitly.
|
|
45731
|
+
*/
|
|
45732
|
+
async countSelected(mode, toLocationId, stop, budget) {
|
|
45122
45733
|
try {
|
|
45123
45734
|
if (mode === "seal") {
|
|
45124
45735
|
let sealable = 0;
|
|
@@ -45135,7 +45746,25 @@ var MediaRelocateEngine = class {
|
|
|
45135
45746
|
for (const collection of collectionsFor(mode)) {
|
|
45136
45747
|
const isRetrain = collection.name === RETRAIN_FRAMES_COLLECTION;
|
|
45137
45748
|
for await (const row of this.pageRows(collection.name, collection.order)) {
|
|
45138
|
-
if (stop())
|
|
45749
|
+
if (stop()) {
|
|
45750
|
+
this.deps.logger.warn("media relocate: census stopped before it finished — reporting the residue as unknown, not as empty", { meta: {
|
|
45751
|
+
mode,
|
|
45752
|
+
toLocationId,
|
|
45753
|
+
collection: collection.name
|
|
45754
|
+
} });
|
|
45755
|
+
return null;
|
|
45756
|
+
}
|
|
45757
|
+
if (budget !== null && budget.spend()) {
|
|
45758
|
+
this.deps.logger.warn("media relocate: census exceeded its budget — this collection is larger than a walk can answer", { meta: {
|
|
45759
|
+
mode,
|
|
45760
|
+
toLocationId,
|
|
45761
|
+
collection: collection.name,
|
|
45762
|
+
rowsVisited: budget.rowsVisited,
|
|
45763
|
+
rowBudget: MEDIA_CENSUS_ROW_BUDGET,
|
|
45764
|
+
deadlineMs: MEDIA_CENSUS_DEADLINE_MS
|
|
45765
|
+
} });
|
|
45766
|
+
return null;
|
|
45767
|
+
}
|
|
45139
45768
|
if (selectsRow(mode, toLocationId, row.data, isRetrain)) rows++;
|
|
45140
45769
|
}
|
|
45141
45770
|
}
|
|
@@ -45161,7 +45790,7 @@ var MediaRelocateEngine = class {
|
|
|
45161
45790
|
* rows in silence reads as "there were none", which is how a migration
|
|
45162
45791
|
* gets believed. */
|
|
45163
45792
|
let pathlessRows = 0;
|
|
45164
|
-
job.filesTotal = (await this.countSelected(job.mode, job.toLocationId, () => job.cancelRequested))?.rows ?? null;
|
|
45793
|
+
job.filesTotal = (await this.countSelected(job.mode, job.toLocationId, () => job.cancelRequested, null))?.rows ?? null;
|
|
45165
45794
|
if (job.mode === "seal") {
|
|
45166
45795
|
for (const collection of collectionsFor(job.mode)) {
|
|
45167
45796
|
if (job.cancelRequested) break;
|
|
@@ -52690,6 +53319,50 @@ var IdentityStore = class {
|
|
|
52690
53319
|
key: id
|
|
52691
53320
|
});
|
|
52692
53321
|
}
|
|
53322
|
+
/**
|
|
53323
|
+
* `identityId → name` for exactly the identities asked about.
|
|
53324
|
+
*
|
|
53325
|
+
* The decorating half of a face or plate page needs a NAME per referenced
|
|
53326
|
+
* identity, and it used to get it by reading the whole `identities`
|
|
53327
|
+
* collection — unbounded, unprojected, on every 30 s poll of
|
|
53328
|
+
* `faceGallery.listRecentFaces`, and again on every `getFaceByTrack`. The
|
|
53329
|
+
* table is small (4 rows on the live hub on 2026-08-31) so the bytes were
|
|
53330
|
+
* never the problem; the SHAPE was. It is a second question whose cost is
|
|
53331
|
+
* proportional to a collection, asked on a timer, to answer a question about
|
|
53332
|
+
* a page — and it carried no `limit`, so past
|
|
53333
|
+
* `SETTINGS_QUERY_DEFAULT_ROW_CAP` it would have handed back a name map
|
|
53334
|
+
* missing entries, shape-identical to a complete one (D320).
|
|
53335
|
+
*
|
|
53336
|
+
* **An empty request costs no round trip at all.** With
|
|
53337
|
+
* `filter: 'unassigned'` — the view the operator lives in — a page
|
|
53338
|
+
* references no identity, and the honest number of queries for that is zero.
|
|
53339
|
+
* `whereIn` with an empty list matches nothing, so asking anyway would be an
|
|
53340
|
+
* RPC to be told what the caller already knew.
|
|
53341
|
+
*
|
|
53342
|
+
* A cache belongs in the owner of the fact, and this store IS the owner —
|
|
53343
|
+
* but a name here changes only when the operator renames, through this same
|
|
53344
|
+
* store, and the read is now one indexed `IN` over at most a page's worth of
|
|
53345
|
+
* ids. There is nothing left to cache that would not be a second authority
|
|
53346
|
+
* over a fact one round trip away (D224).
|
|
53347
|
+
*/
|
|
53348
|
+
async namesFor(identityIds) {
|
|
53349
|
+
const ids = [...new Set(identityIds)];
|
|
53350
|
+
if (ids.length === 0) return /* @__PURE__ */ new Map();
|
|
53351
|
+
const rows = await this.store.query.query({
|
|
53352
|
+
collection: IDENTITIES_COLLECTION,
|
|
53353
|
+
columns: ["name"],
|
|
53354
|
+
filter: {
|
|
53355
|
+
whereIn: { id: ids },
|
|
53356
|
+
limit: ids.length
|
|
53357
|
+
}
|
|
53358
|
+
});
|
|
53359
|
+
const names = /* @__PURE__ */ new Map();
|
|
53360
|
+
for (const r of rows) {
|
|
53361
|
+
const name = r.data["name"];
|
|
53362
|
+
if (typeof name === "string") names.set(r.id, name);
|
|
53363
|
+
}
|
|
53364
|
+
return names;
|
|
53365
|
+
}
|
|
52693
53366
|
async listIdentities() {
|
|
52694
53367
|
return (await this.store.query.query({
|
|
52695
53368
|
collection: IDENTITIES_COLLECTION,
|
|
@@ -53231,13 +53904,55 @@ var PLATE_COLUMNS = [
|
|
|
53231
53904
|
type: "JSON"
|
|
53232
53905
|
}
|
|
53233
53906
|
];
|
|
53234
|
-
|
|
53235
|
-
|
|
53236
|
-
|
|
53237
|
-
|
|
53238
|
-
|
|
53239
|
-
|
|
53240
|
-
|
|
53907
|
+
/**
|
|
53908
|
+
* What the gallery orders by, and what the vehicle-scoped read filters on.
|
|
53909
|
+
*
|
|
53910
|
+
* The face twin's `EXPLAIN QUERY PLAN` measurement applies verbatim: without an
|
|
53911
|
+
* index on `timestamp`, `ORDER BY timestamp DESC LIMIT 500` is
|
|
53912
|
+
* `SCAN + USE TEMP B-TREE FOR ORDER BY` — the read was already BOUNDED at 200,
|
|
53913
|
+
* and still sorted the whole table to produce the newest N. A bound caps what
|
|
53914
|
+
* comes back; only an ordered index caps what is read.
|
|
53915
|
+
*/
|
|
53916
|
+
var PLATE_INDEXES = [
|
|
53917
|
+
{
|
|
53918
|
+
name: "idx_plates_device_ts",
|
|
53919
|
+
columns: ["deviceId", "timestamp"]
|
|
53920
|
+
},
|
|
53921
|
+
{
|
|
53922
|
+
name: "idx_plates_track",
|
|
53923
|
+
columns: ["trackId"]
|
|
53924
|
+
},
|
|
53925
|
+
{
|
|
53926
|
+
name: "idx_plates_ts",
|
|
53927
|
+
columns: ["timestamp"]
|
|
53928
|
+
},
|
|
53929
|
+
{
|
|
53930
|
+
name: "idx_plates_vehicle_ts",
|
|
53931
|
+
columns: ["recognizedVehicleId", "timestamp"]
|
|
53932
|
+
}
|
|
53933
|
+
];
|
|
53934
|
+
/**
|
|
53935
|
+
* The widest plate window a single read may materialise before it REFUSES.
|
|
53936
|
+
*
|
|
53937
|
+
* The corpus reads (`searchPlates`, `suggestPlateClusters`) are genuinely
|
|
53938
|
+
* corpus-shaped — fuzzy text distance is not a predicate — so they cannot be
|
|
53939
|
+
* narrowed the way the gallery list was. What they CAN do is stop lying: they
|
|
53940
|
+
* carried no `limit` at all, so the engine imposed
|
|
53941
|
+
* `SETTINGS_QUERY_DEFAULT_ROW_CAP` and handed back the first 2 000 rows with a
|
|
53942
|
+
* WARN no caller can see. A search over the newest 2 000 of 50 000 plates and a
|
|
53943
|
+
* search over all of them are the same array (D320 R3).
|
|
53944
|
+
*
|
|
53945
|
+
* 10 000 mirrors the faces ceiling and stays under the engine's hard cap so the
|
|
53946
|
+
* engine never truncates first.
|
|
53947
|
+
*/
|
|
53948
|
+
var PLATE_WINDOW_MAX_ROWS = 1e4;
|
|
53949
|
+
/** A corpus read whose window is wider than {@link PLATE_WINDOW_MAX_ROWS}. */
|
|
53950
|
+
var PlateWindowTooWideError = class extends Error {
|
|
53951
|
+
constructor(rows) {
|
|
53952
|
+
super(`plate window exceeds ${String(PLATE_WINDOW_MAX_ROWS)} rows (read ${String(rows)}) — refusing to answer with the part that fit`);
|
|
53953
|
+
this.name = "PlateWindowTooWideError";
|
|
53954
|
+
}
|
|
53955
|
+
};
|
|
53241
53956
|
var PlateStore = class {
|
|
53242
53957
|
store;
|
|
53243
53958
|
logger;
|
|
@@ -53302,18 +54017,53 @@ var PlateStore = class {
|
|
|
53302
54017
|
}
|
|
53303
54018
|
})).map((r) => this.normalizeRow(r));
|
|
53304
54019
|
}
|
|
53305
|
-
/**
|
|
54020
|
+
/**
|
|
54021
|
+
* List ALL recent plate reads across devices, newest first.
|
|
54022
|
+
*
|
|
54023
|
+
* The corpus read, for the two passes whose question is not a predicate
|
|
54024
|
+
* (fuzzy text search, text clustering). It carries an explicit `limit` and
|
|
54025
|
+
* REFUSES past it rather than handing back the part that fit: without one the
|
|
54026
|
+
* engine imposed its own cap and truncated silently, and a search over the
|
|
54027
|
+
* newest 2 000 plates looks exactly like a search over all of them.
|
|
54028
|
+
*/
|
|
53306
54029
|
async listAllRecentPlates(input) {
|
|
53307
|
-
|
|
54030
|
+
const bounded = input.limit !== void 0;
|
|
54031
|
+
const rows = await this.store.query.query({
|
|
53308
54032
|
collection: PLATES_COLLECTION,
|
|
53309
54033
|
filter: {
|
|
53310
54034
|
orderBy: {
|
|
53311
54035
|
field: "timestamp",
|
|
53312
54036
|
direction: "desc"
|
|
53313
54037
|
},
|
|
53314
|
-
|
|
54038
|
+
limit: bounded ? input.limit ?? 0 : 10001
|
|
53315
54039
|
}
|
|
53316
|
-
})
|
|
54040
|
+
});
|
|
54041
|
+
if (!bounded && rows.length > 1e4) throw new PlateWindowTooWideError(rows.length);
|
|
54042
|
+
return rows.map((r) => this.normalizeRow(r));
|
|
54043
|
+
}
|
|
54044
|
+
/**
|
|
54045
|
+
* Every plate row currently attributed to a vehicle, id only.
|
|
54046
|
+
*
|
|
54047
|
+
* `deleteVehicle` detaches these before the vehicle goes. It used to list the
|
|
54048
|
+
* WHOLE buffer and compare in JS — the `deleteIdentity` defect verbatim, and
|
|
54049
|
+
* past the engine's row cap it would detach the plates that fit and leave the
|
|
54050
|
+
* rest pointing at a vehicle that no longer exists.
|
|
54051
|
+
*/
|
|
54052
|
+
async listPlateIdsForVehicle(vehicleId) {
|
|
54053
|
+
const rows = await this.store.query.query({
|
|
54054
|
+
collection: PLATES_COLLECTION,
|
|
54055
|
+
columns: ["deviceId"],
|
|
54056
|
+
filter: {
|
|
54057
|
+
where: { recognizedVehicleId: vehicleId },
|
|
54058
|
+
orderBy: {
|
|
54059
|
+
field: "timestamp",
|
|
54060
|
+
direction: "desc"
|
|
54061
|
+
},
|
|
54062
|
+
limit: 10001
|
|
54063
|
+
}
|
|
54064
|
+
});
|
|
54065
|
+
if (rows.length > 1e4) throw new PlateWindowTooWideError(rows.length);
|
|
54066
|
+
return rows.map((r) => r.id);
|
|
53317
54067
|
}
|
|
53318
54068
|
async get(plateId) {
|
|
53319
54069
|
const raw = await this.store.get.query({
|
|
@@ -55973,6 +56723,38 @@ var VehicleStore = class {
|
|
|
55973
56723
|
key: id
|
|
55974
56724
|
});
|
|
55975
56725
|
}
|
|
56726
|
+
/**
|
|
56727
|
+
* `vehicleId → name` for exactly the vehicles asked about — the plate twin of
|
|
56728
|
+
* `IdentityStore.namesFor`, and it exists for the same reason.
|
|
56729
|
+
*
|
|
56730
|
+
* The decorating half of a plate page needs a NAME per referenced vehicle,
|
|
56731
|
+
* and it got it by reading the whole `vehicles` collection: unbounded,
|
|
56732
|
+
* unprojected, on every 30 s poll of `plateGallery.listPlates` and again on
|
|
56733
|
+
* every `getPlateByTrack` and `searchPlates`. The live table holds **0 rows**
|
|
56734
|
+
* — the bytes were never the point. The shape was: a question proportional to
|
|
56735
|
+
* a collection, on a timer, to decorate a page.
|
|
56736
|
+
*
|
|
56737
|
+
* An empty request costs no round trip: `whereIn` over an empty list matches
|
|
56738
|
+
* nothing, so asking would be an RPC to be told what the caller knew.
|
|
56739
|
+
*/
|
|
56740
|
+
async namesFor(vehicleIds) {
|
|
56741
|
+
const ids = [...new Set(vehicleIds)];
|
|
56742
|
+
if (ids.length === 0) return /* @__PURE__ */ new Map();
|
|
56743
|
+
const rows = await this.store.query.query({
|
|
56744
|
+
collection: VEHICLES_COLLECTION,
|
|
56745
|
+
columns: ["name"],
|
|
56746
|
+
filter: {
|
|
56747
|
+
whereIn: { id: ids },
|
|
56748
|
+
limit: ids.length
|
|
56749
|
+
}
|
|
56750
|
+
});
|
|
56751
|
+
const names = /* @__PURE__ */ new Map();
|
|
56752
|
+
for (const r of rows) {
|
|
56753
|
+
const name = r.data["name"];
|
|
56754
|
+
if (typeof name === "string") names.set(r.id, name);
|
|
56755
|
+
}
|
|
56756
|
+
return names;
|
|
56757
|
+
}
|
|
55976
56758
|
async listVehicles() {
|
|
55977
56759
|
return (await this.store.query.query({
|
|
55978
56760
|
collection: VEHICLES_COLLECTION,
|
|
@@ -62944,6 +63726,50 @@ var ZoneAnalyticsProvider = class {
|
|
|
62944
63726
|
return this.snapshots.get(deviceId) ?? await this.hydrateFromRegistry(deviceId);
|
|
62945
63727
|
}
|
|
62946
63728
|
/**
|
|
63729
|
+
* The same snapshot, for a SET of cameras, in one round trip.
|
|
63730
|
+
*
|
|
63731
|
+
* The Events page's Stationary section polls this every 15 s for every
|
|
63732
|
+
* selected camera. Fanned out client-side that is one browser → hub →
|
|
63733
|
+
* post-analysis round trip per camera to read a `Map.get`; the answer costs
|
|
63734
|
+
* nothing and the transport costs everything. The per-device work here is
|
|
63735
|
+
* unchanged — the same live snapshot, the same parked-registry hydration when
|
|
63736
|
+
* there is no live one — and it runs concurrently inside the owner.
|
|
63737
|
+
*
|
|
63738
|
+
* **Three outcomes stay three.** A camera with a reading, a camera with none
|
|
63739
|
+
* yet (`snapshot: null`, `read: 'read'`), and a camera the owner could not
|
|
63740
|
+
* answer for (`read: 'unreadable'`). The hydration reaches a durable store
|
|
63741
|
+
* and can throw; letting that throw become `snapshot: null` would render an
|
|
63742
|
+
* unread camera as a camera with nothing parked, which is a definite claim
|
|
63743
|
+
* nobody made.
|
|
63744
|
+
*/
|
|
63745
|
+
async getCurrentSnapshotBatch({ deviceIds }) {
|
|
63746
|
+
return Promise.all(deviceIds.map(async (deviceId) => {
|
|
63747
|
+
const live = this.snapshots.get(deviceId);
|
|
63748
|
+
if (live !== void 0) return {
|
|
63749
|
+
deviceId,
|
|
63750
|
+
read: "read",
|
|
63751
|
+
snapshot: live
|
|
63752
|
+
};
|
|
63753
|
+
try {
|
|
63754
|
+
return {
|
|
63755
|
+
deviceId,
|
|
63756
|
+
read: "read",
|
|
63757
|
+
snapshot: await this.hydrateFromRegistry(deviceId)
|
|
63758
|
+
};
|
|
63759
|
+
} catch (err) {
|
|
63760
|
+
this.ctx.logger.warn("occupancy snapshot could not be read", {
|
|
63761
|
+
tags: { deviceId },
|
|
63762
|
+
meta: { error: String(err) }
|
|
63763
|
+
});
|
|
63764
|
+
return {
|
|
63765
|
+
deviceId,
|
|
63766
|
+
read: "unreadable",
|
|
63767
|
+
snapshot: null
|
|
63768
|
+
};
|
|
63769
|
+
}
|
|
63770
|
+
}));
|
|
63771
|
+
}
|
|
63772
|
+
/**
|
|
62947
63773
|
* The cameras a LIVE snapshot exists for.
|
|
62948
63774
|
*
|
|
62949
63775
|
* The Notification Center's occupancy dry-run ("as of now, would this rule
|
|
@@ -65502,6 +66328,23 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
65502
66328
|
},
|
|
65503
66329
|
resolveArtifactUrl: async (artifactId) => await this.ncArtifactPlane?.mintUrl(artifactId) ?? null,
|
|
65504
66330
|
mintActionUrl: (input) => this.ncActionMintUrl?.(input) ?? Promise.resolve(null),
|
|
66331
|
+
listAddonUpdates: async (nodeId) => {
|
|
66332
|
+
return (await this.ctx.api.addons.listUpdates.query({ nodeId })).filter((update) => !update.isSystem).map((update) => update.name);
|
|
66333
|
+
},
|
|
66334
|
+
applyAddonUpdate: async ({ packageName, nodeId }) => {
|
|
66335
|
+
try {
|
|
66336
|
+
await this.ctx.api.addons.updatePackage.mutate({
|
|
66337
|
+
name: packageName,
|
|
66338
|
+
nodeId
|
|
66339
|
+
});
|
|
66340
|
+
return { ok: true };
|
|
66341
|
+
} catch (err) {
|
|
66342
|
+
return {
|
|
66343
|
+
ok: false,
|
|
66344
|
+
error: errMsg(err)
|
|
66345
|
+
};
|
|
66346
|
+
}
|
|
66347
|
+
},
|
|
65505
66348
|
readDeviceStates: async (ids) => {
|
|
65506
66349
|
const out = /* @__PURE__ */ new Map();
|
|
65507
66350
|
for (const id of ids) try {
|
|
@@ -70461,6 +71304,36 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
70461
71304
|
return [];
|
|
70462
71305
|
}
|
|
70463
71306
|
}
|
|
71307
|
+
/**
|
|
71308
|
+
* `getKeyEvents` for a SET of cameras, in one round trip.
|
|
71309
|
+
*
|
|
71310
|
+
* The Detection Intelligence events feed asked this of every selected camera
|
|
71311
|
+
* on a 30 s timer — N browser → hub → post-analysis round trips for N
|
|
71312
|
+
* independent, already-indexed store queries. The queries are unchanged and
|
|
71313
|
+
* still run per camera (each is `where deviceId` + a `firstSeen` range
|
|
71314
|
+
* against its own index, and `limit` stays PER CAMERA so a busy camera cannot
|
|
71315
|
+
* starve a quiet one of its rows); what collapses is the transport.
|
|
71316
|
+
*
|
|
71317
|
+
* Every requested id gets a row. `getKeyEvents` already degrades to `[]`
|
|
71318
|
+
* rather than throwing, so a per-camera failure is reported exactly as it was
|
|
71319
|
+
* before this method existed — this does not make it worse, and the row
|
|
71320
|
+
* carries the `deviceId` the single method's output never did.
|
|
71321
|
+
*/
|
|
71322
|
+
async getKeyEventsBatch(input) {
|
|
71323
|
+
return Promise.all(input.deviceIds.map(async (deviceId) => {
|
|
71324
|
+
return {
|
|
71325
|
+
deviceId,
|
|
71326
|
+
events: await this.getKeyEvents({
|
|
71327
|
+
deviceId,
|
|
71328
|
+
since: input.since,
|
|
71329
|
+
until: input.until,
|
|
71330
|
+
...input.limit !== void 0 ? { limit: input.limit } : {},
|
|
71331
|
+
...input.minImportance !== void 0 ? { minImportance: input.minImportance } : {},
|
|
71332
|
+
...input.classFilter !== void 0 ? { classFilter: input.classFilter } : {}
|
|
71333
|
+
})
|
|
71334
|
+
};
|
|
71335
|
+
}));
|
|
71336
|
+
}
|
|
70464
71337
|
async getAudioEvents(input) {
|
|
70465
71338
|
return this.queryFacade.getAudioEvents(input);
|
|
70466
71339
|
}
|