@camstack/addon-terminal 0.1.11 → 0.1.12
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/addon.js +1053 -210
- package/dist/addon.mjs +1053 -210
- package/package.json +2 -2
package/dist/addon.mjs
CHANGED
|
@@ -7605,12 +7605,11 @@ var RecordingConfigSchema = object({
|
|
|
7605
7605
|
/**
|
|
7606
7606
|
* Entity-relocation job state (storage entity-routing spec, Phase 4).
|
|
7607
7607
|
*
|
|
7608
|
-
* One shape shared by the recorder
|
|
7609
|
-
*
|
|
7610
|
-
*
|
|
7611
|
-
*
|
|
7612
|
-
*
|
|
7613
|
-
* row on the owning addon's surface.
|
|
7608
|
+
* One shape shared by the recorder and pipeline-analytics internal movers.
|
|
7609
|
+
* The public admin surface is `storage-migration`; child jobs remain in RAM
|
|
7610
|
+
* because copy-if-absent, verify, delete and index/row repoint are resumable.
|
|
7611
|
+
* Each completed/failed run also lands one durable ops-log row on its owning
|
|
7612
|
+
* addon surface.
|
|
7614
7613
|
*/
|
|
7615
7614
|
var RelocateJobStateSchema = _enum([
|
|
7616
7615
|
"running",
|
|
@@ -7637,19 +7636,100 @@ var RelocateJobSchema = object({
|
|
|
7637
7636
|
finishedAt: number().nullable(),
|
|
7638
7637
|
error: string().nullable()
|
|
7639
7638
|
});
|
|
7639
|
+
/** Profile-derived footage selection used only by the migration coordinator:
|
|
7640
|
+
* `recordings` owns high+mid; `recordingsLow` owns low. */
|
|
7641
|
+
var RelocateFootageClassSchema = _enum(["recordings", "recordingsLow"]);
|
|
7640
7642
|
var RelocateFootageInputSchema = object({
|
|
7641
|
-
deviceId: number().optional(),
|
|
7642
7643
|
fromLocationId: string(),
|
|
7643
7644
|
toLocationId: string(),
|
|
7644
7645
|
entities: array(_enum(["segments"])).optional(),
|
|
7646
|
+
/** Limits relocation to the logical profile class. Omit only for the
|
|
7647
|
+
* pre-orchestration compatibility path. */
|
|
7648
|
+
footageClass: RelocateFootageClassSchema.optional(),
|
|
7645
7649
|
/** Copy throttle in MB/s (default 40) — the drain is a background chore,
|
|
7646
7650
|
* never allowed to starve live writers. */
|
|
7647
7651
|
throttleMbps: number().min(1).max(1e3).optional()
|
|
7648
7652
|
});
|
|
7649
|
-
|
|
7650
|
-
|
|
7653
|
+
/** Internal, lease-scoped participant operation. It is intentionally separate
|
|
7654
|
+
* from persistent recording settings: a migration never changes
|
|
7655
|
+
* `RecordingConfig.enabled` or camera wrapper bindings. */
|
|
7656
|
+
var StorageMigrationLeaseInputSchema = object({ leaseId: string().min(1) });
|
|
7657
|
+
var StorageMigrationFootageMoveInputSchema = RelocateFootageInputSchema.extend({ leaseId: string().min(1) });
|
|
7658
|
+
var StorageMigrationMediaMoveInputSchema = object({
|
|
7651
7659
|
toLocationId: string(),
|
|
7652
7660
|
throttleMbps: number().min(1).max(1e3).optional()
|
|
7661
|
+
}).extend({ leaseId: string().min(1) });
|
|
7662
|
+
/** The independently selectable logical storage classes. `recordings`
|
|
7663
|
+
* encompasses the high and mid segment profiles; `recordingsLow` is low
|
|
7664
|
+
* segments; `eventMedia` is post-analysis blobs. */
|
|
7665
|
+
var StorageMigrationClassSchema = _enum([
|
|
7666
|
+
"recordings",
|
|
7667
|
+
"recordingsLow",
|
|
7668
|
+
"eventMedia"
|
|
7669
|
+
]);
|
|
7670
|
+
/** A destination is always an existing, fully-qualified location id. The
|
|
7671
|
+
* migration API intentionally never changes a source location's `basePath`:
|
|
7672
|
+
* callers create a new `<type>:<slug>` location, then select it here. */
|
|
7673
|
+
var StorageMigrationDestinationsSchema = object({
|
|
7674
|
+
recordings: string().min(1).optional(),
|
|
7675
|
+
recordingsLow: string().min(1).optional(),
|
|
7676
|
+
eventMedia: string().min(1).optional()
|
|
7677
|
+
}).refine((value) => Object.keys(value).length > 0, { message: "select at least one storage class" });
|
|
7678
|
+
/** Shared input for planning and starting an orchestrated storage migration. */
|
|
7679
|
+
var StorageMigrationInputSchema = object({
|
|
7680
|
+
destinations: StorageMigrationDestinationsSchema,
|
|
7681
|
+
throttleMbps: number().min(1).max(1e3).optional()
|
|
7682
|
+
});
|
|
7683
|
+
/** The durable coordinator state machine. The only phase that changes default
|
|
7684
|
+
* locations is `repointing`, after every selected mover has completed and been
|
|
7685
|
+
* verified. */
|
|
7686
|
+
var StorageMigrationPhaseSchema = _enum([
|
|
7687
|
+
"planning",
|
|
7688
|
+
"pausing",
|
|
7689
|
+
"moving",
|
|
7690
|
+
"verifying",
|
|
7691
|
+
"repointing",
|
|
7692
|
+
"refreshing",
|
|
7693
|
+
"resuming",
|
|
7694
|
+
"done",
|
|
7695
|
+
"failed",
|
|
7696
|
+
"cancelled"
|
|
7697
|
+
]);
|
|
7698
|
+
var StorageMigrationParticipantSchema = _enum([
|
|
7699
|
+
"pipeline",
|
|
7700
|
+
"recorder",
|
|
7701
|
+
"analytics"
|
|
7702
|
+
]);
|
|
7703
|
+
var StorageMigrationMoveSchema = object({
|
|
7704
|
+
storageClass: StorageMigrationClassSchema,
|
|
7705
|
+
fromLocationId: string(),
|
|
7706
|
+
toLocationId: string(),
|
|
7707
|
+
moverJobId: string().nullable(),
|
|
7708
|
+
state: RelocateJobStateSchema.nullable(),
|
|
7709
|
+
error: string().nullable()
|
|
7710
|
+
});
|
|
7711
|
+
var StorageMigrationJobSchema = object({
|
|
7712
|
+
jobId: string(),
|
|
7713
|
+
phase: StorageMigrationPhaseSchema,
|
|
7714
|
+
destinations: StorageMigrationDestinationsSchema,
|
|
7715
|
+
throttleMbps: number(),
|
|
7716
|
+
moves: array(StorageMigrationMoveSchema),
|
|
7717
|
+
pauseLeaseId: string().nullable(),
|
|
7718
|
+
pausedParticipants: array(StorageMigrationParticipantSchema),
|
|
7719
|
+
repointed: boolean(),
|
|
7720
|
+
cancelRequested: boolean(),
|
|
7721
|
+
startedAt: number(),
|
|
7722
|
+
updatedAt: number(),
|
|
7723
|
+
finishedAt: number().nullable(),
|
|
7724
|
+
error: string().nullable()
|
|
7725
|
+
});
|
|
7726
|
+
var StorageMigrationPlanSchema = object({
|
|
7727
|
+
destinations: StorageMigrationDestinationsSchema,
|
|
7728
|
+
moves: array(object({
|
|
7729
|
+
storageClass: StorageMigrationClassSchema,
|
|
7730
|
+
fromLocationId: string(),
|
|
7731
|
+
toLocationId: string()
|
|
7732
|
+
}))
|
|
7653
7733
|
});
|
|
7654
7734
|
/**
|
|
7655
7735
|
* `StorageLocationType` — an addon-declared id that identifies the *kind* of
|
|
@@ -16248,13 +16328,19 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
|
|
|
16248
16328
|
}), method(object({ deviceId: number() }), EventPruneCountsSchema, {
|
|
16249
16329
|
kind: "mutation",
|
|
16250
16330
|
auth: "admin"
|
|
16251
|
-
}), method(
|
|
16331
|
+
}), method(StorageMigrationLeaseInputSchema, object({ paused: literal(true) }), {
|
|
16252
16332
|
kind: "mutation",
|
|
16253
16333
|
auth: "admin"
|
|
16254
|
-
}), method(object({
|
|
16255
|
-
kind: "
|
|
16334
|
+
}), method(StorageMigrationLeaseInputSchema, object({ resumed: literal(true) }), {
|
|
16335
|
+
kind: "mutation",
|
|
16336
|
+
auth: "admin"
|
|
16337
|
+
}), method(StorageMigrationLeaseInputSchema, object({ refreshed: literal(true) }), {
|
|
16338
|
+
kind: "mutation",
|
|
16256
16339
|
auth: "admin"
|
|
16257
|
-
}), method(object({ jobId: string() }),
|
|
16340
|
+
}), method(StorageMigrationMediaMoveInputSchema, object({ jobId: string() }), {
|
|
16341
|
+
kind: "mutation",
|
|
16342
|
+
auth: "admin"
|
|
16343
|
+
}), method(object({ jobId: string() }), RelocateJobSchema.nullable(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
|
|
16258
16344
|
kind: "mutation",
|
|
16259
16345
|
auth: "admin"
|
|
16260
16346
|
}), method(OpsLogQueryInputSchema, array(OpsLogEntrySchema).readonly(), {
|
|
@@ -17784,7 +17870,13 @@ var NodeInferenceDevicesSchema = object({
|
|
|
17784
17870
|
reachable: boolean(),
|
|
17785
17871
|
devices: array(NodeInferenceDeviceSchema).readonly()
|
|
17786
17872
|
});
|
|
17787
|
-
method(object({
|
|
17873
|
+
method(StorageMigrationLeaseInputSchema, object({ paused: literal(true) }), {
|
|
17874
|
+
kind: "mutation",
|
|
17875
|
+
auth: "admin"
|
|
17876
|
+
}), method(StorageMigrationLeaseInputSchema, object({ resumed: literal(true) }), {
|
|
17877
|
+
kind: "mutation",
|
|
17878
|
+
auth: "admin"
|
|
17879
|
+
}), method(object({
|
|
17788
17880
|
deviceId: number(),
|
|
17789
17881
|
agentNodeId: string()
|
|
17790
17882
|
}), object({ success: literal(true) }), {
|
|
@@ -18224,7 +18316,33 @@ var SnapshotImageSchema = object({
|
|
|
18224
18316
|
base64: string(),
|
|
18225
18317
|
contentType: string()
|
|
18226
18318
|
});
|
|
18227
|
-
|
|
18319
|
+
/**
|
|
18320
|
+
* snapshot — device-scoped capability for camera image capture.
|
|
18321
|
+
*
|
|
18322
|
+
* Two kinds of providers coexist behind this cap name:
|
|
18323
|
+
*
|
|
18324
|
+
* - **Native** providers (kind:'native'): registered per-device by
|
|
18325
|
+
* device-driver addons (RtspCamera, OnvifCamera, …) via
|
|
18326
|
+
* `DeviceContext.registerNativeCap`. Each knows how to fetch a frame
|
|
18327
|
+
* straight from the camera (HTTP snapshot URL, ONVIF action, etc.).
|
|
18328
|
+
*
|
|
18329
|
+
* - **Wrapper** providers (kind:'wrapper'): register as a system
|
|
18330
|
+
* provider (SnapshotAddon in `@camstack/system/builtins/snapshot`). The
|
|
18331
|
+
* wrapper owns the cache and invokes the native via
|
|
18332
|
+
* `ctx.getNativeProvider(snapshotCapability, deviceId)` on miss.
|
|
18333
|
+
*
|
|
18334
|
+
* Device-scoped routing: callers use `ctx.fetchDevice(id).snapshot.*`;
|
|
18335
|
+
* the DeviceProxy auto-injects `deviceId` + `nodeId` and dispatches to
|
|
18336
|
+
* the provider currently active for that device (wrapper wins when
|
|
18337
|
+
* activated via `setWrapperActive`, otherwise the native).
|
|
18338
|
+
*/
|
|
18339
|
+
/**
|
|
18340
|
+
* Live readable snapshot state — diagnostic info that a consumer can
|
|
18341
|
+
* pull to know when the last image was captured, how stale the cache
|
|
18342
|
+
* is, and which stream was used. Distinct from `getSnapshot` which
|
|
18343
|
+
* returns the JPEG itself.
|
|
18344
|
+
*/
|
|
18345
|
+
var SnapshotStatusSchema = object({
|
|
18228
18346
|
/** Ms epoch of the last successful capture. Null if none yet. */
|
|
18229
18347
|
lastCapturedAt: number().nullable(),
|
|
18230
18348
|
/** Age of the cached image in ms. Null if no cache. */
|
|
@@ -18234,64 +18352,129 @@ object({
|
|
|
18234
18352
|
/** Stream id used for the last capture ('high'|'mid'|'low' or custom). Null if via HTTP endpoint. */
|
|
18235
18353
|
lastStreamId: string().nullable()
|
|
18236
18354
|
});
|
|
18237
|
-
|
|
18238
|
-
|
|
18239
|
-
|
|
18240
|
-
|
|
18241
|
-
|
|
18242
|
-
|
|
18243
|
-
|
|
18244
|
-
|
|
18245
|
-
|
|
18246
|
-
|
|
18247
|
-
|
|
18248
|
-
|
|
18249
|
-
|
|
18250
|
-
|
|
18251
|
-
|
|
18252
|
-
|
|
18253
|
-
|
|
18254
|
-
|
|
18255
|
-
|
|
18256
|
-
|
|
18257
|
-
|
|
18258
|
-
|
|
18259
|
-
|
|
18260
|
-
|
|
18261
|
-
|
|
18262
|
-
|
|
18263
|
-
|
|
18264
|
-
|
|
18265
|
-
|
|
18266
|
-
|
|
18355
|
+
var snapshotCapability = {
|
|
18356
|
+
name: "snapshot",
|
|
18357
|
+
scope: "device",
|
|
18358
|
+
deviceNative: true,
|
|
18359
|
+
mode: "singleton",
|
|
18360
|
+
kind: "wrapper",
|
|
18361
|
+
defaultActive: true,
|
|
18362
|
+
deviceTypes: [DeviceType.Camera],
|
|
18363
|
+
exposesDeviceSettings: true,
|
|
18364
|
+
methods: {
|
|
18365
|
+
getSnapshot: method(object({
|
|
18366
|
+
deviceId: number(),
|
|
18367
|
+
streamId: string().optional(),
|
|
18368
|
+
/**
|
|
18369
|
+
* Bypass the cache freshness check and fetch directly from the
|
|
18370
|
+
* native (or stream-broker fallback). Triggered by the UI's
|
|
18371
|
+
* "refresh" button so an operator can force a fresh frame
|
|
18372
|
+
* even when the cache is well within the device's
|
|
18373
|
+
* `snapshotMaxAgeS` window.
|
|
18374
|
+
*
|
|
18375
|
+
* **`force` is an OPERATOR signal, not a freshness preference.** On a
|
|
18376
|
+
* battery camera it is the one thing that walks past the wrapper's
|
|
18377
|
+
* sleep gate and wakes the camera, so a background caller — a poller,
|
|
18378
|
+
* an event handler, a thumbnail — must NEVER set it. Every such caller
|
|
18379
|
+
* gets the cached frame, which on a sleeping battery camera is the
|
|
18380
|
+
* correct answer: stale but honest beats woken.
|
|
18381
|
+
*/
|
|
18382
|
+
force: boolean().optional()
|
|
18383
|
+
}), SnapshotImageSchema.nullable()),
|
|
18384
|
+
invalidateCache: method(object({ deviceId: number() }), _void(), {
|
|
18385
|
+
kind: "mutation",
|
|
18386
|
+
auth: "admin"
|
|
18387
|
+
}),
|
|
18388
|
+
/**
|
|
18389
|
+
* Cache-only batch overview — answers from the wrapper's in-memory cache in
|
|
18390
|
+
* O(n) and NEVER triggers a capture. Lets a grid skip requesting images for
|
|
18391
|
+
* devices that never produced a frame, and gives it an ETag per device for
|
|
18392
|
+
* conditional (304-able) image fetches. `lastCapturedAt`/`cacheAgeMs`/`etag`
|
|
18393
|
+
* are null for a device with no cached frame.
|
|
18394
|
+
*/
|
|
18395
|
+
getSnapshotOverview: systemMethod(object({ deviceIds: array(number()).min(1).max(200) }), array(object({
|
|
18396
|
+
deviceId: number(),
|
|
18397
|
+
lastCapturedAt: number().nullable(),
|
|
18398
|
+
cacheAgeMs: number().nullable(),
|
|
18399
|
+
etag: string().nullable()
|
|
18400
|
+
}))),
|
|
18401
|
+
/**
|
|
18402
|
+
* Signed, expiring links to a CLIENT-SIZED frame — and the demand signal
|
|
18403
|
+
* that makes those frames current.
|
|
18404
|
+
*
|
|
18405
|
+
* ## The problem it replaces
|
|
18406
|
+
*
|
|
18407
|
+
* `getSnapshotOverview` is cache-only by contract: it answers from whatever
|
|
18408
|
+
* the wrapper happens to hold and never captures. Under D93 the client
|
|
18409
|
+
* versions its image URL on that answer, and an image REQUEST is what enrols
|
|
18410
|
+
* a camera in the keep-warm loop. Both of those are satisfiable by the
|
|
18411
|
+
* client's own image cache — `expo-image` is URL-keyed and never revalidates
|
|
18412
|
+
* — so a URL painted in a previous session comes off disk with no network,
|
|
18413
|
+
* no enrolment, and nothing warming. Measured on the live hub: reopening
|
|
18414
|
+
* after two minutes idle painted 15 of 16 tiles at **168 s old** with zero
|
|
18415
|
+
* HTTP requests, and the fleet only recovered because a later poll happened
|
|
18416
|
+
* to observe a different identity.
|
|
18417
|
+
*
|
|
18418
|
+
* ## The two properties that fix it
|
|
18419
|
+
*
|
|
18420
|
+
* **It is an RPC, so no client cache can answer it.** The demand signal
|
|
18421
|
+
* always reaches the wrapper. This method therefore MAY create keep-warm
|
|
18422
|
+
* subscriptions, where `getSnapshotOverview` must never (D93) — the
|
|
18423
|
+
* distinction is not "one is newer" but that the overview poll is app-wide
|
|
18424
|
+
* (a creating overview would warm every camera on the install) while this is
|
|
18425
|
+
* called by a rendered surface naming the tiles it is actually painting, at
|
|
18426
|
+
* the width it is painting them.
|
|
18427
|
+
*
|
|
18428
|
+
* **It waits, briefly and boundedly, for the capture it triggered.** The
|
|
18429
|
+
* returned `capturedAt` is the frame the link will serve, not the frame the
|
|
18430
|
+
* cache held when the client asked, so a first paint is honest and current
|
|
18431
|
+
* instead of a generation behind. A device that does not settle inside the
|
|
18432
|
+
* bound still gets a link and its real (older) `capturedAt` — the next poll
|
|
18433
|
+
* carries it forward.
|
|
18434
|
+
*
|
|
18435
|
+
* `force` is never set on behalf of a client here. A sleeping battery camera
|
|
18436
|
+
* is reported with `sleeping: true` and the last frame it produced, however
|
|
18437
|
+
* old; the wrapper's existing sleep gate owns that decision and this method
|
|
18438
|
+
* adds no second one.
|
|
18439
|
+
*/
|
|
18440
|
+
getSnapshotLinks: systemMethod(object({
|
|
18441
|
+
/** The tiles a surface is actually rendering. One entry per (device,
|
|
18442
|
+
* width) the caller will paint — the width is snapped to the server's
|
|
18443
|
+
* ladder and becomes part of the link's SIGNED identity. */
|
|
18267
18444
|
targets: array(object({
|
|
18268
|
-
|
|
18269
|
-
|
|
18270
|
-
|
|
18271
|
-
|
|
18272
|
-
})).min(1).max(200) }), array(object({
|
|
18273
|
-
|
|
18274
|
-
|
|
18275
|
-
|
|
18276
|
-
|
|
18277
|
-
|
|
18278
|
-
|
|
18279
|
-
|
|
18280
|
-
|
|
18281
|
-
|
|
18282
|
-
|
|
18283
|
-
|
|
18284
|
-
|
|
18285
|
-
|
|
18286
|
-
|
|
18287
|
-
|
|
18288
|
-
|
|
18289
|
-
|
|
18290
|
-
|
|
18291
|
-
|
|
18292
|
-
|
|
18293
|
-
|
|
18294
|
-
})))
|
|
18445
|
+
deviceId: number(),
|
|
18446
|
+
/** Target width in px. Omit for the frame as captured — correct
|
|
18447
|
+
* for a full-bleed surface, wrong (and expensive) for a grid. */
|
|
18448
|
+
width: number().int().positive().optional()
|
|
18449
|
+
})).min(1).max(200) }), array(object({
|
|
18450
|
+
deviceId: number(),
|
|
18451
|
+
/** Root-relative signed path, or null when the link plane is not
|
|
18452
|
+
* served (no data-plane facility). Present even for a device that has
|
|
18453
|
+
* never captured — the request is what triggers the first one (D94). */
|
|
18454
|
+
url: string().nullable(),
|
|
18455
|
+
/** Epoch ms of the frame this link serves. Null = never captured.
|
|
18456
|
+
* THE honest age: the tRPC path carried none before this. */
|
|
18457
|
+
capturedAt: number().nullable(),
|
|
18458
|
+
/** Age of that frame at the moment the answer was built. */
|
|
18459
|
+
ageMs: number().nullable(),
|
|
18460
|
+
/** Epoch ms after which `url` stops verifying. */
|
|
18461
|
+
expiresAt: number().nullable(),
|
|
18462
|
+
/** Ladder rung the bytes are at; null = the frame as captured. */
|
|
18463
|
+
width: number().nullable(),
|
|
18464
|
+
/** The device has never produced a frame. An empty state, not a
|
|
18465
|
+
* failure — and never a reason to withhold the link (D94). */
|
|
18466
|
+
neverCaptured: boolean(),
|
|
18467
|
+
/** A sleeping battery camera: the frame is deliberately stale and will
|
|
18468
|
+
* NOT refresh in the background. A surface should say so rather than
|
|
18469
|
+
* present it as current. */
|
|
18470
|
+
sleeping: boolean()
|
|
18471
|
+
})))
|
|
18472
|
+
},
|
|
18473
|
+
status: {
|
|
18474
|
+
schema: SnapshotStatusSchema,
|
|
18475
|
+
kind: "poll"
|
|
18476
|
+
}
|
|
18477
|
+
};
|
|
18295
18478
|
/**
|
|
18296
18479
|
* `sso-bridge` — internal hub-only cap that lets SSO-style auth
|
|
18297
18480
|
* providers (OIDC, SAML, magic-link, …) mint an HMAC-signed token
|
|
@@ -18458,6 +18641,13 @@ method(object({ locationId: string() }), EvictableUsageSchema), method(object({
|
|
|
18458
18641
|
locationId: string(),
|
|
18459
18642
|
targetBytes: number().int().positive()
|
|
18460
18643
|
}), EvictResultSchema, { kind: "mutation" });
|
|
18644
|
+
method(StorageMigrationInputSchema, StorageMigrationPlanSchema, { auth: "admin" }), method(StorageMigrationInputSchema, object({ jobId: string() }), {
|
|
18645
|
+
kind: "mutation",
|
|
18646
|
+
auth: "admin"
|
|
18647
|
+
}), method(object({ jobId: string().optional() }), StorageMigrationJobSchema.nullable(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
|
|
18648
|
+
kind: "mutation",
|
|
18649
|
+
auth: "admin"
|
|
18650
|
+
});
|
|
18461
18651
|
var ProviderInfoSchema = discriminatedUnion("shouldSaveDiskSpace", [object({
|
|
18462
18652
|
providerId: string().min(1),
|
|
18463
18653
|
displayName: string().min(1),
|
|
@@ -18561,6 +18751,28 @@ var TerminalProfileInfoSchema = object({
|
|
|
18561
18751
|
label: string(),
|
|
18562
18752
|
description: string().optional()
|
|
18563
18753
|
});
|
|
18754
|
+
/**
|
|
18755
|
+
* A durable operator-created Terminal instance. Profiles are templates; only
|
|
18756
|
+
* an instance declares a camera.
|
|
18757
|
+
*/
|
|
18758
|
+
var TerminalInstanceInfoSchema = object({
|
|
18759
|
+
instanceId: string(),
|
|
18760
|
+
cameraStableId: string(),
|
|
18761
|
+
nodeId: string(),
|
|
18762
|
+
profileId: string(),
|
|
18763
|
+
profileLabel: string(),
|
|
18764
|
+
name: string(),
|
|
18765
|
+
enabled: boolean()
|
|
18766
|
+
});
|
|
18767
|
+
var TerminalLegacyCameraSchema = object({
|
|
18768
|
+
stableId: string(),
|
|
18769
|
+
nodeId: string(),
|
|
18770
|
+
profileId: string(),
|
|
18771
|
+
profileLabel: string(),
|
|
18772
|
+
name: string(),
|
|
18773
|
+
/** Only legacy monitor cameras can retain their historic stable identity. */
|
|
18774
|
+
adoptable: boolean()
|
|
18775
|
+
});
|
|
18564
18776
|
var TerminalOutputEventSchema = discriminatedUnion("kind", [object({
|
|
18565
18777
|
seq: number().int().positive(),
|
|
18566
18778
|
kind: literal("data"),
|
|
@@ -18580,10 +18792,9 @@ var TerminalOutputBatchSchema = object({
|
|
|
18580
18792
|
/**
|
|
18581
18793
|
* terminal-session — singleton system capability for interactive TTY sessions.
|
|
18582
18794
|
*
|
|
18583
|
-
*
|
|
18584
|
-
*
|
|
18585
|
-
*
|
|
18586
|
-
* change this contract.
|
|
18795
|
+
* Owns both live PTY lifecycle and durable Terminal instance management.
|
|
18796
|
+
* Profiles are allowlisted templates; an explicit instance is the only path
|
|
18797
|
+
* that declares a camera.
|
|
18587
18798
|
*/
|
|
18588
18799
|
var terminalSessionCapability = {
|
|
18589
18800
|
name: "terminal-session",
|
|
@@ -18592,6 +18803,37 @@ var terminalSessionCapability = {
|
|
|
18592
18803
|
methods: {
|
|
18593
18804
|
/** Pre-declared profiles the operator may open. */
|
|
18594
18805
|
listProfiles: method(_void(), array(TerminalProfileInfoSchema).readonly(), { auth: "admin" }),
|
|
18806
|
+
/** Explicit durable Terminal instances, managed centrally on the hub. */
|
|
18807
|
+
listInstances: method(_void(), array(TerminalInstanceInfoSchema).readonly(), { auth: "admin" }),
|
|
18808
|
+
createInstance: method(object({
|
|
18809
|
+
targetNodeId: string().min(1),
|
|
18810
|
+
profileId: string().min(1),
|
|
18811
|
+
name: string().trim().min(1).max(160).optional()
|
|
18812
|
+
}), TerminalInstanceInfoSchema, {
|
|
18813
|
+
kind: "mutation",
|
|
18814
|
+
auth: "admin"
|
|
18815
|
+
}),
|
|
18816
|
+
deleteInstance: method(object({ instanceId: string().min(1) }), _void(), {
|
|
18817
|
+
kind: "mutation",
|
|
18818
|
+
auth: "admin"
|
|
18819
|
+
}),
|
|
18820
|
+
setInstanceEnabled: method(object({
|
|
18821
|
+
instanceId: string().min(1),
|
|
18822
|
+
enabled: boolean()
|
|
18823
|
+
}), TerminalInstanceInfoSchema, {
|
|
18824
|
+
kind: "mutation",
|
|
18825
|
+
auth: "admin"
|
|
18826
|
+
}),
|
|
18827
|
+
/** Existing automatic cameras are shown for explicit migration only. */
|
|
18828
|
+
listLegacyCameras: method(_void(), array(TerminalLegacyCameraSchema).readonly(), { auth: "admin" }),
|
|
18829
|
+
/** Explicitly adopt one legacy monitor camera, retaining its stable id. */
|
|
18830
|
+
adoptLegacyMonitor: method(object({
|
|
18831
|
+
stableId: string().min(1),
|
|
18832
|
+
name: string().trim().min(1).max(160).optional()
|
|
18833
|
+
}), TerminalInstanceInfoSchema, {
|
|
18834
|
+
kind: "mutation",
|
|
18835
|
+
auth: "admin"
|
|
18836
|
+
}),
|
|
18595
18837
|
/** Live sessions currently hosted by the provider. */
|
|
18596
18838
|
listSessions: method(_void(), array(TerminalSessionInfoSchema).readonly(), { auth: "admin" }),
|
|
18597
18839
|
/**
|
|
@@ -18623,7 +18865,13 @@ var terminalSessionCapability = {
|
|
|
18623
18865
|
pullOutput: method(object({
|
|
18624
18866
|
sessionId: string(),
|
|
18625
18867
|
afterSeq: number().int().nonnegative(),
|
|
18626
|
-
waitMs: number().int().min(0).max(2e3).default(0)
|
|
18868
|
+
waitMs: number().int().min(0).max(2e3).default(0),
|
|
18869
|
+
/**
|
|
18870
|
+
* Wait when a just-opened session has no output yet. Kept opt-in so a
|
|
18871
|
+
* browser's initial repaint remains immediate; the camera snapshot
|
|
18872
|
+
* relay uses it to avoid encoding a blank startup frame.
|
|
18873
|
+
*/
|
|
18874
|
+
waitForOutput: boolean().optional()
|
|
18627
18875
|
}), TerminalOutputBatchSchema, {
|
|
18628
18876
|
kind: "mutation",
|
|
18629
18877
|
auth: "admin",
|
|
@@ -24602,13 +24850,19 @@ method(object({
|
|
|
24602
24850
|
}), {
|
|
24603
24851
|
kind: "mutation",
|
|
24604
24852
|
auth: "admin"
|
|
24605
|
-
}), method(
|
|
24853
|
+
}), method(StorageMigrationLeaseInputSchema, object({ paused: literal(true) }), {
|
|
24606
24854
|
kind: "mutation",
|
|
24607
24855
|
auth: "admin"
|
|
24608
|
-
}), method(object({
|
|
24609
|
-
kind: "
|
|
24856
|
+
}), method(StorageMigrationLeaseInputSchema, object({ resumed: literal(true) }), {
|
|
24857
|
+
kind: "mutation",
|
|
24858
|
+
auth: "admin"
|
|
24859
|
+
}), method(StorageMigrationLeaseInputSchema, object({ refreshed: literal(true) }), {
|
|
24860
|
+
kind: "mutation",
|
|
24861
|
+
auth: "admin"
|
|
24862
|
+
}), method(StorageMigrationFootageMoveInputSchema, object({ jobId: string() }), {
|
|
24863
|
+
kind: "mutation",
|
|
24610
24864
|
auth: "admin"
|
|
24611
|
-
}), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
|
|
24865
|
+
}), method(object({ jobId: string() }), RelocateJobSchema.nullable(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
|
|
24612
24866
|
kind: "mutation",
|
|
24613
24867
|
auth: "admin"
|
|
24614
24868
|
});
|
|
@@ -30300,7 +30554,7 @@ Object.freeze({
|
|
|
30300
30554
|
addonId: null,
|
|
30301
30555
|
access: "create"
|
|
30302
30556
|
},
|
|
30303
|
-
"pipelineAnalytics.
|
|
30557
|
+
"pipelineAnalytics.cancelStorageMigrationMove": {
|
|
30304
30558
|
capName: "pipeline-analytics",
|
|
30305
30559
|
capScope: "device",
|
|
30306
30560
|
addonId: null,
|
|
@@ -30372,12 +30626,6 @@ Object.freeze({
|
|
|
30372
30626
|
addonId: null,
|
|
30373
30627
|
access: "view"
|
|
30374
30628
|
},
|
|
30375
|
-
"pipelineAnalytics.getMediaRelocateStatus": {
|
|
30376
|
-
capName: "pipeline-analytics",
|
|
30377
|
-
capScope: "device",
|
|
30378
|
-
addonId: null,
|
|
30379
|
-
access: "view"
|
|
30380
|
-
},
|
|
30381
30629
|
"pipelineAnalytics.getMotionEvents": {
|
|
30382
30630
|
capName: "pipeline-analytics",
|
|
30383
30631
|
capScope: "device",
|
|
@@ -30414,6 +30662,12 @@ Object.freeze({
|
|
|
30414
30662
|
addonId: null,
|
|
30415
30663
|
access: "view"
|
|
30416
30664
|
},
|
|
30665
|
+
"pipelineAnalytics.getStorageMigrationMoveStatus": {
|
|
30666
|
+
capName: "pipeline-analytics",
|
|
30667
|
+
capScope: "device",
|
|
30668
|
+
addonId: null,
|
|
30669
|
+
access: "view"
|
|
30670
|
+
},
|
|
30417
30671
|
"pipelineAnalytics.getTrack": {
|
|
30418
30672
|
capName: "pipeline-analytics",
|
|
30419
30673
|
capScope: "device",
|
|
@@ -30492,6 +30746,12 @@ Object.freeze({
|
|
|
30492
30746
|
addonId: null,
|
|
30493
30747
|
access: "view"
|
|
30494
30748
|
},
|
|
30749
|
+
"pipelineAnalytics.pauseForStorageMigration": {
|
|
30750
|
+
capName: "pipeline-analytics",
|
|
30751
|
+
capScope: "device",
|
|
30752
|
+
addonId: null,
|
|
30753
|
+
access: "create"
|
|
30754
|
+
},
|
|
30495
30755
|
"pipelineAnalytics.proposeRetrainAnnotations": {
|
|
30496
30756
|
capName: "pipeline-analytics",
|
|
30497
30757
|
capScope: "device",
|
|
@@ -30522,7 +30782,7 @@ Object.freeze({
|
|
|
30522
30782
|
addonId: null,
|
|
30523
30783
|
access: "create"
|
|
30524
30784
|
},
|
|
30525
|
-
"pipelineAnalytics.
|
|
30785
|
+
"pipelineAnalytics.refreshStorageLocationsForMigration": {
|
|
30526
30786
|
capName: "pipeline-analytics",
|
|
30527
30787
|
capScope: "device",
|
|
30528
30788
|
addonId: null,
|
|
@@ -30534,6 +30794,12 @@ Object.freeze({
|
|
|
30534
30794
|
addonId: null,
|
|
30535
30795
|
access: "create"
|
|
30536
30796
|
},
|
|
30797
|
+
"pipelineAnalytics.resumeForStorageMigration": {
|
|
30798
|
+
capName: "pipeline-analytics",
|
|
30799
|
+
capScope: "device",
|
|
30800
|
+
addonId: null,
|
|
30801
|
+
access: "create"
|
|
30802
|
+
},
|
|
30537
30803
|
"pipelineAnalytics.saveRetrainAnnotations": {
|
|
30538
30804
|
capName: "pipeline-analytics",
|
|
30539
30805
|
capScope: "device",
|
|
@@ -30558,6 +30824,12 @@ Object.freeze({
|
|
|
30558
30824
|
addonId: null,
|
|
30559
30825
|
access: "create"
|
|
30560
30826
|
},
|
|
30827
|
+
"pipelineAnalytics.startStorageMigrationMove": {
|
|
30828
|
+
capName: "pipeline-analytics",
|
|
30829
|
+
capScope: "device",
|
|
30830
|
+
addonId: null,
|
|
30831
|
+
access: "create"
|
|
30832
|
+
},
|
|
30561
30833
|
"pipelineAnalytics.wipeAllAnalytics": {
|
|
30562
30834
|
capName: "pipeline-analytics",
|
|
30563
30835
|
capScope: "device",
|
|
@@ -30924,6 +31196,12 @@ Object.freeze({
|
|
|
30924
31196
|
addonId: null,
|
|
30925
31197
|
access: "view"
|
|
30926
31198
|
},
|
|
31199
|
+
"pipelineOrchestrator.pauseForStorageMigration": {
|
|
31200
|
+
capName: "pipeline-orchestrator",
|
|
31201
|
+
capScope: "system",
|
|
31202
|
+
addonId: null,
|
|
31203
|
+
access: "create"
|
|
31204
|
+
},
|
|
30927
31205
|
"pipelineOrchestrator.rebalance": {
|
|
30928
31206
|
capName: "pipeline-orchestrator",
|
|
30929
31207
|
capScope: "system",
|
|
@@ -30948,6 +31226,12 @@ Object.freeze({
|
|
|
30948
31226
|
addonId: null,
|
|
30949
31227
|
access: "view"
|
|
30950
31228
|
},
|
|
31229
|
+
"pipelineOrchestrator.resumeForStorageMigration": {
|
|
31230
|
+
capName: "pipeline-orchestrator",
|
|
31231
|
+
capScope: "system",
|
|
31232
|
+
addonId: null,
|
|
31233
|
+
access: "create"
|
|
31234
|
+
},
|
|
30951
31235
|
"pipelineOrchestrator.saveTemplate": {
|
|
30952
31236
|
capName: "pipeline-orchestrator",
|
|
30953
31237
|
capScope: "system",
|
|
@@ -31344,7 +31628,7 @@ Object.freeze({
|
|
|
31344
31628
|
addonId: null,
|
|
31345
31629
|
access: "create"
|
|
31346
31630
|
},
|
|
31347
|
-
"recording.
|
|
31631
|
+
"recording.cancelStorageMigrationMove": {
|
|
31348
31632
|
capName: "recording",
|
|
31349
31633
|
capScope: "system",
|
|
31350
31634
|
addonId: null,
|
|
@@ -31380,7 +31664,7 @@ Object.freeze({
|
|
|
31380
31664
|
addonId: null,
|
|
31381
31665
|
access: "view"
|
|
31382
31666
|
},
|
|
31383
|
-
"recording.
|
|
31667
|
+
"recording.getStorageMigrationMoveStatus": {
|
|
31384
31668
|
capName: "recording",
|
|
31385
31669
|
capScope: "system",
|
|
31386
31670
|
addonId: null,
|
|
@@ -31404,6 +31688,12 @@ Object.freeze({
|
|
|
31404
31688
|
addonId: null,
|
|
31405
31689
|
access: "view"
|
|
31406
31690
|
},
|
|
31691
|
+
"recording.pauseForStorageMigration": {
|
|
31692
|
+
capName: "recording",
|
|
31693
|
+
capScope: "system",
|
|
31694
|
+
addonId: null,
|
|
31695
|
+
access: "create"
|
|
31696
|
+
},
|
|
31407
31697
|
"recording.pruneFootage": {
|
|
31408
31698
|
capName: "recording",
|
|
31409
31699
|
capScope: "system",
|
|
@@ -31422,7 +31712,7 @@ Object.freeze({
|
|
|
31422
31712
|
addonId: null,
|
|
31423
31713
|
access: "view"
|
|
31424
31714
|
},
|
|
31425
|
-
"recording.
|
|
31715
|
+
"recording.refreshStorageLocationsForMigration": {
|
|
31426
31716
|
capName: "recording",
|
|
31427
31717
|
capScope: "system",
|
|
31428
31718
|
addonId: null,
|
|
@@ -31446,12 +31736,24 @@ Object.freeze({
|
|
|
31446
31736
|
addonId: null,
|
|
31447
31737
|
access: "create"
|
|
31448
31738
|
},
|
|
31739
|
+
"recording.resumeForStorageMigration": {
|
|
31740
|
+
capName: "recording",
|
|
31741
|
+
capScope: "system",
|
|
31742
|
+
addonId: null,
|
|
31743
|
+
access: "create"
|
|
31744
|
+
},
|
|
31449
31745
|
"recording.setDeviceConfig": {
|
|
31450
31746
|
capName: "recording",
|
|
31451
31747
|
capScope: "system",
|
|
31452
31748
|
addonId: null,
|
|
31453
31749
|
access: "create"
|
|
31454
31750
|
},
|
|
31751
|
+
"recording.startStorageMigrationMove": {
|
|
31752
|
+
capName: "recording",
|
|
31753
|
+
capScope: "system",
|
|
31754
|
+
addonId: null,
|
|
31755
|
+
access: "create"
|
|
31756
|
+
},
|
|
31455
31757
|
"recordingExport.cancelExport": {
|
|
31456
31758
|
capName: "recordingExport",
|
|
31457
31759
|
capScope: "system",
|
|
@@ -31842,6 +32144,30 @@ Object.freeze({
|
|
|
31842
32144
|
addonId: null,
|
|
31843
32145
|
access: "view"
|
|
31844
32146
|
},
|
|
32147
|
+
"storageMigration.cancel": {
|
|
32148
|
+
capName: "storage-migration",
|
|
32149
|
+
capScope: "system",
|
|
32150
|
+
addonId: null,
|
|
32151
|
+
access: "create"
|
|
32152
|
+
},
|
|
32153
|
+
"storageMigration.plan": {
|
|
32154
|
+
capName: "storage-migration",
|
|
32155
|
+
capScope: "system",
|
|
32156
|
+
addonId: null,
|
|
32157
|
+
access: "view"
|
|
32158
|
+
},
|
|
32159
|
+
"storageMigration.start": {
|
|
32160
|
+
capName: "storage-migration",
|
|
32161
|
+
capScope: "system",
|
|
32162
|
+
addonId: null,
|
|
32163
|
+
access: "create"
|
|
32164
|
+
},
|
|
32165
|
+
"storageMigration.status": {
|
|
32166
|
+
capName: "storage-migration",
|
|
32167
|
+
capScope: "system",
|
|
32168
|
+
addonId: null,
|
|
32169
|
+
access: "view"
|
|
32170
|
+
},
|
|
31845
32171
|
"storageProvider.abortUpload": {
|
|
31846
32172
|
capName: "storage-provider",
|
|
31847
32173
|
capScope: "system",
|
|
@@ -32220,12 +32546,42 @@ Object.freeze({
|
|
|
32220
32546
|
addonId: null,
|
|
32221
32547
|
access: "create"
|
|
32222
32548
|
},
|
|
32549
|
+
"terminalSession.adoptLegacyMonitor": {
|
|
32550
|
+
capName: "terminal-session",
|
|
32551
|
+
capScope: "system",
|
|
32552
|
+
addonId: null,
|
|
32553
|
+
access: "create"
|
|
32554
|
+
},
|
|
32223
32555
|
"terminalSession.close": {
|
|
32224
32556
|
capName: "terminal-session",
|
|
32225
32557
|
capScope: "system",
|
|
32226
32558
|
addonId: null,
|
|
32227
32559
|
access: "create"
|
|
32228
32560
|
},
|
|
32561
|
+
"terminalSession.createInstance": {
|
|
32562
|
+
capName: "terminal-session",
|
|
32563
|
+
capScope: "system",
|
|
32564
|
+
addonId: null,
|
|
32565
|
+
access: "create"
|
|
32566
|
+
},
|
|
32567
|
+
"terminalSession.deleteInstance": {
|
|
32568
|
+
capName: "terminal-session",
|
|
32569
|
+
capScope: "system",
|
|
32570
|
+
addonId: null,
|
|
32571
|
+
access: "delete"
|
|
32572
|
+
},
|
|
32573
|
+
"terminalSession.listInstances": {
|
|
32574
|
+
capName: "terminal-session",
|
|
32575
|
+
capScope: "system",
|
|
32576
|
+
addonId: null,
|
|
32577
|
+
access: "view"
|
|
32578
|
+
},
|
|
32579
|
+
"terminalSession.listLegacyCameras": {
|
|
32580
|
+
capName: "terminal-session",
|
|
32581
|
+
capScope: "system",
|
|
32582
|
+
addonId: null,
|
|
32583
|
+
access: "view"
|
|
32584
|
+
},
|
|
32229
32585
|
"terminalSession.listProfiles": {
|
|
32230
32586
|
capName: "terminal-session",
|
|
32231
32587
|
capScope: "system",
|
|
@@ -32256,6 +32612,12 @@ Object.freeze({
|
|
|
32256
32612
|
addonId: null,
|
|
32257
32613
|
access: "create"
|
|
32258
32614
|
},
|
|
32615
|
+
"terminalSession.setInstanceEnabled": {
|
|
32616
|
+
capName: "terminal-session",
|
|
32617
|
+
capScope: "system",
|
|
32618
|
+
addonId: null,
|
|
32619
|
+
access: "create"
|
|
32620
|
+
},
|
|
32259
32621
|
"terminalSession.writeInput": {
|
|
32260
32622
|
capName: "terminal-session",
|
|
32261
32623
|
capScope: "system",
|
|
@@ -33058,19 +33420,38 @@ async function warmNodePty() {
|
|
|
33058
33420
|
}
|
|
33059
33421
|
//#endregion
|
|
33060
33422
|
//#region src/terminal-camera-declarations.ts
|
|
33061
|
-
|
|
33062
|
-
|
|
33063
|
-
|
|
33064
|
-
|
|
33065
|
-
|
|
33066
|
-
|
|
33067
|
-
|
|
33068
|
-
|
|
33069
|
-
|
|
33070
|
-
|
|
33071
|
-
|
|
33072
|
-
|
|
33073
|
-
|
|
33423
|
+
/**
|
|
33424
|
+
* Feed DeclaredDevices every live declaration plus one deterministic orphan
|
|
33425
|
+
* batch. The generic sweep intentionally refuses an over-limit set; selecting
|
|
33426
|
+
* a batch here drains large historical Terminal orphan sets across convergence
|
|
33427
|
+
* passes without weakening that global safety guard.
|
|
33428
|
+
*/
|
|
33429
|
+
function terminalCameraReconciliationIndex(rows, integrationId, declarations, managedStableIds = /* @__PURE__ */ new Set()) {
|
|
33430
|
+
if (!integrationId) return [];
|
|
33431
|
+
const declared = new Set(declarations.map((camera) => camera.stableId));
|
|
33432
|
+
const owned = rows.filter((row) => row.integrationId === integrationId && (declared.has(row.stableId) || managedStableIds.has(row.stableId) || row.stableId.startsWith("terminal-camera-instance-")));
|
|
33433
|
+
return [...owned.filter((row) => declared.has(row.stableId)), ...owned.filter((row) => !declared.has(row.stableId)).sort((left, right) => left.id - right.id).slice(0, 32)];
|
|
33434
|
+
}
|
|
33435
|
+
/** Explicit persisted instances, never the node × profile template matrix. */
|
|
33436
|
+
function buildTerminalInstanceCameraDeclarations(instances) {
|
|
33437
|
+
return instances.filter((instance) => instance.enabled).map((instance) => ({
|
|
33438
|
+
stableId: instance.cameraStableId,
|
|
33439
|
+
name: instance.name,
|
|
33440
|
+
config: {
|
|
33441
|
+
instanceId: instance.id,
|
|
33442
|
+
nodeId: instance.nodeId,
|
|
33443
|
+
profileId: instance.profileId,
|
|
33444
|
+
profileLabel: instance.profileLabel
|
|
33445
|
+
}
|
|
33446
|
+
}));
|
|
33447
|
+
}
|
|
33448
|
+
/**
|
|
33449
|
+
* `DeviceConfig` materializes schema defaults in memory, so comparing
|
|
33450
|
+
* `config.get()` cannot detect a legacy `{ nodeId }` row. Reconciliation must
|
|
33451
|
+
* inspect the raw persisted blob to make the profile migration durable.
|
|
33452
|
+
*/
|
|
33453
|
+
function needsTerminalCameraConfigMigration(persistedConfig, declaration) {
|
|
33454
|
+
return persistedConfig.instanceId !== declaration.config.instanceId || persistedConfig.nodeId !== declaration.config.nodeId || persistedConfig.profileId !== declaration.config.profileId || persistedConfig.profileLabel !== declaration.config.profileLabel;
|
|
33074
33455
|
}
|
|
33075
33456
|
function escapeXml(value) {
|
|
33076
33457
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
@@ -33087,22 +33468,35 @@ async function renderTerminalJpeg(lines) {
|
|
|
33087
33468
|
//#endregion
|
|
33088
33469
|
//#region src/terminal-camera-device.ts
|
|
33089
33470
|
var terminalCameraSchema = object({
|
|
33471
|
+
instanceId: string().min(1).optional(),
|
|
33090
33472
|
nodeId: string().min(1),
|
|
33091
|
-
profileId: string().min(1),
|
|
33092
|
-
profileLabel: string().min(1)
|
|
33473
|
+
profileId: string().min(1).default("monitor"),
|
|
33474
|
+
profileLabel: string().min(1).default("BTM")
|
|
33093
33475
|
});
|
|
33094
33476
|
var relay = null;
|
|
33095
33477
|
function installTerminalCameraRelay(next) {
|
|
33096
33478
|
relay = next;
|
|
33097
33479
|
}
|
|
33098
33480
|
var TerminalCameraDevice = class extends BaseDevice {
|
|
33099
|
-
features = [];
|
|
33481
|
+
features = [DeviceFeature.NativeSnapshot];
|
|
33100
33482
|
constructor(ctx) {
|
|
33101
33483
|
super(ctx, terminalCameraSchema, { type: ctx.deviceMeta.type });
|
|
33102
33484
|
this.ctx.registerNativeCap(streamCatalogCapability, { getCatalog: async ({ deviceId }) => {
|
|
33103
33485
|
if (deviceId !== this.id) return [];
|
|
33104
33486
|
return this.catalog();
|
|
33105
33487
|
} });
|
|
33488
|
+
this.ctx.registerNativeCap(snapshotCapability, {
|
|
33489
|
+
getSnapshot: async ({ deviceId }) => {
|
|
33490
|
+
if (deviceId !== this.id) throw new Error(`TerminalCameraDevice: deviceId mismatch, expected ${String(this.id)}, got ${String(deviceId)}`);
|
|
33491
|
+
const activeRelay = relay;
|
|
33492
|
+
if (!activeRelay) throw new Error("terminal camera relay is unavailable");
|
|
33493
|
+
return {
|
|
33494
|
+
base64: (await activeRelay.snapshot(this.relayInstanceId(), this.config.get("nodeId"), this.config.get("profileId"))).toString("base64"),
|
|
33495
|
+
contentType: "image/jpeg"
|
|
33496
|
+
};
|
|
33497
|
+
},
|
|
33498
|
+
invalidateCache: async () => {}
|
|
33499
|
+
});
|
|
33106
33500
|
this.markOnline(true);
|
|
33107
33501
|
}
|
|
33108
33502
|
async catalog() {
|
|
@@ -33110,10 +33504,11 @@ var TerminalCameraDevice = class extends BaseDevice {
|
|
|
33110
33504
|
if (!activeRelay) throw new Error("terminal camera relay is unavailable");
|
|
33111
33505
|
const nodeId = this.config.get("nodeId");
|
|
33112
33506
|
const profileId = this.config.get("profileId");
|
|
33507
|
+
const instanceId = this.relayInstanceId();
|
|
33113
33508
|
return [{
|
|
33114
33509
|
camStreamId: profileId,
|
|
33115
33510
|
kind: "pull-http",
|
|
33116
|
-
url: activeRelay.streamUrl(nodeId, profileId),
|
|
33511
|
+
url: activeRelay.streamUrl(instanceId, nodeId, profileId),
|
|
33117
33512
|
codec: "h264",
|
|
33118
33513
|
resolution: {
|
|
33119
33514
|
width: 960,
|
|
@@ -33125,6 +33520,13 @@ var TerminalCameraDevice = class extends BaseDevice {
|
|
|
33125
33520
|
}
|
|
33126
33521
|
setNodeOnline(online) {
|
|
33127
33522
|
this.markOnline(online);
|
|
33523
|
+
if (!online) relay?.closeInstance(this.relayInstanceId());
|
|
33524
|
+
}
|
|
33525
|
+
async removeDevice() {
|
|
33526
|
+
await relay?.closeInstance(this.relayInstanceId());
|
|
33527
|
+
}
|
|
33528
|
+
relayInstanceId() {
|
|
33529
|
+
return this.config.get("instanceId") ?? `legacy:${this.stableId}`;
|
|
33128
33530
|
}
|
|
33129
33531
|
};
|
|
33130
33532
|
//#endregion
|
|
@@ -37973,18 +38375,24 @@ function createXtermScreen(cols, rows) {
|
|
|
37973
38375
|
//#region src/terminal-camera-relay.ts
|
|
37974
38376
|
var MJPEG_BOUNDARY = "camstack-terminal-frame";
|
|
37975
38377
|
var SESSION_IDLE_MS = 3e4;
|
|
37976
|
-
|
|
37977
|
-
|
|
38378
|
+
var SNAPSHOT_STARTUP_WAIT_MS = 1500;
|
|
38379
|
+
var CLOSE_RETRY_BASE_MS = 50;
|
|
38380
|
+
var CLOSE_RETRY_MAX_MS = 1e3;
|
|
38381
|
+
var CLOSE_ATTEMPTS_PER_PASS = 3;
|
|
38382
|
+
function relayKey(instanceId) {
|
|
38383
|
+
return instanceId;
|
|
37978
38384
|
}
|
|
37979
38385
|
function parseStreamPath(url) {
|
|
37980
38386
|
const parts = ((url ?? "").split("?")[0] ?? "").split("/").filter(Boolean);
|
|
37981
|
-
if (parts.length !==
|
|
38387
|
+
if (parts.length !== 4 || parts[0] !== "stream") return null;
|
|
37982
38388
|
try {
|
|
37983
|
-
const
|
|
37984
|
-
const
|
|
38389
|
+
const instanceId = decodeURIComponent(parts[1] ?? "");
|
|
38390
|
+
const nodeId = decodeURIComponent(parts[2] ?? "");
|
|
38391
|
+
const profilePart = parts[3] ?? "";
|
|
37985
38392
|
if (!profilePart.endsWith(".mjpeg")) return null;
|
|
37986
38393
|
const profileId = decodeURIComponent(profilePart.slice(0, -6));
|
|
37987
|
-
return nodeId && profileId ? {
|
|
38394
|
+
return instanceId && nodeId && profileId ? {
|
|
38395
|
+
instanceId,
|
|
37988
38396
|
nodeId,
|
|
37989
38397
|
profileId
|
|
37990
38398
|
} : null;
|
|
@@ -38011,7 +38419,7 @@ var TerminalCameraRelay = class {
|
|
|
38011
38419
|
res.writeHead(404).end();
|
|
38012
38420
|
return;
|
|
38013
38421
|
}
|
|
38014
|
-
this.serve(target.nodeId, target.profileId, res);
|
|
38422
|
+
this.serve(target.instanceId, target.nodeId, target.profileId, res);
|
|
38015
38423
|
});
|
|
38016
38424
|
await new Promise((resolve, reject) => {
|
|
38017
38425
|
server.once("error", reject);
|
|
@@ -38025,55 +38433,105 @@ var TerminalCameraRelay = class {
|
|
|
38025
38433
|
this.server = server;
|
|
38026
38434
|
this.baseUrl = `http://127.0.0.1:${String(address.port)}`;
|
|
38027
38435
|
}
|
|
38028
|
-
streamUrl(nodeId, profileId) {
|
|
38436
|
+
streamUrl(instanceId, nodeId, profileId) {
|
|
38029
38437
|
if (!this.baseUrl) throw new Error("terminal camera relay is not started");
|
|
38030
|
-
return `${this.baseUrl}/stream/${encodeURIComponent(nodeId)}/${encodeURIComponent(profileId)}.mjpeg`;
|
|
38438
|
+
return `${this.baseUrl}/stream/${encodeURIComponent(instanceId)}/${encodeURIComponent(nodeId)}/${encodeURIComponent(profileId)}.mjpeg`;
|
|
38031
38439
|
}
|
|
38032
38440
|
async listProfiles(nodeId) {
|
|
38033
38441
|
return this.api.listProfiles(nodeId);
|
|
38034
38442
|
}
|
|
38035
|
-
state(nodeId, profileId) {
|
|
38036
|
-
const key = relayKey(
|
|
38443
|
+
state(instanceId, nodeId, profileId) {
|
|
38444
|
+
const key = relayKey(instanceId);
|
|
38037
38445
|
const existing = this.states.get(key);
|
|
38038
38446
|
if (existing) return existing;
|
|
38039
38447
|
const created = {
|
|
38448
|
+
instanceId,
|
|
38040
38449
|
nodeId,
|
|
38041
38450
|
profileId,
|
|
38042
38451
|
screen: createXtermScreen(120, 40),
|
|
38043
38452
|
sessionId: null,
|
|
38044
38453
|
cursor: 0,
|
|
38045
38454
|
clients: 0,
|
|
38455
|
+
leases: 0,
|
|
38046
38456
|
jpeg: null,
|
|
38047
38457
|
renderedCursor: -1,
|
|
38048
38458
|
framePromise: null,
|
|
38049
|
-
|
|
38459
|
+
openPromise: null,
|
|
38460
|
+
idleTimer: null,
|
|
38461
|
+
closing: false,
|
|
38462
|
+
closePromise: null,
|
|
38463
|
+
closeRetryTimer: null,
|
|
38464
|
+
closeAttempts: 0,
|
|
38465
|
+
closed: false,
|
|
38466
|
+
responses: /* @__PURE__ */ new Set()
|
|
38050
38467
|
};
|
|
38051
38468
|
this.states.set(key, created);
|
|
38052
38469
|
return created;
|
|
38053
38470
|
}
|
|
38054
38471
|
async ensureSession(state) {
|
|
38472
|
+
if (state.closed || state.closing) throw new Error("terminal camera relay is waiting for prior session cleanup");
|
|
38055
38473
|
if (state.sessionId) return state.sessionId;
|
|
38056
|
-
|
|
38474
|
+
if (state.openPromise) {
|
|
38475
|
+
const opened = await state.openPromise;
|
|
38476
|
+
if (state.closed || state.closing) throw new Error("terminal camera relay state closed while opening its session");
|
|
38477
|
+
return opened.sessionId;
|
|
38478
|
+
}
|
|
38479
|
+
const opening = this.api.openSession(state.nodeId, {
|
|
38057
38480
|
profileId: state.profileId,
|
|
38058
38481
|
cols: 120,
|
|
38059
38482
|
rows: 40
|
|
38060
38483
|
});
|
|
38061
|
-
state.
|
|
38062
|
-
|
|
38063
|
-
|
|
38484
|
+
state.openPromise = opening;
|
|
38485
|
+
try {
|
|
38486
|
+
const opened = await opening;
|
|
38487
|
+
state.openPromise = null;
|
|
38488
|
+
if (state.closed || state.closing) {
|
|
38489
|
+
state.sessionId = opened.sessionId;
|
|
38490
|
+
await this.closeState(state);
|
|
38491
|
+
throw new Error("terminal camera relay state closed while opening its session");
|
|
38492
|
+
}
|
|
38493
|
+
state.sessionId = opened.sessionId;
|
|
38494
|
+
state.cursor = 0;
|
|
38495
|
+
return opened.sessionId;
|
|
38496
|
+
} catch (error) {
|
|
38497
|
+
if (state.closing && !state.sessionId) this.finishClose(state);
|
|
38498
|
+
throw error;
|
|
38499
|
+
} finally {
|
|
38500
|
+
if (state.openPromise === opening) state.openPromise = null;
|
|
38501
|
+
}
|
|
38064
38502
|
}
|
|
38065
|
-
async nextFrame(state) {
|
|
38066
|
-
if (state.framePromise)
|
|
38503
|
+
async nextFrame(state, forceRender = false, waitForInitialOutput = false) {
|
|
38504
|
+
if (state.framePromise) {
|
|
38505
|
+
await state.framePromise;
|
|
38506
|
+
return forceRender ? this.nextFrame(state, true, waitForInitialOutput) : state.jpeg ?? this.nextFrame(state, false, waitForInitialOutput);
|
|
38507
|
+
}
|
|
38067
38508
|
const render = async () => {
|
|
38509
|
+
const openingSession = state.sessionId === null;
|
|
38068
38510
|
const sessionId = await this.ensureSession(state);
|
|
38069
38511
|
let batch;
|
|
38070
38512
|
try {
|
|
38071
38513
|
batch = await this.api.pullOutput(state.nodeId, {
|
|
38072
38514
|
sessionId,
|
|
38073
|
-
afterSeq: state.cursor
|
|
38515
|
+
afterSeq: state.cursor,
|
|
38516
|
+
...openingSession && waitForInitialOutput ? {
|
|
38517
|
+
waitMs: SNAPSHOT_STARTUP_WAIT_MS,
|
|
38518
|
+
waitForOutput: true
|
|
38519
|
+
} : {}
|
|
38074
38520
|
});
|
|
38075
38521
|
} catch (error) {
|
|
38076
|
-
state.sessionId
|
|
38522
|
+
if (state.sessionId === sessionId) {
|
|
38523
|
+
let closed = false;
|
|
38524
|
+
await this.api.closeSession(state.nodeId, sessionId).then(() => {
|
|
38525
|
+
closed = true;
|
|
38526
|
+
}).catch((closeError) => {
|
|
38527
|
+
this.logger.warn("terminal camera session cleanup after output failure failed", { meta: {
|
|
38528
|
+
nodeId: state.nodeId,
|
|
38529
|
+
sessionId,
|
|
38530
|
+
error: closeError instanceof Error ? closeError.message : String(closeError)
|
|
38531
|
+
} });
|
|
38532
|
+
});
|
|
38533
|
+
if (closed) state.sessionId = null;
|
|
38534
|
+
}
|
|
38077
38535
|
state.cursor = 0;
|
|
38078
38536
|
throw error;
|
|
38079
38537
|
}
|
|
@@ -38090,7 +38548,7 @@ var TerminalCameraRelay = class {
|
|
|
38090
38548
|
}
|
|
38091
38549
|
state.cursor = exited ? 0 : batch.cursor;
|
|
38092
38550
|
await state.screen.flush();
|
|
38093
|
-
if (state.jpeg === null || state.renderedCursor !== state.cursor) {
|
|
38551
|
+
if (forceRender || state.jpeg === null || state.renderedCursor !== state.cursor) {
|
|
38094
38552
|
state.jpeg = await renderTerminalJpeg(state.screen.lines());
|
|
38095
38553
|
state.renderedCursor = state.cursor;
|
|
38096
38554
|
}
|
|
@@ -38101,14 +38559,15 @@ var TerminalCameraRelay = class {
|
|
|
38101
38559
|
});
|
|
38102
38560
|
return state.framePromise;
|
|
38103
38561
|
}
|
|
38104
|
-
async serve(nodeId, profileId, res) {
|
|
38562
|
+
async serve(instanceId, nodeId, profileId, res) {
|
|
38105
38563
|
this.responses.add(res);
|
|
38106
|
-
const state = this.state(nodeId, profileId);
|
|
38564
|
+
const state = this.state(instanceId, nodeId, profileId);
|
|
38107
38565
|
if (state.idleTimer) clearTimeout(state.idleTimer);
|
|
38108
38566
|
state.idleTimer = null;
|
|
38109
38567
|
state.clients += 1;
|
|
38568
|
+
state.responses.add(res);
|
|
38110
38569
|
try {
|
|
38111
|
-
const first = await this.nextFrame(state);
|
|
38570
|
+
const first = await this.nextFrame(state, false, true);
|
|
38112
38571
|
res.writeHead(200, {
|
|
38113
38572
|
"content-type": `multipart/x-mixed-replace; boundary=${MJPEG_BOUNDARY}`,
|
|
38114
38573
|
"cache-control": "no-store",
|
|
@@ -38136,11 +38595,13 @@ var TerminalCameraRelay = class {
|
|
|
38136
38595
|
res.end("terminal camera unavailable");
|
|
38137
38596
|
} finally {
|
|
38138
38597
|
this.responses.delete(res);
|
|
38598
|
+
state.responses.delete(res);
|
|
38139
38599
|
state.clients = Math.max(0, state.clients - 1);
|
|
38140
|
-
if (state.clients === 0) this.scheduleIdleClose(state);
|
|
38600
|
+
if (state.clients === 0 && state.leases === 0) this.scheduleIdleClose(state);
|
|
38141
38601
|
}
|
|
38142
38602
|
}
|
|
38143
38603
|
scheduleIdleClose(state) {
|
|
38604
|
+
if (state.closed || state.closing || state.clients > 0 || state.leases > 0) return;
|
|
38144
38605
|
if (state.idleTimer) clearTimeout(state.idleTimer);
|
|
38145
38606
|
state.idleTimer = setTimeout(() => {
|
|
38146
38607
|
this.closeState(state);
|
|
@@ -38148,26 +38609,116 @@ var TerminalCameraRelay = class {
|
|
|
38148
38609
|
state.idleTimer.unref?.();
|
|
38149
38610
|
}
|
|
38150
38611
|
async closeState(state) {
|
|
38151
|
-
if (state.
|
|
38152
|
-
if (state.
|
|
38612
|
+
if (state.closed) return;
|
|
38613
|
+
if (state.clients > 0 || state.leases > 0) return;
|
|
38614
|
+
if (state.closePromise) {
|
|
38615
|
+
await state.closePromise;
|
|
38616
|
+
return;
|
|
38617
|
+
}
|
|
38618
|
+
if (state.openPromise && !state.sessionId) {
|
|
38619
|
+
state.closing = true;
|
|
38620
|
+
if (state.idleTimer) clearTimeout(state.idleTimer);
|
|
38621
|
+
state.idleTimer = null;
|
|
38622
|
+
return;
|
|
38623
|
+
}
|
|
38624
|
+
if (state.closing && !state.sessionId) return;
|
|
38625
|
+
state.closing = true;
|
|
38626
|
+
if (state.idleTimer) clearTimeout(state.idleTimer);
|
|
38627
|
+
state.idleTimer = null;
|
|
38628
|
+
state.closePromise = this.closeWithRetries(state).finally(() => {
|
|
38629
|
+
state.closePromise = null;
|
|
38630
|
+
});
|
|
38631
|
+
await state.closePromise;
|
|
38632
|
+
}
|
|
38633
|
+
async closeWithRetries(state) {
|
|
38634
|
+
const sessionId = state.sessionId;
|
|
38635
|
+
if (!sessionId) {
|
|
38636
|
+
this.finishClose(state);
|
|
38637
|
+
return;
|
|
38638
|
+
}
|
|
38639
|
+
for (let attempt = 0; attempt < CLOSE_ATTEMPTS_PER_PASS; attempt += 1) try {
|
|
38640
|
+
await this.api.closeSession(state.nodeId, sessionId);
|
|
38641
|
+
if (state.sessionId === sessionId) this.finishClose(state);
|
|
38642
|
+
return;
|
|
38643
|
+
} catch (error) {
|
|
38644
|
+
state.closeAttempts += 1;
|
|
38153
38645
|
this.logger.warn("terminal camera session close failed", { meta: {
|
|
38154
38646
|
nodeId: state.nodeId,
|
|
38155
|
-
sessionId
|
|
38647
|
+
sessionId,
|
|
38648
|
+
attempt: state.closeAttempts,
|
|
38156
38649
|
error: error instanceof Error ? error.message : String(error)
|
|
38157
38650
|
} });
|
|
38158
|
-
|
|
38651
|
+
if (attempt + 1 < CLOSE_ATTEMPTS_PER_PASS) await new Promise((resolve) => setTimeout(resolve, this.closeRetryDelay(state.closeAttempts)));
|
|
38652
|
+
}
|
|
38653
|
+
this.scheduleCloseRetry(state);
|
|
38654
|
+
}
|
|
38655
|
+
scheduleCloseRetry(state) {
|
|
38656
|
+
if (state.closeRetryTimer || !state.sessionId) return;
|
|
38657
|
+
state.closeRetryTimer = setTimeout(() => {
|
|
38658
|
+
state.closeRetryTimer = null;
|
|
38659
|
+
state.closing = false;
|
|
38660
|
+
this.closeState(state);
|
|
38661
|
+
}, this.closeRetryDelay(state.closeAttempts));
|
|
38662
|
+
state.closeRetryTimer.unref?.();
|
|
38663
|
+
}
|
|
38664
|
+
closeRetryDelay(attempt) {
|
|
38665
|
+
return Math.min(CLOSE_RETRY_BASE_MS * 2 ** Math.min(attempt - 1, 5), CLOSE_RETRY_MAX_MS);
|
|
38666
|
+
}
|
|
38667
|
+
finishClose(state) {
|
|
38668
|
+
if (state.closed) return;
|
|
38669
|
+
state.closed = true;
|
|
38670
|
+
if (state.closeRetryTimer) clearTimeout(state.closeRetryTimer);
|
|
38671
|
+
state.closeRetryTimer = null;
|
|
38672
|
+
state.sessionId = null;
|
|
38673
|
+
state.closeAttempts = 0;
|
|
38674
|
+
state.closing = false;
|
|
38675
|
+
if (this.states.get(relayKey(state.instanceId)) === state) this.states.delete(relayKey(state.instanceId));
|
|
38159
38676
|
state.screen.dispose();
|
|
38160
|
-
|
|
38677
|
+
}
|
|
38678
|
+
/**
|
|
38679
|
+
* Capture one fresh JPEG using the same xterm renderer as the MJPEG relay.
|
|
38680
|
+
* A snapshot-only caller owns a short lease and tears the state down as soon
|
|
38681
|
+
* as the image is rendered, so snapshots never leave a monitor PTY running.
|
|
38682
|
+
*/
|
|
38683
|
+
async snapshot(instanceId, nodeId, profileId) {
|
|
38684
|
+
const state = this.state(instanceId, nodeId, profileId);
|
|
38685
|
+
if (state.idleTimer) clearTimeout(state.idleTimer);
|
|
38686
|
+
state.idleTimer = null;
|
|
38687
|
+
state.leases += 1;
|
|
38688
|
+
try {
|
|
38689
|
+
return await this.nextFrame(state, true, true);
|
|
38690
|
+
} finally {
|
|
38691
|
+
state.leases = Math.max(0, state.leases - 1);
|
|
38692
|
+
if (state.clients === 0 && state.leases === 0) await this.closeState(state);
|
|
38693
|
+
}
|
|
38694
|
+
}
|
|
38695
|
+
/** Stop a withdrawn/offline camera's relay, including active HTTP readers. */
|
|
38696
|
+
async closeInstance(instanceId) {
|
|
38697
|
+
const state = this.states.get(relayKey(instanceId));
|
|
38698
|
+
if (!state) return;
|
|
38699
|
+
for (const response of state.responses) response.destroy();
|
|
38700
|
+
state.responses.clear();
|
|
38701
|
+
state.clients = 0;
|
|
38702
|
+
state.leases = 0;
|
|
38703
|
+
await this.closeState(state);
|
|
38704
|
+
if (state.openPromise) {
|
|
38705
|
+
await state.openPromise.catch(() => {});
|
|
38706
|
+
await this.closeState(state);
|
|
38707
|
+
}
|
|
38161
38708
|
}
|
|
38162
38709
|
async dispose() {
|
|
38163
38710
|
for (const response of this.responses) response.destroy();
|
|
38164
38711
|
this.responses.clear();
|
|
38165
|
-
for (const state of this.states.values()) {
|
|
38712
|
+
for (const state of [...this.states.values()]) {
|
|
38166
38713
|
if (state.idleTimer) clearTimeout(state.idleTimer);
|
|
38167
38714
|
state.clients = 0;
|
|
38715
|
+
state.leases = 0;
|
|
38168
38716
|
await this.closeState(state);
|
|
38717
|
+
if (state.openPromise) {
|
|
38718
|
+
await state.openPromise.catch(() => {});
|
|
38719
|
+
await this.closeState(state);
|
|
38720
|
+
}
|
|
38169
38721
|
}
|
|
38170
|
-
this.states.clear();
|
|
38171
38722
|
if (this.server) {
|
|
38172
38723
|
const server = this.server;
|
|
38173
38724
|
this.server = null;
|
|
@@ -38283,6 +38834,113 @@ function createTerminalDataPlaneHandler(manager) {
|
|
|
38283
38834
|
};
|
|
38284
38835
|
}
|
|
38285
38836
|
//#endregion
|
|
38837
|
+
//#region src/terminal-instances.ts
|
|
38838
|
+
var TerminalInstanceSchema = object({
|
|
38839
|
+
id: string().uuid(),
|
|
38840
|
+
cameraStableId: string().min(1).max(256),
|
|
38841
|
+
nodeId: string().min(1).max(256),
|
|
38842
|
+
profileId: string().min(1).max(64),
|
|
38843
|
+
profileLabel: string().min(1).max(120),
|
|
38844
|
+
name: string().min(1).max(160),
|
|
38845
|
+
enabled: boolean()
|
|
38846
|
+
});
|
|
38847
|
+
/**
|
|
38848
|
+
* Config is operator-writable, so malformed or duplicate rows are ignored
|
|
38849
|
+
* rather than allowed to make declaration reconciliation destructive.
|
|
38850
|
+
*/
|
|
38851
|
+
function readTerminalInstances(raw, onInvalid) {
|
|
38852
|
+
const ids = /* @__PURE__ */ new Set();
|
|
38853
|
+
const stableIds = /* @__PURE__ */ new Set();
|
|
38854
|
+
const instances = [];
|
|
38855
|
+
for (const value of raw) {
|
|
38856
|
+
const parsed = TerminalInstanceSchema.safeParse(value);
|
|
38857
|
+
if (!parsed.success) {
|
|
38858
|
+
onInvalid?.("Ignoring malformed Terminal instance configuration");
|
|
38859
|
+
continue;
|
|
38860
|
+
}
|
|
38861
|
+
const instance = parsed.data;
|
|
38862
|
+
if (ids.has(instance.id) || stableIds.has(instance.cameraStableId)) {
|
|
38863
|
+
onInvalid?.(`Ignoring duplicate Terminal instance ${instance.id}`);
|
|
38864
|
+
continue;
|
|
38865
|
+
}
|
|
38866
|
+
ids.add(instance.id);
|
|
38867
|
+
stableIds.add(instance.cameraStableId);
|
|
38868
|
+
instances.push(instance);
|
|
38869
|
+
}
|
|
38870
|
+
return instances;
|
|
38871
|
+
}
|
|
38872
|
+
function newTerminalCameraStableId(instanceId) {
|
|
38873
|
+
return `terminal-camera-instance-${instanceId}`;
|
|
38874
|
+
}
|
|
38875
|
+
/**
|
|
38876
|
+
* Legacy automatic cameras are migration candidates only. A tombstone is
|
|
38877
|
+
* durable deletion intent, so a lingering failed device removal must never
|
|
38878
|
+
* make that camera adoptable again.
|
|
38879
|
+
*/
|
|
38880
|
+
function listLegacyTerminalCameraCandidates(rows, instanceStableIds, tombstones) {
|
|
38881
|
+
const legacy = [];
|
|
38882
|
+
for (const row of rows) {
|
|
38883
|
+
if (tombstones.has(row.stableId) || instanceStableIds.has(row.stableId) || row.stableId.startsWith("terminal-camera-instance-") || !row.stableId.startsWith("terminal-camera-")) continue;
|
|
38884
|
+
const nodeId = typeof row.config.nodeId === "string" ? row.config.nodeId : null;
|
|
38885
|
+
if (!nodeId) continue;
|
|
38886
|
+
const profileId = typeof row.config.profileId === "string" ? row.config.profileId : "monitor";
|
|
38887
|
+
const profileLabel = typeof row.config.profileLabel === "string" ? row.config.profileLabel : profileId === "monitor" ? "BTM" : profileId;
|
|
38888
|
+
legacy.push({
|
|
38889
|
+
stableId: row.stableId,
|
|
38890
|
+
nodeId,
|
|
38891
|
+
profileId,
|
|
38892
|
+
profileLabel,
|
|
38893
|
+
name: row.name,
|
|
38894
|
+
adoptable: profileId === "monitor" && row.stableId === `terminal-camera-${nodeId}`
|
|
38895
|
+
});
|
|
38896
|
+
}
|
|
38897
|
+
return legacy;
|
|
38898
|
+
}
|
|
38899
|
+
/** Serializes config read-modify-write operations and their reconciliation. */
|
|
38900
|
+
var TerminalInstanceMutationQueue = class {
|
|
38901
|
+
tail = Promise.resolve();
|
|
38902
|
+
async run(mutation) {
|
|
38903
|
+
const previous = this.tail;
|
|
38904
|
+
let release;
|
|
38905
|
+
this.tail = new Promise((resolve) => {
|
|
38906
|
+
release = resolve;
|
|
38907
|
+
});
|
|
38908
|
+
await previous;
|
|
38909
|
+
try {
|
|
38910
|
+
return await mutation();
|
|
38911
|
+
} finally {
|
|
38912
|
+
release?.();
|
|
38913
|
+
}
|
|
38914
|
+
}
|
|
38915
|
+
};
|
|
38916
|
+
/**
|
|
38917
|
+
* Coalesces periodic/config reconciliation requests onto the same serialized
|
|
38918
|
+
* lane as instance mutations. A pass never applies a declaration snapshot
|
|
38919
|
+
* concurrently with a create/delete/enable write.
|
|
38920
|
+
*/
|
|
38921
|
+
var TerminalInstanceReconcileCoordinator = class {
|
|
38922
|
+
queue;
|
|
38923
|
+
dirty = false;
|
|
38924
|
+
running = null;
|
|
38925
|
+
constructor(queue) {
|
|
38926
|
+
this.queue = queue;
|
|
38927
|
+
}
|
|
38928
|
+
request(apply) {
|
|
38929
|
+
this.dirty = true;
|
|
38930
|
+
if (this.running) return this.running;
|
|
38931
|
+
const running = this.queue.run(async () => {
|
|
38932
|
+
while (this.dirty) {
|
|
38933
|
+
this.dirty = false;
|
|
38934
|
+
await apply();
|
|
38935
|
+
}
|
|
38936
|
+
});
|
|
38937
|
+
this.running = running.finally(() => {
|
|
38938
|
+
this.running = null;
|
|
38939
|
+
});
|
|
38940
|
+
return this.running;
|
|
38941
|
+
}
|
|
38942
|
+
};
|
|
38943
|
+
//#endregion
|
|
38286
38944
|
//#region src/profiles.ts
|
|
38287
38945
|
/**
|
|
38288
38946
|
* The allowlist of programs an operator may open. The capability accepts a
|
|
@@ -38411,6 +39069,7 @@ var TerminalSessionManager = class {
|
|
|
38411
39069
|
now;
|
|
38412
39070
|
resolveBinary;
|
|
38413
39071
|
maxSessions;
|
|
39072
|
+
instanceControl = null;
|
|
38414
39073
|
constructor(opts) {
|
|
38415
39074
|
this.opts = opts;
|
|
38416
39075
|
this.profiles = buildProfiles({
|
|
@@ -38449,6 +39108,27 @@ var TerminalSessionManager = class {
|
|
|
38449
39108
|
...p.description !== void 0 ? { description: p.description } : {}
|
|
38450
39109
|
}));
|
|
38451
39110
|
}
|
|
39111
|
+
setInstanceControl(control) {
|
|
39112
|
+
this.instanceControl = control;
|
|
39113
|
+
}
|
|
39114
|
+
async listInstances() {
|
|
39115
|
+
return this.instanceControl?.listInstances() ?? [];
|
|
39116
|
+
}
|
|
39117
|
+
async createInstance(input) {
|
|
39118
|
+
return this.requireInstanceControl().createInstance(input);
|
|
39119
|
+
}
|
|
39120
|
+
async deleteInstance(input) {
|
|
39121
|
+
await this.requireInstanceControl().deleteInstance(input);
|
|
39122
|
+
}
|
|
39123
|
+
async setInstanceEnabled(input) {
|
|
39124
|
+
return this.requireInstanceControl().setInstanceEnabled(input);
|
|
39125
|
+
}
|
|
39126
|
+
async listLegacyCameras() {
|
|
39127
|
+
return this.instanceControl?.listLegacyCameras() ?? [];
|
|
39128
|
+
}
|
|
39129
|
+
async adoptLegacyMonitor(input) {
|
|
39130
|
+
return this.requireInstanceControl().adoptLegacyMonitor(input);
|
|
39131
|
+
}
|
|
38452
39132
|
async listSessions() {
|
|
38453
39133
|
return [...this.sessions.values()].filter((s) => !s.exited).map((s) => s.info);
|
|
38454
39134
|
}
|
|
@@ -38496,10 +39176,12 @@ var TerminalSessionManager = class {
|
|
|
38496
39176
|
outputWaiters: /* @__PURE__ */ new Set(),
|
|
38497
39177
|
outputChars: 0,
|
|
38498
39178
|
nextSeq: 1,
|
|
38499
|
-
exited: false
|
|
39179
|
+
exited: false,
|
|
39180
|
+
disposed: false
|
|
38500
39181
|
};
|
|
38501
39182
|
this.sessions.set(sessionId, session);
|
|
38502
39183
|
pty.onData((data) => {
|
|
39184
|
+
if (session.exited) return;
|
|
38503
39185
|
session.screen.write(data);
|
|
38504
39186
|
this.appendOutput(session, {
|
|
38505
39187
|
kind: "data",
|
|
@@ -38513,27 +39195,7 @@ var TerminalSessionManager = class {
|
|
|
38513
39195
|
} catch {}
|
|
38514
39196
|
});
|
|
38515
39197
|
pty.onExit((event) => {
|
|
38516
|
-
|
|
38517
|
-
session.lastExit = event;
|
|
38518
|
-
this.appendOutput(session, {
|
|
38519
|
-
kind: "exit",
|
|
38520
|
-
exitCode: event.exitCode,
|
|
38521
|
-
...event.signal !== void 0 ? { signal: event.signal } : {}
|
|
38522
|
-
});
|
|
38523
|
-
for (const sink of session.sinks) try {
|
|
38524
|
-
sink({
|
|
38525
|
-
kind: "exit",
|
|
38526
|
-
exitCode: event.exitCode,
|
|
38527
|
-
signal: event.signal
|
|
38528
|
-
});
|
|
38529
|
-
} catch {}
|
|
38530
|
-
session.sinks.clear();
|
|
38531
|
-
const retire = setTimeout(() => {
|
|
38532
|
-
session.screen.dispose();
|
|
38533
|
-
this.sessions.delete(sessionId);
|
|
38534
|
-
}, EXITED_RETENTION_MS);
|
|
38535
|
-
retire.unref?.();
|
|
38536
|
-
session.retireTimer = retire;
|
|
39198
|
+
this.finishSession(sessionId, session, event, true);
|
|
38537
39199
|
});
|
|
38538
39200
|
this.opts.logger.info("terminal: session opened", { meta: {
|
|
38539
39201
|
sessionId,
|
|
@@ -38564,6 +39226,7 @@ var TerminalSessionManager = class {
|
|
|
38564
39226
|
async close(input) {
|
|
38565
39227
|
const session = this.sessions.get(input.sessionId);
|
|
38566
39228
|
if (!session) return;
|
|
39229
|
+
this.finishSession(input.sessionId, session, { exitCode: 0 }, false);
|
|
38567
39230
|
try {
|
|
38568
39231
|
session.pty.kill();
|
|
38569
39232
|
} catch {}
|
|
@@ -38572,7 +39235,7 @@ var TerminalSessionManager = class {
|
|
|
38572
39235
|
async pullOutput(input) {
|
|
38573
39236
|
const session = this.sessions.get(input.sessionId);
|
|
38574
39237
|
if (!session) throw new Error(`No such terminal session: ${input.sessionId}`);
|
|
38575
|
-
if (input.afterSeq > 0 && input.afterSeq === session.nextSeq - 1 && !session.exited && (input.waitMs ?? 0) > 0) await new Promise((resolve) => {
|
|
39238
|
+
if ((input.afterSeq > 0 || input.waitForOutput === true) && input.afterSeq === session.nextSeq - 1 && !session.exited && (input.waitMs ?? 0) > 0) await new Promise((resolve) => {
|
|
38576
39239
|
const wake = () => {
|
|
38577
39240
|
clearTimeout(timer);
|
|
38578
39241
|
session.outputWaiters.delete(wake);
|
|
@@ -38583,6 +39246,11 @@ var TerminalSessionManager = class {
|
|
|
38583
39246
|
session.outputWaiters.add(wake);
|
|
38584
39247
|
});
|
|
38585
39248
|
const cursor = session.nextSeq - 1;
|
|
39249
|
+
if (session.disposed) return {
|
|
39250
|
+
cursor,
|
|
39251
|
+
reset: false,
|
|
39252
|
+
events: session.output.filter((event) => event.seq > input.afterSeq)
|
|
39253
|
+
};
|
|
38586
39254
|
const oldestSeq = session.output[0]?.seq ?? session.nextSeq;
|
|
38587
39255
|
if (input.afterSeq === 0 || input.afterSeq < oldestSeq - 1 || input.afterSeq > cursor) {
|
|
38588
39256
|
await session.screen.flush();
|
|
@@ -38663,14 +39331,51 @@ var TerminalSessionManager = class {
|
|
|
38663
39331
|
}
|
|
38664
39332
|
/** Kill every live session — called on addon shutdown. */
|
|
38665
39333
|
disposeAll() {
|
|
38666
|
-
for (const session of this.sessions
|
|
38667
|
-
|
|
39334
|
+
for (const [sessionId, session] of this.sessions) {
|
|
39335
|
+
this.finishSession(sessionId, session, { exitCode: 0 }, false);
|
|
38668
39336
|
try {
|
|
38669
39337
|
session.pty.kill();
|
|
38670
39338
|
} catch {}
|
|
38671
|
-
session.screen.dispose();
|
|
38672
39339
|
}
|
|
38673
|
-
|
|
39340
|
+
}
|
|
39341
|
+
finishSession(sessionId, session, exit, retainForLateExit) {
|
|
39342
|
+
if (session.exited) return;
|
|
39343
|
+
session.exited = true;
|
|
39344
|
+
session.lastExit = exit;
|
|
39345
|
+
this.appendOutput(session, {
|
|
39346
|
+
kind: "exit",
|
|
39347
|
+
exitCode: exit.exitCode,
|
|
39348
|
+
...exit.signal !== void 0 ? { signal: exit.signal } : {}
|
|
39349
|
+
});
|
|
39350
|
+
for (const sink of session.sinks) try {
|
|
39351
|
+
sink({
|
|
39352
|
+
kind: "exit",
|
|
39353
|
+
exitCode: exit.exitCode,
|
|
39354
|
+
...exit.signal !== void 0 ? { signal: exit.signal } : {}
|
|
39355
|
+
});
|
|
39356
|
+
} catch {}
|
|
39357
|
+
session.sinks.clear();
|
|
39358
|
+
if (!retainForLateExit) {
|
|
39359
|
+
this.disposeSession(session);
|
|
39360
|
+
this.sessions.delete(sessionId);
|
|
39361
|
+
return;
|
|
39362
|
+
}
|
|
39363
|
+
const retire = setTimeout(() => {
|
|
39364
|
+
this.disposeSession(session);
|
|
39365
|
+
this.sessions.delete(sessionId);
|
|
39366
|
+
}, EXITED_RETENTION_MS);
|
|
39367
|
+
retire.unref?.();
|
|
39368
|
+
session.retireTimer = retire;
|
|
39369
|
+
}
|
|
39370
|
+
disposeSession(session) {
|
|
39371
|
+
if (session.retireTimer !== void 0) clearTimeout(session.retireTimer);
|
|
39372
|
+
if (session.disposed) return;
|
|
39373
|
+
session.disposed = true;
|
|
39374
|
+
session.screen.dispose();
|
|
39375
|
+
}
|
|
39376
|
+
requireInstanceControl() {
|
|
39377
|
+
if (!this.instanceControl) throw new Error("Terminal instances are managed on the hub");
|
|
39378
|
+
return this.instanceControl;
|
|
38674
39379
|
}
|
|
38675
39380
|
};
|
|
38676
39381
|
//#endregion
|
|
@@ -38688,7 +39393,9 @@ var DEFAULTS = {
|
|
|
38688
39393
|
allowShell: false,
|
|
38689
39394
|
shellPath: "",
|
|
38690
39395
|
maxSessions: 4,
|
|
38691
|
-
customProfiles: []
|
|
39396
|
+
customProfiles: [],
|
|
39397
|
+
terminalInstances: [],
|
|
39398
|
+
terminalCameraTombstones: []
|
|
38692
39399
|
};
|
|
38693
39400
|
var DATA_PLANE_PREFIX = "io";
|
|
38694
39401
|
var CAMERA_RECONCILE_MS = 6e4;
|
|
@@ -38699,6 +39406,11 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
38699
39406
|
cameraRelay = null;
|
|
38700
39407
|
cameraReconcileTimer = null;
|
|
38701
39408
|
cameraProfilesByNode = /* @__PURE__ */ new Map();
|
|
39409
|
+
/** Prevent repeat writes when a legacy device stays live after migration. */
|
|
39410
|
+
migratedTerminalCameraConfigIds = /* @__PURE__ */ new Set();
|
|
39411
|
+
terminalCameraTombstones = /* @__PURE__ */ new Set();
|
|
39412
|
+
instanceMutationQueue = new TerminalInstanceMutationQueue();
|
|
39413
|
+
terminalReconcileCoordinator = new TerminalInstanceReconcileCoordinator(this.instanceMutationQueue);
|
|
38702
39414
|
glancesPythonPath = "";
|
|
38703
39415
|
constructor() {
|
|
38704
39416
|
super({ ...DEFAULTS });
|
|
@@ -38727,18 +39439,22 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
38727
39439
|
customProfiles: this.config.customProfiles
|
|
38728
39440
|
});
|
|
38729
39441
|
this.manager = manager;
|
|
39442
|
+
this.replaceTerminalCameraTombstones(this.config.terminalCameraTombstones);
|
|
38730
39443
|
if (declarationOwnerNodeId(this.ctx.kernel.localNodeId) === "hub") {
|
|
39444
|
+
const localNodeId = declarationOwnerNodeId(this.ctx.kernel.localNodeId);
|
|
38731
39445
|
const cameraRelay = new TerminalCameraRelay({
|
|
38732
|
-
listProfiles: (nodeId) => this.ctx.api.terminalSession.listProfiles.query(void 0, nodePin(nodeId)),
|
|
38733
|
-
openSession: (nodeId, input) => this.ctx.api.terminalSession.openSession.mutate(input, nodePin(nodeId)),
|
|
38734
|
-
pullOutput: (nodeId, input) => this.ctx.api.terminalSession.pullOutput.mutate(input, nodePin(nodeId)),
|
|
39446
|
+
listProfiles: (nodeId) => nodeId === localNodeId ? manager.listProfiles() : this.ctx.api.terminalSession.listProfiles.query(void 0, nodePin(nodeId)),
|
|
39447
|
+
openSession: (nodeId, input) => nodeId === localNodeId ? manager.openSession(input) : this.ctx.api.terminalSession.openSession.mutate(input, nodePin(nodeId)),
|
|
39448
|
+
pullOutput: (nodeId, input) => nodeId === localNodeId ? manager.pullOutput(input) : this.ctx.api.terminalSession.pullOutput.mutate(input, nodePin(nodeId)),
|
|
38735
39449
|
closeSession: async (nodeId, sessionId) => {
|
|
38736
|
-
await
|
|
39450
|
+
if (nodeId === localNodeId) await manager.close({ sessionId });
|
|
39451
|
+
else await this.ctx.api.terminalSession.close.mutate({ sessionId }, nodePin(nodeId));
|
|
38737
39452
|
}
|
|
38738
39453
|
}, this.ctx.logger.child("camera"));
|
|
38739
39454
|
await cameraRelay.start();
|
|
38740
39455
|
this.cameraRelay = cameraRelay;
|
|
38741
39456
|
installTerminalCameraRelay(cameraRelay);
|
|
39457
|
+
manager.setInstanceControl(this.terminalInstanceControl());
|
|
38742
39458
|
await this.reconcileTerminalCameras().catch((error) => {
|
|
38743
39459
|
this.ctx.logger.warn("initial terminal camera reconciliation failed — will retry", { meta: { error: error instanceof Error ? error.message : String(error) } });
|
|
38744
39460
|
});
|
|
@@ -38763,6 +39479,7 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
38763
39479
|
}];
|
|
38764
39480
|
}
|
|
38765
39481
|
async onConfigChanged() {
|
|
39482
|
+
this.replaceTerminalCameraTombstones(this.config.terminalCameraTombstones);
|
|
38766
39483
|
this.manager?.reconfigureProfiles({
|
|
38767
39484
|
btmPath: this.config.btmPath,
|
|
38768
39485
|
btmEnabled: this.config.btmEnabled,
|
|
@@ -38779,6 +39496,9 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
38779
39496
|
maxSessions: this.config.maxSessions,
|
|
38780
39497
|
customProfiles: this.config.customProfiles
|
|
38781
39498
|
});
|
|
39499
|
+
if (this.cameraRelay) this.reconcileTerminalCameras().catch((error) => {
|
|
39500
|
+
this.ctx.logger.warn("terminal camera reconciliation after config change failed", { meta: { error: error instanceof Error ? error.message : String(error) } });
|
|
39501
|
+
});
|
|
38782
39502
|
}
|
|
38783
39503
|
async onShutdown() {
|
|
38784
39504
|
if (this.cameraReconcileTimer) clearInterval(this.cameraReconcileTimer);
|
|
@@ -38794,55 +39514,46 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
38794
39514
|
this.manager = null;
|
|
38795
39515
|
}
|
|
38796
39516
|
async reconcileTerminalCameras() {
|
|
39517
|
+
return this.terminalReconcileCoordinator.request(() => this.applyTerminalCameraReconciliation());
|
|
39518
|
+
}
|
|
39519
|
+
async applyTerminalCameraReconciliation() {
|
|
38797
39520
|
if (!this.cameraRelay) return;
|
|
39521
|
+
let terminalIntegrationId;
|
|
38798
39522
|
const topology = await this.ctx.api.nodes.topology.query();
|
|
38799
39523
|
if (topology.length === 0) throw new Error("cluster topology returned no nodes; refusing to withdraw declared cameras");
|
|
38800
39524
|
const nodes = topology.filter((node) => typeof node.id === "string" && node.id.length > 0);
|
|
39525
|
+
const nodeIds = new Set(nodes.map((node) => node.id));
|
|
39526
|
+
for (const cachedNodeId of this.cameraProfilesByNode.keys()) if (!nodeIds.has(cachedNodeId)) this.cameraProfilesByNode.delete(cachedNodeId);
|
|
38801
39527
|
const unavailableNodeIds = /* @__PURE__ */ new Set();
|
|
38802
39528
|
await Promise.all(nodes.map(async (node) => {
|
|
38803
39529
|
try {
|
|
38804
39530
|
this.cameraProfilesByNode.set(node.id, await this.cameraRelay.listProfiles(node.id));
|
|
38805
39531
|
} catch (error) {
|
|
38806
|
-
|
|
38807
|
-
|
|
38808
|
-
|
|
38809
|
-
|
|
38810
|
-
|
|
38811
|
-
|
|
38812
|
-
}
|
|
39532
|
+
unavailableNodeIds.add(node.id);
|
|
39533
|
+
this.ctx.logger.warn("terminal profiles unavailable — keeping Terminal instance cameras offline", { meta: {
|
|
39534
|
+
nodeId: node.id,
|
|
39535
|
+
cachedProfiles: this.cameraProfilesByNode.has(node.id),
|
|
39536
|
+
error: error instanceof Error ? error.message : String(error)
|
|
39537
|
+
} });
|
|
38813
39538
|
}
|
|
38814
39539
|
}));
|
|
38815
|
-
const cameraDeclarations =
|
|
38816
|
-
|
|
38817
|
-
const existing = await this.ctx.api.deviceManager.listAll.query({ addonId: this.ctx.id });
|
|
38818
|
-
const unavailableNodes = nodes.filter((node) => unavailableNodeIds.has(node.id)).sort((left, right) => right.id.length - left.id.length);
|
|
38819
|
-
for (const row of existing) {
|
|
38820
|
-
const node = unavailableNodes.find((candidate) => {
|
|
38821
|
-
const base = `terminal-camera-${candidate.id}`;
|
|
38822
|
-
return row.stableId === base || row.stableId.startsWith(`${base}-`);
|
|
38823
|
-
});
|
|
38824
|
-
if (!node || cameraDeclarations.some((camera) => camera.stableId === row.stableId)) continue;
|
|
38825
|
-
const base = `terminal-camera-${node.id}`;
|
|
38826
|
-
const profileId = row.stableId === base ? "monitor" : row.stableId.slice(base.length + 1);
|
|
38827
|
-
const profileLabel = profileId === "monitor" ? "BTM" : profileId;
|
|
38828
|
-
cameraDeclarations.push({
|
|
38829
|
-
stableId: row.stableId,
|
|
38830
|
-
name: `Terminal ${profileLabel} - ${node.isHub ? "Hub" : node.name}`,
|
|
38831
|
-
config: {
|
|
38832
|
-
nodeId: node.id,
|
|
38833
|
-
profileId,
|
|
38834
|
-
profileLabel
|
|
38835
|
-
}
|
|
38836
|
-
});
|
|
38837
|
-
}
|
|
38838
|
-
}
|
|
39540
|
+
const cameraDeclarations = buildTerminalInstanceCameraDeclarations(this.terminalInstances());
|
|
39541
|
+
const declarationsByStableId = new Map(cameraDeclarations.map((camera) => [camera.stableId, camera]));
|
|
38839
39542
|
const result = await new DeclaredDevices({
|
|
38840
39543
|
logger: this.ctx.logger.child("camera-declaration"),
|
|
38841
39544
|
addonId: this.ctx.id,
|
|
38842
39545
|
devices: this.ctx.kernel.devices,
|
|
38843
39546
|
localNodeId: this.ctx.kernel.localNodeId,
|
|
38844
|
-
getIntegration: async (addonId) =>
|
|
38845
|
-
|
|
39547
|
+
getIntegration: async (addonId) => {
|
|
39548
|
+
const integration = await this.ctx.api.integrations.getByAddonId.query({ addonId });
|
|
39549
|
+
terminalIntegrationId = integration?.id ?? null;
|
|
39550
|
+
return integration;
|
|
39551
|
+
},
|
|
39552
|
+
createIntegration: async (input) => {
|
|
39553
|
+
const integration = await this.ctx.api.integrations.create.mutate(input);
|
|
39554
|
+
terminalIntegrationId = integration.id;
|
|
39555
|
+
return integration;
|
|
39556
|
+
},
|
|
38846
39557
|
updateIntegration: async ({ id, info }) => {
|
|
38847
39558
|
await this.ctx.api.integrations.update.mutate({
|
|
38848
39559
|
id,
|
|
@@ -38850,7 +39561,9 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
38850
39561
|
skipRestart: true
|
|
38851
39562
|
});
|
|
38852
39563
|
},
|
|
38853
|
-
listOwnDevices: async () =>
|
|
39564
|
+
listOwnDevices: async () => {
|
|
39565
|
+
return terminalCameraReconciliationIndex(await this.ctx.api.deviceManager.listAll.query({ addonId: this.ctx.id }), terminalIntegrationId, cameraDeclarations, this.terminalCameraTombstones);
|
|
39566
|
+
}
|
|
38854
39567
|
}).reconcile({
|
|
38855
39568
|
integrationName: TERMINAL_CAMERA_INTEGRATION,
|
|
38856
39569
|
placement: "hub",
|
|
@@ -38863,12 +39576,142 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
38863
39576
|
role: "terminal-camera"
|
|
38864
39577
|
}))
|
|
38865
39578
|
});
|
|
38866
|
-
const onlineByNode = new Map(nodes.map((node) => [node.id, node.isOnline]));
|
|
39579
|
+
const onlineByNode = new Map(nodes.map((node) => [node.id, node.isOnline && !unavailableNodeIds.has(node.id)]));
|
|
38867
39580
|
for (const outcome of result.devices) if (outcome.device instanceof TerminalCameraDevice) {
|
|
39581
|
+
const declaration = declarationsByStableId.get(outcome.stableId);
|
|
39582
|
+
if (declaration) {
|
|
39583
|
+
const config = outcome.device.config;
|
|
39584
|
+
if (!(config.get("instanceId") === declaration.config.instanceId && config.get("nodeId") === declaration.config.nodeId && config.get("profileId") === declaration.config.profileId && config.get("profileLabel") === declaration.config.profileLabel) || !this.migratedTerminalCameraConfigIds.has(outcome.device.id) && needsTerminalCameraConfigMigration(outcome.device.ctx.persistedConfig ?? {}, declaration)) {
|
|
39585
|
+
await config.setAll(declaration.config);
|
|
39586
|
+
this.migratedTerminalCameraConfigIds.add(outcome.device.id);
|
|
39587
|
+
}
|
|
39588
|
+
}
|
|
38868
39589
|
const nodeId = outcome.device.config.get("nodeId");
|
|
38869
|
-
outcome.device.
|
|
39590
|
+
const profileId = outcome.device.config.get("profileId");
|
|
39591
|
+
const profileAvailable = this.cameraProfilesByNode.get(nodeId)?.some((profile) => profile.profileId === profileId) ?? false;
|
|
39592
|
+
outcome.device.setNodeOnline((onlineByNode.get(nodeId) ?? false) && profileAvailable);
|
|
38870
39593
|
}
|
|
38871
39594
|
}
|
|
39595
|
+
terminalInstanceControl() {
|
|
39596
|
+
return {
|
|
39597
|
+
listInstances: async () => this.terminalInstances().map((instance) => this.instanceInfo(instance)),
|
|
39598
|
+
createInstance: async (input) => this.createTerminalInstance(input),
|
|
39599
|
+
deleteInstance: async ({ instanceId }) => this.deleteTerminalInstance(instanceId),
|
|
39600
|
+
setInstanceEnabled: async ({ instanceId, enabled }) => this.setTerminalInstanceEnabled(instanceId, enabled),
|
|
39601
|
+
listLegacyCameras: async () => this.listLegacyTerminalCameras(),
|
|
39602
|
+
adoptLegacyMonitor: async ({ stableId, name }) => this.adoptLegacyMonitor(stableId, name)
|
|
39603
|
+
};
|
|
39604
|
+
}
|
|
39605
|
+
terminalInstances() {
|
|
39606
|
+
return readTerminalInstances(this.config.terminalInstances, (message) => {
|
|
39607
|
+
this.ctx.logger.warn(message);
|
|
39608
|
+
});
|
|
39609
|
+
}
|
|
39610
|
+
instanceInfo(instance) {
|
|
39611
|
+
return {
|
|
39612
|
+
instanceId: instance.id,
|
|
39613
|
+
cameraStableId: instance.cameraStableId,
|
|
39614
|
+
nodeId: instance.nodeId,
|
|
39615
|
+
profileId: instance.profileId,
|
|
39616
|
+
profileLabel: instance.profileLabel,
|
|
39617
|
+
name: instance.name,
|
|
39618
|
+
enabled: instance.enabled
|
|
39619
|
+
};
|
|
39620
|
+
}
|
|
39621
|
+
replaceTerminalCameraTombstones(stableIds) {
|
|
39622
|
+
this.terminalCameraTombstones.clear();
|
|
39623
|
+
for (const stableId of stableIds) if (typeof stableId === "string" && stableId.length > 0) this.terminalCameraTombstones.add(stableId);
|
|
39624
|
+
}
|
|
39625
|
+
async createTerminalInstance(input) {
|
|
39626
|
+
const instance = await this.instanceMutationQueue.run(() => this.createTerminalInstanceUnlocked(input));
|
|
39627
|
+
await this.reconcileTerminalCameras();
|
|
39628
|
+
return instance;
|
|
39629
|
+
}
|
|
39630
|
+
async createTerminalInstanceUnlocked(input) {
|
|
39631
|
+
const relay = this.cameraRelay;
|
|
39632
|
+
if (!relay) throw new Error("Terminal instances are managed on the hub");
|
|
39633
|
+
const profile = (await relay.listProfiles(input.targetNodeId)).find((candidate) => candidate.profileId === input.profileId);
|
|
39634
|
+
if (!profile) throw new Error(`Terminal profile '${input.profileId}' is not available on ${input.targetNodeId}`);
|
|
39635
|
+
const node = (await this.ctx.api.nodes.topology.query()).find((candidate) => candidate.id === input.targetNodeId);
|
|
39636
|
+
if (!node) throw new Error(`Terminal node '${input.targetNodeId}' no longer exists`);
|
|
39637
|
+
const id = crypto.randomUUID();
|
|
39638
|
+
const name = input.name?.trim() || `Terminal ${profile.label} - ${node.isHub ? "Hub" : node.name}`;
|
|
39639
|
+
const instance = {
|
|
39640
|
+
id,
|
|
39641
|
+
cameraStableId: newTerminalCameraStableId(id),
|
|
39642
|
+
nodeId: input.targetNodeId,
|
|
39643
|
+
profileId: profile.profileId,
|
|
39644
|
+
profileLabel: profile.label,
|
|
39645
|
+
name,
|
|
39646
|
+
enabled: true
|
|
39647
|
+
};
|
|
39648
|
+
await this.updateGlobalSettings({ terminalInstances: [...this.config.terminalInstances, instance] });
|
|
39649
|
+
return this.instanceInfo(instance);
|
|
39650
|
+
}
|
|
39651
|
+
async deleteTerminalInstance(instanceId) {
|
|
39652
|
+
await this.instanceMutationQueue.run(() => this.deleteTerminalInstanceUnlocked(instanceId));
|
|
39653
|
+
await this.reconcileTerminalCameras();
|
|
39654
|
+
}
|
|
39655
|
+
async deleteTerminalInstanceUnlocked(instanceId) {
|
|
39656
|
+
const instance = this.terminalInstances().find((candidate) => candidate.id === instanceId);
|
|
39657
|
+
if (!instance) return;
|
|
39658
|
+
this.terminalCameraTombstones.add(instance.cameraStableId);
|
|
39659
|
+
await this.cameraRelay?.closeInstance(instance.id);
|
|
39660
|
+
await this.updateGlobalSettings({
|
|
39661
|
+
terminalInstances: this.config.terminalInstances.filter((candidate) => candidate.id !== instanceId),
|
|
39662
|
+
terminalCameraTombstones: [...new Set([...this.config.terminalCameraTombstones, instance.cameraStableId])]
|
|
39663
|
+
});
|
|
39664
|
+
}
|
|
39665
|
+
async setTerminalInstanceEnabled(instanceId, enabled) {
|
|
39666
|
+
const instance = await this.instanceMutationQueue.run(() => this.setTerminalInstanceEnabledUnlocked(instanceId, enabled));
|
|
39667
|
+
await this.reconcileTerminalCameras();
|
|
39668
|
+
return instance;
|
|
39669
|
+
}
|
|
39670
|
+
async setTerminalInstanceEnabledUnlocked(instanceId, enabled) {
|
|
39671
|
+
const instance = this.terminalInstances().find((candidate) => candidate.id === instanceId);
|
|
39672
|
+
if (!instance) throw new Error(`No such Terminal instance: ${instanceId}`);
|
|
39673
|
+
if (!enabled) {
|
|
39674
|
+
this.terminalCameraTombstones.add(instance.cameraStableId);
|
|
39675
|
+
await this.cameraRelay?.closeInstance(instance.id);
|
|
39676
|
+
}
|
|
39677
|
+
const updated = {
|
|
39678
|
+
...instance,
|
|
39679
|
+
enabled
|
|
39680
|
+
};
|
|
39681
|
+
const terminalInstances = this.config.terminalInstances.map((candidate) => candidate.id === instanceId ? updated : candidate);
|
|
39682
|
+
const terminalCameraTombstones = enabled ? this.config.terminalCameraTombstones.filter((stableId) => stableId !== instance.cameraStableId) : [...new Set([...this.config.terminalCameraTombstones, instance.cameraStableId])];
|
|
39683
|
+
await this.updateGlobalSettings({
|
|
39684
|
+
terminalInstances,
|
|
39685
|
+
terminalCameraTombstones
|
|
39686
|
+
});
|
|
39687
|
+
return this.instanceInfo(updated);
|
|
39688
|
+
}
|
|
39689
|
+
async listLegacyTerminalCameras() {
|
|
39690
|
+
const instances = this.terminalInstances();
|
|
39691
|
+
const instanceStableIds = new Set(instances.map((instance) => instance.cameraStableId));
|
|
39692
|
+
return listLegacyTerminalCameraCandidates(await this.ctx.api.deviceManager.listAll.query({ addonId: this.ctx.id }), instanceStableIds, this.terminalCameraTombstones);
|
|
39693
|
+
}
|
|
39694
|
+
async adoptLegacyMonitor(stableId, requestedName) {
|
|
39695
|
+
const instance = await this.instanceMutationQueue.run(() => this.adoptLegacyMonitorUnlocked(stableId, requestedName));
|
|
39696
|
+
await this.reconcileTerminalCameras();
|
|
39697
|
+
return instance;
|
|
39698
|
+
}
|
|
39699
|
+
async adoptLegacyMonitorUnlocked(stableId, requestedName) {
|
|
39700
|
+
if (this.terminalCameraTombstones.has(stableId)) throw new Error("This legacy Terminal camera was deleted and cannot be adopted");
|
|
39701
|
+
const legacy = (await this.listLegacyTerminalCameras()).find((camera) => camera.stableId === stableId);
|
|
39702
|
+
if (!legacy?.adoptable) throw new Error("Only a legacy monitor camera with its original stable id can be adopted");
|
|
39703
|
+
const instance = {
|
|
39704
|
+
id: crypto.randomUUID(),
|
|
39705
|
+
cameraStableId: legacy.stableId,
|
|
39706
|
+
nodeId: legacy.nodeId,
|
|
39707
|
+
profileId: "monitor",
|
|
39708
|
+
profileLabel: legacy.profileLabel,
|
|
39709
|
+
name: requestedName?.trim() || legacy.name,
|
|
39710
|
+
enabled: true
|
|
39711
|
+
};
|
|
39712
|
+
await this.updateGlobalSettings({ terminalInstances: [...this.config.terminalInstances, instance] });
|
|
39713
|
+
return this.instanceInfo(instance);
|
|
39714
|
+
}
|
|
38872
39715
|
globalSettingsSchema() {
|
|
38873
39716
|
return this.schema({ sections: [{
|
|
38874
39717
|
id: "terminal",
|
|
@@ -38979,7 +39822,7 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
38979
39822
|
}, {
|
|
38980
39823
|
id: "terminal-profiles",
|
|
38981
39824
|
title: "Custom profiles",
|
|
38982
|
-
description: "
|
|
39825
|
+
description: "Enabled profiles are available templates on this node. Create a Terminal instance on the Terminal page to declare a camera; session requests only carry the profile ID and commands cannot be overridden by clients.",
|
|
38983
39826
|
columns: 1,
|
|
38984
39827
|
fields: [this.field({
|
|
38985
39828
|
type: "editable-array",
|