@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.js
CHANGED
|
@@ -7627,12 +7627,11 @@ var RecordingConfigSchema = object({
|
|
|
7627
7627
|
/**
|
|
7628
7628
|
* Entity-relocation job state (storage entity-routing spec, Phase 4).
|
|
7629
7629
|
*
|
|
7630
|
-
* One shape shared by the recorder
|
|
7631
|
-
*
|
|
7632
|
-
*
|
|
7633
|
-
*
|
|
7634
|
-
*
|
|
7635
|
-
* row on the owning addon's surface.
|
|
7630
|
+
* One shape shared by the recorder and pipeline-analytics internal movers.
|
|
7631
|
+
* The public admin surface is `storage-migration`; child jobs remain in RAM
|
|
7632
|
+
* because copy-if-absent, verify, delete and index/row repoint are resumable.
|
|
7633
|
+
* Each completed/failed run also lands one durable ops-log row on its owning
|
|
7634
|
+
* addon surface.
|
|
7636
7635
|
*/
|
|
7637
7636
|
var RelocateJobStateSchema = _enum([
|
|
7638
7637
|
"running",
|
|
@@ -7659,19 +7658,100 @@ var RelocateJobSchema = object({
|
|
|
7659
7658
|
finishedAt: number().nullable(),
|
|
7660
7659
|
error: string().nullable()
|
|
7661
7660
|
});
|
|
7661
|
+
/** Profile-derived footage selection used only by the migration coordinator:
|
|
7662
|
+
* `recordings` owns high+mid; `recordingsLow` owns low. */
|
|
7663
|
+
var RelocateFootageClassSchema = _enum(["recordings", "recordingsLow"]);
|
|
7662
7664
|
var RelocateFootageInputSchema = object({
|
|
7663
|
-
deviceId: number().optional(),
|
|
7664
7665
|
fromLocationId: string(),
|
|
7665
7666
|
toLocationId: string(),
|
|
7666
7667
|
entities: array(_enum(["segments"])).optional(),
|
|
7668
|
+
/** Limits relocation to the logical profile class. Omit only for the
|
|
7669
|
+
* pre-orchestration compatibility path. */
|
|
7670
|
+
footageClass: RelocateFootageClassSchema.optional(),
|
|
7667
7671
|
/** Copy throttle in MB/s (default 40) — the drain is a background chore,
|
|
7668
7672
|
* never allowed to starve live writers. */
|
|
7669
7673
|
throttleMbps: number().min(1).max(1e3).optional()
|
|
7670
7674
|
});
|
|
7671
|
-
|
|
7672
|
-
|
|
7675
|
+
/** Internal, lease-scoped participant operation. It is intentionally separate
|
|
7676
|
+
* from persistent recording settings: a migration never changes
|
|
7677
|
+
* `RecordingConfig.enabled` or camera wrapper bindings. */
|
|
7678
|
+
var StorageMigrationLeaseInputSchema = object({ leaseId: string().min(1) });
|
|
7679
|
+
var StorageMigrationFootageMoveInputSchema = RelocateFootageInputSchema.extend({ leaseId: string().min(1) });
|
|
7680
|
+
var StorageMigrationMediaMoveInputSchema = object({
|
|
7673
7681
|
toLocationId: string(),
|
|
7674
7682
|
throttleMbps: number().min(1).max(1e3).optional()
|
|
7683
|
+
}).extend({ leaseId: string().min(1) });
|
|
7684
|
+
/** The independently selectable logical storage classes. `recordings`
|
|
7685
|
+
* encompasses the high and mid segment profiles; `recordingsLow` is low
|
|
7686
|
+
* segments; `eventMedia` is post-analysis blobs. */
|
|
7687
|
+
var StorageMigrationClassSchema = _enum([
|
|
7688
|
+
"recordings",
|
|
7689
|
+
"recordingsLow",
|
|
7690
|
+
"eventMedia"
|
|
7691
|
+
]);
|
|
7692
|
+
/** A destination is always an existing, fully-qualified location id. The
|
|
7693
|
+
* migration API intentionally never changes a source location's `basePath`:
|
|
7694
|
+
* callers create a new `<type>:<slug>` location, then select it here. */
|
|
7695
|
+
var StorageMigrationDestinationsSchema = object({
|
|
7696
|
+
recordings: string().min(1).optional(),
|
|
7697
|
+
recordingsLow: string().min(1).optional(),
|
|
7698
|
+
eventMedia: string().min(1).optional()
|
|
7699
|
+
}).refine((value) => Object.keys(value).length > 0, { message: "select at least one storage class" });
|
|
7700
|
+
/** Shared input for planning and starting an orchestrated storage migration. */
|
|
7701
|
+
var StorageMigrationInputSchema = object({
|
|
7702
|
+
destinations: StorageMigrationDestinationsSchema,
|
|
7703
|
+
throttleMbps: number().min(1).max(1e3).optional()
|
|
7704
|
+
});
|
|
7705
|
+
/** The durable coordinator state machine. The only phase that changes default
|
|
7706
|
+
* locations is `repointing`, after every selected mover has completed and been
|
|
7707
|
+
* verified. */
|
|
7708
|
+
var StorageMigrationPhaseSchema = _enum([
|
|
7709
|
+
"planning",
|
|
7710
|
+
"pausing",
|
|
7711
|
+
"moving",
|
|
7712
|
+
"verifying",
|
|
7713
|
+
"repointing",
|
|
7714
|
+
"refreshing",
|
|
7715
|
+
"resuming",
|
|
7716
|
+
"done",
|
|
7717
|
+
"failed",
|
|
7718
|
+
"cancelled"
|
|
7719
|
+
]);
|
|
7720
|
+
var StorageMigrationParticipantSchema = _enum([
|
|
7721
|
+
"pipeline",
|
|
7722
|
+
"recorder",
|
|
7723
|
+
"analytics"
|
|
7724
|
+
]);
|
|
7725
|
+
var StorageMigrationMoveSchema = object({
|
|
7726
|
+
storageClass: StorageMigrationClassSchema,
|
|
7727
|
+
fromLocationId: string(),
|
|
7728
|
+
toLocationId: string(),
|
|
7729
|
+
moverJobId: string().nullable(),
|
|
7730
|
+
state: RelocateJobStateSchema.nullable(),
|
|
7731
|
+
error: string().nullable()
|
|
7732
|
+
});
|
|
7733
|
+
var StorageMigrationJobSchema = object({
|
|
7734
|
+
jobId: string(),
|
|
7735
|
+
phase: StorageMigrationPhaseSchema,
|
|
7736
|
+
destinations: StorageMigrationDestinationsSchema,
|
|
7737
|
+
throttleMbps: number(),
|
|
7738
|
+
moves: array(StorageMigrationMoveSchema),
|
|
7739
|
+
pauseLeaseId: string().nullable(),
|
|
7740
|
+
pausedParticipants: array(StorageMigrationParticipantSchema),
|
|
7741
|
+
repointed: boolean(),
|
|
7742
|
+
cancelRequested: boolean(),
|
|
7743
|
+
startedAt: number(),
|
|
7744
|
+
updatedAt: number(),
|
|
7745
|
+
finishedAt: number().nullable(),
|
|
7746
|
+
error: string().nullable()
|
|
7747
|
+
});
|
|
7748
|
+
var StorageMigrationPlanSchema = object({
|
|
7749
|
+
destinations: StorageMigrationDestinationsSchema,
|
|
7750
|
+
moves: array(object({
|
|
7751
|
+
storageClass: StorageMigrationClassSchema,
|
|
7752
|
+
fromLocationId: string(),
|
|
7753
|
+
toLocationId: string()
|
|
7754
|
+
}))
|
|
7675
7755
|
});
|
|
7676
7756
|
/**
|
|
7677
7757
|
* `StorageLocationType` — an addon-declared id that identifies the *kind* of
|
|
@@ -16270,13 +16350,19 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
|
|
|
16270
16350
|
}), method(object({ deviceId: number() }), EventPruneCountsSchema, {
|
|
16271
16351
|
kind: "mutation",
|
|
16272
16352
|
auth: "admin"
|
|
16273
|
-
}), method(
|
|
16353
|
+
}), method(StorageMigrationLeaseInputSchema, object({ paused: literal(true) }), {
|
|
16274
16354
|
kind: "mutation",
|
|
16275
16355
|
auth: "admin"
|
|
16276
|
-
}), method(object({
|
|
16277
|
-
kind: "
|
|
16356
|
+
}), method(StorageMigrationLeaseInputSchema, object({ resumed: literal(true) }), {
|
|
16357
|
+
kind: "mutation",
|
|
16358
|
+
auth: "admin"
|
|
16359
|
+
}), method(StorageMigrationLeaseInputSchema, object({ refreshed: literal(true) }), {
|
|
16360
|
+
kind: "mutation",
|
|
16278
16361
|
auth: "admin"
|
|
16279
|
-
}), method(object({ jobId: string() }),
|
|
16362
|
+
}), method(StorageMigrationMediaMoveInputSchema, object({ jobId: string() }), {
|
|
16363
|
+
kind: "mutation",
|
|
16364
|
+
auth: "admin"
|
|
16365
|
+
}), method(object({ jobId: string() }), RelocateJobSchema.nullable(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
|
|
16280
16366
|
kind: "mutation",
|
|
16281
16367
|
auth: "admin"
|
|
16282
16368
|
}), method(OpsLogQueryInputSchema, array(OpsLogEntrySchema).readonly(), {
|
|
@@ -17806,7 +17892,13 @@ var NodeInferenceDevicesSchema = object({
|
|
|
17806
17892
|
reachable: boolean(),
|
|
17807
17893
|
devices: array(NodeInferenceDeviceSchema).readonly()
|
|
17808
17894
|
});
|
|
17809
|
-
method(object({
|
|
17895
|
+
method(StorageMigrationLeaseInputSchema, object({ paused: literal(true) }), {
|
|
17896
|
+
kind: "mutation",
|
|
17897
|
+
auth: "admin"
|
|
17898
|
+
}), method(StorageMigrationLeaseInputSchema, object({ resumed: literal(true) }), {
|
|
17899
|
+
kind: "mutation",
|
|
17900
|
+
auth: "admin"
|
|
17901
|
+
}), method(object({
|
|
17810
17902
|
deviceId: number(),
|
|
17811
17903
|
agentNodeId: string()
|
|
17812
17904
|
}), object({ success: literal(true) }), {
|
|
@@ -18246,7 +18338,33 @@ var SnapshotImageSchema = object({
|
|
|
18246
18338
|
base64: string(),
|
|
18247
18339
|
contentType: string()
|
|
18248
18340
|
});
|
|
18249
|
-
|
|
18341
|
+
/**
|
|
18342
|
+
* snapshot — device-scoped capability for camera image capture.
|
|
18343
|
+
*
|
|
18344
|
+
* Two kinds of providers coexist behind this cap name:
|
|
18345
|
+
*
|
|
18346
|
+
* - **Native** providers (kind:'native'): registered per-device by
|
|
18347
|
+
* device-driver addons (RtspCamera, OnvifCamera, …) via
|
|
18348
|
+
* `DeviceContext.registerNativeCap`. Each knows how to fetch a frame
|
|
18349
|
+
* straight from the camera (HTTP snapshot URL, ONVIF action, etc.).
|
|
18350
|
+
*
|
|
18351
|
+
* - **Wrapper** providers (kind:'wrapper'): register as a system
|
|
18352
|
+
* provider (SnapshotAddon in `@camstack/system/builtins/snapshot`). The
|
|
18353
|
+
* wrapper owns the cache and invokes the native via
|
|
18354
|
+
* `ctx.getNativeProvider(snapshotCapability, deviceId)` on miss.
|
|
18355
|
+
*
|
|
18356
|
+
* Device-scoped routing: callers use `ctx.fetchDevice(id).snapshot.*`;
|
|
18357
|
+
* the DeviceProxy auto-injects `deviceId` + `nodeId` and dispatches to
|
|
18358
|
+
* the provider currently active for that device (wrapper wins when
|
|
18359
|
+
* activated via `setWrapperActive`, otherwise the native).
|
|
18360
|
+
*/
|
|
18361
|
+
/**
|
|
18362
|
+
* Live readable snapshot state — diagnostic info that a consumer can
|
|
18363
|
+
* pull to know when the last image was captured, how stale the cache
|
|
18364
|
+
* is, and which stream was used. Distinct from `getSnapshot` which
|
|
18365
|
+
* returns the JPEG itself.
|
|
18366
|
+
*/
|
|
18367
|
+
var SnapshotStatusSchema = object({
|
|
18250
18368
|
/** Ms epoch of the last successful capture. Null if none yet. */
|
|
18251
18369
|
lastCapturedAt: number().nullable(),
|
|
18252
18370
|
/** Age of the cached image in ms. Null if no cache. */
|
|
@@ -18256,64 +18374,129 @@ object({
|
|
|
18256
18374
|
/** Stream id used for the last capture ('high'|'mid'|'low' or custom). Null if via HTTP endpoint. */
|
|
18257
18375
|
lastStreamId: string().nullable()
|
|
18258
18376
|
});
|
|
18259
|
-
|
|
18260
|
-
|
|
18261
|
-
|
|
18262
|
-
|
|
18263
|
-
|
|
18264
|
-
|
|
18265
|
-
|
|
18266
|
-
|
|
18267
|
-
|
|
18268
|
-
|
|
18269
|
-
|
|
18270
|
-
|
|
18271
|
-
|
|
18272
|
-
|
|
18273
|
-
|
|
18274
|
-
|
|
18275
|
-
|
|
18276
|
-
|
|
18277
|
-
|
|
18278
|
-
|
|
18279
|
-
|
|
18280
|
-
|
|
18281
|
-
|
|
18282
|
-
|
|
18283
|
-
|
|
18284
|
-
|
|
18285
|
-
|
|
18286
|
-
|
|
18287
|
-
|
|
18288
|
-
|
|
18377
|
+
var snapshotCapability = {
|
|
18378
|
+
name: "snapshot",
|
|
18379
|
+
scope: "device",
|
|
18380
|
+
deviceNative: true,
|
|
18381
|
+
mode: "singleton",
|
|
18382
|
+
kind: "wrapper",
|
|
18383
|
+
defaultActive: true,
|
|
18384
|
+
deviceTypes: [DeviceType.Camera],
|
|
18385
|
+
exposesDeviceSettings: true,
|
|
18386
|
+
methods: {
|
|
18387
|
+
getSnapshot: method(object({
|
|
18388
|
+
deviceId: number(),
|
|
18389
|
+
streamId: string().optional(),
|
|
18390
|
+
/**
|
|
18391
|
+
* Bypass the cache freshness check and fetch directly from the
|
|
18392
|
+
* native (or stream-broker fallback). Triggered by the UI's
|
|
18393
|
+
* "refresh" button so an operator can force a fresh frame
|
|
18394
|
+
* even when the cache is well within the device's
|
|
18395
|
+
* `snapshotMaxAgeS` window.
|
|
18396
|
+
*
|
|
18397
|
+
* **`force` is an OPERATOR signal, not a freshness preference.** On a
|
|
18398
|
+
* battery camera it is the one thing that walks past the wrapper's
|
|
18399
|
+
* sleep gate and wakes the camera, so a background caller — a poller,
|
|
18400
|
+
* an event handler, a thumbnail — must NEVER set it. Every such caller
|
|
18401
|
+
* gets the cached frame, which on a sleeping battery camera is the
|
|
18402
|
+
* correct answer: stale but honest beats woken.
|
|
18403
|
+
*/
|
|
18404
|
+
force: boolean().optional()
|
|
18405
|
+
}), SnapshotImageSchema.nullable()),
|
|
18406
|
+
invalidateCache: method(object({ deviceId: number() }), _void(), {
|
|
18407
|
+
kind: "mutation",
|
|
18408
|
+
auth: "admin"
|
|
18409
|
+
}),
|
|
18410
|
+
/**
|
|
18411
|
+
* Cache-only batch overview — answers from the wrapper's in-memory cache in
|
|
18412
|
+
* O(n) and NEVER triggers a capture. Lets a grid skip requesting images for
|
|
18413
|
+
* devices that never produced a frame, and gives it an ETag per device for
|
|
18414
|
+
* conditional (304-able) image fetches. `lastCapturedAt`/`cacheAgeMs`/`etag`
|
|
18415
|
+
* are null for a device with no cached frame.
|
|
18416
|
+
*/
|
|
18417
|
+
getSnapshotOverview: systemMethod(object({ deviceIds: array(number()).min(1).max(200) }), array(object({
|
|
18418
|
+
deviceId: number(),
|
|
18419
|
+
lastCapturedAt: number().nullable(),
|
|
18420
|
+
cacheAgeMs: number().nullable(),
|
|
18421
|
+
etag: string().nullable()
|
|
18422
|
+
}))),
|
|
18423
|
+
/**
|
|
18424
|
+
* Signed, expiring links to a CLIENT-SIZED frame — and the demand signal
|
|
18425
|
+
* that makes those frames current.
|
|
18426
|
+
*
|
|
18427
|
+
* ## The problem it replaces
|
|
18428
|
+
*
|
|
18429
|
+
* `getSnapshotOverview` is cache-only by contract: it answers from whatever
|
|
18430
|
+
* the wrapper happens to hold and never captures. Under D93 the client
|
|
18431
|
+
* versions its image URL on that answer, and an image REQUEST is what enrols
|
|
18432
|
+
* a camera in the keep-warm loop. Both of those are satisfiable by the
|
|
18433
|
+
* client's own image cache — `expo-image` is URL-keyed and never revalidates
|
|
18434
|
+
* — so a URL painted in a previous session comes off disk with no network,
|
|
18435
|
+
* no enrolment, and nothing warming. Measured on the live hub: reopening
|
|
18436
|
+
* after two minutes idle painted 15 of 16 tiles at **168 s old** with zero
|
|
18437
|
+
* HTTP requests, and the fleet only recovered because a later poll happened
|
|
18438
|
+
* to observe a different identity.
|
|
18439
|
+
*
|
|
18440
|
+
* ## The two properties that fix it
|
|
18441
|
+
*
|
|
18442
|
+
* **It is an RPC, so no client cache can answer it.** The demand signal
|
|
18443
|
+
* always reaches the wrapper. This method therefore MAY create keep-warm
|
|
18444
|
+
* subscriptions, where `getSnapshotOverview` must never (D93) — the
|
|
18445
|
+
* distinction is not "one is newer" but that the overview poll is app-wide
|
|
18446
|
+
* (a creating overview would warm every camera on the install) while this is
|
|
18447
|
+
* called by a rendered surface naming the tiles it is actually painting, at
|
|
18448
|
+
* the width it is painting them.
|
|
18449
|
+
*
|
|
18450
|
+
* **It waits, briefly and boundedly, for the capture it triggered.** The
|
|
18451
|
+
* returned `capturedAt` is the frame the link will serve, not the frame the
|
|
18452
|
+
* cache held when the client asked, so a first paint is honest and current
|
|
18453
|
+
* instead of a generation behind. A device that does not settle inside the
|
|
18454
|
+
* bound still gets a link and its real (older) `capturedAt` — the next poll
|
|
18455
|
+
* carries it forward.
|
|
18456
|
+
*
|
|
18457
|
+
* `force` is never set on behalf of a client here. A sleeping battery camera
|
|
18458
|
+
* is reported with `sleeping: true` and the last frame it produced, however
|
|
18459
|
+
* old; the wrapper's existing sleep gate owns that decision and this method
|
|
18460
|
+
* adds no second one.
|
|
18461
|
+
*/
|
|
18462
|
+
getSnapshotLinks: systemMethod(object({
|
|
18463
|
+
/** The tiles a surface is actually rendering. One entry per (device,
|
|
18464
|
+
* width) the caller will paint — the width is snapped to the server's
|
|
18465
|
+
* ladder and becomes part of the link's SIGNED identity. */
|
|
18289
18466
|
targets: array(object({
|
|
18290
|
-
|
|
18291
|
-
|
|
18292
|
-
|
|
18293
|
-
|
|
18294
|
-
})).min(1).max(200) }), array(object({
|
|
18295
|
-
|
|
18296
|
-
|
|
18297
|
-
|
|
18298
|
-
|
|
18299
|
-
|
|
18300
|
-
|
|
18301
|
-
|
|
18302
|
-
|
|
18303
|
-
|
|
18304
|
-
|
|
18305
|
-
|
|
18306
|
-
|
|
18307
|
-
|
|
18308
|
-
|
|
18309
|
-
|
|
18310
|
-
|
|
18311
|
-
|
|
18312
|
-
|
|
18313
|
-
|
|
18314
|
-
|
|
18315
|
-
|
|
18316
|
-
})))
|
|
18467
|
+
deviceId: number(),
|
|
18468
|
+
/** Target width in px. Omit for the frame as captured — correct
|
|
18469
|
+
* for a full-bleed surface, wrong (and expensive) for a grid. */
|
|
18470
|
+
width: number().int().positive().optional()
|
|
18471
|
+
})).min(1).max(200) }), array(object({
|
|
18472
|
+
deviceId: number(),
|
|
18473
|
+
/** Root-relative signed path, or null when the link plane is not
|
|
18474
|
+
* served (no data-plane facility). Present even for a device that has
|
|
18475
|
+
* never captured — the request is what triggers the first one (D94). */
|
|
18476
|
+
url: string().nullable(),
|
|
18477
|
+
/** Epoch ms of the frame this link serves. Null = never captured.
|
|
18478
|
+
* THE honest age: the tRPC path carried none before this. */
|
|
18479
|
+
capturedAt: number().nullable(),
|
|
18480
|
+
/** Age of that frame at the moment the answer was built. */
|
|
18481
|
+
ageMs: number().nullable(),
|
|
18482
|
+
/** Epoch ms after which `url` stops verifying. */
|
|
18483
|
+
expiresAt: number().nullable(),
|
|
18484
|
+
/** Ladder rung the bytes are at; null = the frame as captured. */
|
|
18485
|
+
width: number().nullable(),
|
|
18486
|
+
/** The device has never produced a frame. An empty state, not a
|
|
18487
|
+
* failure — and never a reason to withhold the link (D94). */
|
|
18488
|
+
neverCaptured: boolean(),
|
|
18489
|
+
/** A sleeping battery camera: the frame is deliberately stale and will
|
|
18490
|
+
* NOT refresh in the background. A surface should say so rather than
|
|
18491
|
+
* present it as current. */
|
|
18492
|
+
sleeping: boolean()
|
|
18493
|
+
})))
|
|
18494
|
+
},
|
|
18495
|
+
status: {
|
|
18496
|
+
schema: SnapshotStatusSchema,
|
|
18497
|
+
kind: "poll"
|
|
18498
|
+
}
|
|
18499
|
+
};
|
|
18317
18500
|
/**
|
|
18318
18501
|
* `sso-bridge` — internal hub-only cap that lets SSO-style auth
|
|
18319
18502
|
* providers (OIDC, SAML, magic-link, …) mint an HMAC-signed token
|
|
@@ -18480,6 +18663,13 @@ method(object({ locationId: string() }), EvictableUsageSchema), method(object({
|
|
|
18480
18663
|
locationId: string(),
|
|
18481
18664
|
targetBytes: number().int().positive()
|
|
18482
18665
|
}), EvictResultSchema, { kind: "mutation" });
|
|
18666
|
+
method(StorageMigrationInputSchema, StorageMigrationPlanSchema, { auth: "admin" }), method(StorageMigrationInputSchema, object({ jobId: string() }), {
|
|
18667
|
+
kind: "mutation",
|
|
18668
|
+
auth: "admin"
|
|
18669
|
+
}), method(object({ jobId: string().optional() }), StorageMigrationJobSchema.nullable(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
|
|
18670
|
+
kind: "mutation",
|
|
18671
|
+
auth: "admin"
|
|
18672
|
+
});
|
|
18483
18673
|
var ProviderInfoSchema = discriminatedUnion("shouldSaveDiskSpace", [object({
|
|
18484
18674
|
providerId: string().min(1),
|
|
18485
18675
|
displayName: string().min(1),
|
|
@@ -18583,6 +18773,28 @@ var TerminalProfileInfoSchema = object({
|
|
|
18583
18773
|
label: string(),
|
|
18584
18774
|
description: string().optional()
|
|
18585
18775
|
});
|
|
18776
|
+
/**
|
|
18777
|
+
* A durable operator-created Terminal instance. Profiles are templates; only
|
|
18778
|
+
* an instance declares a camera.
|
|
18779
|
+
*/
|
|
18780
|
+
var TerminalInstanceInfoSchema = object({
|
|
18781
|
+
instanceId: string(),
|
|
18782
|
+
cameraStableId: string(),
|
|
18783
|
+
nodeId: string(),
|
|
18784
|
+
profileId: string(),
|
|
18785
|
+
profileLabel: string(),
|
|
18786
|
+
name: string(),
|
|
18787
|
+
enabled: boolean()
|
|
18788
|
+
});
|
|
18789
|
+
var TerminalLegacyCameraSchema = object({
|
|
18790
|
+
stableId: string(),
|
|
18791
|
+
nodeId: string(),
|
|
18792
|
+
profileId: string(),
|
|
18793
|
+
profileLabel: string(),
|
|
18794
|
+
name: string(),
|
|
18795
|
+
/** Only legacy monitor cameras can retain their historic stable identity. */
|
|
18796
|
+
adoptable: boolean()
|
|
18797
|
+
});
|
|
18586
18798
|
var TerminalOutputEventSchema = discriminatedUnion("kind", [object({
|
|
18587
18799
|
seq: number().int().positive(),
|
|
18588
18800
|
kind: literal("data"),
|
|
@@ -18602,10 +18814,9 @@ var TerminalOutputBatchSchema = object({
|
|
|
18602
18814
|
/**
|
|
18603
18815
|
* terminal-session — singleton system capability for interactive TTY sessions.
|
|
18604
18816
|
*
|
|
18605
|
-
*
|
|
18606
|
-
*
|
|
18607
|
-
*
|
|
18608
|
-
* change this contract.
|
|
18817
|
+
* Owns both live PTY lifecycle and durable Terminal instance management.
|
|
18818
|
+
* Profiles are allowlisted templates; an explicit instance is the only path
|
|
18819
|
+
* that declares a camera.
|
|
18609
18820
|
*/
|
|
18610
18821
|
var terminalSessionCapability = {
|
|
18611
18822
|
name: "terminal-session",
|
|
@@ -18614,6 +18825,37 @@ var terminalSessionCapability = {
|
|
|
18614
18825
|
methods: {
|
|
18615
18826
|
/** Pre-declared profiles the operator may open. */
|
|
18616
18827
|
listProfiles: method(_void(), array(TerminalProfileInfoSchema).readonly(), { auth: "admin" }),
|
|
18828
|
+
/** Explicit durable Terminal instances, managed centrally on the hub. */
|
|
18829
|
+
listInstances: method(_void(), array(TerminalInstanceInfoSchema).readonly(), { auth: "admin" }),
|
|
18830
|
+
createInstance: method(object({
|
|
18831
|
+
targetNodeId: string().min(1),
|
|
18832
|
+
profileId: string().min(1),
|
|
18833
|
+
name: string().trim().min(1).max(160).optional()
|
|
18834
|
+
}), TerminalInstanceInfoSchema, {
|
|
18835
|
+
kind: "mutation",
|
|
18836
|
+
auth: "admin"
|
|
18837
|
+
}),
|
|
18838
|
+
deleteInstance: method(object({ instanceId: string().min(1) }), _void(), {
|
|
18839
|
+
kind: "mutation",
|
|
18840
|
+
auth: "admin"
|
|
18841
|
+
}),
|
|
18842
|
+
setInstanceEnabled: method(object({
|
|
18843
|
+
instanceId: string().min(1),
|
|
18844
|
+
enabled: boolean()
|
|
18845
|
+
}), TerminalInstanceInfoSchema, {
|
|
18846
|
+
kind: "mutation",
|
|
18847
|
+
auth: "admin"
|
|
18848
|
+
}),
|
|
18849
|
+
/** Existing automatic cameras are shown for explicit migration only. */
|
|
18850
|
+
listLegacyCameras: method(_void(), array(TerminalLegacyCameraSchema).readonly(), { auth: "admin" }),
|
|
18851
|
+
/** Explicitly adopt one legacy monitor camera, retaining its stable id. */
|
|
18852
|
+
adoptLegacyMonitor: method(object({
|
|
18853
|
+
stableId: string().min(1),
|
|
18854
|
+
name: string().trim().min(1).max(160).optional()
|
|
18855
|
+
}), TerminalInstanceInfoSchema, {
|
|
18856
|
+
kind: "mutation",
|
|
18857
|
+
auth: "admin"
|
|
18858
|
+
}),
|
|
18617
18859
|
/** Live sessions currently hosted by the provider. */
|
|
18618
18860
|
listSessions: method(_void(), array(TerminalSessionInfoSchema).readonly(), { auth: "admin" }),
|
|
18619
18861
|
/**
|
|
@@ -18645,7 +18887,13 @@ var terminalSessionCapability = {
|
|
|
18645
18887
|
pullOutput: method(object({
|
|
18646
18888
|
sessionId: string(),
|
|
18647
18889
|
afterSeq: number().int().nonnegative(),
|
|
18648
|
-
waitMs: number().int().min(0).max(2e3).default(0)
|
|
18890
|
+
waitMs: number().int().min(0).max(2e3).default(0),
|
|
18891
|
+
/**
|
|
18892
|
+
* Wait when a just-opened session has no output yet. Kept opt-in so a
|
|
18893
|
+
* browser's initial repaint remains immediate; the camera snapshot
|
|
18894
|
+
* relay uses it to avoid encoding a blank startup frame.
|
|
18895
|
+
*/
|
|
18896
|
+
waitForOutput: boolean().optional()
|
|
18649
18897
|
}), TerminalOutputBatchSchema, {
|
|
18650
18898
|
kind: "mutation",
|
|
18651
18899
|
auth: "admin",
|
|
@@ -24624,13 +24872,19 @@ method(object({
|
|
|
24624
24872
|
}), {
|
|
24625
24873
|
kind: "mutation",
|
|
24626
24874
|
auth: "admin"
|
|
24627
|
-
}), method(
|
|
24875
|
+
}), method(StorageMigrationLeaseInputSchema, object({ paused: literal(true) }), {
|
|
24628
24876
|
kind: "mutation",
|
|
24629
24877
|
auth: "admin"
|
|
24630
|
-
}), method(object({
|
|
24631
|
-
kind: "
|
|
24878
|
+
}), method(StorageMigrationLeaseInputSchema, object({ resumed: literal(true) }), {
|
|
24879
|
+
kind: "mutation",
|
|
24880
|
+
auth: "admin"
|
|
24881
|
+
}), method(StorageMigrationLeaseInputSchema, object({ refreshed: literal(true) }), {
|
|
24882
|
+
kind: "mutation",
|
|
24883
|
+
auth: "admin"
|
|
24884
|
+
}), method(StorageMigrationFootageMoveInputSchema, object({ jobId: string() }), {
|
|
24885
|
+
kind: "mutation",
|
|
24632
24886
|
auth: "admin"
|
|
24633
|
-
}), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
|
|
24887
|
+
}), method(object({ jobId: string() }), RelocateJobSchema.nullable(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
|
|
24634
24888
|
kind: "mutation",
|
|
24635
24889
|
auth: "admin"
|
|
24636
24890
|
});
|
|
@@ -30322,7 +30576,7 @@ Object.freeze({
|
|
|
30322
30576
|
addonId: null,
|
|
30323
30577
|
access: "create"
|
|
30324
30578
|
},
|
|
30325
|
-
"pipelineAnalytics.
|
|
30579
|
+
"pipelineAnalytics.cancelStorageMigrationMove": {
|
|
30326
30580
|
capName: "pipeline-analytics",
|
|
30327
30581
|
capScope: "device",
|
|
30328
30582
|
addonId: null,
|
|
@@ -30394,12 +30648,6 @@ Object.freeze({
|
|
|
30394
30648
|
addonId: null,
|
|
30395
30649
|
access: "view"
|
|
30396
30650
|
},
|
|
30397
|
-
"pipelineAnalytics.getMediaRelocateStatus": {
|
|
30398
|
-
capName: "pipeline-analytics",
|
|
30399
|
-
capScope: "device",
|
|
30400
|
-
addonId: null,
|
|
30401
|
-
access: "view"
|
|
30402
|
-
},
|
|
30403
30651
|
"pipelineAnalytics.getMotionEvents": {
|
|
30404
30652
|
capName: "pipeline-analytics",
|
|
30405
30653
|
capScope: "device",
|
|
@@ -30436,6 +30684,12 @@ Object.freeze({
|
|
|
30436
30684
|
addonId: null,
|
|
30437
30685
|
access: "view"
|
|
30438
30686
|
},
|
|
30687
|
+
"pipelineAnalytics.getStorageMigrationMoveStatus": {
|
|
30688
|
+
capName: "pipeline-analytics",
|
|
30689
|
+
capScope: "device",
|
|
30690
|
+
addonId: null,
|
|
30691
|
+
access: "view"
|
|
30692
|
+
},
|
|
30439
30693
|
"pipelineAnalytics.getTrack": {
|
|
30440
30694
|
capName: "pipeline-analytics",
|
|
30441
30695
|
capScope: "device",
|
|
@@ -30514,6 +30768,12 @@ Object.freeze({
|
|
|
30514
30768
|
addonId: null,
|
|
30515
30769
|
access: "view"
|
|
30516
30770
|
},
|
|
30771
|
+
"pipelineAnalytics.pauseForStorageMigration": {
|
|
30772
|
+
capName: "pipeline-analytics",
|
|
30773
|
+
capScope: "device",
|
|
30774
|
+
addonId: null,
|
|
30775
|
+
access: "create"
|
|
30776
|
+
},
|
|
30517
30777
|
"pipelineAnalytics.proposeRetrainAnnotations": {
|
|
30518
30778
|
capName: "pipeline-analytics",
|
|
30519
30779
|
capScope: "device",
|
|
@@ -30544,7 +30804,7 @@ Object.freeze({
|
|
|
30544
30804
|
addonId: null,
|
|
30545
30805
|
access: "create"
|
|
30546
30806
|
},
|
|
30547
|
-
"pipelineAnalytics.
|
|
30807
|
+
"pipelineAnalytics.refreshStorageLocationsForMigration": {
|
|
30548
30808
|
capName: "pipeline-analytics",
|
|
30549
30809
|
capScope: "device",
|
|
30550
30810
|
addonId: null,
|
|
@@ -30556,6 +30816,12 @@ Object.freeze({
|
|
|
30556
30816
|
addonId: null,
|
|
30557
30817
|
access: "create"
|
|
30558
30818
|
},
|
|
30819
|
+
"pipelineAnalytics.resumeForStorageMigration": {
|
|
30820
|
+
capName: "pipeline-analytics",
|
|
30821
|
+
capScope: "device",
|
|
30822
|
+
addonId: null,
|
|
30823
|
+
access: "create"
|
|
30824
|
+
},
|
|
30559
30825
|
"pipelineAnalytics.saveRetrainAnnotations": {
|
|
30560
30826
|
capName: "pipeline-analytics",
|
|
30561
30827
|
capScope: "device",
|
|
@@ -30580,6 +30846,12 @@ Object.freeze({
|
|
|
30580
30846
|
addonId: null,
|
|
30581
30847
|
access: "create"
|
|
30582
30848
|
},
|
|
30849
|
+
"pipelineAnalytics.startStorageMigrationMove": {
|
|
30850
|
+
capName: "pipeline-analytics",
|
|
30851
|
+
capScope: "device",
|
|
30852
|
+
addonId: null,
|
|
30853
|
+
access: "create"
|
|
30854
|
+
},
|
|
30583
30855
|
"pipelineAnalytics.wipeAllAnalytics": {
|
|
30584
30856
|
capName: "pipeline-analytics",
|
|
30585
30857
|
capScope: "device",
|
|
@@ -30946,6 +31218,12 @@ Object.freeze({
|
|
|
30946
31218
|
addonId: null,
|
|
30947
31219
|
access: "view"
|
|
30948
31220
|
},
|
|
31221
|
+
"pipelineOrchestrator.pauseForStorageMigration": {
|
|
31222
|
+
capName: "pipeline-orchestrator",
|
|
31223
|
+
capScope: "system",
|
|
31224
|
+
addonId: null,
|
|
31225
|
+
access: "create"
|
|
31226
|
+
},
|
|
30949
31227
|
"pipelineOrchestrator.rebalance": {
|
|
30950
31228
|
capName: "pipeline-orchestrator",
|
|
30951
31229
|
capScope: "system",
|
|
@@ -30970,6 +31248,12 @@ Object.freeze({
|
|
|
30970
31248
|
addonId: null,
|
|
30971
31249
|
access: "view"
|
|
30972
31250
|
},
|
|
31251
|
+
"pipelineOrchestrator.resumeForStorageMigration": {
|
|
31252
|
+
capName: "pipeline-orchestrator",
|
|
31253
|
+
capScope: "system",
|
|
31254
|
+
addonId: null,
|
|
31255
|
+
access: "create"
|
|
31256
|
+
},
|
|
30973
31257
|
"pipelineOrchestrator.saveTemplate": {
|
|
30974
31258
|
capName: "pipeline-orchestrator",
|
|
30975
31259
|
capScope: "system",
|
|
@@ -31366,7 +31650,7 @@ Object.freeze({
|
|
|
31366
31650
|
addonId: null,
|
|
31367
31651
|
access: "create"
|
|
31368
31652
|
},
|
|
31369
|
-
"recording.
|
|
31653
|
+
"recording.cancelStorageMigrationMove": {
|
|
31370
31654
|
capName: "recording",
|
|
31371
31655
|
capScope: "system",
|
|
31372
31656
|
addonId: null,
|
|
@@ -31402,7 +31686,7 @@ Object.freeze({
|
|
|
31402
31686
|
addonId: null,
|
|
31403
31687
|
access: "view"
|
|
31404
31688
|
},
|
|
31405
|
-
"recording.
|
|
31689
|
+
"recording.getStorageMigrationMoveStatus": {
|
|
31406
31690
|
capName: "recording",
|
|
31407
31691
|
capScope: "system",
|
|
31408
31692
|
addonId: null,
|
|
@@ -31426,6 +31710,12 @@ Object.freeze({
|
|
|
31426
31710
|
addonId: null,
|
|
31427
31711
|
access: "view"
|
|
31428
31712
|
},
|
|
31713
|
+
"recording.pauseForStorageMigration": {
|
|
31714
|
+
capName: "recording",
|
|
31715
|
+
capScope: "system",
|
|
31716
|
+
addonId: null,
|
|
31717
|
+
access: "create"
|
|
31718
|
+
},
|
|
31429
31719
|
"recording.pruneFootage": {
|
|
31430
31720
|
capName: "recording",
|
|
31431
31721
|
capScope: "system",
|
|
@@ -31444,7 +31734,7 @@ Object.freeze({
|
|
|
31444
31734
|
addonId: null,
|
|
31445
31735
|
access: "view"
|
|
31446
31736
|
},
|
|
31447
|
-
"recording.
|
|
31737
|
+
"recording.refreshStorageLocationsForMigration": {
|
|
31448
31738
|
capName: "recording",
|
|
31449
31739
|
capScope: "system",
|
|
31450
31740
|
addonId: null,
|
|
@@ -31468,12 +31758,24 @@ Object.freeze({
|
|
|
31468
31758
|
addonId: null,
|
|
31469
31759
|
access: "create"
|
|
31470
31760
|
},
|
|
31761
|
+
"recording.resumeForStorageMigration": {
|
|
31762
|
+
capName: "recording",
|
|
31763
|
+
capScope: "system",
|
|
31764
|
+
addonId: null,
|
|
31765
|
+
access: "create"
|
|
31766
|
+
},
|
|
31471
31767
|
"recording.setDeviceConfig": {
|
|
31472
31768
|
capName: "recording",
|
|
31473
31769
|
capScope: "system",
|
|
31474
31770
|
addonId: null,
|
|
31475
31771
|
access: "create"
|
|
31476
31772
|
},
|
|
31773
|
+
"recording.startStorageMigrationMove": {
|
|
31774
|
+
capName: "recording",
|
|
31775
|
+
capScope: "system",
|
|
31776
|
+
addonId: null,
|
|
31777
|
+
access: "create"
|
|
31778
|
+
},
|
|
31477
31779
|
"recordingExport.cancelExport": {
|
|
31478
31780
|
capName: "recordingExport",
|
|
31479
31781
|
capScope: "system",
|
|
@@ -31864,6 +32166,30 @@ Object.freeze({
|
|
|
31864
32166
|
addonId: null,
|
|
31865
32167
|
access: "view"
|
|
31866
32168
|
},
|
|
32169
|
+
"storageMigration.cancel": {
|
|
32170
|
+
capName: "storage-migration",
|
|
32171
|
+
capScope: "system",
|
|
32172
|
+
addonId: null,
|
|
32173
|
+
access: "create"
|
|
32174
|
+
},
|
|
32175
|
+
"storageMigration.plan": {
|
|
32176
|
+
capName: "storage-migration",
|
|
32177
|
+
capScope: "system",
|
|
32178
|
+
addonId: null,
|
|
32179
|
+
access: "view"
|
|
32180
|
+
},
|
|
32181
|
+
"storageMigration.start": {
|
|
32182
|
+
capName: "storage-migration",
|
|
32183
|
+
capScope: "system",
|
|
32184
|
+
addonId: null,
|
|
32185
|
+
access: "create"
|
|
32186
|
+
},
|
|
32187
|
+
"storageMigration.status": {
|
|
32188
|
+
capName: "storage-migration",
|
|
32189
|
+
capScope: "system",
|
|
32190
|
+
addonId: null,
|
|
32191
|
+
access: "view"
|
|
32192
|
+
},
|
|
31867
32193
|
"storageProvider.abortUpload": {
|
|
31868
32194
|
capName: "storage-provider",
|
|
31869
32195
|
capScope: "system",
|
|
@@ -32242,12 +32568,42 @@ Object.freeze({
|
|
|
32242
32568
|
addonId: null,
|
|
32243
32569
|
access: "create"
|
|
32244
32570
|
},
|
|
32571
|
+
"terminalSession.adoptLegacyMonitor": {
|
|
32572
|
+
capName: "terminal-session",
|
|
32573
|
+
capScope: "system",
|
|
32574
|
+
addonId: null,
|
|
32575
|
+
access: "create"
|
|
32576
|
+
},
|
|
32245
32577
|
"terminalSession.close": {
|
|
32246
32578
|
capName: "terminal-session",
|
|
32247
32579
|
capScope: "system",
|
|
32248
32580
|
addonId: null,
|
|
32249
32581
|
access: "create"
|
|
32250
32582
|
},
|
|
32583
|
+
"terminalSession.createInstance": {
|
|
32584
|
+
capName: "terminal-session",
|
|
32585
|
+
capScope: "system",
|
|
32586
|
+
addonId: null,
|
|
32587
|
+
access: "create"
|
|
32588
|
+
},
|
|
32589
|
+
"terminalSession.deleteInstance": {
|
|
32590
|
+
capName: "terminal-session",
|
|
32591
|
+
capScope: "system",
|
|
32592
|
+
addonId: null,
|
|
32593
|
+
access: "delete"
|
|
32594
|
+
},
|
|
32595
|
+
"terminalSession.listInstances": {
|
|
32596
|
+
capName: "terminal-session",
|
|
32597
|
+
capScope: "system",
|
|
32598
|
+
addonId: null,
|
|
32599
|
+
access: "view"
|
|
32600
|
+
},
|
|
32601
|
+
"terminalSession.listLegacyCameras": {
|
|
32602
|
+
capName: "terminal-session",
|
|
32603
|
+
capScope: "system",
|
|
32604
|
+
addonId: null,
|
|
32605
|
+
access: "view"
|
|
32606
|
+
},
|
|
32251
32607
|
"terminalSession.listProfiles": {
|
|
32252
32608
|
capName: "terminal-session",
|
|
32253
32609
|
capScope: "system",
|
|
@@ -32278,6 +32634,12 @@ Object.freeze({
|
|
|
32278
32634
|
addonId: null,
|
|
32279
32635
|
access: "create"
|
|
32280
32636
|
},
|
|
32637
|
+
"terminalSession.setInstanceEnabled": {
|
|
32638
|
+
capName: "terminal-session",
|
|
32639
|
+
capScope: "system",
|
|
32640
|
+
addonId: null,
|
|
32641
|
+
access: "create"
|
|
32642
|
+
},
|
|
32281
32643
|
"terminalSession.writeInput": {
|
|
32282
32644
|
capName: "terminal-session",
|
|
32283
32645
|
capScope: "system",
|
|
@@ -33080,19 +33442,38 @@ async function warmNodePty() {
|
|
|
33080
33442
|
}
|
|
33081
33443
|
//#endregion
|
|
33082
33444
|
//#region src/terminal-camera-declarations.ts
|
|
33083
|
-
|
|
33084
|
-
|
|
33085
|
-
|
|
33086
|
-
|
|
33087
|
-
|
|
33088
|
-
|
|
33089
|
-
|
|
33090
|
-
|
|
33091
|
-
|
|
33092
|
-
|
|
33093
|
-
|
|
33094
|
-
|
|
33095
|
-
|
|
33445
|
+
/**
|
|
33446
|
+
* Feed DeclaredDevices every live declaration plus one deterministic orphan
|
|
33447
|
+
* batch. The generic sweep intentionally refuses an over-limit set; selecting
|
|
33448
|
+
* a batch here drains large historical Terminal orphan sets across convergence
|
|
33449
|
+
* passes without weakening that global safety guard.
|
|
33450
|
+
*/
|
|
33451
|
+
function terminalCameraReconciliationIndex(rows, integrationId, declarations, managedStableIds = /* @__PURE__ */ new Set()) {
|
|
33452
|
+
if (!integrationId) return [];
|
|
33453
|
+
const declared = new Set(declarations.map((camera) => camera.stableId));
|
|
33454
|
+
const owned = rows.filter((row) => row.integrationId === integrationId && (declared.has(row.stableId) || managedStableIds.has(row.stableId) || row.stableId.startsWith("terminal-camera-instance-")));
|
|
33455
|
+
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)];
|
|
33456
|
+
}
|
|
33457
|
+
/** Explicit persisted instances, never the node × profile template matrix. */
|
|
33458
|
+
function buildTerminalInstanceCameraDeclarations(instances) {
|
|
33459
|
+
return instances.filter((instance) => instance.enabled).map((instance) => ({
|
|
33460
|
+
stableId: instance.cameraStableId,
|
|
33461
|
+
name: instance.name,
|
|
33462
|
+
config: {
|
|
33463
|
+
instanceId: instance.id,
|
|
33464
|
+
nodeId: instance.nodeId,
|
|
33465
|
+
profileId: instance.profileId,
|
|
33466
|
+
profileLabel: instance.profileLabel
|
|
33467
|
+
}
|
|
33468
|
+
}));
|
|
33469
|
+
}
|
|
33470
|
+
/**
|
|
33471
|
+
* `DeviceConfig` materializes schema defaults in memory, so comparing
|
|
33472
|
+
* `config.get()` cannot detect a legacy `{ nodeId }` row. Reconciliation must
|
|
33473
|
+
* inspect the raw persisted blob to make the profile migration durable.
|
|
33474
|
+
*/
|
|
33475
|
+
function needsTerminalCameraConfigMigration(persistedConfig, declaration) {
|
|
33476
|
+
return persistedConfig.instanceId !== declaration.config.instanceId || persistedConfig.nodeId !== declaration.config.nodeId || persistedConfig.profileId !== declaration.config.profileId || persistedConfig.profileLabel !== declaration.config.profileLabel;
|
|
33096
33477
|
}
|
|
33097
33478
|
function escapeXml(value) {
|
|
33098
33479
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
@@ -33109,22 +33490,35 @@ async function renderTerminalJpeg(lines) {
|
|
|
33109
33490
|
//#endregion
|
|
33110
33491
|
//#region src/terminal-camera-device.ts
|
|
33111
33492
|
var terminalCameraSchema = object({
|
|
33493
|
+
instanceId: string().min(1).optional(),
|
|
33112
33494
|
nodeId: string().min(1),
|
|
33113
|
-
profileId: string().min(1),
|
|
33114
|
-
profileLabel: string().min(1)
|
|
33495
|
+
profileId: string().min(1).default("monitor"),
|
|
33496
|
+
profileLabel: string().min(1).default("BTM")
|
|
33115
33497
|
});
|
|
33116
33498
|
var relay = null;
|
|
33117
33499
|
function installTerminalCameraRelay(next) {
|
|
33118
33500
|
relay = next;
|
|
33119
33501
|
}
|
|
33120
33502
|
var TerminalCameraDevice = class extends BaseDevice {
|
|
33121
|
-
features = [];
|
|
33503
|
+
features = [DeviceFeature.NativeSnapshot];
|
|
33122
33504
|
constructor(ctx) {
|
|
33123
33505
|
super(ctx, terminalCameraSchema, { type: ctx.deviceMeta.type });
|
|
33124
33506
|
this.ctx.registerNativeCap(streamCatalogCapability, { getCatalog: async ({ deviceId }) => {
|
|
33125
33507
|
if (deviceId !== this.id) return [];
|
|
33126
33508
|
return this.catalog();
|
|
33127
33509
|
} });
|
|
33510
|
+
this.ctx.registerNativeCap(snapshotCapability, {
|
|
33511
|
+
getSnapshot: async ({ deviceId }) => {
|
|
33512
|
+
if (deviceId !== this.id) throw new Error(`TerminalCameraDevice: deviceId mismatch, expected ${String(this.id)}, got ${String(deviceId)}`);
|
|
33513
|
+
const activeRelay = relay;
|
|
33514
|
+
if (!activeRelay) throw new Error("terminal camera relay is unavailable");
|
|
33515
|
+
return {
|
|
33516
|
+
base64: (await activeRelay.snapshot(this.relayInstanceId(), this.config.get("nodeId"), this.config.get("profileId"))).toString("base64"),
|
|
33517
|
+
contentType: "image/jpeg"
|
|
33518
|
+
};
|
|
33519
|
+
},
|
|
33520
|
+
invalidateCache: async () => {}
|
|
33521
|
+
});
|
|
33128
33522
|
this.markOnline(true);
|
|
33129
33523
|
}
|
|
33130
33524
|
async catalog() {
|
|
@@ -33132,10 +33526,11 @@ var TerminalCameraDevice = class extends BaseDevice {
|
|
|
33132
33526
|
if (!activeRelay) throw new Error("terminal camera relay is unavailable");
|
|
33133
33527
|
const nodeId = this.config.get("nodeId");
|
|
33134
33528
|
const profileId = this.config.get("profileId");
|
|
33529
|
+
const instanceId = this.relayInstanceId();
|
|
33135
33530
|
return [{
|
|
33136
33531
|
camStreamId: profileId,
|
|
33137
33532
|
kind: "pull-http",
|
|
33138
|
-
url: activeRelay.streamUrl(nodeId, profileId),
|
|
33533
|
+
url: activeRelay.streamUrl(instanceId, nodeId, profileId),
|
|
33139
33534
|
codec: "h264",
|
|
33140
33535
|
resolution: {
|
|
33141
33536
|
width: 960,
|
|
@@ -33147,6 +33542,13 @@ var TerminalCameraDevice = class extends BaseDevice {
|
|
|
33147
33542
|
}
|
|
33148
33543
|
setNodeOnline(online) {
|
|
33149
33544
|
this.markOnline(online);
|
|
33545
|
+
if (!online) relay?.closeInstance(this.relayInstanceId());
|
|
33546
|
+
}
|
|
33547
|
+
async removeDevice() {
|
|
33548
|
+
await relay?.closeInstance(this.relayInstanceId());
|
|
33549
|
+
}
|
|
33550
|
+
relayInstanceId() {
|
|
33551
|
+
return this.config.get("instanceId") ?? `legacy:${this.stableId}`;
|
|
33150
33552
|
}
|
|
33151
33553
|
};
|
|
33152
33554
|
//#endregion
|
|
@@ -37995,18 +38397,24 @@ function createXtermScreen(cols, rows) {
|
|
|
37995
38397
|
//#region src/terminal-camera-relay.ts
|
|
37996
38398
|
var MJPEG_BOUNDARY = "camstack-terminal-frame";
|
|
37997
38399
|
var SESSION_IDLE_MS = 3e4;
|
|
37998
|
-
|
|
37999
|
-
|
|
38400
|
+
var SNAPSHOT_STARTUP_WAIT_MS = 1500;
|
|
38401
|
+
var CLOSE_RETRY_BASE_MS = 50;
|
|
38402
|
+
var CLOSE_RETRY_MAX_MS = 1e3;
|
|
38403
|
+
var CLOSE_ATTEMPTS_PER_PASS = 3;
|
|
38404
|
+
function relayKey(instanceId) {
|
|
38405
|
+
return instanceId;
|
|
38000
38406
|
}
|
|
38001
38407
|
function parseStreamPath(url) {
|
|
38002
38408
|
const parts = ((url ?? "").split("?")[0] ?? "").split("/").filter(Boolean);
|
|
38003
|
-
if (parts.length !==
|
|
38409
|
+
if (parts.length !== 4 || parts[0] !== "stream") return null;
|
|
38004
38410
|
try {
|
|
38005
|
-
const
|
|
38006
|
-
const
|
|
38411
|
+
const instanceId = decodeURIComponent(parts[1] ?? "");
|
|
38412
|
+
const nodeId = decodeURIComponent(parts[2] ?? "");
|
|
38413
|
+
const profilePart = parts[3] ?? "";
|
|
38007
38414
|
if (!profilePart.endsWith(".mjpeg")) return null;
|
|
38008
38415
|
const profileId = decodeURIComponent(profilePart.slice(0, -6));
|
|
38009
|
-
return nodeId && profileId ? {
|
|
38416
|
+
return instanceId && nodeId && profileId ? {
|
|
38417
|
+
instanceId,
|
|
38010
38418
|
nodeId,
|
|
38011
38419
|
profileId
|
|
38012
38420
|
} : null;
|
|
@@ -38033,7 +38441,7 @@ var TerminalCameraRelay = class {
|
|
|
38033
38441
|
res.writeHead(404).end();
|
|
38034
38442
|
return;
|
|
38035
38443
|
}
|
|
38036
|
-
this.serve(target.nodeId, target.profileId, res);
|
|
38444
|
+
this.serve(target.instanceId, target.nodeId, target.profileId, res);
|
|
38037
38445
|
});
|
|
38038
38446
|
await new Promise((resolve, reject) => {
|
|
38039
38447
|
server.once("error", reject);
|
|
@@ -38047,55 +38455,105 @@ var TerminalCameraRelay = class {
|
|
|
38047
38455
|
this.server = server;
|
|
38048
38456
|
this.baseUrl = `http://127.0.0.1:${String(address.port)}`;
|
|
38049
38457
|
}
|
|
38050
|
-
streamUrl(nodeId, profileId) {
|
|
38458
|
+
streamUrl(instanceId, nodeId, profileId) {
|
|
38051
38459
|
if (!this.baseUrl) throw new Error("terminal camera relay is not started");
|
|
38052
|
-
return `${this.baseUrl}/stream/${encodeURIComponent(nodeId)}/${encodeURIComponent(profileId)}.mjpeg`;
|
|
38460
|
+
return `${this.baseUrl}/stream/${encodeURIComponent(instanceId)}/${encodeURIComponent(nodeId)}/${encodeURIComponent(profileId)}.mjpeg`;
|
|
38053
38461
|
}
|
|
38054
38462
|
async listProfiles(nodeId) {
|
|
38055
38463
|
return this.api.listProfiles(nodeId);
|
|
38056
38464
|
}
|
|
38057
|
-
state(nodeId, profileId) {
|
|
38058
|
-
const key = relayKey(
|
|
38465
|
+
state(instanceId, nodeId, profileId) {
|
|
38466
|
+
const key = relayKey(instanceId);
|
|
38059
38467
|
const existing = this.states.get(key);
|
|
38060
38468
|
if (existing) return existing;
|
|
38061
38469
|
const created = {
|
|
38470
|
+
instanceId,
|
|
38062
38471
|
nodeId,
|
|
38063
38472
|
profileId,
|
|
38064
38473
|
screen: createXtermScreen(120, 40),
|
|
38065
38474
|
sessionId: null,
|
|
38066
38475
|
cursor: 0,
|
|
38067
38476
|
clients: 0,
|
|
38477
|
+
leases: 0,
|
|
38068
38478
|
jpeg: null,
|
|
38069
38479
|
renderedCursor: -1,
|
|
38070
38480
|
framePromise: null,
|
|
38071
|
-
|
|
38481
|
+
openPromise: null,
|
|
38482
|
+
idleTimer: null,
|
|
38483
|
+
closing: false,
|
|
38484
|
+
closePromise: null,
|
|
38485
|
+
closeRetryTimer: null,
|
|
38486
|
+
closeAttempts: 0,
|
|
38487
|
+
closed: false,
|
|
38488
|
+
responses: /* @__PURE__ */ new Set()
|
|
38072
38489
|
};
|
|
38073
38490
|
this.states.set(key, created);
|
|
38074
38491
|
return created;
|
|
38075
38492
|
}
|
|
38076
38493
|
async ensureSession(state) {
|
|
38494
|
+
if (state.closed || state.closing) throw new Error("terminal camera relay is waiting for prior session cleanup");
|
|
38077
38495
|
if (state.sessionId) return state.sessionId;
|
|
38078
|
-
|
|
38496
|
+
if (state.openPromise) {
|
|
38497
|
+
const opened = await state.openPromise;
|
|
38498
|
+
if (state.closed || state.closing) throw new Error("terminal camera relay state closed while opening its session");
|
|
38499
|
+
return opened.sessionId;
|
|
38500
|
+
}
|
|
38501
|
+
const opening = this.api.openSession(state.nodeId, {
|
|
38079
38502
|
profileId: state.profileId,
|
|
38080
38503
|
cols: 120,
|
|
38081
38504
|
rows: 40
|
|
38082
38505
|
});
|
|
38083
|
-
state.
|
|
38084
|
-
|
|
38085
|
-
|
|
38506
|
+
state.openPromise = opening;
|
|
38507
|
+
try {
|
|
38508
|
+
const opened = await opening;
|
|
38509
|
+
state.openPromise = null;
|
|
38510
|
+
if (state.closed || state.closing) {
|
|
38511
|
+
state.sessionId = opened.sessionId;
|
|
38512
|
+
await this.closeState(state);
|
|
38513
|
+
throw new Error("terminal camera relay state closed while opening its session");
|
|
38514
|
+
}
|
|
38515
|
+
state.sessionId = opened.sessionId;
|
|
38516
|
+
state.cursor = 0;
|
|
38517
|
+
return opened.sessionId;
|
|
38518
|
+
} catch (error) {
|
|
38519
|
+
if (state.closing && !state.sessionId) this.finishClose(state);
|
|
38520
|
+
throw error;
|
|
38521
|
+
} finally {
|
|
38522
|
+
if (state.openPromise === opening) state.openPromise = null;
|
|
38523
|
+
}
|
|
38086
38524
|
}
|
|
38087
|
-
async nextFrame(state) {
|
|
38088
|
-
if (state.framePromise)
|
|
38525
|
+
async nextFrame(state, forceRender = false, waitForInitialOutput = false) {
|
|
38526
|
+
if (state.framePromise) {
|
|
38527
|
+
await state.framePromise;
|
|
38528
|
+
return forceRender ? this.nextFrame(state, true, waitForInitialOutput) : state.jpeg ?? this.nextFrame(state, false, waitForInitialOutput);
|
|
38529
|
+
}
|
|
38089
38530
|
const render = async () => {
|
|
38531
|
+
const openingSession = state.sessionId === null;
|
|
38090
38532
|
const sessionId = await this.ensureSession(state);
|
|
38091
38533
|
let batch;
|
|
38092
38534
|
try {
|
|
38093
38535
|
batch = await this.api.pullOutput(state.nodeId, {
|
|
38094
38536
|
sessionId,
|
|
38095
|
-
afterSeq: state.cursor
|
|
38537
|
+
afterSeq: state.cursor,
|
|
38538
|
+
...openingSession && waitForInitialOutput ? {
|
|
38539
|
+
waitMs: SNAPSHOT_STARTUP_WAIT_MS,
|
|
38540
|
+
waitForOutput: true
|
|
38541
|
+
} : {}
|
|
38096
38542
|
});
|
|
38097
38543
|
} catch (error) {
|
|
38098
|
-
state.sessionId
|
|
38544
|
+
if (state.sessionId === sessionId) {
|
|
38545
|
+
let closed = false;
|
|
38546
|
+
await this.api.closeSession(state.nodeId, sessionId).then(() => {
|
|
38547
|
+
closed = true;
|
|
38548
|
+
}).catch((closeError) => {
|
|
38549
|
+
this.logger.warn("terminal camera session cleanup after output failure failed", { meta: {
|
|
38550
|
+
nodeId: state.nodeId,
|
|
38551
|
+
sessionId,
|
|
38552
|
+
error: closeError instanceof Error ? closeError.message : String(closeError)
|
|
38553
|
+
} });
|
|
38554
|
+
});
|
|
38555
|
+
if (closed) state.sessionId = null;
|
|
38556
|
+
}
|
|
38099
38557
|
state.cursor = 0;
|
|
38100
38558
|
throw error;
|
|
38101
38559
|
}
|
|
@@ -38112,7 +38570,7 @@ var TerminalCameraRelay = class {
|
|
|
38112
38570
|
}
|
|
38113
38571
|
state.cursor = exited ? 0 : batch.cursor;
|
|
38114
38572
|
await state.screen.flush();
|
|
38115
|
-
if (state.jpeg === null || state.renderedCursor !== state.cursor) {
|
|
38573
|
+
if (forceRender || state.jpeg === null || state.renderedCursor !== state.cursor) {
|
|
38116
38574
|
state.jpeg = await renderTerminalJpeg(state.screen.lines());
|
|
38117
38575
|
state.renderedCursor = state.cursor;
|
|
38118
38576
|
}
|
|
@@ -38123,14 +38581,15 @@ var TerminalCameraRelay = class {
|
|
|
38123
38581
|
});
|
|
38124
38582
|
return state.framePromise;
|
|
38125
38583
|
}
|
|
38126
|
-
async serve(nodeId, profileId, res) {
|
|
38584
|
+
async serve(instanceId, nodeId, profileId, res) {
|
|
38127
38585
|
this.responses.add(res);
|
|
38128
|
-
const state = this.state(nodeId, profileId);
|
|
38586
|
+
const state = this.state(instanceId, nodeId, profileId);
|
|
38129
38587
|
if (state.idleTimer) clearTimeout(state.idleTimer);
|
|
38130
38588
|
state.idleTimer = null;
|
|
38131
38589
|
state.clients += 1;
|
|
38590
|
+
state.responses.add(res);
|
|
38132
38591
|
try {
|
|
38133
|
-
const first = await this.nextFrame(state);
|
|
38592
|
+
const first = await this.nextFrame(state, false, true);
|
|
38134
38593
|
res.writeHead(200, {
|
|
38135
38594
|
"content-type": `multipart/x-mixed-replace; boundary=${MJPEG_BOUNDARY}`,
|
|
38136
38595
|
"cache-control": "no-store",
|
|
@@ -38158,11 +38617,13 @@ var TerminalCameraRelay = class {
|
|
|
38158
38617
|
res.end("terminal camera unavailable");
|
|
38159
38618
|
} finally {
|
|
38160
38619
|
this.responses.delete(res);
|
|
38620
|
+
state.responses.delete(res);
|
|
38161
38621
|
state.clients = Math.max(0, state.clients - 1);
|
|
38162
|
-
if (state.clients === 0) this.scheduleIdleClose(state);
|
|
38622
|
+
if (state.clients === 0 && state.leases === 0) this.scheduleIdleClose(state);
|
|
38163
38623
|
}
|
|
38164
38624
|
}
|
|
38165
38625
|
scheduleIdleClose(state) {
|
|
38626
|
+
if (state.closed || state.closing || state.clients > 0 || state.leases > 0) return;
|
|
38166
38627
|
if (state.idleTimer) clearTimeout(state.idleTimer);
|
|
38167
38628
|
state.idleTimer = setTimeout(() => {
|
|
38168
38629
|
this.closeState(state);
|
|
@@ -38170,26 +38631,116 @@ var TerminalCameraRelay = class {
|
|
|
38170
38631
|
state.idleTimer.unref?.();
|
|
38171
38632
|
}
|
|
38172
38633
|
async closeState(state) {
|
|
38173
|
-
if (state.
|
|
38174
|
-
if (state.
|
|
38634
|
+
if (state.closed) return;
|
|
38635
|
+
if (state.clients > 0 || state.leases > 0) return;
|
|
38636
|
+
if (state.closePromise) {
|
|
38637
|
+
await state.closePromise;
|
|
38638
|
+
return;
|
|
38639
|
+
}
|
|
38640
|
+
if (state.openPromise && !state.sessionId) {
|
|
38641
|
+
state.closing = true;
|
|
38642
|
+
if (state.idleTimer) clearTimeout(state.idleTimer);
|
|
38643
|
+
state.idleTimer = null;
|
|
38644
|
+
return;
|
|
38645
|
+
}
|
|
38646
|
+
if (state.closing && !state.sessionId) return;
|
|
38647
|
+
state.closing = true;
|
|
38648
|
+
if (state.idleTimer) clearTimeout(state.idleTimer);
|
|
38649
|
+
state.idleTimer = null;
|
|
38650
|
+
state.closePromise = this.closeWithRetries(state).finally(() => {
|
|
38651
|
+
state.closePromise = null;
|
|
38652
|
+
});
|
|
38653
|
+
await state.closePromise;
|
|
38654
|
+
}
|
|
38655
|
+
async closeWithRetries(state) {
|
|
38656
|
+
const sessionId = state.sessionId;
|
|
38657
|
+
if (!sessionId) {
|
|
38658
|
+
this.finishClose(state);
|
|
38659
|
+
return;
|
|
38660
|
+
}
|
|
38661
|
+
for (let attempt = 0; attempt < CLOSE_ATTEMPTS_PER_PASS; attempt += 1) try {
|
|
38662
|
+
await this.api.closeSession(state.nodeId, sessionId);
|
|
38663
|
+
if (state.sessionId === sessionId) this.finishClose(state);
|
|
38664
|
+
return;
|
|
38665
|
+
} catch (error) {
|
|
38666
|
+
state.closeAttempts += 1;
|
|
38175
38667
|
this.logger.warn("terminal camera session close failed", { meta: {
|
|
38176
38668
|
nodeId: state.nodeId,
|
|
38177
|
-
sessionId
|
|
38669
|
+
sessionId,
|
|
38670
|
+
attempt: state.closeAttempts,
|
|
38178
38671
|
error: error instanceof Error ? error.message : String(error)
|
|
38179
38672
|
} });
|
|
38180
|
-
|
|
38673
|
+
if (attempt + 1 < CLOSE_ATTEMPTS_PER_PASS) await new Promise((resolve) => setTimeout(resolve, this.closeRetryDelay(state.closeAttempts)));
|
|
38674
|
+
}
|
|
38675
|
+
this.scheduleCloseRetry(state);
|
|
38676
|
+
}
|
|
38677
|
+
scheduleCloseRetry(state) {
|
|
38678
|
+
if (state.closeRetryTimer || !state.sessionId) return;
|
|
38679
|
+
state.closeRetryTimer = setTimeout(() => {
|
|
38680
|
+
state.closeRetryTimer = null;
|
|
38681
|
+
state.closing = false;
|
|
38682
|
+
this.closeState(state);
|
|
38683
|
+
}, this.closeRetryDelay(state.closeAttempts));
|
|
38684
|
+
state.closeRetryTimer.unref?.();
|
|
38685
|
+
}
|
|
38686
|
+
closeRetryDelay(attempt) {
|
|
38687
|
+
return Math.min(CLOSE_RETRY_BASE_MS * 2 ** Math.min(attempt - 1, 5), CLOSE_RETRY_MAX_MS);
|
|
38688
|
+
}
|
|
38689
|
+
finishClose(state) {
|
|
38690
|
+
if (state.closed) return;
|
|
38691
|
+
state.closed = true;
|
|
38692
|
+
if (state.closeRetryTimer) clearTimeout(state.closeRetryTimer);
|
|
38693
|
+
state.closeRetryTimer = null;
|
|
38694
|
+
state.sessionId = null;
|
|
38695
|
+
state.closeAttempts = 0;
|
|
38696
|
+
state.closing = false;
|
|
38697
|
+
if (this.states.get(relayKey(state.instanceId)) === state) this.states.delete(relayKey(state.instanceId));
|
|
38181
38698
|
state.screen.dispose();
|
|
38182
|
-
|
|
38699
|
+
}
|
|
38700
|
+
/**
|
|
38701
|
+
* Capture one fresh JPEG using the same xterm renderer as the MJPEG relay.
|
|
38702
|
+
* A snapshot-only caller owns a short lease and tears the state down as soon
|
|
38703
|
+
* as the image is rendered, so snapshots never leave a monitor PTY running.
|
|
38704
|
+
*/
|
|
38705
|
+
async snapshot(instanceId, nodeId, profileId) {
|
|
38706
|
+
const state = this.state(instanceId, nodeId, profileId);
|
|
38707
|
+
if (state.idleTimer) clearTimeout(state.idleTimer);
|
|
38708
|
+
state.idleTimer = null;
|
|
38709
|
+
state.leases += 1;
|
|
38710
|
+
try {
|
|
38711
|
+
return await this.nextFrame(state, true, true);
|
|
38712
|
+
} finally {
|
|
38713
|
+
state.leases = Math.max(0, state.leases - 1);
|
|
38714
|
+
if (state.clients === 0 && state.leases === 0) await this.closeState(state);
|
|
38715
|
+
}
|
|
38716
|
+
}
|
|
38717
|
+
/** Stop a withdrawn/offline camera's relay, including active HTTP readers. */
|
|
38718
|
+
async closeInstance(instanceId) {
|
|
38719
|
+
const state = this.states.get(relayKey(instanceId));
|
|
38720
|
+
if (!state) return;
|
|
38721
|
+
for (const response of state.responses) response.destroy();
|
|
38722
|
+
state.responses.clear();
|
|
38723
|
+
state.clients = 0;
|
|
38724
|
+
state.leases = 0;
|
|
38725
|
+
await this.closeState(state);
|
|
38726
|
+
if (state.openPromise) {
|
|
38727
|
+
await state.openPromise.catch(() => {});
|
|
38728
|
+
await this.closeState(state);
|
|
38729
|
+
}
|
|
38183
38730
|
}
|
|
38184
38731
|
async dispose() {
|
|
38185
38732
|
for (const response of this.responses) response.destroy();
|
|
38186
38733
|
this.responses.clear();
|
|
38187
|
-
for (const state of this.states.values()) {
|
|
38734
|
+
for (const state of [...this.states.values()]) {
|
|
38188
38735
|
if (state.idleTimer) clearTimeout(state.idleTimer);
|
|
38189
38736
|
state.clients = 0;
|
|
38737
|
+
state.leases = 0;
|
|
38190
38738
|
await this.closeState(state);
|
|
38739
|
+
if (state.openPromise) {
|
|
38740
|
+
await state.openPromise.catch(() => {});
|
|
38741
|
+
await this.closeState(state);
|
|
38742
|
+
}
|
|
38191
38743
|
}
|
|
38192
|
-
this.states.clear();
|
|
38193
38744
|
if (this.server) {
|
|
38194
38745
|
const server = this.server;
|
|
38195
38746
|
this.server = null;
|
|
@@ -38305,6 +38856,113 @@ function createTerminalDataPlaneHandler(manager) {
|
|
|
38305
38856
|
};
|
|
38306
38857
|
}
|
|
38307
38858
|
//#endregion
|
|
38859
|
+
//#region src/terminal-instances.ts
|
|
38860
|
+
var TerminalInstanceSchema = object({
|
|
38861
|
+
id: string().uuid(),
|
|
38862
|
+
cameraStableId: string().min(1).max(256),
|
|
38863
|
+
nodeId: string().min(1).max(256),
|
|
38864
|
+
profileId: string().min(1).max(64),
|
|
38865
|
+
profileLabel: string().min(1).max(120),
|
|
38866
|
+
name: string().min(1).max(160),
|
|
38867
|
+
enabled: boolean()
|
|
38868
|
+
});
|
|
38869
|
+
/**
|
|
38870
|
+
* Config is operator-writable, so malformed or duplicate rows are ignored
|
|
38871
|
+
* rather than allowed to make declaration reconciliation destructive.
|
|
38872
|
+
*/
|
|
38873
|
+
function readTerminalInstances(raw, onInvalid) {
|
|
38874
|
+
const ids = /* @__PURE__ */ new Set();
|
|
38875
|
+
const stableIds = /* @__PURE__ */ new Set();
|
|
38876
|
+
const instances = [];
|
|
38877
|
+
for (const value of raw) {
|
|
38878
|
+
const parsed = TerminalInstanceSchema.safeParse(value);
|
|
38879
|
+
if (!parsed.success) {
|
|
38880
|
+
onInvalid?.("Ignoring malformed Terminal instance configuration");
|
|
38881
|
+
continue;
|
|
38882
|
+
}
|
|
38883
|
+
const instance = parsed.data;
|
|
38884
|
+
if (ids.has(instance.id) || stableIds.has(instance.cameraStableId)) {
|
|
38885
|
+
onInvalid?.(`Ignoring duplicate Terminal instance ${instance.id}`);
|
|
38886
|
+
continue;
|
|
38887
|
+
}
|
|
38888
|
+
ids.add(instance.id);
|
|
38889
|
+
stableIds.add(instance.cameraStableId);
|
|
38890
|
+
instances.push(instance);
|
|
38891
|
+
}
|
|
38892
|
+
return instances;
|
|
38893
|
+
}
|
|
38894
|
+
function newTerminalCameraStableId(instanceId) {
|
|
38895
|
+
return `terminal-camera-instance-${instanceId}`;
|
|
38896
|
+
}
|
|
38897
|
+
/**
|
|
38898
|
+
* Legacy automatic cameras are migration candidates only. A tombstone is
|
|
38899
|
+
* durable deletion intent, so a lingering failed device removal must never
|
|
38900
|
+
* make that camera adoptable again.
|
|
38901
|
+
*/
|
|
38902
|
+
function listLegacyTerminalCameraCandidates(rows, instanceStableIds, tombstones) {
|
|
38903
|
+
const legacy = [];
|
|
38904
|
+
for (const row of rows) {
|
|
38905
|
+
if (tombstones.has(row.stableId) || instanceStableIds.has(row.stableId) || row.stableId.startsWith("terminal-camera-instance-") || !row.stableId.startsWith("terminal-camera-")) continue;
|
|
38906
|
+
const nodeId = typeof row.config.nodeId === "string" ? row.config.nodeId : null;
|
|
38907
|
+
if (!nodeId) continue;
|
|
38908
|
+
const profileId = typeof row.config.profileId === "string" ? row.config.profileId : "monitor";
|
|
38909
|
+
const profileLabel = typeof row.config.profileLabel === "string" ? row.config.profileLabel : profileId === "monitor" ? "BTM" : profileId;
|
|
38910
|
+
legacy.push({
|
|
38911
|
+
stableId: row.stableId,
|
|
38912
|
+
nodeId,
|
|
38913
|
+
profileId,
|
|
38914
|
+
profileLabel,
|
|
38915
|
+
name: row.name,
|
|
38916
|
+
adoptable: profileId === "monitor" && row.stableId === `terminal-camera-${nodeId}`
|
|
38917
|
+
});
|
|
38918
|
+
}
|
|
38919
|
+
return legacy;
|
|
38920
|
+
}
|
|
38921
|
+
/** Serializes config read-modify-write operations and their reconciliation. */
|
|
38922
|
+
var TerminalInstanceMutationQueue = class {
|
|
38923
|
+
tail = Promise.resolve();
|
|
38924
|
+
async run(mutation) {
|
|
38925
|
+
const previous = this.tail;
|
|
38926
|
+
let release;
|
|
38927
|
+
this.tail = new Promise((resolve) => {
|
|
38928
|
+
release = resolve;
|
|
38929
|
+
});
|
|
38930
|
+
await previous;
|
|
38931
|
+
try {
|
|
38932
|
+
return await mutation();
|
|
38933
|
+
} finally {
|
|
38934
|
+
release?.();
|
|
38935
|
+
}
|
|
38936
|
+
}
|
|
38937
|
+
};
|
|
38938
|
+
/**
|
|
38939
|
+
* Coalesces periodic/config reconciliation requests onto the same serialized
|
|
38940
|
+
* lane as instance mutations. A pass never applies a declaration snapshot
|
|
38941
|
+
* concurrently with a create/delete/enable write.
|
|
38942
|
+
*/
|
|
38943
|
+
var TerminalInstanceReconcileCoordinator = class {
|
|
38944
|
+
queue;
|
|
38945
|
+
dirty = false;
|
|
38946
|
+
running = null;
|
|
38947
|
+
constructor(queue) {
|
|
38948
|
+
this.queue = queue;
|
|
38949
|
+
}
|
|
38950
|
+
request(apply) {
|
|
38951
|
+
this.dirty = true;
|
|
38952
|
+
if (this.running) return this.running;
|
|
38953
|
+
const running = this.queue.run(async () => {
|
|
38954
|
+
while (this.dirty) {
|
|
38955
|
+
this.dirty = false;
|
|
38956
|
+
await apply();
|
|
38957
|
+
}
|
|
38958
|
+
});
|
|
38959
|
+
this.running = running.finally(() => {
|
|
38960
|
+
this.running = null;
|
|
38961
|
+
});
|
|
38962
|
+
return this.running;
|
|
38963
|
+
}
|
|
38964
|
+
};
|
|
38965
|
+
//#endregion
|
|
38308
38966
|
//#region src/profiles.ts
|
|
38309
38967
|
/**
|
|
38310
38968
|
* The allowlist of programs an operator may open. The capability accepts a
|
|
@@ -38433,6 +39091,7 @@ var TerminalSessionManager = class {
|
|
|
38433
39091
|
now;
|
|
38434
39092
|
resolveBinary;
|
|
38435
39093
|
maxSessions;
|
|
39094
|
+
instanceControl = null;
|
|
38436
39095
|
constructor(opts) {
|
|
38437
39096
|
this.opts = opts;
|
|
38438
39097
|
this.profiles = buildProfiles({
|
|
@@ -38471,6 +39130,27 @@ var TerminalSessionManager = class {
|
|
|
38471
39130
|
...p.description !== void 0 ? { description: p.description } : {}
|
|
38472
39131
|
}));
|
|
38473
39132
|
}
|
|
39133
|
+
setInstanceControl(control) {
|
|
39134
|
+
this.instanceControl = control;
|
|
39135
|
+
}
|
|
39136
|
+
async listInstances() {
|
|
39137
|
+
return this.instanceControl?.listInstances() ?? [];
|
|
39138
|
+
}
|
|
39139
|
+
async createInstance(input) {
|
|
39140
|
+
return this.requireInstanceControl().createInstance(input);
|
|
39141
|
+
}
|
|
39142
|
+
async deleteInstance(input) {
|
|
39143
|
+
await this.requireInstanceControl().deleteInstance(input);
|
|
39144
|
+
}
|
|
39145
|
+
async setInstanceEnabled(input) {
|
|
39146
|
+
return this.requireInstanceControl().setInstanceEnabled(input);
|
|
39147
|
+
}
|
|
39148
|
+
async listLegacyCameras() {
|
|
39149
|
+
return this.instanceControl?.listLegacyCameras() ?? [];
|
|
39150
|
+
}
|
|
39151
|
+
async adoptLegacyMonitor(input) {
|
|
39152
|
+
return this.requireInstanceControl().adoptLegacyMonitor(input);
|
|
39153
|
+
}
|
|
38474
39154
|
async listSessions() {
|
|
38475
39155
|
return [...this.sessions.values()].filter((s) => !s.exited).map((s) => s.info);
|
|
38476
39156
|
}
|
|
@@ -38518,10 +39198,12 @@ var TerminalSessionManager = class {
|
|
|
38518
39198
|
outputWaiters: /* @__PURE__ */ new Set(),
|
|
38519
39199
|
outputChars: 0,
|
|
38520
39200
|
nextSeq: 1,
|
|
38521
|
-
exited: false
|
|
39201
|
+
exited: false,
|
|
39202
|
+
disposed: false
|
|
38522
39203
|
};
|
|
38523
39204
|
this.sessions.set(sessionId, session);
|
|
38524
39205
|
pty.onData((data) => {
|
|
39206
|
+
if (session.exited) return;
|
|
38525
39207
|
session.screen.write(data);
|
|
38526
39208
|
this.appendOutput(session, {
|
|
38527
39209
|
kind: "data",
|
|
@@ -38535,27 +39217,7 @@ var TerminalSessionManager = class {
|
|
|
38535
39217
|
} catch {}
|
|
38536
39218
|
});
|
|
38537
39219
|
pty.onExit((event) => {
|
|
38538
|
-
|
|
38539
|
-
session.lastExit = event;
|
|
38540
|
-
this.appendOutput(session, {
|
|
38541
|
-
kind: "exit",
|
|
38542
|
-
exitCode: event.exitCode,
|
|
38543
|
-
...event.signal !== void 0 ? { signal: event.signal } : {}
|
|
38544
|
-
});
|
|
38545
|
-
for (const sink of session.sinks) try {
|
|
38546
|
-
sink({
|
|
38547
|
-
kind: "exit",
|
|
38548
|
-
exitCode: event.exitCode,
|
|
38549
|
-
signal: event.signal
|
|
38550
|
-
});
|
|
38551
|
-
} catch {}
|
|
38552
|
-
session.sinks.clear();
|
|
38553
|
-
const retire = setTimeout(() => {
|
|
38554
|
-
session.screen.dispose();
|
|
38555
|
-
this.sessions.delete(sessionId);
|
|
38556
|
-
}, EXITED_RETENTION_MS);
|
|
38557
|
-
retire.unref?.();
|
|
38558
|
-
session.retireTimer = retire;
|
|
39220
|
+
this.finishSession(sessionId, session, event, true);
|
|
38559
39221
|
});
|
|
38560
39222
|
this.opts.logger.info("terminal: session opened", { meta: {
|
|
38561
39223
|
sessionId,
|
|
@@ -38586,6 +39248,7 @@ var TerminalSessionManager = class {
|
|
|
38586
39248
|
async close(input) {
|
|
38587
39249
|
const session = this.sessions.get(input.sessionId);
|
|
38588
39250
|
if (!session) return;
|
|
39251
|
+
this.finishSession(input.sessionId, session, { exitCode: 0 }, false);
|
|
38589
39252
|
try {
|
|
38590
39253
|
session.pty.kill();
|
|
38591
39254
|
} catch {}
|
|
@@ -38594,7 +39257,7 @@ var TerminalSessionManager = class {
|
|
|
38594
39257
|
async pullOutput(input) {
|
|
38595
39258
|
const session = this.sessions.get(input.sessionId);
|
|
38596
39259
|
if (!session) throw new Error(`No such terminal session: ${input.sessionId}`);
|
|
38597
|
-
if (input.afterSeq > 0 && input.afterSeq === session.nextSeq - 1 && !session.exited && (input.waitMs ?? 0) > 0) await new Promise((resolve) => {
|
|
39260
|
+
if ((input.afterSeq > 0 || input.waitForOutput === true) && input.afterSeq === session.nextSeq - 1 && !session.exited && (input.waitMs ?? 0) > 0) await new Promise((resolve) => {
|
|
38598
39261
|
const wake = () => {
|
|
38599
39262
|
clearTimeout(timer);
|
|
38600
39263
|
session.outputWaiters.delete(wake);
|
|
@@ -38605,6 +39268,11 @@ var TerminalSessionManager = class {
|
|
|
38605
39268
|
session.outputWaiters.add(wake);
|
|
38606
39269
|
});
|
|
38607
39270
|
const cursor = session.nextSeq - 1;
|
|
39271
|
+
if (session.disposed) return {
|
|
39272
|
+
cursor,
|
|
39273
|
+
reset: false,
|
|
39274
|
+
events: session.output.filter((event) => event.seq > input.afterSeq)
|
|
39275
|
+
};
|
|
38608
39276
|
const oldestSeq = session.output[0]?.seq ?? session.nextSeq;
|
|
38609
39277
|
if (input.afterSeq === 0 || input.afterSeq < oldestSeq - 1 || input.afterSeq > cursor) {
|
|
38610
39278
|
await session.screen.flush();
|
|
@@ -38685,14 +39353,51 @@ var TerminalSessionManager = class {
|
|
|
38685
39353
|
}
|
|
38686
39354
|
/** Kill every live session — called on addon shutdown. */
|
|
38687
39355
|
disposeAll() {
|
|
38688
|
-
for (const session of this.sessions
|
|
38689
|
-
|
|
39356
|
+
for (const [sessionId, session] of this.sessions) {
|
|
39357
|
+
this.finishSession(sessionId, session, { exitCode: 0 }, false);
|
|
38690
39358
|
try {
|
|
38691
39359
|
session.pty.kill();
|
|
38692
39360
|
} catch {}
|
|
38693
|
-
session.screen.dispose();
|
|
38694
39361
|
}
|
|
38695
|
-
|
|
39362
|
+
}
|
|
39363
|
+
finishSession(sessionId, session, exit, retainForLateExit) {
|
|
39364
|
+
if (session.exited) return;
|
|
39365
|
+
session.exited = true;
|
|
39366
|
+
session.lastExit = exit;
|
|
39367
|
+
this.appendOutput(session, {
|
|
39368
|
+
kind: "exit",
|
|
39369
|
+
exitCode: exit.exitCode,
|
|
39370
|
+
...exit.signal !== void 0 ? { signal: exit.signal } : {}
|
|
39371
|
+
});
|
|
39372
|
+
for (const sink of session.sinks) try {
|
|
39373
|
+
sink({
|
|
39374
|
+
kind: "exit",
|
|
39375
|
+
exitCode: exit.exitCode,
|
|
39376
|
+
...exit.signal !== void 0 ? { signal: exit.signal } : {}
|
|
39377
|
+
});
|
|
39378
|
+
} catch {}
|
|
39379
|
+
session.sinks.clear();
|
|
39380
|
+
if (!retainForLateExit) {
|
|
39381
|
+
this.disposeSession(session);
|
|
39382
|
+
this.sessions.delete(sessionId);
|
|
39383
|
+
return;
|
|
39384
|
+
}
|
|
39385
|
+
const retire = setTimeout(() => {
|
|
39386
|
+
this.disposeSession(session);
|
|
39387
|
+
this.sessions.delete(sessionId);
|
|
39388
|
+
}, EXITED_RETENTION_MS);
|
|
39389
|
+
retire.unref?.();
|
|
39390
|
+
session.retireTimer = retire;
|
|
39391
|
+
}
|
|
39392
|
+
disposeSession(session) {
|
|
39393
|
+
if (session.retireTimer !== void 0) clearTimeout(session.retireTimer);
|
|
39394
|
+
if (session.disposed) return;
|
|
39395
|
+
session.disposed = true;
|
|
39396
|
+
session.screen.dispose();
|
|
39397
|
+
}
|
|
39398
|
+
requireInstanceControl() {
|
|
39399
|
+
if (!this.instanceControl) throw new Error("Terminal instances are managed on the hub");
|
|
39400
|
+
return this.instanceControl;
|
|
38696
39401
|
}
|
|
38697
39402
|
};
|
|
38698
39403
|
//#endregion
|
|
@@ -38710,7 +39415,9 @@ var DEFAULTS = {
|
|
|
38710
39415
|
allowShell: false,
|
|
38711
39416
|
shellPath: "",
|
|
38712
39417
|
maxSessions: 4,
|
|
38713
|
-
customProfiles: []
|
|
39418
|
+
customProfiles: [],
|
|
39419
|
+
terminalInstances: [],
|
|
39420
|
+
terminalCameraTombstones: []
|
|
38714
39421
|
};
|
|
38715
39422
|
var DATA_PLANE_PREFIX = "io";
|
|
38716
39423
|
var CAMERA_RECONCILE_MS = 6e4;
|
|
@@ -38721,6 +39428,11 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
38721
39428
|
cameraRelay = null;
|
|
38722
39429
|
cameraReconcileTimer = null;
|
|
38723
39430
|
cameraProfilesByNode = /* @__PURE__ */ new Map();
|
|
39431
|
+
/** Prevent repeat writes when a legacy device stays live after migration. */
|
|
39432
|
+
migratedTerminalCameraConfigIds = /* @__PURE__ */ new Set();
|
|
39433
|
+
terminalCameraTombstones = /* @__PURE__ */ new Set();
|
|
39434
|
+
instanceMutationQueue = new TerminalInstanceMutationQueue();
|
|
39435
|
+
terminalReconcileCoordinator = new TerminalInstanceReconcileCoordinator(this.instanceMutationQueue);
|
|
38724
39436
|
glancesPythonPath = "";
|
|
38725
39437
|
constructor() {
|
|
38726
39438
|
super({ ...DEFAULTS });
|
|
@@ -38749,18 +39461,22 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
38749
39461
|
customProfiles: this.config.customProfiles
|
|
38750
39462
|
});
|
|
38751
39463
|
this.manager = manager;
|
|
39464
|
+
this.replaceTerminalCameraTombstones(this.config.terminalCameraTombstones);
|
|
38752
39465
|
if (declarationOwnerNodeId(this.ctx.kernel.localNodeId) === "hub") {
|
|
39466
|
+
const localNodeId = declarationOwnerNodeId(this.ctx.kernel.localNodeId);
|
|
38753
39467
|
const cameraRelay = new TerminalCameraRelay({
|
|
38754
|
-
listProfiles: (nodeId) => this.ctx.api.terminalSession.listProfiles.query(void 0, nodePin(nodeId)),
|
|
38755
|
-
openSession: (nodeId, input) => this.ctx.api.terminalSession.openSession.mutate(input, nodePin(nodeId)),
|
|
38756
|
-
pullOutput: (nodeId, input) => this.ctx.api.terminalSession.pullOutput.mutate(input, nodePin(nodeId)),
|
|
39468
|
+
listProfiles: (nodeId) => nodeId === localNodeId ? manager.listProfiles() : this.ctx.api.terminalSession.listProfiles.query(void 0, nodePin(nodeId)),
|
|
39469
|
+
openSession: (nodeId, input) => nodeId === localNodeId ? manager.openSession(input) : this.ctx.api.terminalSession.openSession.mutate(input, nodePin(nodeId)),
|
|
39470
|
+
pullOutput: (nodeId, input) => nodeId === localNodeId ? manager.pullOutput(input) : this.ctx.api.terminalSession.pullOutput.mutate(input, nodePin(nodeId)),
|
|
38757
39471
|
closeSession: async (nodeId, sessionId) => {
|
|
38758
|
-
await
|
|
39472
|
+
if (nodeId === localNodeId) await manager.close({ sessionId });
|
|
39473
|
+
else await this.ctx.api.terminalSession.close.mutate({ sessionId }, nodePin(nodeId));
|
|
38759
39474
|
}
|
|
38760
39475
|
}, this.ctx.logger.child("camera"));
|
|
38761
39476
|
await cameraRelay.start();
|
|
38762
39477
|
this.cameraRelay = cameraRelay;
|
|
38763
39478
|
installTerminalCameraRelay(cameraRelay);
|
|
39479
|
+
manager.setInstanceControl(this.terminalInstanceControl());
|
|
38764
39480
|
await this.reconcileTerminalCameras().catch((error) => {
|
|
38765
39481
|
this.ctx.logger.warn("initial terminal camera reconciliation failed — will retry", { meta: { error: error instanceof Error ? error.message : String(error) } });
|
|
38766
39482
|
});
|
|
@@ -38785,6 +39501,7 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
38785
39501
|
}];
|
|
38786
39502
|
}
|
|
38787
39503
|
async onConfigChanged() {
|
|
39504
|
+
this.replaceTerminalCameraTombstones(this.config.terminalCameraTombstones);
|
|
38788
39505
|
this.manager?.reconfigureProfiles({
|
|
38789
39506
|
btmPath: this.config.btmPath,
|
|
38790
39507
|
btmEnabled: this.config.btmEnabled,
|
|
@@ -38801,6 +39518,9 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
38801
39518
|
maxSessions: this.config.maxSessions,
|
|
38802
39519
|
customProfiles: this.config.customProfiles
|
|
38803
39520
|
});
|
|
39521
|
+
if (this.cameraRelay) this.reconcileTerminalCameras().catch((error) => {
|
|
39522
|
+
this.ctx.logger.warn("terminal camera reconciliation after config change failed", { meta: { error: error instanceof Error ? error.message : String(error) } });
|
|
39523
|
+
});
|
|
38804
39524
|
}
|
|
38805
39525
|
async onShutdown() {
|
|
38806
39526
|
if (this.cameraReconcileTimer) clearInterval(this.cameraReconcileTimer);
|
|
@@ -38816,55 +39536,46 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
38816
39536
|
this.manager = null;
|
|
38817
39537
|
}
|
|
38818
39538
|
async reconcileTerminalCameras() {
|
|
39539
|
+
return this.terminalReconcileCoordinator.request(() => this.applyTerminalCameraReconciliation());
|
|
39540
|
+
}
|
|
39541
|
+
async applyTerminalCameraReconciliation() {
|
|
38819
39542
|
if (!this.cameraRelay) return;
|
|
39543
|
+
let terminalIntegrationId;
|
|
38820
39544
|
const topology = await this.ctx.api.nodes.topology.query();
|
|
38821
39545
|
if (topology.length === 0) throw new Error("cluster topology returned no nodes; refusing to withdraw declared cameras");
|
|
38822
39546
|
const nodes = topology.filter((node) => typeof node.id === "string" && node.id.length > 0);
|
|
39547
|
+
const nodeIds = new Set(nodes.map((node) => node.id));
|
|
39548
|
+
for (const cachedNodeId of this.cameraProfilesByNode.keys()) if (!nodeIds.has(cachedNodeId)) this.cameraProfilesByNode.delete(cachedNodeId);
|
|
38823
39549
|
const unavailableNodeIds = /* @__PURE__ */ new Set();
|
|
38824
39550
|
await Promise.all(nodes.map(async (node) => {
|
|
38825
39551
|
try {
|
|
38826
39552
|
this.cameraProfilesByNode.set(node.id, await this.cameraRelay.listProfiles(node.id));
|
|
38827
39553
|
} catch (error) {
|
|
38828
|
-
|
|
38829
|
-
|
|
38830
|
-
|
|
38831
|
-
|
|
38832
|
-
|
|
38833
|
-
|
|
38834
|
-
}
|
|
39554
|
+
unavailableNodeIds.add(node.id);
|
|
39555
|
+
this.ctx.logger.warn("terminal profiles unavailable — keeping Terminal instance cameras offline", { meta: {
|
|
39556
|
+
nodeId: node.id,
|
|
39557
|
+
cachedProfiles: this.cameraProfilesByNode.has(node.id),
|
|
39558
|
+
error: error instanceof Error ? error.message : String(error)
|
|
39559
|
+
} });
|
|
38835
39560
|
}
|
|
38836
39561
|
}));
|
|
38837
|
-
const cameraDeclarations =
|
|
38838
|
-
|
|
38839
|
-
const existing = await this.ctx.api.deviceManager.listAll.query({ addonId: this.ctx.id });
|
|
38840
|
-
const unavailableNodes = nodes.filter((node) => unavailableNodeIds.has(node.id)).sort((left, right) => right.id.length - left.id.length);
|
|
38841
|
-
for (const row of existing) {
|
|
38842
|
-
const node = unavailableNodes.find((candidate) => {
|
|
38843
|
-
const base = `terminal-camera-${candidate.id}`;
|
|
38844
|
-
return row.stableId === base || row.stableId.startsWith(`${base}-`);
|
|
38845
|
-
});
|
|
38846
|
-
if (!node || cameraDeclarations.some((camera) => camera.stableId === row.stableId)) continue;
|
|
38847
|
-
const base = `terminal-camera-${node.id}`;
|
|
38848
|
-
const profileId = row.stableId === base ? "monitor" : row.stableId.slice(base.length + 1);
|
|
38849
|
-
const profileLabel = profileId === "monitor" ? "BTM" : profileId;
|
|
38850
|
-
cameraDeclarations.push({
|
|
38851
|
-
stableId: row.stableId,
|
|
38852
|
-
name: `Terminal ${profileLabel} - ${node.isHub ? "Hub" : node.name}`,
|
|
38853
|
-
config: {
|
|
38854
|
-
nodeId: node.id,
|
|
38855
|
-
profileId,
|
|
38856
|
-
profileLabel
|
|
38857
|
-
}
|
|
38858
|
-
});
|
|
38859
|
-
}
|
|
38860
|
-
}
|
|
39562
|
+
const cameraDeclarations = buildTerminalInstanceCameraDeclarations(this.terminalInstances());
|
|
39563
|
+
const declarationsByStableId = new Map(cameraDeclarations.map((camera) => [camera.stableId, camera]));
|
|
38861
39564
|
const result = await new DeclaredDevices({
|
|
38862
39565
|
logger: this.ctx.logger.child("camera-declaration"),
|
|
38863
39566
|
addonId: this.ctx.id,
|
|
38864
39567
|
devices: this.ctx.kernel.devices,
|
|
38865
39568
|
localNodeId: this.ctx.kernel.localNodeId,
|
|
38866
|
-
getIntegration: async (addonId) =>
|
|
38867
|
-
|
|
39569
|
+
getIntegration: async (addonId) => {
|
|
39570
|
+
const integration = await this.ctx.api.integrations.getByAddonId.query({ addonId });
|
|
39571
|
+
terminalIntegrationId = integration?.id ?? null;
|
|
39572
|
+
return integration;
|
|
39573
|
+
},
|
|
39574
|
+
createIntegration: async (input) => {
|
|
39575
|
+
const integration = await this.ctx.api.integrations.create.mutate(input);
|
|
39576
|
+
terminalIntegrationId = integration.id;
|
|
39577
|
+
return integration;
|
|
39578
|
+
},
|
|
38868
39579
|
updateIntegration: async ({ id, info }) => {
|
|
38869
39580
|
await this.ctx.api.integrations.update.mutate({
|
|
38870
39581
|
id,
|
|
@@ -38872,7 +39583,9 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
38872
39583
|
skipRestart: true
|
|
38873
39584
|
});
|
|
38874
39585
|
},
|
|
38875
|
-
listOwnDevices: async () =>
|
|
39586
|
+
listOwnDevices: async () => {
|
|
39587
|
+
return terminalCameraReconciliationIndex(await this.ctx.api.deviceManager.listAll.query({ addonId: this.ctx.id }), terminalIntegrationId, cameraDeclarations, this.terminalCameraTombstones);
|
|
39588
|
+
}
|
|
38876
39589
|
}).reconcile({
|
|
38877
39590
|
integrationName: TERMINAL_CAMERA_INTEGRATION,
|
|
38878
39591
|
placement: "hub",
|
|
@@ -38885,12 +39598,142 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
38885
39598
|
role: "terminal-camera"
|
|
38886
39599
|
}))
|
|
38887
39600
|
});
|
|
38888
|
-
const onlineByNode = new Map(nodes.map((node) => [node.id, node.isOnline]));
|
|
39601
|
+
const onlineByNode = new Map(nodes.map((node) => [node.id, node.isOnline && !unavailableNodeIds.has(node.id)]));
|
|
38889
39602
|
for (const outcome of result.devices) if (outcome.device instanceof TerminalCameraDevice) {
|
|
39603
|
+
const declaration = declarationsByStableId.get(outcome.stableId);
|
|
39604
|
+
if (declaration) {
|
|
39605
|
+
const config = outcome.device.config;
|
|
39606
|
+
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)) {
|
|
39607
|
+
await config.setAll(declaration.config);
|
|
39608
|
+
this.migratedTerminalCameraConfigIds.add(outcome.device.id);
|
|
39609
|
+
}
|
|
39610
|
+
}
|
|
38890
39611
|
const nodeId = outcome.device.config.get("nodeId");
|
|
38891
|
-
outcome.device.
|
|
39612
|
+
const profileId = outcome.device.config.get("profileId");
|
|
39613
|
+
const profileAvailable = this.cameraProfilesByNode.get(nodeId)?.some((profile) => profile.profileId === profileId) ?? false;
|
|
39614
|
+
outcome.device.setNodeOnline((onlineByNode.get(nodeId) ?? false) && profileAvailable);
|
|
38892
39615
|
}
|
|
38893
39616
|
}
|
|
39617
|
+
terminalInstanceControl() {
|
|
39618
|
+
return {
|
|
39619
|
+
listInstances: async () => this.terminalInstances().map((instance) => this.instanceInfo(instance)),
|
|
39620
|
+
createInstance: async (input) => this.createTerminalInstance(input),
|
|
39621
|
+
deleteInstance: async ({ instanceId }) => this.deleteTerminalInstance(instanceId),
|
|
39622
|
+
setInstanceEnabled: async ({ instanceId, enabled }) => this.setTerminalInstanceEnabled(instanceId, enabled),
|
|
39623
|
+
listLegacyCameras: async () => this.listLegacyTerminalCameras(),
|
|
39624
|
+
adoptLegacyMonitor: async ({ stableId, name }) => this.adoptLegacyMonitor(stableId, name)
|
|
39625
|
+
};
|
|
39626
|
+
}
|
|
39627
|
+
terminalInstances() {
|
|
39628
|
+
return readTerminalInstances(this.config.terminalInstances, (message) => {
|
|
39629
|
+
this.ctx.logger.warn(message);
|
|
39630
|
+
});
|
|
39631
|
+
}
|
|
39632
|
+
instanceInfo(instance) {
|
|
39633
|
+
return {
|
|
39634
|
+
instanceId: instance.id,
|
|
39635
|
+
cameraStableId: instance.cameraStableId,
|
|
39636
|
+
nodeId: instance.nodeId,
|
|
39637
|
+
profileId: instance.profileId,
|
|
39638
|
+
profileLabel: instance.profileLabel,
|
|
39639
|
+
name: instance.name,
|
|
39640
|
+
enabled: instance.enabled
|
|
39641
|
+
};
|
|
39642
|
+
}
|
|
39643
|
+
replaceTerminalCameraTombstones(stableIds) {
|
|
39644
|
+
this.terminalCameraTombstones.clear();
|
|
39645
|
+
for (const stableId of stableIds) if (typeof stableId === "string" && stableId.length > 0) this.terminalCameraTombstones.add(stableId);
|
|
39646
|
+
}
|
|
39647
|
+
async createTerminalInstance(input) {
|
|
39648
|
+
const instance = await this.instanceMutationQueue.run(() => this.createTerminalInstanceUnlocked(input));
|
|
39649
|
+
await this.reconcileTerminalCameras();
|
|
39650
|
+
return instance;
|
|
39651
|
+
}
|
|
39652
|
+
async createTerminalInstanceUnlocked(input) {
|
|
39653
|
+
const relay = this.cameraRelay;
|
|
39654
|
+
if (!relay) throw new Error("Terminal instances are managed on the hub");
|
|
39655
|
+
const profile = (await relay.listProfiles(input.targetNodeId)).find((candidate) => candidate.profileId === input.profileId);
|
|
39656
|
+
if (!profile) throw new Error(`Terminal profile '${input.profileId}' is not available on ${input.targetNodeId}`);
|
|
39657
|
+
const node = (await this.ctx.api.nodes.topology.query()).find((candidate) => candidate.id === input.targetNodeId);
|
|
39658
|
+
if (!node) throw new Error(`Terminal node '${input.targetNodeId}' no longer exists`);
|
|
39659
|
+
const id = crypto.randomUUID();
|
|
39660
|
+
const name = input.name?.trim() || `Terminal ${profile.label} - ${node.isHub ? "Hub" : node.name}`;
|
|
39661
|
+
const instance = {
|
|
39662
|
+
id,
|
|
39663
|
+
cameraStableId: newTerminalCameraStableId(id),
|
|
39664
|
+
nodeId: input.targetNodeId,
|
|
39665
|
+
profileId: profile.profileId,
|
|
39666
|
+
profileLabel: profile.label,
|
|
39667
|
+
name,
|
|
39668
|
+
enabled: true
|
|
39669
|
+
};
|
|
39670
|
+
await this.updateGlobalSettings({ terminalInstances: [...this.config.terminalInstances, instance] });
|
|
39671
|
+
return this.instanceInfo(instance);
|
|
39672
|
+
}
|
|
39673
|
+
async deleteTerminalInstance(instanceId) {
|
|
39674
|
+
await this.instanceMutationQueue.run(() => this.deleteTerminalInstanceUnlocked(instanceId));
|
|
39675
|
+
await this.reconcileTerminalCameras();
|
|
39676
|
+
}
|
|
39677
|
+
async deleteTerminalInstanceUnlocked(instanceId) {
|
|
39678
|
+
const instance = this.terminalInstances().find((candidate) => candidate.id === instanceId);
|
|
39679
|
+
if (!instance) return;
|
|
39680
|
+
this.terminalCameraTombstones.add(instance.cameraStableId);
|
|
39681
|
+
await this.cameraRelay?.closeInstance(instance.id);
|
|
39682
|
+
await this.updateGlobalSettings({
|
|
39683
|
+
terminalInstances: this.config.terminalInstances.filter((candidate) => candidate.id !== instanceId),
|
|
39684
|
+
terminalCameraTombstones: [...new Set([...this.config.terminalCameraTombstones, instance.cameraStableId])]
|
|
39685
|
+
});
|
|
39686
|
+
}
|
|
39687
|
+
async setTerminalInstanceEnabled(instanceId, enabled) {
|
|
39688
|
+
const instance = await this.instanceMutationQueue.run(() => this.setTerminalInstanceEnabledUnlocked(instanceId, enabled));
|
|
39689
|
+
await this.reconcileTerminalCameras();
|
|
39690
|
+
return instance;
|
|
39691
|
+
}
|
|
39692
|
+
async setTerminalInstanceEnabledUnlocked(instanceId, enabled) {
|
|
39693
|
+
const instance = this.terminalInstances().find((candidate) => candidate.id === instanceId);
|
|
39694
|
+
if (!instance) throw new Error(`No such Terminal instance: ${instanceId}`);
|
|
39695
|
+
if (!enabled) {
|
|
39696
|
+
this.terminalCameraTombstones.add(instance.cameraStableId);
|
|
39697
|
+
await this.cameraRelay?.closeInstance(instance.id);
|
|
39698
|
+
}
|
|
39699
|
+
const updated = {
|
|
39700
|
+
...instance,
|
|
39701
|
+
enabled
|
|
39702
|
+
};
|
|
39703
|
+
const terminalInstances = this.config.terminalInstances.map((candidate) => candidate.id === instanceId ? updated : candidate);
|
|
39704
|
+
const terminalCameraTombstones = enabled ? this.config.terminalCameraTombstones.filter((stableId) => stableId !== instance.cameraStableId) : [...new Set([...this.config.terminalCameraTombstones, instance.cameraStableId])];
|
|
39705
|
+
await this.updateGlobalSettings({
|
|
39706
|
+
terminalInstances,
|
|
39707
|
+
terminalCameraTombstones
|
|
39708
|
+
});
|
|
39709
|
+
return this.instanceInfo(updated);
|
|
39710
|
+
}
|
|
39711
|
+
async listLegacyTerminalCameras() {
|
|
39712
|
+
const instances = this.terminalInstances();
|
|
39713
|
+
const instanceStableIds = new Set(instances.map((instance) => instance.cameraStableId));
|
|
39714
|
+
return listLegacyTerminalCameraCandidates(await this.ctx.api.deviceManager.listAll.query({ addonId: this.ctx.id }), instanceStableIds, this.terminalCameraTombstones);
|
|
39715
|
+
}
|
|
39716
|
+
async adoptLegacyMonitor(stableId, requestedName) {
|
|
39717
|
+
const instance = await this.instanceMutationQueue.run(() => this.adoptLegacyMonitorUnlocked(stableId, requestedName));
|
|
39718
|
+
await this.reconcileTerminalCameras();
|
|
39719
|
+
return instance;
|
|
39720
|
+
}
|
|
39721
|
+
async adoptLegacyMonitorUnlocked(stableId, requestedName) {
|
|
39722
|
+
if (this.terminalCameraTombstones.has(stableId)) throw new Error("This legacy Terminal camera was deleted and cannot be adopted");
|
|
39723
|
+
const legacy = (await this.listLegacyTerminalCameras()).find((camera) => camera.stableId === stableId);
|
|
39724
|
+
if (!legacy?.adoptable) throw new Error("Only a legacy monitor camera with its original stable id can be adopted");
|
|
39725
|
+
const instance = {
|
|
39726
|
+
id: crypto.randomUUID(),
|
|
39727
|
+
cameraStableId: legacy.stableId,
|
|
39728
|
+
nodeId: legacy.nodeId,
|
|
39729
|
+
profileId: "monitor",
|
|
39730
|
+
profileLabel: legacy.profileLabel,
|
|
39731
|
+
name: requestedName?.trim() || legacy.name,
|
|
39732
|
+
enabled: true
|
|
39733
|
+
};
|
|
39734
|
+
await this.updateGlobalSettings({ terminalInstances: [...this.config.terminalInstances, instance] });
|
|
39735
|
+
return this.instanceInfo(instance);
|
|
39736
|
+
}
|
|
38894
39737
|
globalSettingsSchema() {
|
|
38895
39738
|
return this.schema({ sections: [{
|
|
38896
39739
|
id: "terminal",
|
|
@@ -39001,7 +39844,7 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
39001
39844
|
}, {
|
|
39002
39845
|
id: "terminal-profiles",
|
|
39003
39846
|
title: "Custom profiles",
|
|
39004
|
-
description: "
|
|
39847
|
+
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.",
|
|
39005
39848
|
columns: 1,
|
|
39006
39849
|
fields: [this.field({
|
|
39007
39850
|
type: "editable-array",
|