@camstack/addon-post-analysis 1.2.70 → 1.2.72
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-6T-F0YJO.mjs → dist-6te1200B.mjs} +1907 -65
- package/dist/{dist-DnpVHUcH.js → dist-C2w3cl2p.js} +1907 -65
- 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-Cxzt8LHT.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Brh9RYTk.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-BfeJzFhL.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-DW1qt-In.mjs → hostInit-DpWdIydx.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +1136 -182
- package/dist/pipeline-analytics/index.mjs +1136 -182
- 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-B57Jy4_o.mjs +0 -26
|
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_dist = require("../dist-
|
|
5
|
+
const require_dist = require("../dist-C2w3cl2p.js");
|
|
6
6
|
let node_fs = require("node:fs");
|
|
7
7
|
node_fs = require_dist.__toESM(node_fs, 1);
|
|
8
8
|
let node_path = require("node:path");
|
|
@@ -555,7 +555,14 @@ function alarmCoverageGap(coverage) {
|
|
|
555
555
|
return "all-skipped";
|
|
556
556
|
}
|
|
557
557
|
/**
|
|
558
|
-
* "
|
|
558
|
+
* "Attivi: Cancello, Garage — Esclusi: Porta retro."
|
|
559
|
+
*
|
|
560
|
+
* A LIST, not a sentence. The mode is in the title and the body is one
|
|
561
|
+
* `Label: names` line per device group, because this is read on a lock screen
|
|
562
|
+
* and the question is always "which one" — the prose version ("3 dispositivi
|
|
563
|
+
* attivi: …. Esclusi: ….") made the operator parse a paragraph to find a
|
|
564
|
+
* window. Each variant still owns its own catalog key, so the Texts tab can
|
|
565
|
+
* re-word "nothing is armed" without touching "these are armed".
|
|
559
566
|
*
|
|
560
567
|
* Names, not ids: this is read by a person standing at a door. An id that has
|
|
561
568
|
* no name falls back to a NAMED, translated placeholder rather than being
|
|
@@ -634,7 +641,7 @@ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
|
|
|
634
641
|
};
|
|
635
642
|
}
|
|
636
643
|
/**
|
|
637
|
-
* "
|
|
644
|
+
* "Attivi: 2 — Bypassati: Finestra veranda."
|
|
638
645
|
*
|
|
639
646
|
* The bypassed sensors are named and the armed ones are COUNTED, not listed:
|
|
640
647
|
* this sentence's subject is the exception, and burying it under thirteen
|
|
@@ -657,13 +664,13 @@ function armedWithBypass(coverage, bypassed, deviceName) {
|
|
|
657
664
|
};
|
|
658
665
|
}
|
|
659
666
|
/**
|
|
660
|
-
* "
|
|
667
|
+
* "Aperti: Finestra veranda, Porta retro" + how to arm anyway.
|
|
661
668
|
*
|
|
662
669
|
* The one alarm sentence about something that did not happen, so it has to
|
|
663
670
|
* carry both halves an operator needs to act: WHICH sensors are open, and that
|
|
664
671
|
* pressing arm again arms without them. Naming them is the whole message — a
|
|
665
672
|
* refusal that says only "cannot arm" is a panel the operator will conclude is
|
|
666
|
-
* broken.
|
|
673
|
+
* broken — so the names are the FIRST line and the retry hint is the second.
|
|
667
674
|
*/
|
|
668
675
|
function buildArmRefusedAnnouncement(mode, names, retrySeconds) {
|
|
669
676
|
return {
|
|
@@ -678,7 +685,7 @@ function buildArmRefusedAnnouncement(mode, names, retrySeconds) {
|
|
|
678
685
|
};
|
|
679
686
|
}
|
|
680
687
|
/**
|
|
681
|
-
* The same coverage, said 30 seconds earlier
|
|
688
|
+
* The EXCEPTIONS to the same coverage, said 30 seconds earlier.
|
|
682
689
|
*
|
|
683
690
|
* The exit-delay sentence is deliberately SHORTER than the arm one — it is read
|
|
684
691
|
* on the way out of a door — and it carries exactly one thing the arm sentence
|
|
@@ -686,8 +693,15 @@ function buildArmRefusedAnnouncement(mode, names, retrySeconds) {
|
|
|
686
693
|
* A mode that arms nothing is worth interrupting somebody for at second 0 and
|
|
687
694
|
* merely worth recording at second 30.
|
|
688
695
|
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
696
|
+
* **It never repeats the device list.** The countdown lives in the TITLE
|
|
697
|
+
* (`system.alarm-arming.title.<mode>` renders `{{seconds}}`), and the armed
|
|
698
|
+
* list arrives thirty seconds later in the arm announcement — printing it
|
|
699
|
+
* twice is the verbosity that made the operator stop reading both. So the body
|
|
700
|
+
* is what is WRONG: what was bypassed, what is excluded, or that nothing is.
|
|
701
|
+
*
|
|
702
|
+
* `seconds` stays in the params because the title asks for it, and `textCount`
|
|
703
|
+
* stays with it: a row enqueued before this deploy still pluralises on the
|
|
704
|
+
* countdown it was written for.
|
|
691
705
|
*/
|
|
692
706
|
function buildArmingAnnouncement(coverage, seconds, deviceName, bypassed = []) {
|
|
693
707
|
const textParams = {
|
|
@@ -702,6 +716,7 @@ function buildArmingAnnouncement(coverage, seconds, deviceName, bypassed = []) {
|
|
|
702
716
|
},
|
|
703
717
|
textCount: seconds
|
|
704
718
|
};
|
|
719
|
+
const skippedNames = coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ");
|
|
705
720
|
const gap = alarmCoverageGap(coverage);
|
|
706
721
|
if (gap === "uncovered") return {
|
|
707
722
|
bodyKey: "alarm.arming.body.uncovered",
|
|
@@ -712,10 +727,18 @@ function buildArmingAnnouncement(coverage, seconds, deviceName, bypassed = []) {
|
|
|
712
727
|
bodyKey: "alarm.arming.body.allSkipped",
|
|
713
728
|
textParams: {
|
|
714
729
|
...textParams,
|
|
715
|
-
names:
|
|
730
|
+
names: skippedNames
|
|
716
731
|
},
|
|
717
732
|
textCount: seconds
|
|
718
733
|
};
|
|
734
|
+
if (coverage.skippedDevices.length > 0) return {
|
|
735
|
+
bodyKey: "alarm.arming.body.skipped",
|
|
736
|
+
textParams: {
|
|
737
|
+
...textParams,
|
|
738
|
+
names: skippedNames
|
|
739
|
+
},
|
|
740
|
+
textCount: coverage.skippedDevices.length
|
|
741
|
+
};
|
|
719
742
|
return {
|
|
720
743
|
bodyKey: "alarm.arming.body",
|
|
721
744
|
textParams,
|
|
@@ -1556,6 +1579,401 @@ var NC_ALARM_DECLARATION = {
|
|
|
1556
1579
|
DeviceClass: NcAlarmPanelDevice
|
|
1557
1580
|
};
|
|
1558
1581
|
//#endregion
|
|
1582
|
+
//#region src/notification-center/artifact-index.ts
|
|
1583
|
+
var NC_ARTIFACTS_COLLECTION = "notification-center:artifacts";
|
|
1584
|
+
var NcArtifactKindSchema = require_dist._enum([
|
|
1585
|
+
"timelapse",
|
|
1586
|
+
"timelapse-preview",
|
|
1587
|
+
"summary-mosaic"
|
|
1588
|
+
]);
|
|
1589
|
+
/**
|
|
1590
|
+
* How the viewer should DRAW it. Derived from the mime once, here, so no client
|
|
1591
|
+
* has to carry a mime table — and so a GIF that degraded to a JPEG is drawn as
|
|
1592
|
+
* the JPEG it actually is.
|
|
1593
|
+
*/
|
|
1594
|
+
var NcArtifactMediaTypeSchema = require_dist._enum([
|
|
1595
|
+
"image",
|
|
1596
|
+
"gif",
|
|
1597
|
+
"video"
|
|
1598
|
+
]);
|
|
1599
|
+
/** The relative hub path of the authenticated bytes route for an artefact id. */
|
|
1600
|
+
var NC_ARTIFACT_READ_PREFIX = "nc-artifact-read";
|
|
1601
|
+
var NcArtifactRecordSchema = require_dist.object({
|
|
1602
|
+
/** The `NcArtifactStore` id — the same id the bytes route resolves. */
|
|
1603
|
+
id: require_dist.string().min(1),
|
|
1604
|
+
kind: NcArtifactKindSchema,
|
|
1605
|
+
mediaType: NcArtifactMediaTypeSchema,
|
|
1606
|
+
mime: require_dist.string().min(1),
|
|
1607
|
+
sizeBytes: require_dist.number().int().nonnegative(),
|
|
1608
|
+
/** When the artefact was PRODUCED. */
|
|
1609
|
+
createdAt: require_dist.number().int(),
|
|
1610
|
+
/** What the content is ABOUT (window end / event time). The sort key: a
|
|
1611
|
+
* timelapse rendered at 06:05 is about the night before, and a list ordered
|
|
1612
|
+
* by production time puts it in the wrong morning. */
|
|
1613
|
+
capturedAt: require_dist.number().int(),
|
|
1614
|
+
/** After this the bytes are gone. Derived from the retained shelf's own age
|
|
1615
|
+
* bound — never a second retention policy. */
|
|
1616
|
+
expiresAt: require_dist.number().int(),
|
|
1617
|
+
/** The camera the artefact is FILED under. Null only if it belongs to none. */
|
|
1618
|
+
deviceId: require_dist.number().int().nullable(),
|
|
1619
|
+
/** Every camera the artefact covers, for display. */
|
|
1620
|
+
deviceIds: require_dist.array(require_dist.number().int()),
|
|
1621
|
+
ruleId: require_dist.string().optional(),
|
|
1622
|
+
ruleName: require_dist.string().optional(),
|
|
1623
|
+
title: require_dist.string().optional(),
|
|
1624
|
+
/** A companion still for a video artefact (a timelapse's preview frame) —
|
|
1625
|
+
* what a list renders as the thumbnail. */
|
|
1626
|
+
posterArtifactId: require_dist.string().optional(),
|
|
1627
|
+
/** RELATIVE hub path of the authenticated bytes route. Relative on purpose:
|
|
1628
|
+
* the viewer already holds the hub origin it is authenticated against, and
|
|
1629
|
+
* only the SIGNED plane needs to guess which host a stranger can reach. */
|
|
1630
|
+
downloadPath: require_dist.string().min(1)
|
|
1631
|
+
});
|
|
1632
|
+
var NcArtifactQuerySchema = require_dist.object({
|
|
1633
|
+
kinds: require_dist.array(NcArtifactKindSchema).optional(),
|
|
1634
|
+
deviceIds: require_dist.array(require_dist.number().int()).optional(),
|
|
1635
|
+
ruleId: require_dist.string().optional(),
|
|
1636
|
+
/** Inclusive `capturedAt` window. */
|
|
1637
|
+
fromMs: require_dist.number().int().optional(),
|
|
1638
|
+
toMs: require_dist.number().int().optional(),
|
|
1639
|
+
limit: require_dist.number().int().min(1).max(200).default(50),
|
|
1640
|
+
offset: require_dist.number().int().min(0).default(0)
|
|
1641
|
+
});
|
|
1642
|
+
var NC_ARTIFACTS_COLUMNS = [
|
|
1643
|
+
{
|
|
1644
|
+
name: "id",
|
|
1645
|
+
type: "TEXT",
|
|
1646
|
+
primaryKey: true,
|
|
1647
|
+
notNull: true
|
|
1648
|
+
},
|
|
1649
|
+
{
|
|
1650
|
+
name: "kind",
|
|
1651
|
+
type: "TEXT",
|
|
1652
|
+
notNull: true
|
|
1653
|
+
},
|
|
1654
|
+
{
|
|
1655
|
+
name: "deviceId",
|
|
1656
|
+
type: "INTEGER"
|
|
1657
|
+
},
|
|
1658
|
+
{
|
|
1659
|
+
name: "ruleId",
|
|
1660
|
+
type: "TEXT"
|
|
1661
|
+
},
|
|
1662
|
+
(
|
|
1663
|
+
/** `''` for a global artefact — see the header note on visibility. */
|
|
1664
|
+
{
|
|
1665
|
+
name: "ownerUserId",
|
|
1666
|
+
type: "TEXT",
|
|
1667
|
+
notNull: true
|
|
1668
|
+
}),
|
|
1669
|
+
{
|
|
1670
|
+
name: "capturedAt",
|
|
1671
|
+
type: "INTEGER",
|
|
1672
|
+
notNull: true
|
|
1673
|
+
},
|
|
1674
|
+
{
|
|
1675
|
+
name: "expiresAt",
|
|
1676
|
+
type: "INTEGER",
|
|
1677
|
+
notNull: true
|
|
1678
|
+
},
|
|
1679
|
+
(
|
|
1680
|
+
/** The FULL record (Zod-validated on read) — the scalars above are indexed
|
|
1681
|
+
* projections only. */
|
|
1682
|
+
{
|
|
1683
|
+
name: "record",
|
|
1684
|
+
type: "JSON",
|
|
1685
|
+
notNull: true
|
|
1686
|
+
})
|
|
1687
|
+
];
|
|
1688
|
+
var NC_ARTIFACTS_INDEXES = [
|
|
1689
|
+
{
|
|
1690
|
+
name: "idx_nc_artifacts_captured",
|
|
1691
|
+
columns: ["capturedAt"]
|
|
1692
|
+
},
|
|
1693
|
+
{
|
|
1694
|
+
name: "idx_nc_artifacts_device",
|
|
1695
|
+
columns: ["deviceId", "capturedAt"]
|
|
1696
|
+
},
|
|
1697
|
+
{
|
|
1698
|
+
name: "idx_nc_artifacts_kind",
|
|
1699
|
+
columns: ["kind", "capturedAt"]
|
|
1700
|
+
},
|
|
1701
|
+
{
|
|
1702
|
+
name: "idx_nc_artifacts_expires",
|
|
1703
|
+
columns: ["expiresAt"]
|
|
1704
|
+
}
|
|
1705
|
+
];
|
|
1706
|
+
/** Upper bound for a half-open `whereBetween` — the store has no `>=`. */
|
|
1707
|
+
var TIME_MAX = 864e13;
|
|
1708
|
+
var TIME_MIN = -864e13;
|
|
1709
|
+
/** Prune scan ceiling — the index is bounded by the shelf it mirrors (2000
|
|
1710
|
+
* entries by default), so this only has to be comfortably above it. */
|
|
1711
|
+
var NC_ARTIFACT_PRUNE_SCAN_LIMIT = 2e4;
|
|
1712
|
+
/** `image/gif` → `gif`. Keyed on what the bytes ARE, never on what was asked
|
|
1713
|
+
* for: a GIF that degraded to a still must be drawn as a still. */
|
|
1714
|
+
function mediaTypeForMime(mime) {
|
|
1715
|
+
if (mime === "image/gif") return "gif";
|
|
1716
|
+
if (mime.startsWith("video/")) return "video";
|
|
1717
|
+
return "image";
|
|
1718
|
+
}
|
|
1719
|
+
/** The relative hub path an authenticated client fetches the bytes from. */
|
|
1720
|
+
function artifactDownloadPath(addonId, artifactId) {
|
|
1721
|
+
return `/addon/${addonId}/${NC_ARTIFACT_READ_PREFIX}/${artifactId}`;
|
|
1722
|
+
}
|
|
1723
|
+
var NcArtifactIndex = class {
|
|
1724
|
+
store;
|
|
1725
|
+
logger;
|
|
1726
|
+
addonId;
|
|
1727
|
+
retentionMs;
|
|
1728
|
+
now;
|
|
1729
|
+
constructor(deps) {
|
|
1730
|
+
this.store = deps.store;
|
|
1731
|
+
this.logger = deps.logger;
|
|
1732
|
+
this.addonId = deps.addonId;
|
|
1733
|
+
this.retentionMs = deps.retentionMs;
|
|
1734
|
+
this.now = deps.now ?? (() => Date.now());
|
|
1735
|
+
}
|
|
1736
|
+
static async declare(store) {
|
|
1737
|
+
await store.declareCollection.mutate({
|
|
1738
|
+
collection: NC_ARTIFACTS_COLLECTION,
|
|
1739
|
+
columns: [...NC_ARTIFACTS_COLUMNS],
|
|
1740
|
+
indexes: [...NC_ARTIFACTS_INDEXES]
|
|
1741
|
+
});
|
|
1742
|
+
}
|
|
1743
|
+
/**
|
|
1744
|
+
* Record one retained artefact. Returns the row as the viewer will see it, or
|
|
1745
|
+
* null when the write failed — and a failure is LOGGED, per camera: an
|
|
1746
|
+
* artefact silently absent from the index reads to an operator as an artefact
|
|
1747
|
+
* that was never produced.
|
|
1748
|
+
*/
|
|
1749
|
+
async record(input) {
|
|
1750
|
+
const createdAt = this.now();
|
|
1751
|
+
const record = NcArtifactRecordSchema.parse({
|
|
1752
|
+
id: input.id,
|
|
1753
|
+
kind: input.kind,
|
|
1754
|
+
mediaType: mediaTypeForMime(input.mime),
|
|
1755
|
+
mime: input.mime,
|
|
1756
|
+
sizeBytes: input.sizeBytes,
|
|
1757
|
+
createdAt,
|
|
1758
|
+
capturedAt: input.capturedAt,
|
|
1759
|
+
expiresAt: createdAt + this.retentionMs,
|
|
1760
|
+
deviceId: input.deviceId,
|
|
1761
|
+
deviceIds: [...input.deviceIds],
|
|
1762
|
+
...input.ruleId !== void 0 ? { ruleId: input.ruleId } : {},
|
|
1763
|
+
...input.ruleName !== void 0 ? { ruleName: input.ruleName } : {},
|
|
1764
|
+
...input.title !== void 0 ? { title: input.title } : {},
|
|
1765
|
+
...input.posterArtifactId !== void 0 ? { posterArtifactId: input.posterArtifactId } : {},
|
|
1766
|
+
downloadPath: artifactDownloadPath(this.addonId, input.id)
|
|
1767
|
+
});
|
|
1768
|
+
try {
|
|
1769
|
+
await this.store.set.mutate({
|
|
1770
|
+
collection: NC_ARTIFACTS_COLLECTION,
|
|
1771
|
+
key: record.id,
|
|
1772
|
+
value: {
|
|
1773
|
+
kind: record.kind,
|
|
1774
|
+
deviceId: record.deviceId,
|
|
1775
|
+
ruleId: record.ruleId ?? "",
|
|
1776
|
+
ownerUserId: input.ownerUserId ?? "",
|
|
1777
|
+
capturedAt: record.capturedAt,
|
|
1778
|
+
expiresAt: record.expiresAt,
|
|
1779
|
+
record
|
|
1780
|
+
}
|
|
1781
|
+
});
|
|
1782
|
+
return record;
|
|
1783
|
+
} catch (err) {
|
|
1784
|
+
this.logger.warn("artifact not indexed — it will not appear in the viewer", {
|
|
1785
|
+
...record.deviceId !== null ? { tags: { deviceId: record.deviceId } } : {},
|
|
1786
|
+
meta: {
|
|
1787
|
+
artifactId: record.id,
|
|
1788
|
+
kind: record.kind,
|
|
1789
|
+
error: String(err)
|
|
1790
|
+
}
|
|
1791
|
+
});
|
|
1792
|
+
return null;
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
/**
|
|
1796
|
+
* One page, newest `capturedAt` first.
|
|
1797
|
+
*
|
|
1798
|
+
* Expiry is a PREDICATE, not a post-filter: a row whose bytes the shelf has
|
|
1799
|
+
* already swept must not consume a slot of the page the caller asked for.
|
|
1800
|
+
*/
|
|
1801
|
+
async list(query, viewer) {
|
|
1802
|
+
const filter = this.buildFilter(query, viewer);
|
|
1803
|
+
try {
|
|
1804
|
+
const [rows, total] = await Promise.all([this.store.query.query({
|
|
1805
|
+
collection: NC_ARTIFACTS_COLLECTION,
|
|
1806
|
+
filter: {
|
|
1807
|
+
...filter,
|
|
1808
|
+
orderBy: {
|
|
1809
|
+
field: "capturedAt",
|
|
1810
|
+
direction: "desc"
|
|
1811
|
+
},
|
|
1812
|
+
limit: query.limit,
|
|
1813
|
+
offset: query.offset
|
|
1814
|
+
}
|
|
1815
|
+
}), this.store.count.query({
|
|
1816
|
+
collection: NC_ARTIFACTS_COLLECTION,
|
|
1817
|
+
filter
|
|
1818
|
+
})]);
|
|
1819
|
+
const artifacts = this.parseRows(rows);
|
|
1820
|
+
return {
|
|
1821
|
+
artifacts,
|
|
1822
|
+
total,
|
|
1823
|
+
hasMore: query.offset + artifacts.length < total
|
|
1824
|
+
};
|
|
1825
|
+
} catch (err) {
|
|
1826
|
+
this.logger.warn("artifact index query failed", { meta: { error: String(err) } });
|
|
1827
|
+
return {
|
|
1828
|
+
artifacts: [],
|
|
1829
|
+
total: 0,
|
|
1830
|
+
hasMore: false
|
|
1831
|
+
};
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
/**
|
|
1835
|
+
* One artefact by id, or null when it does not exist, has expired, or belongs
|
|
1836
|
+
* to another user. All three answer the same way ON PURPOSE — a surface that
|
|
1837
|
+
* distinguishes "not yours" from "not there" tells a caller what other people
|
|
1838
|
+
* own.
|
|
1839
|
+
*/
|
|
1840
|
+
async get(artifactId, viewer) {
|
|
1841
|
+
const row = await this.readRow(artifactId);
|
|
1842
|
+
if (row === null) return null;
|
|
1843
|
+
if (row.record.expiresAt <= this.now()) return null;
|
|
1844
|
+
if (!viewer.isAdmin && row.ownerUserId !== "" && row.ownerUserId !== viewer.userId) return null;
|
|
1845
|
+
return row.record;
|
|
1846
|
+
}
|
|
1847
|
+
/**
|
|
1848
|
+
* Resolve an id for the BYTES route — visibility-free by design.
|
|
1849
|
+
*
|
|
1850
|
+
* The route is served `authenticated`, so the hub has already established a
|
|
1851
|
+
* principal; what this answers is only "is this id an indexed artefact, and is
|
|
1852
|
+
* it still meant to exist". Per-user artefact scoping on the byte route would
|
|
1853
|
+
* be a second authorization model layered on an unguessable id, and this repo
|
|
1854
|
+
* has one of those already (`data-plane-access.ts`): a plane serving ONE item
|
|
1855
|
+
* the caller had to be told about is `authenticated`.
|
|
1856
|
+
*/
|
|
1857
|
+
async resolve(artifactId) {
|
|
1858
|
+
return (await this.readRow(artifactId))?.record ?? null;
|
|
1859
|
+
}
|
|
1860
|
+
/** One row by primary key, parsed. Null when absent or no longer valid. */
|
|
1861
|
+
async readRow(artifactId) {
|
|
1862
|
+
let raw;
|
|
1863
|
+
try {
|
|
1864
|
+
raw = await this.store.get.query({
|
|
1865
|
+
collection: NC_ARTIFACTS_COLLECTION,
|
|
1866
|
+
key: artifactId
|
|
1867
|
+
});
|
|
1868
|
+
} catch (err) {
|
|
1869
|
+
this.logger.warn("artifact index lookup failed", { meta: {
|
|
1870
|
+
artifactId,
|
|
1871
|
+
error: String(err)
|
|
1872
|
+
} });
|
|
1873
|
+
return null;
|
|
1874
|
+
}
|
|
1875
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
|
1876
|
+
const row = { ...raw };
|
|
1877
|
+
const parsed = NcArtifactRecordSchema.safeParse(row["record"]);
|
|
1878
|
+
if (!parsed.success) return null;
|
|
1879
|
+
const owner = row["ownerUserId"];
|
|
1880
|
+
return {
|
|
1881
|
+
record: parsed.data,
|
|
1882
|
+
ownerUserId: typeof owner === "string" ? owner : ""
|
|
1883
|
+
};
|
|
1884
|
+
}
|
|
1885
|
+
/**
|
|
1886
|
+
* Drop rows whose bytes are gone, in ONE `deleteWhere` rather than an N+1
|
|
1887
|
+
* drain.
|
|
1888
|
+
*
|
|
1889
|
+
* TWO halves, and only one of them is safe everywhere:
|
|
1890
|
+
*
|
|
1891
|
+
* - **Expired rows** are time-based and node-independent. Always swept.
|
|
1892
|
+
* - **Orphans** — rows whose file a byte/count bound took before the age bound
|
|
1893
|
+
* did — are found by reconciling against the shelf. That reconcile is only
|
|
1894
|
+
* valid for a caller whose shelf holds the bytes for the WHOLE index, and
|
|
1895
|
+
* the index is CENTRAL while the shelf is local: a second node running this
|
|
1896
|
+
* with its own (empty) shelf would read every row as an orphan and delete
|
|
1897
|
+
* the entire library. Hence `keptIds: null` — a caller that is not the
|
|
1898
|
+
* producing node states so, and the destructive half becomes unreachable
|
|
1899
|
+
* rather than merely un-called.
|
|
1900
|
+
*
|
|
1901
|
+
* @param keptIds the ids the caller's shelf holds, or `null` when this node is
|
|
1902
|
+
* not the one that produces artefacts.
|
|
1903
|
+
*/
|
|
1904
|
+
async prune(keptIds) {
|
|
1905
|
+
let removed = 0;
|
|
1906
|
+
try {
|
|
1907
|
+
const expired = await this.store.deleteWhere.mutate({
|
|
1908
|
+
collection: NC_ARTIFACTS_COLLECTION,
|
|
1909
|
+
filter: { whereBetween: { expiresAt: [TIME_MIN, this.now() - 1] } }
|
|
1910
|
+
});
|
|
1911
|
+
removed += expired.deleted;
|
|
1912
|
+
if (keptIds === null) {
|
|
1913
|
+
if (removed > 0) this.logger.debug("artifact index pruned (expired only)", { meta: { removed } });
|
|
1914
|
+
return removed;
|
|
1915
|
+
}
|
|
1916
|
+
const alive = new Set(keptIds);
|
|
1917
|
+
const orphans = (await this.store.query.query({
|
|
1918
|
+
collection: NC_ARTIFACTS_COLLECTION,
|
|
1919
|
+
filter: { limit: NC_ARTIFACT_PRUNE_SCAN_LIMIT }
|
|
1920
|
+
})).map((r) => r.id).filter((id) => !alive.has(id));
|
|
1921
|
+
for (const id of orphans) {
|
|
1922
|
+
await this.store.delete.mutate({
|
|
1923
|
+
collection: NC_ARTIFACTS_COLLECTION,
|
|
1924
|
+
key: id
|
|
1925
|
+
});
|
|
1926
|
+
removed += 1;
|
|
1927
|
+
}
|
|
1928
|
+
if (removed > 0) this.logger.debug("artifact index pruned", { meta: {
|
|
1929
|
+
removed,
|
|
1930
|
+
expired: expired.deleted,
|
|
1931
|
+
orphaned: orphans.length
|
|
1932
|
+
} });
|
|
1933
|
+
return removed;
|
|
1934
|
+
} catch (err) {
|
|
1935
|
+
this.logger.warn("artifact index prune failed", { meta: { error: String(err) } });
|
|
1936
|
+
return removed;
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
/** The visibility predicate: a caller sees global artefacts plus his own; an
|
|
1940
|
+
* admin sees every one. */
|
|
1941
|
+
visibility(viewer) {
|
|
1942
|
+
if (viewer.isAdmin) return {};
|
|
1943
|
+
return { whereIn: { ownerUserId: ["", viewer.userId] } };
|
|
1944
|
+
}
|
|
1945
|
+
buildFilter(query, viewer) {
|
|
1946
|
+
const whereIn = { ...this.visibility(viewer).whereIn };
|
|
1947
|
+
if (query.kinds !== void 0 && query.kinds.length > 0) whereIn["kind"] = [...query.kinds];
|
|
1948
|
+
if (query.deviceIds !== void 0 && query.deviceIds.length > 0) whereIn["deviceId"] = [...query.deviceIds];
|
|
1949
|
+
const whereBetween = {
|
|
1950
|
+
capturedAt: [query.fromMs ?? TIME_MIN, query.toMs ?? TIME_MAX],
|
|
1951
|
+
expiresAt: [this.now(), TIME_MAX]
|
|
1952
|
+
};
|
|
1953
|
+
return {
|
|
1954
|
+
...query.ruleId !== void 0 ? { where: { ruleId: query.ruleId } } : {},
|
|
1955
|
+
...Object.keys(whereIn).length > 0 ? { whereIn } : {},
|
|
1956
|
+
whereBetween
|
|
1957
|
+
};
|
|
1958
|
+
}
|
|
1959
|
+
/** Parse the JSON column, skipping rows that no longer validate. A degraded
|
|
1960
|
+
* row must never cost the whole page. */
|
|
1961
|
+
parseRows(rows) {
|
|
1962
|
+
const out = [];
|
|
1963
|
+
let skipped = 0;
|
|
1964
|
+
for (const row of rows) {
|
|
1965
|
+
const parsed = NcArtifactRecordSchema.safeParse(row.data["record"]);
|
|
1966
|
+
if (!parsed.success) {
|
|
1967
|
+
skipped += 1;
|
|
1968
|
+
continue;
|
|
1969
|
+
}
|
|
1970
|
+
out.push(parsed.data);
|
|
1971
|
+
}
|
|
1972
|
+
if (skipped > 0) this.logger.warn("artifact index rows skipped as invalid", { meta: { skipped } });
|
|
1973
|
+
return out;
|
|
1974
|
+
}
|
|
1975
|
+
};
|
|
1976
|
+
//#endregion
|
|
1559
1977
|
//#region ../types/dist/node.mjs
|
|
1560
1978
|
var node_exports = /* @__PURE__ */ require_dist.__exportAll({
|
|
1561
1979
|
FilesystemStorageProvider: () => FilesystemStorageProvider,
|
|
@@ -1946,6 +2364,61 @@ var NcArtifactPlane = class {
|
|
|
1946
2364
|
}
|
|
1947
2365
|
}
|
|
1948
2366
|
/**
|
|
2367
|
+
* Publish bytes AND file them in the viewer's browsable index (task #36).
|
|
2368
|
+
*
|
|
2369
|
+
* Two differences from {@link publish}, both deliberate:
|
|
2370
|
+
*
|
|
2371
|
+
* 1. **The bytes are stored FIRST, and stored even when no public base URL
|
|
2372
|
+
* exists.** `publish` returns early on a loopback-only install because a
|
|
2373
|
+
* signed link nobody can open is worth nothing — but the VIEWER reaches
|
|
2374
|
+
* the hub directly over its own session, so an install with no tunnel
|
|
2375
|
+
* still gets a full artifact library. Making the index depend on a
|
|
2376
|
+
* reachable public address would have made the feature absent exactly on
|
|
2377
|
+
* the installs that are LAN-only.
|
|
2378
|
+
* 2. **It answers the artefact id**, so a caller can link one artefact to
|
|
2379
|
+
* another — a timelapse's poster is its preview frame, and the video row
|
|
2380
|
+
* cannot name it without knowing the id the preview got.
|
|
2381
|
+
*
|
|
2382
|
+
* Never throws, for the same reason `publish` does not: an artefact library
|
|
2383
|
+
* is an enhancement and must never cost a notification.
|
|
2384
|
+
*/
|
|
2385
|
+
async publishIndexed(bytes, mime, meta) {
|
|
2386
|
+
let artifactId = null;
|
|
2387
|
+
try {
|
|
2388
|
+
const index = this.deps.index;
|
|
2389
|
+
const stored = await this.deps.store.put(bytes, mime, { retain: index !== void 0 });
|
|
2390
|
+
if (index !== void 0) artifactId = (await index.record({
|
|
2391
|
+
...meta,
|
|
2392
|
+
id: stored.id,
|
|
2393
|
+
mime,
|
|
2394
|
+
sizeBytes: stored.bytes
|
|
2395
|
+
}))?.id ?? null;
|
|
2396
|
+
const baseUrl = await this.resolveBaseUrl();
|
|
2397
|
+
return {
|
|
2398
|
+
url: baseUrl === null ? null : buildArtifactUrl({
|
|
2399
|
+
baseUrl,
|
|
2400
|
+
routePrefix: this.deps.routePrefix,
|
|
2401
|
+
id: stored.id,
|
|
2402
|
+
secret: this.deps.secret,
|
|
2403
|
+
expMs: this.now() + (this.deps.ttlMs ?? DEFAULT_TTL_MS)
|
|
2404
|
+
}),
|
|
2405
|
+
artifactId
|
|
2406
|
+
};
|
|
2407
|
+
} catch (err) {
|
|
2408
|
+
this.deps.logger.warn("indexed artifact publish failed — shipping bytes only", {
|
|
2409
|
+
...meta.deviceId !== null ? { tags: { deviceId: meta.deviceId } } : {},
|
|
2410
|
+
meta: {
|
|
2411
|
+
kind: meta.kind,
|
|
2412
|
+
error: String(err)
|
|
2413
|
+
}
|
|
2414
|
+
});
|
|
2415
|
+
return {
|
|
2416
|
+
url: null,
|
|
2417
|
+
artifactId
|
|
2418
|
+
};
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
/**
|
|
1949
2422
|
* Absolutize a path against the SAME base URL an artifact link would use.
|
|
1950
2423
|
*
|
|
1951
2424
|
* For links this plane does not host but a RECIPIENT still has to open — a
|
|
@@ -2011,6 +2484,124 @@ var NcArtifactPlane = class {
|
|
|
2011
2484
|
}
|
|
2012
2485
|
};
|
|
2013
2486
|
//#endregion
|
|
2487
|
+
//#region src/notification-center/artifact-read-plane.ts
|
|
2488
|
+
/**
|
|
2489
|
+
* Parse a single-range `Range` header against a known length.
|
|
2490
|
+
*
|
|
2491
|
+
* Returns `null` when there is nothing to honour (absent, malformed, multi-range
|
|
2492
|
+
* — all of which mean "send the whole thing"), and `'unsatisfiable'` when the
|
|
2493
|
+
* client asked for a range that cannot exist, which is a 416 and not a 200.
|
|
2494
|
+
*/
|
|
2495
|
+
function parseByteRange(header, length) {
|
|
2496
|
+
if (header === void 0) return null;
|
|
2497
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
|
2498
|
+
if (match === null) return null;
|
|
2499
|
+
const rawStart = match[1] ?? "";
|
|
2500
|
+
const rawEnd = match[2] ?? "";
|
|
2501
|
+
if (rawStart === "" && rawEnd === "") return null;
|
|
2502
|
+
if (length === 0) return "unsatisfiable";
|
|
2503
|
+
if (rawStart === "") {
|
|
2504
|
+
const suffix = Number(rawEnd);
|
|
2505
|
+
if (suffix === 0) return "unsatisfiable";
|
|
2506
|
+
return {
|
|
2507
|
+
start: Math.max(0, length - suffix),
|
|
2508
|
+
end: length - 1
|
|
2509
|
+
};
|
|
2510
|
+
}
|
|
2511
|
+
const start = Number(rawStart);
|
|
2512
|
+
if (start >= length) return "unsatisfiable";
|
|
2513
|
+
const end = rawEnd === "" ? length - 1 : Math.min(Number(rawEnd), length - 1);
|
|
2514
|
+
if (end < start) return "unsatisfiable";
|
|
2515
|
+
return {
|
|
2516
|
+
start,
|
|
2517
|
+
end
|
|
2518
|
+
};
|
|
2519
|
+
}
|
|
2520
|
+
/** The last path segment, percent-decoded. */
|
|
2521
|
+
function artifactIdFrom(req) {
|
|
2522
|
+
const url = new URL(req.url ?? "/", "http://placeholder");
|
|
2523
|
+
try {
|
|
2524
|
+
return decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() ?? "");
|
|
2525
|
+
} catch {
|
|
2526
|
+
return "";
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
var NcArtifactReadPlane = class {
|
|
2530
|
+
deps;
|
|
2531
|
+
constructor(deps) {
|
|
2532
|
+
this.deps = deps;
|
|
2533
|
+
}
|
|
2534
|
+
handler = async (req, res) => {
|
|
2535
|
+
const artifactId = artifactIdFrom(req);
|
|
2536
|
+
const record = await this.deps.index.resolve(artifactId);
|
|
2537
|
+
if (record === null) {
|
|
2538
|
+
this.refuse(res, 404, "not found");
|
|
2539
|
+
return;
|
|
2540
|
+
}
|
|
2541
|
+
const found = await this.deps.store.read(artifactId);
|
|
2542
|
+
if (found === null) {
|
|
2543
|
+
this.deps.logger.warn("artifact bytes gone — index row outlived the shelf", {
|
|
2544
|
+
...record.deviceId !== null ? { tags: { deviceId: record.deviceId } } : {},
|
|
2545
|
+
meta: {
|
|
2546
|
+
artifactId,
|
|
2547
|
+
kind: record.kind,
|
|
2548
|
+
expiresAt: record.expiresAt
|
|
2549
|
+
}
|
|
2550
|
+
});
|
|
2551
|
+
this.refuse(res, 404, "not found");
|
|
2552
|
+
return;
|
|
2553
|
+
}
|
|
2554
|
+
if (req.method === "HEAD") {
|
|
2555
|
+
res.writeHead(200, this.baseHeaders(record, found.bytes.byteLength, found.mime));
|
|
2556
|
+
res.end();
|
|
2557
|
+
return;
|
|
2558
|
+
}
|
|
2559
|
+
const range = parseByteRange(headerOf(req, "range"), found.bytes.byteLength);
|
|
2560
|
+
if (range === "unsatisfiable") {
|
|
2561
|
+
res.writeHead(416, {
|
|
2562
|
+
"content-range": `bytes */${found.bytes.byteLength}`,
|
|
2563
|
+
"content-type": "text/plain"
|
|
2564
|
+
});
|
|
2565
|
+
res.end("range not satisfiable");
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
if (range === null) {
|
|
2569
|
+
res.writeHead(200, this.baseHeaders(record, found.bytes.byteLength, found.mime));
|
|
2570
|
+
res.end(found.bytes);
|
|
2571
|
+
return;
|
|
2572
|
+
}
|
|
2573
|
+
const slice = found.bytes.subarray(range.start, range.end + 1);
|
|
2574
|
+
res.writeHead(206, {
|
|
2575
|
+
...this.baseHeaders(record, slice.byteLength, found.mime),
|
|
2576
|
+
"content-range": `bytes ${range.start}-${range.end}/${found.bytes.byteLength}`
|
|
2577
|
+
});
|
|
2578
|
+
res.end(slice);
|
|
2579
|
+
};
|
|
2580
|
+
baseHeaders(record, length, mime) {
|
|
2581
|
+
return {
|
|
2582
|
+
"content-type": mime,
|
|
2583
|
+
"content-length": String(length),
|
|
2584
|
+
"accept-ranges": "bytes",
|
|
2585
|
+
"cache-control": "private, max-age=3600",
|
|
2586
|
+
"content-disposition": `inline; filename="${filenameFor(record)}"`
|
|
2587
|
+
};
|
|
2588
|
+
}
|
|
2589
|
+
refuse(res, status, body) {
|
|
2590
|
+
res.writeHead(status, { "content-type": "text/plain" });
|
|
2591
|
+
res.end(body);
|
|
2592
|
+
}
|
|
2593
|
+
};
|
|
2594
|
+
/** A name a client will accept, from what the artefact IS. */
|
|
2595
|
+
function filenameFor(record) {
|
|
2596
|
+
const ext = record.mime === "video/mp4" ? "mp4" : record.mime === "image/gif" ? "gif" : record.mime === "image/png" ? "png" : "jpg";
|
|
2597
|
+
return `${record.kind}-${record.capturedAt}.${ext}`;
|
|
2598
|
+
}
|
|
2599
|
+
function headerOf(req, name) {
|
|
2600
|
+
const value = req.headers[name];
|
|
2601
|
+
if (Array.isArray(value)) return value[0];
|
|
2602
|
+
return value;
|
|
2603
|
+
}
|
|
2604
|
+
//#endregion
|
|
2014
2605
|
//#region src/notification-center/artifact-store.ts
|
|
2015
2606
|
/**
|
|
2016
2607
|
* NcArtifactStore — the bytes behind a signed notification-artifact URL.
|
|
@@ -2025,9 +2616,25 @@ var NcArtifactPlane = class {
|
|
|
2025
2616
|
* bites first, the oldest artifacts go. A sweep on boot clears whatever a crash
|
|
2026
2617
|
* left behind — the directory is disposable by construction, so a lost file is
|
|
2027
2618
|
* a 404 on one link, never a corrupt state.
|
|
2619
|
+
*
|
|
2620
|
+
* ## The retained shelf (`kept/`)
|
|
2621
|
+
*
|
|
2622
|
+
* Task #36 gave the viewer a browsable artifact index, and an index whose bytes
|
|
2623
|
+
* evaporate in six hours is an index of 404s. So a `put` may ask to be RETAINED:
|
|
2624
|
+
* the bytes then land on a second shelf inside the same directory with its OWN
|
|
2625
|
+
* three bounds (age, count, and — because a timelapse mp4 is megabytes — total
|
|
2626
|
+
* BYTES). It is still a scratch, just a longer one: the index row carries an
|
|
2627
|
+
* `expiresAt` derived from the same age bound, so "gone" is a state the viewer
|
|
2628
|
+
* is told about rather than a surprise 404.
|
|
2629
|
+
*
|
|
2630
|
+
* The shelf is a subdirectory ON PURPOSE. The scratch's `list()` skips
|
|
2631
|
+
* non-files, so the two shelves cannot sweep each other, and neither bound had
|
|
2632
|
+
* to learn about the other's entries.
|
|
2028
2633
|
*/
|
|
2029
2634
|
var DEFAULT_MAX_AGE_MS = 6 * 36e5;
|
|
2030
2635
|
var DEFAULT_MAX_ENTRIES = 500;
|
|
2636
|
+
/** The retained shelf's subdirectory name, inside the scratch directory. */
|
|
2637
|
+
var KEPT_SUBDIR = "kept";
|
|
2031
2638
|
/** `image/jpeg` → `jpg`, so a fetched link has a name a client will accept. */
|
|
2032
2639
|
function extensionFor(mime) {
|
|
2033
2640
|
if (mime === "image/jpeg") return "jpg";
|
|
@@ -2041,6 +2648,10 @@ var NcArtifactStore = class {
|
|
|
2041
2648
|
now;
|
|
2042
2649
|
maxAgeMs;
|
|
2043
2650
|
maxEntries;
|
|
2651
|
+
keptMaxAgeMs;
|
|
2652
|
+
keptMaxEntries;
|
|
2653
|
+
keptMaxBytes;
|
|
2654
|
+
keptDir;
|
|
2044
2655
|
/** id → mime, so the route can answer with the right content-type without
|
|
2045
2656
|
* re-deriving it from the extension on every request. */
|
|
2046
2657
|
mimeById = /* @__PURE__ */ new Map();
|
|
@@ -2049,17 +2660,32 @@ var NcArtifactStore = class {
|
|
|
2049
2660
|
this.now = options.now ?? (() => Date.now());
|
|
2050
2661
|
this.maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
|
|
2051
2662
|
this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
2663
|
+
this.keptMaxAgeMs = options.keptMaxAgeMs ?? 12096e5;
|
|
2664
|
+
this.keptMaxEntries = options.keptMaxEntries ?? 2e3;
|
|
2665
|
+
this.keptMaxBytes = options.keptMaxBytes ?? 2147483648;
|
|
2666
|
+
this.keptDir = node_path.default.join(options.dir, KEPT_SUBDIR);
|
|
2667
|
+
}
|
|
2668
|
+
/** How long a retained artifact stays fetchable — the index's `expiresAt`
|
|
2669
|
+
* is derived from this, so the two can never disagree. */
|
|
2670
|
+
get retentionMs() {
|
|
2671
|
+
return this.keptMaxAgeMs;
|
|
2052
2672
|
}
|
|
2053
2673
|
/** Create the directory and sweep whatever a previous run left behind. */
|
|
2054
2674
|
async start() {
|
|
2055
2675
|
await node_fs.promises.mkdir(this.options.dir, { recursive: true });
|
|
2056
2676
|
await this.sweep();
|
|
2057
2677
|
}
|
|
2058
|
-
/**
|
|
2059
|
-
|
|
2678
|
+
/**
|
|
2679
|
+
* Persist bytes and return the artifact handle (never throws on sweep).
|
|
2680
|
+
*
|
|
2681
|
+
* The retained shelf is created LAZILY: an install that never produces a
|
|
2682
|
+
* browsable artifact keeps a directory that is empty when it is empty.
|
|
2683
|
+
*/
|
|
2684
|
+
async put(bytes, mime, options = {}) {
|
|
2060
2685
|
const id = `${this.now()}-${(0, node_crypto.randomUUID)()}`;
|
|
2061
|
-
const
|
|
2062
|
-
|
|
2686
|
+
const dir = options.retain === true ? this.keptDir : this.options.dir;
|
|
2687
|
+
const file = node_path.default.join(dir, `${id}.${extensionFor(mime)}`);
|
|
2688
|
+
await node_fs.promises.mkdir(dir, { recursive: true });
|
|
2063
2689
|
await node_fs.promises.writeFile(file, bytes);
|
|
2064
2690
|
this.mimeById.set(id, mime);
|
|
2065
2691
|
await this.sweep();
|
|
@@ -2072,7 +2698,7 @@ var NcArtifactStore = class {
|
|
|
2072
2698
|
/** Read an artifact's bytes + mime, or null when it is gone (expired/swept). */
|
|
2073
2699
|
async read(id) {
|
|
2074
2700
|
if (!/^[0-9]+-[0-9a-f-]{36}$/i.test(id)) return null;
|
|
2075
|
-
const found = (await this.list()
|
|
2701
|
+
const found = [...await this.list(this.options.dir), ...await this.list(this.keptDir)].find((e) => e.id === id);
|
|
2076
2702
|
if (!found) return null;
|
|
2077
2703
|
try {
|
|
2078
2704
|
return {
|
|
@@ -2083,6 +2709,10 @@ var NcArtifactStore = class {
|
|
|
2083
2709
|
return null;
|
|
2084
2710
|
}
|
|
2085
2711
|
}
|
|
2712
|
+
/** The ids still on the retained shelf — what the index reconciles against. */
|
|
2713
|
+
async keptIds() {
|
|
2714
|
+
return (await this.list(this.keptDir)).map((e) => e.id);
|
|
2715
|
+
}
|
|
2086
2716
|
/** Delete every artifact (operator cleanup / shutdown). */
|
|
2087
2717
|
async clear() {
|
|
2088
2718
|
await node_fs.promises.rm(this.options.dir, {
|
|
@@ -2091,13 +2721,39 @@ var NcArtifactStore = class {
|
|
|
2091
2721
|
}).catch(() => {});
|
|
2092
2722
|
this.mimeById.clear();
|
|
2093
2723
|
}
|
|
2094
|
-
/**
|
|
2724
|
+
/** Prune both shelves under their own bounds. Best-effort: a failed unlink is
|
|
2725
|
+
* logged once and the rest of the sweep still runs. */
|
|
2095
2726
|
async sweep() {
|
|
2096
|
-
|
|
2097
|
-
|
|
2727
|
+
await this.sweepShelf(await this.list(this.options.dir), {
|
|
2728
|
+
maxAgeMs: this.maxAgeMs,
|
|
2729
|
+
maxEntries: this.maxEntries
|
|
2730
|
+
});
|
|
2731
|
+
await this.sweepShelf(await this.list(this.keptDir), {
|
|
2732
|
+
maxAgeMs: this.keptMaxAgeMs,
|
|
2733
|
+
maxEntries: this.keptMaxEntries,
|
|
2734
|
+
maxBytes: this.keptMaxBytes
|
|
2735
|
+
});
|
|
2736
|
+
}
|
|
2737
|
+
/**
|
|
2738
|
+
* Age first, then count, then total bytes — newest always survives.
|
|
2739
|
+
*
|
|
2740
|
+
* The newest entry is never swept by the byte bound even when it alone
|
|
2741
|
+
* exceeds it: a shelf that deletes what it has just written can serve
|
|
2742
|
+
* nothing, and the caller has already paid for the render.
|
|
2743
|
+
*/
|
|
2744
|
+
async sweepShelf(entries, bounds) {
|
|
2745
|
+
const cutoff = this.now() - bounds.maxAgeMs;
|
|
2098
2746
|
const doomed = entries.filter((e) => e.mtimeMs < cutoff);
|
|
2099
|
-
const
|
|
2100
|
-
doomed.push(...
|
|
2747
|
+
const fresh = entries.filter((e) => e.mtimeMs >= cutoff).toSorted((a, b) => b.mtimeMs - a.mtimeMs);
|
|
2748
|
+
doomed.push(...fresh.slice(bounds.maxEntries));
|
|
2749
|
+
const maxBytes = bounds.maxBytes;
|
|
2750
|
+
if (maxBytes !== void 0) {
|
|
2751
|
+
let running = 0;
|
|
2752
|
+
for (const [i, e] of fresh.slice(0, bounds.maxEntries).entries()) {
|
|
2753
|
+
running += e.sizeBytes;
|
|
2754
|
+
if (running > maxBytes && i > 0) doomed.push(e);
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2101
2757
|
for (const e of doomed) try {
|
|
2102
2758
|
await node_fs.promises.rm(e.file, { force: true });
|
|
2103
2759
|
this.mimeById.delete(e.id);
|
|
@@ -2108,23 +2764,24 @@ var NcArtifactStore = class {
|
|
|
2108
2764
|
} });
|
|
2109
2765
|
}
|
|
2110
2766
|
}
|
|
2111
|
-
async list() {
|
|
2767
|
+
async list(dir) {
|
|
2112
2768
|
let names;
|
|
2113
2769
|
try {
|
|
2114
|
-
names = await node_fs.promises.readdir(
|
|
2770
|
+
names = await node_fs.promises.readdir(dir);
|
|
2115
2771
|
} catch {
|
|
2116
2772
|
return [];
|
|
2117
2773
|
}
|
|
2118
2774
|
const out = [];
|
|
2119
2775
|
for (const name of names) {
|
|
2120
|
-
const file = node_path.default.join(
|
|
2776
|
+
const file = node_path.default.join(dir, name);
|
|
2121
2777
|
try {
|
|
2122
2778
|
const st = await node_fs.promises.stat(file);
|
|
2123
2779
|
if (!st.isFile()) continue;
|
|
2124
2780
|
out.push({
|
|
2125
2781
|
id: name.slice(0, name.lastIndexOf(".")),
|
|
2126
2782
|
file,
|
|
2127
|
-
mtimeMs: st.mtimeMs
|
|
2783
|
+
mtimeMs: st.mtimeMs,
|
|
2784
|
+
sizeBytes: st.size
|
|
2128
2785
|
});
|
|
2129
2786
|
} catch {}
|
|
2130
2787
|
}
|
|
@@ -4342,69 +4999,38 @@ var en_default = {
|
|
|
4342
4999
|
"snooze.scope.anyDevice": "A camera",
|
|
4343
5000
|
"snooze.scope.anyRule": "A rule",
|
|
4344
5001
|
"snooze.scope.rule": "“{{rule}}”",
|
|
4345
|
-
"alarm.arm.body.allDevices":
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
},
|
|
4349
|
-
"alarm.arm.body.
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
"alarm.
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
},
|
|
4357
|
-
"alarm.
|
|
4358
|
-
"one": "{{devices}} device armed: {{names}}. Excluded: {{skippedNames}}.",
|
|
4359
|
-
"other": "{{devices}} devices armed: {{names}}. Excluded: {{skippedNames}}."
|
|
4360
|
-
},
|
|
4361
|
-
"alarm.arm.body.uncovered": "Nothing is armed — no enabled rule is gated on this mode.",
|
|
4362
|
-
"alarm.arm.body.allSkipped": {
|
|
4363
|
-
"one": "Nothing is armed: the {{devices}} device this mode covers is excluded ({{names}}).",
|
|
4364
|
-
"other": "Nothing is armed: all {{devices}} devices this mode covers are excluded ({{names}})."
|
|
4365
|
-
},
|
|
4366
|
-
"alarm.arming.body": {
|
|
4367
|
-
"one": "Armed in {{seconds}} second.",
|
|
4368
|
-
"other": "Armed in {{seconds}} seconds."
|
|
4369
|
-
},
|
|
4370
|
-
"alarm.arming.body.uncovered": {
|
|
4371
|
-
"one": "Armed in {{seconds}} second — but no enabled rule is gated on this mode, so it will protect nothing.",
|
|
4372
|
-
"other": "Armed in {{seconds}} seconds — but no enabled rule is gated on this mode, so it will protect nothing."
|
|
4373
|
-
},
|
|
4374
|
-
"alarm.arming.body.allSkipped": {
|
|
4375
|
-
"one": "Armed in {{seconds}} second — but every device this mode covers is excluded ({{names}}), so it will protect nothing.",
|
|
4376
|
-
"other": "Armed in {{seconds}} seconds — but every device this mode covers is excluded ({{names}}), so it will protect nothing."
|
|
4377
|
-
},
|
|
4378
|
-
"alarm.arm.body.bypassed": {
|
|
4379
|
-
"one": "{{devices}} device armed — open and excluded: {{bypassedNames}}.",
|
|
4380
|
-
"other": "{{devices}} devices armed — open and excluded: {{bypassedNames}}."
|
|
4381
|
-
},
|
|
4382
|
-
"alarm.arming.body.bypassed": {
|
|
4383
|
-
"one": "Armed in {{seconds}} second, excluding what is open: {{bypassedNames}}.",
|
|
4384
|
-
"other": "Armed in {{seconds}} seconds, excluding what is open: {{bypassedNames}}."
|
|
4385
|
-
},
|
|
4386
|
-
"alarm.armRefused.body": {
|
|
4387
|
-
"one": "{{names}} is open. Press arm again within {{seconds}}s to arm without it.",
|
|
4388
|
-
"other": "These are open: {{names}}. Press arm again within {{seconds}}s to arm without them."
|
|
4389
|
-
},
|
|
5002
|
+
"alarm.arm.body.allDevices": "Active: all devices",
|
|
5003
|
+
"alarm.arm.body.noDevices": "Active: no device restriction",
|
|
5004
|
+
"alarm.arm.body.devices": "Active: {{names}}",
|
|
5005
|
+
"alarm.arm.body.devicesSkipped": "Active: {{names}}\nExcluded: {{skippedNames}}",
|
|
5006
|
+
"alarm.arm.body.uncovered": "Active: none — no rule uses this mode",
|
|
5007
|
+
"alarm.arm.body.allSkipped": "Active: none\nExcluded: {{names}}",
|
|
5008
|
+
"alarm.arming.body": "No exclusions",
|
|
5009
|
+
"alarm.arming.body.uncovered": "Active: none — no rule uses this mode",
|
|
5010
|
+
"alarm.arming.body.allSkipped": "Active: none\nExcluded: {{names}}",
|
|
5011
|
+
"alarm.arming.body.skipped": "Excluded: {{names}}",
|
|
5012
|
+
"alarm.arm.body.bypassed": "Active: {{devices}}\nBypassed: {{bypassedNames}}",
|
|
5013
|
+
"alarm.arming.body.bypassed": "Bypassed: {{bypassedNames}}",
|
|
5014
|
+
"alarm.armRefused.body": "Open: {{names}}\nRepeat within {{seconds}}s to arm with bypass.",
|
|
4390
5015
|
"alarm.device.unnamed": "Device {{id}}",
|
|
4391
|
-
"
|
|
4392
|
-
"system.alarm-arm-refused.title.
|
|
4393
|
-
"system.alarm-arm-refused.title.
|
|
4394
|
-
"system.alarm-arm-refused.
|
|
4395
|
-
"system.alarm-
|
|
4396
|
-
"system.alarm-arming.title.
|
|
4397
|
-
"system.alarm-arming.title.
|
|
4398
|
-
"system.alarm-arming.
|
|
4399
|
-
"system.alarm-
|
|
4400
|
-
"system.alarm-armed.title.
|
|
4401
|
-
"system.alarm-armed.title.
|
|
4402
|
-
"system.alarm-armed.
|
|
4403
|
-
"system.alarm-
|
|
4404
|
-
"system.alarm-disarmed.
|
|
5016
|
+
"alarm.combined.title": "Alarm: {{deviceName}}",
|
|
5017
|
+
"system.alarm-arm-refused.title.home": "Arming home refused",
|
|
5018
|
+
"system.alarm-arm-refused.title.away": "Arming away refused",
|
|
5019
|
+
"system.alarm-arm-refused.title.night": "Arming night refused",
|
|
5020
|
+
"system.alarm-arm-refused.body": "Arming refused",
|
|
5021
|
+
"system.alarm-arming.title.home": "Arming home — {{seconds}}s",
|
|
5022
|
+
"system.alarm-arming.title.away": "Arming away — {{seconds}}s",
|
|
5023
|
+
"system.alarm-arming.title.night": "Arming night — {{seconds}}s",
|
|
5024
|
+
"system.alarm-arming.body": "Arming",
|
|
5025
|
+
"system.alarm-armed.title.home": "Armed: home",
|
|
5026
|
+
"system.alarm-armed.title.away": "Armed: away",
|
|
5027
|
+
"system.alarm-armed.title.night": "Armed: night",
|
|
5028
|
+
"system.alarm-armed.body": "Alarm armed",
|
|
5029
|
+
"system.alarm-disarmed.title": "Disarmed",
|
|
5030
|
+
"system.alarm-disarmed.body": "Alarm disarmed",
|
|
4405
5031
|
"system.alarm-triggered.title": "ALARM",
|
|
4406
|
-
"system.alarm-triggered.body": "
|
|
4407
|
-
"system.alarm-triggered.body.rule": "
|
|
5032
|
+
"system.alarm-triggered.body": "Alarm triggered",
|
|
5033
|
+
"system.alarm-triggered.body.rule": "Triggered by {{rule}} on {{deviceName}}",
|
|
4408
5034
|
"button.snooze.minutes": "Snooze {{minutes}} min",
|
|
4409
5035
|
"button.snooze.hours": {
|
|
4410
5036
|
"one": "Snooze {{hours}} hour",
|
|
@@ -4536,69 +5162,38 @@ var it_default = {
|
|
|
4536
5162
|
"snooze.scope.anyDevice": "Una camera",
|
|
4537
5163
|
"snooze.scope.anyRule": "Una regola",
|
|
4538
5164
|
"snooze.scope.rule": "“{{rule}}”",
|
|
4539
|
-
"alarm.arm.body.allDevices":
|
|
4540
|
-
|
|
4541
|
-
|
|
4542
|
-
},
|
|
4543
|
-
"alarm.arm.body.
|
|
4544
|
-
|
|
4545
|
-
|
|
4546
|
-
|
|
4547
|
-
"alarm.
|
|
4548
|
-
|
|
4549
|
-
|
|
4550
|
-
},
|
|
4551
|
-
"alarm.
|
|
4552
|
-
"one": "{{devices}} dispositivo attivo: {{names}}. Esclusi: {{skippedNames}}.",
|
|
4553
|
-
"other": "{{devices}} dispositivi attivi: {{names}}. Esclusi: {{skippedNames}}."
|
|
4554
|
-
},
|
|
4555
|
-
"alarm.arm.body.uncovered": "Non è attivo nulla — nessuna regola abilitata è legata a questa modalità.",
|
|
4556
|
-
"alarm.arm.body.allSkipped": {
|
|
4557
|
-
"one": "Non è attivo nulla: l'unico dispositivo ({{devices}}) coperto da questa modalità è escluso ({{names}}).",
|
|
4558
|
-
"other": "Non è attivo nulla: tutti i {{devices}} dispositivi coperti da questa modalità sono esclusi ({{names}})."
|
|
4559
|
-
},
|
|
4560
|
-
"alarm.arming.body": {
|
|
4561
|
-
"one": "Attivo tra {{seconds}} secondo.",
|
|
4562
|
-
"other": "Attivo tra {{seconds}} secondi."
|
|
4563
|
-
},
|
|
4564
|
-
"alarm.arming.body.uncovered": {
|
|
4565
|
-
"one": "Attivo tra {{seconds}} secondo — ma nessuna regola abilitata è legata a questa modalità: non proteggerà nulla.",
|
|
4566
|
-
"other": "Attivo tra {{seconds}} secondi — ma nessuna regola abilitata è legata a questa modalità: non proteggerà nulla."
|
|
4567
|
-
},
|
|
4568
|
-
"alarm.arming.body.allSkipped": {
|
|
4569
|
-
"one": "Attivo tra {{seconds}} secondo — ma ogni dispositivo coperto da questa modalità è escluso ({{names}}): non proteggerà nulla.",
|
|
4570
|
-
"other": "Attivo tra {{seconds}} secondi — ma ogni dispositivo coperto da questa modalità è escluso ({{names}}): non proteggerà nulla."
|
|
4571
|
-
},
|
|
4572
|
-
"alarm.arm.body.bypassed": {
|
|
4573
|
-
"one": "{{devices}} dispositivo attivo — aperto ed escluso: {{bypassedNames}}.",
|
|
4574
|
-
"other": "{{devices}} dispositivi attivi — aperti ed esclusi: {{bypassedNames}}."
|
|
4575
|
-
},
|
|
4576
|
-
"alarm.arming.body.bypassed": {
|
|
4577
|
-
"one": "Attivo tra {{seconds}} secondo, escludendo ciò che è aperto: {{bypassedNames}}.",
|
|
4578
|
-
"other": "Attivo tra {{seconds}} secondi, escludendo ciò che è aperto: {{bypassedNames}}."
|
|
4579
|
-
},
|
|
4580
|
-
"alarm.armRefused.body": {
|
|
4581
|
-
"one": "{{names}} è aperto. Premi di nuovo entro {{seconds}}s per inserire senza di esso.",
|
|
4582
|
-
"other": "Sono aperti: {{names}}. Premi di nuovo entro {{seconds}}s per inserire senza di essi."
|
|
4583
|
-
},
|
|
5165
|
+
"alarm.arm.body.allDevices": "Attivi: tutti i dispositivi",
|
|
5166
|
+
"alarm.arm.body.noDevices": "Attivi: nessuna restrizione",
|
|
5167
|
+
"alarm.arm.body.devices": "Attivi: {{names}}",
|
|
5168
|
+
"alarm.arm.body.devicesSkipped": "Attivi: {{names}}\nEsclusi: {{skippedNames}}",
|
|
5169
|
+
"alarm.arm.body.uncovered": "Attivi: nessuno — nessuna regola usa questa modalità",
|
|
5170
|
+
"alarm.arm.body.allSkipped": "Attivi: nessuno\nEsclusi: {{names}}",
|
|
5171
|
+
"alarm.arming.body": "Nessuna esclusione",
|
|
5172
|
+
"alarm.arming.body.uncovered": "Attivi: nessuno — nessuna regola usa questa modalità",
|
|
5173
|
+
"alarm.arming.body.allSkipped": "Attivi: nessuno\nEsclusi: {{names}}",
|
|
5174
|
+
"alarm.arming.body.skipped": "Esclusi: {{names}}",
|
|
5175
|
+
"alarm.arm.body.bypassed": "Attivi: {{devices}}\nBypassati: {{bypassedNames}}",
|
|
5176
|
+
"alarm.arming.body.bypassed": "Bypassati: {{bypassedNames}}",
|
|
5177
|
+
"alarm.armRefused.body": "Aperti: {{names}}\nRipeti entro {{seconds}}s per inserire con bypass.",
|
|
4584
5178
|
"alarm.device.unnamed": "Dispositivo {{id}}",
|
|
5179
|
+
"alarm.combined.title": "Allarme: {{deviceName}}",
|
|
4585
5180
|
"system.alarm-arm-refused.title.home": "Inserimento Casa rifiutato",
|
|
4586
5181
|
"system.alarm-arm-refused.title.away": "Inserimento Fuori rifiutato",
|
|
4587
5182
|
"system.alarm-arm-refused.title.night": "Inserimento Notte rifiutato",
|
|
4588
|
-
"system.alarm-arm-refused.body": "
|
|
4589
|
-
"system.alarm-arming.title.home": "Inserimento Casa",
|
|
4590
|
-
"system.alarm-arming.title.away": "Inserimento Fuori",
|
|
4591
|
-
"system.alarm-arming.title.night": "Inserimento Notte",
|
|
4592
|
-
"system.alarm-arming.body": "
|
|
4593
|
-
"system.alarm-armed.title.home": "Casa
|
|
4594
|
-
"system.alarm-armed.title.away": "Fuori
|
|
4595
|
-
"system.alarm-armed.title.night": "Notte
|
|
4596
|
-
"system.alarm-armed.body": "
|
|
4597
|
-
"system.alarm-disarmed.title": "
|
|
4598
|
-
"system.alarm-disarmed.body": "
|
|
5183
|
+
"system.alarm-arm-refused.body": "Inserimento rifiutato",
|
|
5184
|
+
"system.alarm-arming.title.home": "Inserimento Casa — {{seconds}}s",
|
|
5185
|
+
"system.alarm-arming.title.away": "Inserimento Fuori — {{seconds}}s",
|
|
5186
|
+
"system.alarm-arming.title.night": "Inserimento Notte — {{seconds}}s",
|
|
5187
|
+
"system.alarm-arming.body": "Inserimento in corso",
|
|
5188
|
+
"system.alarm-armed.title.home": "Inserito: Casa",
|
|
5189
|
+
"system.alarm-armed.title.away": "Inserito: Fuori",
|
|
5190
|
+
"system.alarm-armed.title.night": "Inserito: Notte",
|
|
5191
|
+
"system.alarm-armed.body": "Allarme inserito",
|
|
5192
|
+
"system.alarm-disarmed.title": "Disinserito",
|
|
5193
|
+
"system.alarm-disarmed.body": "Allarme disinserito",
|
|
4599
5194
|
"system.alarm-triggered.title": "ALLARME",
|
|
4600
|
-
"system.alarm-triggered.body": "
|
|
4601
|
-
"system.alarm-triggered.body.rule": "
|
|
5195
|
+
"system.alarm-triggered.body": "Allarme scattato",
|
|
5196
|
+
"system.alarm-triggered.body.rule": "Scattato da {{rule}} su {{deviceName}}",
|
|
4602
5197
|
"button.snooze.minutes": "Silenzia {{minutes}} min",
|
|
4603
5198
|
"button.snooze.hours": {
|
|
4604
5199
|
"one": "Silenzia {{hours}} ora",
|
|
@@ -5317,7 +5912,8 @@ var NcDispatcher = class {
|
|
|
5317
5912
|
const systemEvent = subject.systemEvent;
|
|
5318
5913
|
const displayDeviceId = systemEvent?.deviceId ?? subject.deviceId;
|
|
5319
5914
|
const deviceName = systemEvent !== void 0 && systemEvent.deviceId === void 0 ? "system" : await this.deps.getDeviceName(displayDeviceId).catch(() => null) ?? `camera ${displayDeviceId}`;
|
|
5320
|
-
const
|
|
5915
|
+
const alarmSource = systemEvent?.alarmSource;
|
|
5916
|
+
const zoneLabels = systemEvent === void 0 ? await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones) : alarmSource?.zones !== void 0 && alarmSource.zones.length > 0 ? await resolveZoneLabels(this.deps.getZoneNames, alarmSource.deviceId, alarmSource.zones) : [];
|
|
5321
5917
|
const vars = buildTemplateVars(this.texts, entry, deviceName, zoneLabels);
|
|
5322
5918
|
const systemCount = systemEvent?.textCount !== void 0 ? { count: systemEvent.textCount } : {};
|
|
5323
5919
|
const title = this.renderText({
|
|
@@ -5660,6 +6256,10 @@ async function resolveZoneLabels(getZoneNames, deviceId, zoneIds) {
|
|
|
5660
6256
|
return [...zoneIds];
|
|
5661
6257
|
}
|
|
5662
6258
|
}
|
|
6259
|
+
/** `{{confidence}}` as a whole percent, or empty when nothing measured one. */
|
|
6260
|
+
function percentOf(confidence) {
|
|
6261
|
+
return confidence !== void 0 ? `${Math.round(confidence * 100)}%` : "";
|
|
6262
|
+
}
|
|
5663
6263
|
/**
|
|
5664
6264
|
* The variable map ONE render pass sees: scalars, then the composites built
|
|
5665
6265
|
* from them, then the params a system-event row froze at intake.
|
|
@@ -5675,19 +6275,23 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels) {
|
|
|
5675
6275
|
const subject = entry.payload.subject;
|
|
5676
6276
|
const occupancy = subject.occupancy;
|
|
5677
6277
|
const systemEvent = subject.systemEvent;
|
|
6278
|
+
const detection = systemEvent?.alarmSource?.className !== void 0 ? systemEvent.alarmSource : void 0;
|
|
5678
6279
|
return {
|
|
5679
6280
|
camera: deviceName,
|
|
5680
|
-
class: subject.className,
|
|
5681
|
-
label: subject.label ?? "",
|
|
6281
|
+
class: detection?.className ?? subject.className,
|
|
6282
|
+
label: detection?.label ?? subject.label ?? "",
|
|
5682
6283
|
zones: zoneLabels.join(", "),
|
|
5683
|
-
subject:
|
|
6284
|
+
subject: detection?.className !== void 0 ? ncSubjectText(texts, {
|
|
6285
|
+
className: detection.className,
|
|
6286
|
+
...detection.label !== void 0 ? { label: detection.label } : {}
|
|
6287
|
+
}) : systemEvent?.subject ?? ncSubjectText(texts, {
|
|
5684
6288
|
className: subject.className,
|
|
5685
6289
|
...subject.label !== void 0 ? { label: subject.label } : {}
|
|
5686
6290
|
}),
|
|
5687
6291
|
inZones: ncInZonesText(texts, zoneLabels),
|
|
5688
6292
|
scope: occupancy?.zone ?? deviceName,
|
|
5689
6293
|
zone: occupancy?.zone ?? zoneLabels[0] ?? "",
|
|
5690
|
-
confidence:
|
|
6294
|
+
confidence: percentOf(detection?.confidence ?? subject.confidence),
|
|
5691
6295
|
time: new Date(subject.timestamp).toLocaleTimeString(),
|
|
5692
6296
|
rule: entry.payload.ruleName,
|
|
5693
6297
|
count: occupancy !== void 0 ? `${occupancy.count}` : "",
|
|
@@ -6227,6 +6831,35 @@ function incomingFromSystemEvent(event, lookup) {
|
|
|
6227
6831
|
}
|
|
6228
6832
|
}
|
|
6229
6833
|
/**
|
|
6834
|
+
* The COMBINED row's two keys, or null when this transition is not one.
|
|
6835
|
+
*
|
|
6836
|
+
* A rule that trips the alarm produces ONE notification, and the operator has
|
|
6837
|
+
* to be able to read it: the title is the severity marker over the camera
|
|
6838
|
+
* (`alarm.combined.title`, shipped as `Alarm: {{deviceName}}` and re-wordable
|
|
6839
|
+
* per rule or in the Texts tab), and the body is `detection.body` — the exact
|
|
6840
|
+
* sentence that rule's ordinary notification would have carried, with its own
|
|
6841
|
+
* class, identity and zones.
|
|
6842
|
+
*
|
|
6843
|
+
* The alarm used to write its own prose here ("The alarm was triggered by
|
|
6844
|
+
* Person at gate on Ingresso"), which made the one notification that matters
|
|
6845
|
+
* the only one in the system that did not look like the rule's others: no
|
|
6846
|
+
* identity, no zone, a different subject. The prefix carries the severity; the
|
|
6847
|
+
* familiar line carries the meaning.
|
|
6848
|
+
*
|
|
6849
|
+
* **Gated on the detection vocabulary, not on the source.** `detection.body`
|
|
6850
|
+
* renders `{{subject}}`, and a fire ref with no class composes it from the
|
|
6851
|
+
* system event's dedup slot — "alarm:42 su Ingresso". A source without a class
|
|
6852
|
+
* therefore keeps the alarm's own sentence, which is also what every row
|
|
6853
|
+
* enqueued before this deploy carries.
|
|
6854
|
+
*/
|
|
6855
|
+
function combinedAlarmText(source) {
|
|
6856
|
+
if (source?.className === void 0) return null;
|
|
6857
|
+
return {
|
|
6858
|
+
titleKey: "alarm.combined.title",
|
|
6859
|
+
bodyKey: "detection.body"
|
|
6860
|
+
};
|
|
6861
|
+
}
|
|
6862
|
+
/**
|
|
6230
6863
|
* The panel's own transition, as a system-event subject.
|
|
6231
6864
|
*
|
|
6232
6865
|
* IN-PROCESS, not off the bus. The alarm and the Notification Center live in
|
|
@@ -6249,6 +6882,7 @@ function incomingFromAlarmTransition(input) {
|
|
|
6249
6882
|
const deviceId = source?.deviceId ?? panelDeviceId;
|
|
6250
6883
|
const identity = input.lookup(deviceId);
|
|
6251
6884
|
const kind = transition.kind;
|
|
6885
|
+
const combined = combinedAlarmText(source);
|
|
6252
6886
|
return systemSubject({
|
|
6253
6887
|
facts: {
|
|
6254
6888
|
eventId: `alarm:${kind}:${String(transition.at)}`,
|
|
@@ -6264,12 +6898,13 @@ function incomingFromAlarmTransition(input) {
|
|
|
6264
6898
|
},
|
|
6265
6899
|
kind,
|
|
6266
6900
|
subject: `alarm:${String(panelDeviceId)}`,
|
|
6267
|
-
titleKey: transition.mode !== void 0 ? `system.${kind}.title.${transition.mode}` : `system.${kind}.title`,
|
|
6268
|
-
bodyKey: input.body?.bodyKey ?? (source !== void 0 ? `system.${kind}.body.rule` : `system.${kind}.body`),
|
|
6901
|
+
titleKey: combined !== null ? combined.titleKey : transition.mode !== void 0 ? `system.${kind}.title.${transition.mode}` : `system.${kind}.title`,
|
|
6902
|
+
bodyKey: combined?.bodyKey ?? input.body?.bodyKey ?? (source !== void 0 ? `system.${kind}.body.rule` : `system.${kind}.body`),
|
|
6269
6903
|
textParams: {
|
|
6270
6904
|
...deviceTextParams(deviceId, identity),
|
|
6271
6905
|
...transition.mode !== void 0 ? { mode: transition.mode } : {},
|
|
6272
6906
|
...source !== void 0 ? { rule: source.ruleName } : {},
|
|
6907
|
+
...kind === "alarm-arming" ? { seconds: `${transition.seconds ?? 0}` } : {},
|
|
6273
6908
|
...input.body?.textParams
|
|
6274
6909
|
},
|
|
6275
6910
|
deviceId,
|
|
@@ -10010,7 +10645,15 @@ var NcSummaryProducer = class {
|
|
|
10010
10645
|
}
|
|
10011
10646
|
const bytes = new Uint8Array(rendered.jpeg.byteLength);
|
|
10012
10647
|
bytes.set(rendered.jpeg);
|
|
10013
|
-
const url = await this.deps.publish(bytes, "image/jpeg"
|
|
10648
|
+
const { url } = await this.deps.publish(bytes, "image/jpeg", {
|
|
10649
|
+
kind: "summary-mosaic",
|
|
10650
|
+
deviceId: rule.deviceIds[0] ?? null,
|
|
10651
|
+
deviceIds: [...new Set(tiles.map((t) => t.candidate.deviceId))],
|
|
10652
|
+
capturedAt: window.endMs,
|
|
10653
|
+
ruleId: rule.id,
|
|
10654
|
+
ruleName: rule.name,
|
|
10655
|
+
...rule.ownerUserId !== void 0 ? { ownerUserId: rule.ownerUserId } : {}
|
|
10656
|
+
});
|
|
10014
10657
|
if (url === null) {
|
|
10015
10658
|
this.deps.logger.warn("summary mosaic not published: no reachable public endpoint", { meta: { ruleId: rule.id } });
|
|
10016
10659
|
return {
|
|
@@ -11012,7 +11655,7 @@ function unknownNcTextKeys(keys, bundles = NC_TEXT_BUNDLES) {
|
|
|
11012
11655
|
* durable document stays the sparse map its schema describes.
|
|
11013
11656
|
*/
|
|
11014
11657
|
function applyNcTextOverridePatch(current, language, patch) {
|
|
11015
|
-
const next = { ...current[language]
|
|
11658
|
+
const next = { ...current[language] };
|
|
11016
11659
|
for (const [key, value] of Object.entries(patch)) {
|
|
11017
11660
|
if (value === null || value.trim().length === 0) {
|
|
11018
11661
|
delete next[key];
|
|
@@ -11580,7 +12223,7 @@ var TimelapseRenderer = class {
|
|
|
11580
12223
|
}
|
|
11581
12224
|
});
|
|
11582
12225
|
const finished = await this.awaitReady(queued, tags);
|
|
11583
|
-
const published = await this.publish(finished.id,
|
|
12226
|
+
const published = await this.publish(finished.id, request, tags);
|
|
11584
12227
|
const downloadUrl = await this.ports.resolveDownloadUrl?.({ exportId: finished.id }).catch(() => null);
|
|
11585
12228
|
return {
|
|
11586
12229
|
exportId: finished.id,
|
|
@@ -11632,7 +12275,9 @@ var TimelapseRenderer = class {
|
|
|
11632
12275
|
* notification, not a failed window — and failing here would leave the pair
|
|
11633
12276
|
* unstamped and re-render the same eight hours on the next tick.
|
|
11634
12277
|
*/
|
|
11635
|
-
async publish(exportId,
|
|
12278
|
+
async publish(exportId, request, tags) {
|
|
12279
|
+
const { deviceId, previewText } = request;
|
|
12280
|
+
const previewMode = request.previewMode ?? "image";
|
|
11636
12281
|
const publish = this.ports.publishArtifact;
|
|
11637
12282
|
const none = {
|
|
11638
12283
|
video: null,
|
|
@@ -11652,7 +12297,11 @@ var TimelapseRenderer = class {
|
|
|
11652
12297
|
...previewText !== void 0 ? {
|
|
11653
12298
|
previewText,
|
|
11654
12299
|
previewMode
|
|
11655
|
-
} : {}
|
|
12300
|
+
} : {},
|
|
12301
|
+
capturedAt: request.window.endMs,
|
|
12302
|
+
ruleId: request.ruleId,
|
|
12303
|
+
ruleName: request.ruleName,
|
|
12304
|
+
...request.ownerUserId !== void 0 ? { ownerUserId: request.ownerUserId } : {}
|
|
11656
12305
|
});
|
|
11657
12306
|
if (set.video === null) this.ports.logger.warn("timelapse artifact could not be published — link-less delivery", {
|
|
11658
12307
|
tags,
|
|
@@ -12008,7 +12657,8 @@ var TimelapseScheduler = class {
|
|
|
12008
12657
|
...rule.previewText !== void 0 ? { text: rule.previewText } : {}
|
|
12009
12658
|
}),
|
|
12010
12659
|
previewMode: rule.previewMode,
|
|
12011
|
-
...rule.reportClasses !== void 0 ? { reportClasses: rule.reportClasses } : {}
|
|
12660
|
+
...rule.reportClasses !== void 0 ? { reportClasses: rule.reportClasses } : {},
|
|
12661
|
+
...rule.ownerUserId !== void 0 ? { ownerUserId: rule.ownerUserId } : {}
|
|
12012
12662
|
});
|
|
12013
12663
|
const rows = buildTimelapseOutboxInputs({
|
|
12014
12664
|
texts: this.texts,
|
|
@@ -12584,6 +13234,7 @@ function frozenOccupancy(rule, occ) {
|
|
|
12584
13234
|
*/
|
|
12585
13235
|
function fireRefFor(rule, subject, kind) {
|
|
12586
13236
|
const hasEventMedia = kind === "object-event" || kind === "package-event";
|
|
13237
|
+
const className = subject.classNames[0];
|
|
12587
13238
|
return {
|
|
12588
13239
|
ruleId: rule.id,
|
|
12589
13240
|
ruleName: rule.name,
|
|
@@ -12592,7 +13243,11 @@ function fireRefFor(rule, subject, kind) {
|
|
|
12592
13243
|
recordId: subject.recordId,
|
|
12593
13244
|
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
|
|
12594
13245
|
...hasEventMedia ? { eventId: subject.recordId } : {},
|
|
12595
|
-
timestamp: subject.timestamp
|
|
13246
|
+
timestamp: subject.timestamp,
|
|
13247
|
+
...className !== void 0 ? { className } : {},
|
|
13248
|
+
...subject.label !== void 0 ? { label: subject.label } : {},
|
|
13249
|
+
zones: notificationZones(rule, subject),
|
|
13250
|
+
...subject.confidence !== void 0 ? { confidence: subject.confidence } : {}
|
|
12596
13251
|
};
|
|
12597
13252
|
}
|
|
12598
13253
|
function notificationZones(rule, subject) {
|
|
@@ -13319,6 +13974,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
13319
13974
|
await TimelapseStore.declare(store);
|
|
13320
13975
|
await NcSummaryStore.declare(store);
|
|
13321
13976
|
await NcTextCatalogStore.declare(store);
|
|
13977
|
+
await NcArtifactIndex.declare(store);
|
|
13322
13978
|
}
|
|
13323
13979
|
/**
|
|
13324
13980
|
* Load rules (every node — the cap provider serves CRUD from any node).
|
|
@@ -15376,7 +16032,23 @@ var ncActions = require_dist.defineCustomActions({
|
|
|
15376
16032
|
}), NcTextCatalogViewSchema, {
|
|
15377
16033
|
kind: "mutation",
|
|
15378
16034
|
auth: "admin"
|
|
15379
|
-
})
|
|
16035
|
+
}),
|
|
16036
|
+
/**
|
|
16037
|
+
* One page of artefacts, newest `capturedAt` first.
|
|
16038
|
+
*
|
|
16039
|
+
* `deviceIds` filters on the camera an artefact is FILED under. A digest
|
|
16040
|
+
* spans cameras and is filed under its rule's first one (the same camera its
|
|
16041
|
+
* outbox row uses); to ask "everything this digest produced" use `ruleId`.
|
|
16042
|
+
*/
|
|
16043
|
+
"nc.listArtifacts": require_dist.customAction(NcArtifactQuerySchema, require_dist.object({
|
|
16044
|
+
artifacts: require_dist.array(NcArtifactRecordSchema),
|
|
16045
|
+
/** Rows matching the filter, not rows on this page. */
|
|
16046
|
+
total: require_dist.number().int().nonnegative(),
|
|
16047
|
+
hasMore: require_dist.boolean()
|
|
16048
|
+
}), { caller: "required" }),
|
|
16049
|
+
/** One artefact's metadata. `null` for unknown, expired AND not-yours alike —
|
|
16050
|
+
* a surface that distinguishes them reports what other people own. */
|
|
16051
|
+
"nc.getArtifact": require_dist.customAction(require_dist.object({ artifactId: require_dist.string().min(1) }), require_dist.object({ artifact: NcArtifactRecordSchema.nullable() }), { caller: "required" })
|
|
15380
16052
|
});
|
|
15381
16053
|
/** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
|
|
15382
16054
|
function requireCaller(caller) {
|
|
@@ -15426,6 +16098,12 @@ function makeNcActionHandlers(deps) {
|
|
|
15426
16098
|
if (sum === void 0) throw new Error("this node does not host the summary producer — use the hub");
|
|
15427
16099
|
return sum;
|
|
15428
16100
|
};
|
|
16101
|
+
/** The artefact index, or an honest refusal. */
|
|
16102
|
+
const requireArtifacts = () => {
|
|
16103
|
+
const index = deps.artifacts;
|
|
16104
|
+
if (index === void 0) throw new Error("this node does not host the notification artifact index — use the hub");
|
|
16105
|
+
return index;
|
|
16106
|
+
};
|
|
15429
16107
|
/** The text catalog editor, or an honest refusal. */
|
|
15430
16108
|
const requireTexts = () => {
|
|
15431
16109
|
const editor = deps.texts;
|
|
@@ -15737,7 +16415,26 @@ function makeNcActionHandlers(deps) {
|
|
|
15737
16415
|
return input.language === void 0 ? texts.view() : texts.view(input.language);
|
|
15738
16416
|
},
|
|
15739
16417
|
"nc.setLanguage": async (input) => requireTexts().setLanguage(input.language),
|
|
15740
|
-
"nc.setTextOverrides": async (input) => requireTexts().setOverrides(input.language, input.overrides)
|
|
16418
|
+
"nc.setTextOverrides": async (input) => requireTexts().setOverrides(input.language, input.overrides),
|
|
16419
|
+
"nc.listArtifacts": async (input, caller) => {
|
|
16420
|
+
const c = requireCaller(caller);
|
|
16421
|
+
const page = await requireArtifacts().list(input, {
|
|
16422
|
+
userId: c.userId,
|
|
16423
|
+
isAdmin: c.isAdmin
|
|
16424
|
+
});
|
|
16425
|
+
return {
|
|
16426
|
+
artifacts: [...page.artifacts],
|
|
16427
|
+
total: page.total,
|
|
16428
|
+
hasMore: page.hasMore
|
|
16429
|
+
};
|
|
16430
|
+
},
|
|
16431
|
+
"nc.getArtifact": async (input, caller) => {
|
|
16432
|
+
const c = requireCaller(caller);
|
|
16433
|
+
return { artifact: await requireArtifacts().get(input.artifactId, {
|
|
16434
|
+
userId: c.userId,
|
|
16435
|
+
isAdmin: c.isAdmin
|
|
16436
|
+
}) };
|
|
16437
|
+
}
|
|
15741
16438
|
};
|
|
15742
16439
|
}
|
|
15743
16440
|
//#endregion
|
|
@@ -15791,7 +16488,8 @@ var PREVIEW_FALLBACK_NAME = "timelapse-preview.jpg";
|
|
|
15791
16488
|
* member has a logged reason. It never rejects.
|
|
15792
16489
|
*/
|
|
15793
16490
|
function buildTimelapseArtifactPublisher(ports) {
|
|
15794
|
-
return async (
|
|
16491
|
+
return async (request) => {
|
|
16492
|
+
const { exportId, deviceId, previewText, previewMode } = request;
|
|
15795
16493
|
const tags = { deviceId };
|
|
15796
16494
|
const none = {
|
|
15797
16495
|
video: null,
|
|
@@ -15822,15 +16520,32 @@ function buildTimelapseArtifactPublisher(ports) {
|
|
|
15822
16520
|
return none;
|
|
15823
16521
|
}
|
|
15824
16522
|
const mp4 = node_buffer.Buffer.from(read.base64, "base64");
|
|
16523
|
+
const capturedAt = request.capturedAt ?? Date.now();
|
|
16524
|
+
const common = {
|
|
16525
|
+
deviceId,
|
|
16526
|
+
deviceIds: [deviceId],
|
|
16527
|
+
capturedAt,
|
|
16528
|
+
...request.ruleId !== void 0 ? { ruleId: request.ruleId } : {},
|
|
16529
|
+
...request.ruleName !== void 0 ? { ruleName: request.ruleName } : {},
|
|
16530
|
+
...request.ownerUserId !== void 0 ? { ownerUserId: request.ownerUserId } : {}
|
|
16531
|
+
};
|
|
15825
16532
|
const preview = await publishPreview(ports, {
|
|
15826
16533
|
mp4,
|
|
15827
16534
|
deviceId,
|
|
15828
16535
|
tags,
|
|
15829
16536
|
previewText,
|
|
15830
|
-
previewMode: previewMode ?? "image"
|
|
16537
|
+
previewMode: previewMode ?? "image",
|
|
16538
|
+
meta: {
|
|
16539
|
+
...common,
|
|
16540
|
+
kind: "timelapse-preview"
|
|
16541
|
+
}
|
|
15831
16542
|
});
|
|
15832
16543
|
try {
|
|
15833
|
-
const url = await ports.publish(mp4, read.contentType
|
|
16544
|
+
const { url } = await ports.publish(mp4, read.contentType, {
|
|
16545
|
+
...common,
|
|
16546
|
+
kind: "timelapse",
|
|
16547
|
+
...preview.artifactId !== null ? { posterArtifactId: preview.artifactId } : {}
|
|
16548
|
+
});
|
|
15834
16549
|
if (url === null) {
|
|
15835
16550
|
ports.logger.warn("timelapse artifact not published: no reachable public endpoint", {
|
|
15836
16551
|
tags,
|
|
@@ -15838,7 +16553,7 @@ function buildTimelapseArtifactPublisher(ports) {
|
|
|
15838
16553
|
});
|
|
15839
16554
|
return {
|
|
15840
16555
|
video: null,
|
|
15841
|
-
preview
|
|
16556
|
+
preview: preview.ref
|
|
15842
16557
|
};
|
|
15843
16558
|
}
|
|
15844
16559
|
ports.logger.info("timelapse artifact published", {
|
|
@@ -15847,7 +16562,7 @@ function buildTimelapseArtifactPublisher(ports) {
|
|
|
15847
16562
|
exportId,
|
|
15848
16563
|
bytes: read.bytes,
|
|
15849
16564
|
name: read.name,
|
|
15850
|
-
preview: preview !== null
|
|
16565
|
+
preview: preview.ref !== null
|
|
15851
16566
|
}
|
|
15852
16567
|
});
|
|
15853
16568
|
return {
|
|
@@ -15856,7 +16571,7 @@ function buildTimelapseArtifactPublisher(ports) {
|
|
|
15856
16571
|
mime: read.contentType,
|
|
15857
16572
|
name: read.name
|
|
15858
16573
|
},
|
|
15859
|
-
preview
|
|
16574
|
+
preview: preview.ref
|
|
15860
16575
|
};
|
|
15861
16576
|
} catch (err) {
|
|
15862
16577
|
ports.logger.warn("timelapse artifact not published: artifact plane refused the bytes", {
|
|
@@ -15868,18 +16583,22 @@ function buildTimelapseArtifactPublisher(ports) {
|
|
|
15868
16583
|
});
|
|
15869
16584
|
return {
|
|
15870
16585
|
video: null,
|
|
15871
|
-
preview
|
|
16586
|
+
preview: preview.ref
|
|
15872
16587
|
};
|
|
15873
16588
|
}
|
|
15874
16589
|
};
|
|
15875
16590
|
}
|
|
16591
|
+
var NO_PREVIEW = {
|
|
16592
|
+
ref: null,
|
|
16593
|
+
artifactId: null
|
|
16594
|
+
};
|
|
15876
16595
|
/**
|
|
15877
16596
|
* The preview half. Best-effort at every step and never throws: the picture is
|
|
15878
16597
|
* an enhancement, the video is the notification.
|
|
15879
16598
|
*/
|
|
15880
16599
|
async function publishPreview(ports, input) {
|
|
15881
16600
|
const render = ports.renderPreview;
|
|
15882
|
-
if (render === void 0 || input.previewText === void 0) return
|
|
16601
|
+
if (render === void 0 || input.previewText === void 0) return NO_PREVIEW;
|
|
15883
16602
|
try {
|
|
15884
16603
|
const rendered = await render({
|
|
15885
16604
|
mp4: input.mp4,
|
|
@@ -15887,23 +16606,29 @@ async function publishPreview(ports, input) {
|
|
|
15887
16606
|
deviceId: input.deviceId,
|
|
15888
16607
|
mode: input.previewMode
|
|
15889
16608
|
});
|
|
15890
|
-
if (rendered === null) return
|
|
15891
|
-
const url = await ports.publish(rendered.bytes, rendered.mime);
|
|
16609
|
+
if (rendered === null) return NO_PREVIEW;
|
|
16610
|
+
const { url, artifactId } = await ports.publish(rendered.bytes, rendered.mime, input.meta);
|
|
15892
16611
|
if (url === null) {
|
|
15893
16612
|
ports.logger.warn("timelapse preview not published: no reachable public endpoint", { tags: input.tags });
|
|
15894
|
-
return
|
|
16613
|
+
return {
|
|
16614
|
+
ref: null,
|
|
16615
|
+
artifactId
|
|
16616
|
+
};
|
|
15895
16617
|
}
|
|
15896
16618
|
return {
|
|
15897
|
-
|
|
15898
|
-
|
|
15899
|
-
|
|
16619
|
+
ref: {
|
|
16620
|
+
url,
|
|
16621
|
+
mime: rendered.mime,
|
|
16622
|
+
name: PREVIEW_NAME_BY_MIME[rendered.mime] ?? PREVIEW_FALLBACK_NAME
|
|
16623
|
+
},
|
|
16624
|
+
artifactId
|
|
15900
16625
|
};
|
|
15901
16626
|
} catch (err) {
|
|
15902
16627
|
ports.logger.warn("timelapse preview not published: publish threw", {
|
|
15903
16628
|
tags: input.tags,
|
|
15904
16629
|
meta: { error: String(err) }
|
|
15905
16630
|
});
|
|
15906
|
-
return
|
|
16631
|
+
return NO_PREVIEW;
|
|
15907
16632
|
}
|
|
15908
16633
|
}
|
|
15909
16634
|
/** Title size as a fraction of the frame WIDTH — so it reads the same on a
|
|
@@ -16390,6 +17115,10 @@ function startAlarmPanelConvergence(ports) {
|
|
|
16390
17115
|
* `data-plane-access.spec.ts` asserts they still match the constants their
|
|
16391
17116
|
* handlers register with — a mirror nothing checks is a lie waiting to happen.
|
|
16392
17117
|
*/
|
|
17118
|
+
/** The artefact read route's prefix, imported rather than mirrored: it is
|
|
17119
|
+
* ALSO the prefix `artifactDownloadPath` writes into every index row, and two
|
|
17120
|
+
* copies of that string is a 404 nobody would find. */
|
|
17121
|
+
var NC_ARTIFACT_READ_PREFIX_REF = NC_ARTIFACT_READ_PREFIX;
|
|
16393
17122
|
var TRAINING_EXPORT_PREFIX_REF = "training-export";
|
|
16394
17123
|
var RETRAIN_EXPORT_PREFIX_REF = "retrain-export";
|
|
16395
17124
|
var DEBUG_EXPORT_PREFIX_REF = "debug-export";
|
|
@@ -16405,6 +17134,14 @@ var PIPELINE_ANALYTICS_PLANE_ACCESS = {
|
|
|
16405
17134
|
"nc-action": "public",
|
|
16406
17135
|
/** Self-authenticating single-use artifact token (D47). */
|
|
16407
17136
|
"nc-artifact": "public",
|
|
17137
|
+
/**
|
|
17138
|
+
* The VIEWER's read of the same bytes (task #36). One indexed artefact by an
|
|
17139
|
+
* unguessable id the caller was handed by `nc.listArtifacts` — the rule above,
|
|
17140
|
+
* applied unchanged. It is deliberately NOT the `public` signed plane: the
|
|
17141
|
+
* viewer holds a session, and pre-minting a 24 h signed link for every
|
|
17142
|
+
* artefact a user might scroll to is a link-leak surface for no gain.
|
|
17143
|
+
*/
|
|
17144
|
+
[NC_ARTIFACT_READ_PREFIX_REF]: "authenticated",
|
|
16408
17145
|
/** Every frame behind a marked selection — a corpus, not an item. */
|
|
16409
17146
|
[TRAINING_EXPORT_PREFIX_REF]: "admin",
|
|
16410
17147
|
[RETRAIN_EXPORT_PREFIX_REF]: "admin",
|
|
@@ -36070,6 +36807,138 @@ var BindingCache = class {
|
|
|
36070
36807
|
}
|
|
36071
36808
|
};
|
|
36072
36809
|
//#endregion
|
|
36810
|
+
//#region src/pipeline-analytics/runtime/detection-coverage-cache.ts
|
|
36811
|
+
/**
|
|
36812
|
+
* DetectionCoverageCache — fleet-seeded, event-invalidated mirror of WHICH
|
|
36813
|
+
* devices carry a WRAPPED `detection-pipeline` binding. This is the alarm's
|
|
36814
|
+
* coverage authority ("Away armed, 12 cameras" is false if three of them are
|
|
36815
|
+
* offline or no longer wrapped), read by the notification centre's device
|
|
36816
|
+
* directory (`listDeviceIdentities`).
|
|
36817
|
+
*
|
|
36818
|
+
* WHY a mirror, not a per-tick sweep. The directory refreshes on the NC's 30 s
|
|
36819
|
+
* rule-reload tick. Answering "who still has detection?" with a fleet-wide
|
|
36820
|
+
* `getAllBindings` on EVERY tick cost 903 ms / 373 KB / ~505 `getBindings:
|
|
36821
|
+
* default wrappers skipped` log lines on the live hub (524 devices), forever —
|
|
36822
|
+
* ~3% of hub-main's main thread plus a permanent log-spam baseline. Same defect
|
|
36823
|
+
* and fix as `SystemMirror.refreshBinding` (639073e8): ONE fleet read to warm,
|
|
36824
|
+
* then per-device updates driven by `DeviceBindingsChanged` events. A wrapper
|
|
36825
|
+
* toggle emits that event carrying the device's id and `capName`
|
|
36826
|
+
* (`detection-pipeline`), so a single `getBindings(deviceId)` — 21 ms / 613 B —
|
|
36827
|
+
* keeps the mirror current without ever touching the fleet again.
|
|
36828
|
+
*
|
|
36829
|
+
* ── Destructive-direction discipline (D49) ──────────────────────────────────
|
|
36830
|
+
*
|
|
36831
|
+
* `detectionActive: false` reports a camera as UNCOVERED / switched-off (D62) —
|
|
36832
|
+
* the destructive answer, and the one an operator reads as "my alarm is not
|
|
36833
|
+
* watching that door". A transient `getBindings` blip must never flip a
|
|
36834
|
+
* known-covered camera to uncovered:
|
|
36835
|
+
* • a failed refresh KEEPS the previous value (a read that throws changes
|
|
36836
|
+
* nothing), and
|
|
36837
|
+
* • a single read that would flip a known `true → false` is confirmed by a
|
|
36838
|
+
* concordant SECOND read before it lands — an empty binding list is not
|
|
36839
|
+
* proof the wrapper is gone.
|
|
36840
|
+
* A COLD mirror (seed not yet landed, or a seed that failed) answers `undefined`
|
|
36841
|
+
* for every id — and unknown never reports a camera as excluded from coverage.
|
|
36842
|
+
*/
|
|
36843
|
+
var DetectionCoverageCache = class {
|
|
36844
|
+
api;
|
|
36845
|
+
logger;
|
|
36846
|
+
/** deviceId → "has a WRAPPED detection-pipeline binding". Absent = unknown. */
|
|
36847
|
+
state = /* @__PURE__ */ new Map();
|
|
36848
|
+
warm = false;
|
|
36849
|
+
seeding = null;
|
|
36850
|
+
constructor(deps) {
|
|
36851
|
+
this.api = deps.api;
|
|
36852
|
+
this.logger = deps.logger;
|
|
36853
|
+
}
|
|
36854
|
+
/** Has the ONE fleet seed ever landed? A cold mirror knows nothing. */
|
|
36855
|
+
isWarm() {
|
|
36856
|
+
return this.warm;
|
|
36857
|
+
}
|
|
36858
|
+
/** Do we have an answer for this id at all? (`false` ⇒ absent ⇒ unknown.) */
|
|
36859
|
+
has(deviceId) {
|
|
36860
|
+
return this.state.has(deviceId);
|
|
36861
|
+
}
|
|
36862
|
+
/** The coverage answer, or `undefined` when this id is unknown. No I/O. */
|
|
36863
|
+
get(deviceId) {
|
|
36864
|
+
return this.state.get(deviceId);
|
|
36865
|
+
}
|
|
36866
|
+
/**
|
|
36867
|
+
* Seed the whole fleet ONCE with a single `getAllBindings`. Idempotent: a
|
|
36868
|
+
* warm cache is a no-op, and concurrent callers coalesce onto one read. A
|
|
36869
|
+
* failed seed leaves the mirror COLD so the next tick retries — during that
|
|
36870
|
+
* window every id answers unknown, which never reports a camera as excluded.
|
|
36871
|
+
*/
|
|
36872
|
+
async ensureWarm() {
|
|
36873
|
+
if (this.warm) return;
|
|
36874
|
+
if (this.seeding) return this.seeding;
|
|
36875
|
+
this.seeding = this.seed();
|
|
36876
|
+
try {
|
|
36877
|
+
await this.seeding;
|
|
36878
|
+
} finally {
|
|
36879
|
+
this.seeding = null;
|
|
36880
|
+
}
|
|
36881
|
+
}
|
|
36882
|
+
async seed() {
|
|
36883
|
+
try {
|
|
36884
|
+
const rows = await this.api.deviceManager.getAllBindings.query({});
|
|
36885
|
+
for (const row of rows) {
|
|
36886
|
+
if (this.state.has(row.deviceId)) continue;
|
|
36887
|
+
this.state.set(row.deviceId, hasWrappedDetection(row.entries));
|
|
36888
|
+
}
|
|
36889
|
+
this.warm = true;
|
|
36890
|
+
this.logger.info("detection-coverage mirror warm", { meta: { devices: this.state.size } });
|
|
36891
|
+
} catch (err) {
|
|
36892
|
+
this.logger.warn("detection-coverage seed failed — coverage unknown until it lands", { meta: { error: String(err) } });
|
|
36893
|
+
}
|
|
36894
|
+
}
|
|
36895
|
+
/**
|
|
36896
|
+
* A `DeviceBindingsChanged` for the detection cap re-reads THAT ONE device
|
|
36897
|
+
* (never the fleet). Fire-and-forget from the synchronous event handler.
|
|
36898
|
+
*/
|
|
36899
|
+
onBindingsChanged(event) {
|
|
36900
|
+
if (event.capName !== "detection-pipeline") return Promise.resolve();
|
|
36901
|
+
return this.refreshDevice(event.deviceId);
|
|
36902
|
+
}
|
|
36903
|
+
/** Drop a device the fleet no longer has (`device.unregistered`). */
|
|
36904
|
+
forget(deviceId) {
|
|
36905
|
+
this.state.delete(deviceId);
|
|
36906
|
+
}
|
|
36907
|
+
/** Return to cold — used on addon shutdown. */
|
|
36908
|
+
clearAll() {
|
|
36909
|
+
this.state.clear();
|
|
36910
|
+
this.warm = false;
|
|
36911
|
+
this.seeding = null;
|
|
36912
|
+
}
|
|
36913
|
+
async refreshDevice(deviceId) {
|
|
36914
|
+
try {
|
|
36915
|
+
const active = await this.readWrappedDetection(deviceId);
|
|
36916
|
+
const prev = this.state.get(deviceId);
|
|
36917
|
+
if (!active && prev === true) {
|
|
36918
|
+
if (await this.readWrappedDetection(deviceId)) {
|
|
36919
|
+
this.state.set(deviceId, true);
|
|
36920
|
+
return;
|
|
36921
|
+
}
|
|
36922
|
+
}
|
|
36923
|
+
this.state.set(deviceId, active);
|
|
36924
|
+
} catch (err) {
|
|
36925
|
+
this.logger.debug("detection-coverage refresh failed — keeping the previous value", {
|
|
36926
|
+
tags: { deviceId },
|
|
36927
|
+
meta: { error: String(err) }
|
|
36928
|
+
});
|
|
36929
|
+
}
|
|
36930
|
+
}
|
|
36931
|
+
async readWrappedDetection(deviceId) {
|
|
36932
|
+
return hasWrappedDetection((await this.api.deviceManager.getBindings.query({ deviceId })).entries);
|
|
36933
|
+
}
|
|
36934
|
+
};
|
|
36935
|
+
/** The one authority: a WRAPPED `detection-pipeline` binding is present. A
|
|
36936
|
+
* native binding of the same cap is NOT coverage (D62: absence of the WRAPPED
|
|
36937
|
+
* binding is how a deactivated wrapper is represented). */
|
|
36938
|
+
function hasWrappedDetection(entries) {
|
|
36939
|
+
return entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped");
|
|
36940
|
+
}
|
|
36941
|
+
//#endregion
|
|
36073
36942
|
//#region src/pipeline-analytics/runtime/capture-scheduler.ts
|
|
36074
36943
|
/**
|
|
36075
36944
|
* Kind-derived default priorities (0 = highest). Explicit `priority` wins.
|
|
@@ -41215,6 +42084,15 @@ var REBUILD_FALLBACK_NODE_ID = "hub";
|
|
|
41215
42084
|
*/
|
|
41216
42085
|
var TIMELAPSE_TRACK_QUERY_LIMIT = 2e3;
|
|
41217
42086
|
var PIPELINE_ANALYTICS_ADDON_ID = "pipeline-analytics";
|
|
42087
|
+
/** Artefact index ⇄ shelf reconcile cadence. Hourly: the two only drift on the
|
|
42088
|
+
* bounds' own timescale (14 days / 2000 entries / 2 GiB). */
|
|
42089
|
+
var NC_ARTIFACT_PRUNE_INTERVAL_MS = 36e5;
|
|
42090
|
+
/** What a publish answers on a node whose artifact plane never came up: no
|
|
42091
|
+
* link, no index row — and, exactly as before, bytes-only delivery. */
|
|
42092
|
+
var NO_PUBLICATION = {
|
|
42093
|
+
url: null,
|
|
42094
|
+
artifactId: null
|
|
42095
|
+
};
|
|
41218
42096
|
/**
|
|
41219
42097
|
* The FULL action catalog for this addon.
|
|
41220
42098
|
*
|
|
@@ -41452,6 +42330,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
41452
42330
|
* path). Null on non-post-processing nodes or when disabled. */
|
|
41453
42331
|
embeddingDispatcher = null;
|
|
41454
42332
|
bindingCache = null;
|
|
42333
|
+
/** Alarm-coverage mirror the NC device directory reads — WHICH devices carry
|
|
42334
|
+
* a wrapped `detection-pipeline` binding. Fleet-seeded ONCE, then per-device
|
|
42335
|
+
* updates off `DeviceBindingsChanged`, so the 30 s rule-reload tick stops
|
|
42336
|
+
* sweeping `getAllBindings` over the whole fleet. See
|
|
42337
|
+
* `runtime/detection-coverage-cache.ts`. */
|
|
42338
|
+
detectionCoverage = null;
|
|
41455
42339
|
/** Per-device throttle for the inactive-binding frame-drop warn (5 min). */
|
|
41456
42340
|
inactiveBindingDropWarnAt = /* @__PURE__ */ new Map();
|
|
41457
42341
|
zoneAnalytics = null;
|
|
@@ -41490,6 +42374,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
41490
42374
|
* data-plane handle. Null until served / when the facility is absent. */
|
|
41491
42375
|
ncArtifactPlane = null;
|
|
41492
42376
|
ncArtifactDataPlane = null;
|
|
42377
|
+
/** The viewer's artefact library (task #36): the index, its authenticated
|
|
42378
|
+
* bytes route, and the store the two share. Null until served. */
|
|
42379
|
+
ncArtifactIndex = null;
|
|
42380
|
+
ncArtifactStore = null;
|
|
42381
|
+
ncArtifactReadDataPlane = null;
|
|
42382
|
+
ncArtifactPruneTimer = null;
|
|
41493
42383
|
/**
|
|
41494
42384
|
* Roadmap 4b: ONE cut of an event, in every container the rule asked for.
|
|
41495
42385
|
*
|
|
@@ -41676,6 +42566,31 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
41676
42566
|
* Best-effort at every step: no facility, no secret or no reachable base URL
|
|
41677
42567
|
* ⇒ no plane, and the dispatcher keeps shipping bytes exactly as before.
|
|
41678
42568
|
*/
|
|
42569
|
+
/**
|
|
42570
|
+
* Reconcile the artefact index against the shelf that holds the bytes.
|
|
42571
|
+
*
|
|
42572
|
+
* Hourly, because the two drift only on the bounds' timescale: a row expires
|
|
42573
|
+
* on the shelf's own age bound (14 d) or its file is evicted by the count/byte
|
|
42574
|
+
* bound. Without this, a list eventually serves rows whose bytes 404 — and a
|
|
42575
|
+
* broken tile is the one failure an operator DOES report.
|
|
42576
|
+
*/
|
|
42577
|
+
startNcArtifactPrune() {
|
|
42578
|
+
if (this.ncArtifactPruneTimer !== null) return;
|
|
42579
|
+
const run = async () => {
|
|
42580
|
+
const index = this.ncArtifactIndex;
|
|
42581
|
+
const store = this.ncArtifactStore;
|
|
42582
|
+
if (index === null || store === null) return;
|
|
42583
|
+
try {
|
|
42584
|
+
await store.sweep();
|
|
42585
|
+
await index.prune(this.isPostProcessingNode ? await store.keptIds() : null);
|
|
42586
|
+
} catch (err) {
|
|
42587
|
+
this.ctx.logger.warn("nc artifact prune failed", { meta: { error: require_dist.errMsg(err) } });
|
|
42588
|
+
}
|
|
42589
|
+
};
|
|
42590
|
+
run();
|
|
42591
|
+
this.ncArtifactPruneTimer = setInterval(() => void run(), NC_ARTIFACT_PRUNE_INTERVAL_MS);
|
|
42592
|
+
this.ncArtifactPruneTimer.unref?.();
|
|
42593
|
+
}
|
|
41679
42594
|
async serveNcArtifactPlane() {
|
|
41680
42595
|
try {
|
|
41681
42596
|
const secretState = this.state("ncArtifactSecret", NcArtifactSecretSchema, "");
|
|
@@ -41689,9 +42604,18 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
41689
42604
|
logger: this.ctx.logger.child("nc-artifacts")
|
|
41690
42605
|
});
|
|
41691
42606
|
await store.start();
|
|
42607
|
+
this.ncArtifactStore = store;
|
|
42608
|
+
const index = new NcArtifactIndex({
|
|
42609
|
+
store: this.ctx.api.settingsStore,
|
|
42610
|
+
logger: this.ctx.logger.child("nc-artifacts"),
|
|
42611
|
+
addonId: this.ctx.id,
|
|
42612
|
+
retentionMs: store.retentionMs
|
|
42613
|
+
});
|
|
42614
|
+
this.ncArtifactIndex = index;
|
|
41692
42615
|
const plane = new NcArtifactPlane({
|
|
41693
42616
|
store,
|
|
41694
42617
|
secret,
|
|
42618
|
+
index,
|
|
41695
42619
|
logger: this.ctx.logger.child("nc-artifacts"),
|
|
41696
42620
|
routePrefix: `/addon/${this.ctx.id}/nc-artifact`,
|
|
41697
42621
|
listEndpoints: () => this.ncEndpoints()
|
|
@@ -41702,7 +42626,23 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
41702
42626
|
handler: plane.handler
|
|
41703
42627
|
}) ?? null;
|
|
41704
42628
|
this.ncArtifactPlane = this.ncArtifactDataPlane !== null ? plane : null;
|
|
41705
|
-
|
|
42629
|
+
const readPlane = new NcArtifactReadPlane({
|
|
42630
|
+
index,
|
|
42631
|
+
store,
|
|
42632
|
+
logger: this.ctx.logger.child("nc-artifacts")
|
|
42633
|
+
});
|
|
42634
|
+
this.ncArtifactReadDataPlane = await this.ctx.dataPlane?.serve({
|
|
42635
|
+
prefix: NC_ARTIFACT_READ_PREFIX_REF,
|
|
42636
|
+
access: planeAccess(NC_ARTIFACT_READ_PREFIX_REF),
|
|
42637
|
+
handler: readPlane.handler
|
|
42638
|
+
}) ?? null;
|
|
42639
|
+
this.startNcArtifactPrune();
|
|
42640
|
+
this.ctx.logger.info("nc-artifact data-plane served", { meta: {
|
|
42641
|
+
served: this.ncArtifactPlane !== null,
|
|
42642
|
+
libraryServed: this.ncArtifactReadDataPlane !== null,
|
|
42643
|
+
libraryAccess: planeAccess(NC_ARTIFACT_READ_PREFIX_REF),
|
|
42644
|
+
retentionDays: Math.round(store.retentionMs / 864e5)
|
|
42645
|
+
} });
|
|
41706
42646
|
} catch (err) {
|
|
41707
42647
|
this.ctx.logger.warn("nc-artifact data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
|
|
41708
42648
|
}
|
|
@@ -42150,7 +43090,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
42150
43090
|
store: center.summaryStore,
|
|
42151
43091
|
runNow: (input) => center.runSummaryNow(input)
|
|
42152
43092
|
},
|
|
42153
|
-
texts: center.textCatalogEditor
|
|
43093
|
+
texts: center.textCatalogEditor,
|
|
43094
|
+
...this.ncArtifactIndex !== null ? { artifacts: this.ncArtifactIndex } : {}
|
|
42154
43095
|
});
|
|
42155
43096
|
}
|
|
42156
43097
|
/** Declare typed collections up-front so the first insert doesn't race with
|
|
@@ -42635,6 +43576,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
42635
43576
|
api,
|
|
42636
43577
|
logger: logger.child("BindingCache")
|
|
42637
43578
|
});
|
|
43579
|
+
this.detectionCoverage = new DetectionCoverageCache({
|
|
43580
|
+
api,
|
|
43581
|
+
logger: logger.child("DetectionCoverage")
|
|
43582
|
+
});
|
|
42638
43583
|
const zoneAnalytics = new ZoneAnalyticsProvider({
|
|
42639
43584
|
logger: logger.child("ZoneAnalytics"),
|
|
42640
43585
|
onOccupancySample: (deviceId, count, at, zoneId) => this.lts?.note("occupancy", deviceId, count, at, zoneId ?? ""),
|
|
@@ -42716,17 +43661,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
42716
43661
|
},
|
|
42717
43662
|
listDeviceIdentities: async () => {
|
|
42718
43663
|
const all = await api.deviceManager.listAll.query({ projection: "slim" });
|
|
42719
|
-
const
|
|
42720
|
-
|
|
42721
|
-
return /* @__PURE__ */ new Map();
|
|
42722
|
-
});
|
|
43664
|
+
const coverage = this.detectionCoverage;
|
|
43665
|
+
if (coverage) await coverage.ensureWarm();
|
|
42723
43666
|
return all.map((d) => ({
|
|
42724
43667
|
deviceId: d.id,
|
|
42725
43668
|
type: d.type,
|
|
42726
43669
|
name: d.name,
|
|
42727
43670
|
isCamera: d.isCamera,
|
|
42728
43671
|
online: d.online && !d.disabled,
|
|
42729
|
-
...
|
|
43672
|
+
...coverage?.has(d.id) ? { detectionActive: coverage.get(d.id) === true } : {}
|
|
42730
43673
|
}));
|
|
42731
43674
|
},
|
|
42732
43675
|
readDeviceZoneIds: async (ids) => {
|
|
@@ -42937,7 +43880,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
42937
43880
|
timestamp,
|
|
42938
43881
|
data: jpeg
|
|
42939
43882
|
}),
|
|
42940
|
-
publish: async (bytes, mime) => await this.ncArtifactPlane?.
|
|
43883
|
+
publish: async (bytes, mime, meta) => await this.ncArtifactPlane?.publishIndexed(bytes, mime, meta) ?? NO_PUBLICATION
|
|
42941
43884
|
};
|
|
42942
43885
|
}
|
|
42943
43886
|
/**
|
|
@@ -43010,7 +43953,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
43010
43953
|
publishArtifact: buildTimelapseArtifactPublisher({
|
|
43011
43954
|
logger: this.ctx.logger.child("Timelapse"),
|
|
43012
43955
|
readExportBytes: ({ exportId }) => api.recordingExport.readExportBytes.query({ exportId }),
|
|
43013
|
-
publish: async (bytes, mime) => await this.ncArtifactPlane?.
|
|
43956
|
+
publish: async (bytes, mime, meta) => await this.ncArtifactPlane?.publishIndexed(bytes, mime, meta) ?? NO_PUBLICATION,
|
|
43014
43957
|
renderPreview: async ({ mode, ...input }) => {
|
|
43015
43958
|
const previewPorts = {
|
|
43016
43959
|
logger: this.ctx.logger.child("Timelapse"),
|
|
@@ -43434,6 +44377,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
43434
44377
|
* `runtime/wire-subscriptions.ts`. */
|
|
43435
44378
|
handleBindingsChangedEvent(data) {
|
|
43436
44379
|
this.bindingCache?.onBindingsChanged(data);
|
|
44380
|
+
this.detectionCoverage?.onBindingsChanged(data);
|
|
43437
44381
|
if (data.capName === "pipeline-analytics" && data.reason === "wrapper-deactivated") {
|
|
43438
44382
|
this.trackStore?.clearDevice(data.deviceId);
|
|
43439
44383
|
this.residents.closeDevice(data.deviceId);
|
|
@@ -43459,6 +44403,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
43459
44403
|
this.audioMarkers?.forgetDevice(deviceId);
|
|
43460
44404
|
this.settingsCaches.clearDevice(deviceId);
|
|
43461
44405
|
this.bindingCache?.invalidate(deviceId);
|
|
44406
|
+
this.detectionCoverage?.forget(deviceId);
|
|
43462
44407
|
this.zoneAnalytics?.forgetDevice(deviceId);
|
|
43463
44408
|
this.audioMetrics?.forgetDevice(deviceId);
|
|
43464
44409
|
this.releaseProxy(deviceId);
|
|
@@ -43664,6 +44609,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
43664
44609
|
this.trackStore?.clearAll();
|
|
43665
44610
|
this.stationaryRegistry = null;
|
|
43666
44611
|
this.bindingCache?.clearAll();
|
|
44612
|
+
this.detectionCoverage?.clearAll();
|
|
43667
44613
|
await this.eventMediaDataPlane?.dispose();
|
|
43668
44614
|
this.eventMediaDataPlane = null;
|
|
43669
44615
|
this.eventMediaBaseUrl = null;
|
|
@@ -43674,6 +44620,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
43674
44620
|
this.retrainExportDataPlane = null;
|
|
43675
44621
|
await this.debugExportDataPlane?.dispose();
|
|
43676
44622
|
this.debugExportDataPlane = null;
|
|
44623
|
+
if (this.ncArtifactPruneTimer !== null) {
|
|
44624
|
+
clearInterval(this.ncArtifactPruneTimer);
|
|
44625
|
+
this.ncArtifactPruneTimer = null;
|
|
44626
|
+
}
|
|
44627
|
+
await this.ncArtifactReadDataPlane?.dispose();
|
|
44628
|
+
this.ncArtifactReadDataPlane = null;
|
|
44629
|
+
this.ncArtifactIndex = null;
|
|
44630
|
+
this.ncArtifactStore = null;
|
|
43677
44631
|
}
|
|
43678
44632
|
async handleInferenceResult(data) {
|
|
43679
44633
|
if (this.shuttingDown) return;
|