@camstack/addon-terminal 0.1.9 → 0.1.11

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 CHANGED
@@ -23,6 +23,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
23
23
  }) : target, mod));
24
24
  //#endregion
25
25
  let node_fs = require("node:fs");
26
+ let node_module = require("node:module");
26
27
  let node_path = require("node:path");
27
28
  let sharp = require("sharp");
28
29
  sharp = __toESM(sharp);
@@ -21148,6 +21149,7 @@ var FaceInfoSchema = object({
21148
21149
  var FaceFilterEnum = _enum([
21149
21150
  "unassigned",
21150
21151
  "recognized",
21152
+ "identified",
21151
21153
  "all"
21152
21154
  ]);
21153
21155
  var MediaFileLiteSchema$1 = object({
@@ -21176,6 +21178,8 @@ method(_void(), array(IdentitySchema).readonly()), method(object({ name: string(
21176
21178
  kind: "mutation",
21177
21179
  auth: "admin"
21178
21180
  }), method(object({
21181
+ /** Restrict to one camera. Absent keeps the cluster-wide gallery view. */
21182
+ deviceId: number().int().optional(),
21179
21183
  limit: number().int().positive().optional(),
21180
21184
  filter: FaceFilterEnum.optional(),
21181
21185
  /**
@@ -23405,6 +23409,10 @@ var OsdSourceSchema = discriminatedUnion("kind", [
23405
23409
  capName: string().min(1).max(64),
23406
23410
  /** Dot path inside the slice, e.g. `detected`, `value`, `mode`. */
23407
23411
  valuePath: string().min(1).max(64)
23412
+ }),
23413
+ object({
23414
+ kind: literal("latest-recognition"),
23415
+ recognition: _enum(["person", "plate"])
23408
23416
  })
23409
23417
  ]);
23410
23418
  var OsdSlotBindingSchema = object({
@@ -23510,6 +23518,15 @@ method(object({ deviceId: number().int() }), object({
23510
23518
  }), object({ success: literal(true) }), {
23511
23519
  kind: "mutation",
23512
23520
  auth: "admin"
23521
+ }), method(object({
23522
+ sourceDeviceId: number().int(),
23523
+ targetDeviceId: number().int()
23524
+ }), object({
23525
+ copied: number().int().nonnegative(),
23526
+ skipped: number().int().nonnegative()
23527
+ }), {
23528
+ kind: "mutation",
23529
+ auth: "admin"
23513
23530
  }), method(object({
23514
23531
  deviceId: number().int(),
23515
23532
  slotId: string().min(1),
@@ -30203,6 +30220,12 @@ Object.freeze({
30203
30220
  addonId: null,
30204
30221
  access: "delete"
30205
30222
  },
30223
+ "osdManager.copyDeviceConfiguration": {
30224
+ capName: "osd-manager",
30225
+ capScope: "system",
30226
+ addonId: null,
30227
+ access: "create"
30228
+ },
30206
30229
  "osdManager.getConditionSupport": {
30207
30230
  capName: "osd-manager",
30208
30231
  capScope: "system",
@@ -32970,6 +32993,7 @@ DEFAULT_NATIVE_LEASE_SETTINGS.admission;
32970
32993
  * a full-screen program (`btm`) gets no terminal and no runtime resize channel.
32971
32994
  * See docs/design/2026-07-26-terminal-session-design.md ("The pty").
32972
32995
  */
32996
+ var require$1 = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href);
32973
32997
  /**
32974
32998
  * Resolve `file` to an executable path the way a shell would: a path-qualified
32975
32999
  * name is checked directly, a bare name is searched on `PATH`. Returns the
@@ -32996,8 +33020,19 @@ function isExecutableFile(p) {
32996
33020
  }
32997
33021
  }
32998
33022
  var cachedModule = null;
33023
+ function ensureSpawnHelperExecutable() {
33024
+ if (process.platform === "win32") return;
33025
+ const packageRoot = (0, node_path.dirname)(require$1.resolve("node-pty/package.json"));
33026
+ const candidates = [(0, node_path.join)(packageRoot, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"), (0, node_path.join)(packageRoot, "build", "Release", "spawn-helper")];
33027
+ for (const candidate of candidates) {
33028
+ if (!(0, node_fs.existsSync)(candidate)) continue;
33029
+ const mode = (0, node_fs.statSync)(candidate).mode & 511;
33030
+ if ((mode & 73) === 0) (0, node_fs.chmodSync)(candidate, mode | (mode & 292) >> 2);
33031
+ }
33032
+ }
32999
33033
  async function loadNodePty() {
33000
33034
  if (cachedModule) return cachedModule;
33035
+ ensureSpawnHelperExecutable();
33001
33036
  const mod = await import("node-pty");
33002
33037
  cachedModule = mod;
33003
33038
  return mod;
@@ -33043,6 +33078,22 @@ function createNodePtySpawner() {
33043
33078
  async function warmNodePty() {
33044
33079
  await loadNodePty();
33045
33080
  }
33081
+ //#endregion
33082
+ //#region src/terminal-camera-declarations.ts
33083
+ function buildTerminalCameraDeclarations(nodes, profilesByNode) {
33084
+ return nodes.flatMap((node) => {
33085
+ const nodeLabel = node.isHub ? "Hub" : node.name;
33086
+ return (profilesByNode.get(node.id) ?? []).map((profile) => ({
33087
+ stableId: profile.profileId === "monitor" ? `terminal-camera-${node.id}` : `terminal-camera-${node.id}-${profile.profileId}`,
33088
+ name: `Terminal ${profile.label} - ${nodeLabel}`,
33089
+ config: {
33090
+ nodeId: node.id,
33091
+ profileId: profile.profileId,
33092
+ profileLabel: profile.label
33093
+ }
33094
+ }));
33095
+ });
33096
+ }
33046
33097
  function escapeXml(value) {
33047
33098
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
33048
33099
  }
@@ -33057,7 +33108,11 @@ async function renderTerminalJpeg(lines) {
33057
33108
  }
33058
33109
  //#endregion
33059
33110
  //#region src/terminal-camera-device.ts
33060
- var terminalCameraSchema = object({ nodeId: string().min(1) });
33111
+ var terminalCameraSchema = object({
33112
+ nodeId: string().min(1),
33113
+ profileId: string().min(1),
33114
+ profileLabel: string().min(1)
33115
+ });
33061
33116
  var relay = null;
33062
33117
  function installTerminalCameraRelay(next) {
33063
33118
  relay = next;
@@ -33076,18 +33131,19 @@ var TerminalCameraDevice = class extends BaseDevice {
33076
33131
  const activeRelay = relay;
33077
33132
  if (!activeRelay) throw new Error("terminal camera relay is unavailable");
33078
33133
  const nodeId = this.config.get("nodeId");
33079
- return (await activeRelay.listProfiles(nodeId)).map((profile) => ({
33080
- camStreamId: profile.profileId,
33134
+ const profileId = this.config.get("profileId");
33135
+ return [{
33136
+ camStreamId: profileId,
33081
33137
  kind: "pull-http",
33082
- url: activeRelay.streamUrl(nodeId, profile.profileId),
33138
+ url: activeRelay.streamUrl(nodeId, profileId),
33083
33139
  codec: "h264",
33084
33140
  resolution: {
33085
33141
  width: 960,
33086
33142
  height: 640
33087
33143
  },
33088
33144
  fps: 2,
33089
- label: profile.label
33090
- }));
33145
+ label: this.config.get("profileLabel")
33146
+ }];
33091
33147
  }
33092
33148
  setNodeOnline(online) {
33093
33149
  this.markOnline(online);
@@ -38259,7 +38315,12 @@ function createTerminalDataPlaneHandler(manager) {
38259
38315
  var ProfileIdSchema = string().trim().min(1).max(64).regex(/^[a-z][a-z0-9-]*$/, "must be a lowercase slug");
38260
38316
  var ConfiguredTerminalProfileSchema = object({
38261
38317
  enabled: boolean().default(true),
38262
- profileId: ProfileIdSchema.refine((id) => id !== "monitor" && id !== "shell", { message: "monitor and shell are reserved profile IDs" }),
38318
+ profileId: ProfileIdSchema.refine((id) => ![
38319
+ "monitor",
38320
+ "top",
38321
+ "glances",
38322
+ "shell"
38323
+ ].includes(id), { message: "monitor, top, glances and shell are reserved profile IDs" }),
38263
38324
  label: string().trim().min(1).max(100),
38264
38325
  description: string().trim().max(500).optional().default(""),
38265
38326
  executable: string().trim().min(1).max(1024).refine((value) => !value.includes("\0"), { message: "must not contain NUL bytes" }),
@@ -38274,17 +38335,36 @@ var ConfiguredTerminalProfileSchema = object({
38274
38335
  * surface.
38275
38336
  */
38276
38337
  function buildProfiles(options) {
38277
- const profiles = [{
38338
+ const profiles = [];
38339
+ if (options.btmEnabled !== false) profiles.push({
38278
38340
  profileId: "monitor",
38279
- label: "System Monitor",
38341
+ label: "BTM",
38280
38342
  description: "bottom (btm) — CPU, memory, network and process monitor",
38281
38343
  file: options.btmPath.trim().length > 0 ? options.btmPath.trim() : "btm",
38282
- args: [],
38283
- ...options.btmPath.trim().length === 0 ? { fallbacks: [{
38284
- file: "top",
38285
- args: []
38286
- }] } : {}
38287
- }];
38344
+ args: options.btmArgs ?? []
38345
+ });
38346
+ if (options.topEnabled !== false) profiles.push({
38347
+ profileId: "top",
38348
+ label: "Top",
38349
+ description: "The operating system process and resource monitor",
38350
+ file: options.topPath?.trim() || "top",
38351
+ args: options.topArgs ?? []
38352
+ });
38353
+ if (options.glancesEnabled !== false) {
38354
+ const configuredPath = options.glancesPath?.trim();
38355
+ const pythonPath = options.glancesPythonPath?.trim();
38356
+ profiles.push({
38357
+ profileId: "glances",
38358
+ label: "Glances",
38359
+ description: "Cross-platform curses monitor installed in CamStack embedded Python",
38360
+ file: configuredPath || pythonPath || "glances",
38361
+ args: configuredPath || !pythonPath ? options.glancesArgs ?? [] : [
38362
+ "-m",
38363
+ "glances",
38364
+ ...options.glancesArgs ?? []
38365
+ ]
38366
+ });
38367
+ }
38288
38368
  if (options.allowShell) profiles.push({
38289
38369
  profileId: "shell",
38290
38370
  label: "Shell (interactive)",
@@ -38357,6 +38437,15 @@ var TerminalSessionManager = class {
38357
38437
  this.opts = opts;
38358
38438
  this.profiles = buildProfiles({
38359
38439
  btmPath: opts.btmPath,
38440
+ btmEnabled: opts.btmEnabled,
38441
+ btmArgs: opts.btmArgs,
38442
+ topEnabled: opts.topEnabled,
38443
+ topPath: opts.topPath,
38444
+ topArgs: opts.topArgs,
38445
+ glancesEnabled: opts.glancesEnabled,
38446
+ glancesPath: opts.glancesPath,
38447
+ glancesArgs: opts.glancesArgs,
38448
+ glancesPythonPath: opts.glancesPythonPath,
38360
38449
  allowShell: opts.allowShell,
38361
38450
  shellPath: opts.shellPath,
38362
38451
  customProfiles: opts.customProfiles,
@@ -38610,6 +38699,14 @@ var TerminalSessionManager = class {
38610
38699
  //#region src/addon.ts
38611
38700
  var DEFAULTS = {
38612
38701
  btmPath: "",
38702
+ btmEnabled: true,
38703
+ btmArgs: [],
38704
+ topEnabled: true,
38705
+ topPath: "",
38706
+ topArgs: [],
38707
+ glancesEnabled: true,
38708
+ glancesPath: "",
38709
+ glancesArgs: [],
38613
38710
  allowShell: false,
38614
38711
  shellPath: "",
38615
38712
  maxSessions: 4,
@@ -38623,17 +38720,29 @@ var TerminalAddon = class extends BaseAddon {
38623
38720
  dataPlane = null;
38624
38721
  cameraRelay = null;
38625
38722
  cameraReconcileTimer = null;
38723
+ cameraProfilesByNode = /* @__PURE__ */ new Map();
38724
+ glancesPythonPath = "";
38626
38725
  constructor() {
38627
38726
  super({ ...DEFAULTS });
38628
38727
  }
38629
38728
  async onInitialize() {
38630
38729
  await warmNodePty();
38730
+ this.glancesPythonPath = await this.ctx.deps.ensurePython() ?? "";
38631
38731
  const manager = new TerminalSessionManager({
38632
38732
  spawn: createNodePtySpawner(),
38633
38733
  screenFactory: createXtermScreen,
38634
38734
  resolveBinary: resolveExecutable,
38635
38735
  logger: this.ctx.logger,
38636
38736
  btmPath: this.config.btmPath,
38737
+ btmEnabled: this.config.btmEnabled,
38738
+ btmArgs: this.config.btmArgs,
38739
+ topEnabled: this.config.topEnabled,
38740
+ topPath: this.config.topPath,
38741
+ topArgs: this.config.topArgs,
38742
+ glancesEnabled: this.config.glancesEnabled,
38743
+ glancesPath: this.config.glancesPath,
38744
+ glancesArgs: this.config.glancesArgs,
38745
+ glancesPythonPath: this.glancesPythonPath,
38637
38746
  allowShell: this.config.allowShell,
38638
38747
  shellPath: this.config.shellPath,
38639
38748
  maxSessions: this.config.maxSessions,
@@ -38678,6 +38787,15 @@ var TerminalAddon = class extends BaseAddon {
38678
38787
  async onConfigChanged() {
38679
38788
  this.manager?.reconfigureProfiles({
38680
38789
  btmPath: this.config.btmPath,
38790
+ btmEnabled: this.config.btmEnabled,
38791
+ btmArgs: this.config.btmArgs,
38792
+ topEnabled: this.config.topEnabled,
38793
+ topPath: this.config.topPath,
38794
+ topArgs: this.config.topArgs,
38795
+ glancesEnabled: this.config.glancesEnabled,
38796
+ glancesPath: this.config.glancesPath,
38797
+ glancesArgs: this.config.glancesArgs,
38798
+ glancesPythonPath: this.glancesPythonPath,
38681
38799
  allowShell: this.config.allowShell,
38682
38800
  shellPath: this.config.shellPath,
38683
38801
  maxSessions: this.config.maxSessions,
@@ -38702,6 +38820,44 @@ var TerminalAddon = class extends BaseAddon {
38702
38820
  const topology = await this.ctx.api.nodes.topology.query();
38703
38821
  if (topology.length === 0) throw new Error("cluster topology returned no nodes; refusing to withdraw declared cameras");
38704
38822
  const nodes = topology.filter((node) => typeof node.id === "string" && node.id.length > 0);
38823
+ const unavailableNodeIds = /* @__PURE__ */ new Set();
38824
+ await Promise.all(nodes.map(async (node) => {
38825
+ try {
38826
+ this.cameraProfilesByNode.set(node.id, await this.cameraRelay.listProfiles(node.id));
38827
+ } catch (error) {
38828
+ if (!this.cameraProfilesByNode.has(node.id)) {
38829
+ unavailableNodeIds.add(node.id);
38830
+ this.ctx.logger.warn("terminal profiles unavailable — preserving existing cameras for this node", { meta: {
38831
+ nodeId: node.id,
38832
+ error: error instanceof Error ? error.message : String(error)
38833
+ } });
38834
+ }
38835
+ }
38836
+ }));
38837
+ const cameraDeclarations = [...buildTerminalCameraDeclarations(nodes, this.cameraProfilesByNode)];
38838
+ if (unavailableNodeIds.size > 0) {
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
+ }
38705
38861
  const result = await new DeclaredDevices({
38706
38862
  logger: this.ctx.logger.child("camera-declaration"),
38707
38863
  addonId: this.ctx.id,
@@ -38720,12 +38876,12 @@ var TerminalAddon = class extends BaseAddon {
38720
38876
  }).reconcile({
38721
38877
  integrationName: TERMINAL_CAMERA_INTEGRATION,
38722
38878
  placement: "hub",
38723
- devices: nodes.map((node) => ({
38724
- stableId: `terminal-camera-${node.id}`,
38725
- name: node.isHub ? "Terminal Hub" : `Terminal ${node.name}`,
38879
+ devices: cameraDeclarations.map((camera) => ({
38880
+ stableId: camera.stableId,
38881
+ name: camera.name,
38726
38882
  type: DeviceType.Camera,
38727
38883
  DeviceClass: TerminalCameraDevice,
38728
- config: { nodeId: node.id },
38884
+ config: camera.config,
38729
38885
  role: "terminal-camera"
38730
38886
  }))
38731
38887
  });
@@ -38742,15 +38898,78 @@ var TerminalAddon = class extends BaseAddon {
38742
38898
  description: "Interactive terminal sessions in the Admin UI. Only pre-declared profiles can be opened; a free-form command is never accepted.",
38743
38899
  columns: 2,
38744
38900
  fields: [
38901
+ this.field({
38902
+ type: "boolean",
38903
+ key: "btmEnabled",
38904
+ label: "Enable BTM camera",
38905
+ default: true,
38906
+ perNode: true
38907
+ }),
38745
38908
  this.field({
38746
38909
  type: "text",
38747
38910
  key: "btmPath",
38748
- label: "Monitor binary",
38911
+ label: "BTM binary",
38749
38912
  description: "Path to the `btm` (bottom) executable. Leave empty to resolve from PATH.",
38750
38913
  placeholder: "btm",
38751
38914
  default: "",
38752
38915
  perNode: true
38753
38916
  }),
38917
+ this.field({
38918
+ type: "tags",
38919
+ key: "btmArgs",
38920
+ label: "BTM arguments",
38921
+ description: "Exact arguments passed to btm.",
38922
+ default: [],
38923
+ perNode: true
38924
+ }),
38925
+ this.field({
38926
+ type: "boolean",
38927
+ key: "topEnabled",
38928
+ label: "Enable Top camera",
38929
+ default: true,
38930
+ perNode: true
38931
+ }),
38932
+ this.field({
38933
+ type: "text",
38934
+ key: "topPath",
38935
+ label: "Top binary",
38936
+ placeholder: "top",
38937
+ default: "",
38938
+ perNode: true
38939
+ }),
38940
+ this.field({
38941
+ type: "tags",
38942
+ key: "topArgs",
38943
+ label: "Top arguments",
38944
+ description: "Exact arguments passed to top.",
38945
+ default: [],
38946
+ perNode: true
38947
+ }),
38948
+ this.field({
38949
+ type: "boolean",
38950
+ key: "glancesEnabled",
38951
+ label: "Enable Glances camera",
38952
+ description: "Glances is installed automatically into CamStack embedded Python.",
38953
+ default: true,
38954
+ perNode: true
38955
+ }),
38956
+ this.field({
38957
+ type: "text",
38958
+ key: "glancesPath",
38959
+ label: "Glances binary override",
38960
+ description: "Optional executable override. Empty uses the automatically managed Python package.",
38961
+ placeholder: "glances",
38962
+ default: "",
38963
+ perNode: true
38964
+ }),
38965
+ this.field({
38966
+ type: "tags",
38967
+ key: "glancesArgs",
38968
+ label: "Glances arguments",
38969
+ description: "Exact arguments passed to Glances.",
38970
+ default: [],
38971
+ perNode: true
38972
+ }),
38754
38973
  this.field({
38755
38974
  type: "number",
38756
38975
  key: "maxSessions",
@@ -38782,7 +39001,7 @@ var TerminalAddon = class extends BaseAddon {
38782
39001
  }, {
38783
39002
  id: "terminal-profiles",
38784
39003
  title: "Custom profiles",
38785
- description: "Define allowlisted programs available from the Terminal page on this node. Session requests only carry the profile ID; commands cannot be overridden by clients.",
39004
+ description: "Each enabled profile creates its own Terminal camera on this node and is available from the Terminal page. Session requests only carry the profile ID; commands cannot be overridden by clients.",
38786
39005
  columns: 1,
38787
39006
  fields: [this.field({
38788
39007
  type: "editable-array",
package/dist/addon.mjs CHANGED
@@ -1,5 +1,6 @@
1
- import { accessSync, constants, statSync } from "node:fs";
2
- import { delimiter, join } from "node:path";
1
+ import { createRequire } from "node:module";
2
+ import { accessSync, chmodSync, constants, existsSync, statSync } from "node:fs";
3
+ import { delimiter, dirname, join } from "node:path";
3
4
  import sharp from "sharp";
4
5
  import { createServer } from "node:http";
5
6
  //#region \0rolldown/runtime.js
@@ -21126,6 +21127,7 @@ var FaceInfoSchema = object({
21126
21127
  var FaceFilterEnum = _enum([
21127
21128
  "unassigned",
21128
21129
  "recognized",
21130
+ "identified",
21129
21131
  "all"
21130
21132
  ]);
21131
21133
  var MediaFileLiteSchema$1 = object({
@@ -21154,6 +21156,8 @@ method(_void(), array(IdentitySchema).readonly()), method(object({ name: string(
21154
21156
  kind: "mutation",
21155
21157
  auth: "admin"
21156
21158
  }), method(object({
21159
+ /** Restrict to one camera. Absent keeps the cluster-wide gallery view. */
21160
+ deviceId: number().int().optional(),
21157
21161
  limit: number().int().positive().optional(),
21158
21162
  filter: FaceFilterEnum.optional(),
21159
21163
  /**
@@ -23383,6 +23387,10 @@ var OsdSourceSchema = discriminatedUnion("kind", [
23383
23387
  capName: string().min(1).max(64),
23384
23388
  /** Dot path inside the slice, e.g. `detected`, `value`, `mode`. */
23385
23389
  valuePath: string().min(1).max(64)
23390
+ }),
23391
+ object({
23392
+ kind: literal("latest-recognition"),
23393
+ recognition: _enum(["person", "plate"])
23386
23394
  })
23387
23395
  ]);
23388
23396
  var OsdSlotBindingSchema = object({
@@ -23488,6 +23496,15 @@ method(object({ deviceId: number().int() }), object({
23488
23496
  }), object({ success: literal(true) }), {
23489
23497
  kind: "mutation",
23490
23498
  auth: "admin"
23499
+ }), method(object({
23500
+ sourceDeviceId: number().int(),
23501
+ targetDeviceId: number().int()
23502
+ }), object({
23503
+ copied: number().int().nonnegative(),
23504
+ skipped: number().int().nonnegative()
23505
+ }), {
23506
+ kind: "mutation",
23507
+ auth: "admin"
23491
23508
  }), method(object({
23492
23509
  deviceId: number().int(),
23493
23510
  slotId: string().min(1),
@@ -30181,6 +30198,12 @@ Object.freeze({
30181
30198
  addonId: null,
30182
30199
  access: "delete"
30183
30200
  },
30201
+ "osdManager.copyDeviceConfiguration": {
30202
+ capName: "osd-manager",
30203
+ capScope: "system",
30204
+ addonId: null,
30205
+ access: "create"
30206
+ },
30184
30207
  "osdManager.getConditionSupport": {
30185
30208
  capName: "osd-manager",
30186
30209
  capScope: "system",
@@ -32948,6 +32971,7 @@ DEFAULT_NATIVE_LEASE_SETTINGS.admission;
32948
32971
  * a full-screen program (`btm`) gets no terminal and no runtime resize channel.
32949
32972
  * See docs/design/2026-07-26-terminal-session-design.md ("The pty").
32950
32973
  */
32974
+ var require = createRequire(import.meta.url);
32951
32975
  /**
32952
32976
  * Resolve `file` to an executable path the way a shell would: a path-qualified
32953
32977
  * name is checked directly, a bare name is searched on `PATH`. Returns the
@@ -32974,8 +32998,19 @@ function isExecutableFile(p) {
32974
32998
  }
32975
32999
  }
32976
33000
  var cachedModule = null;
33001
+ function ensureSpawnHelperExecutable() {
33002
+ if (process.platform === "win32") return;
33003
+ const packageRoot = dirname(require.resolve("node-pty/package.json"));
33004
+ const candidates = [join(packageRoot, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"), join(packageRoot, "build", "Release", "spawn-helper")];
33005
+ for (const candidate of candidates) {
33006
+ if (!existsSync(candidate)) continue;
33007
+ const mode = statSync(candidate).mode & 511;
33008
+ if ((mode & 73) === 0) chmodSync(candidate, mode | (mode & 292) >> 2);
33009
+ }
33010
+ }
32977
33011
  async function loadNodePty() {
32978
33012
  if (cachedModule) return cachedModule;
33013
+ ensureSpawnHelperExecutable();
32979
33014
  const mod = await import("node-pty");
32980
33015
  cachedModule = mod;
32981
33016
  return mod;
@@ -33021,6 +33056,22 @@ function createNodePtySpawner() {
33021
33056
  async function warmNodePty() {
33022
33057
  await loadNodePty();
33023
33058
  }
33059
+ //#endregion
33060
+ //#region src/terminal-camera-declarations.ts
33061
+ function buildTerminalCameraDeclarations(nodes, profilesByNode) {
33062
+ return nodes.flatMap((node) => {
33063
+ const nodeLabel = node.isHub ? "Hub" : node.name;
33064
+ return (profilesByNode.get(node.id) ?? []).map((profile) => ({
33065
+ stableId: profile.profileId === "monitor" ? `terminal-camera-${node.id}` : `terminal-camera-${node.id}-${profile.profileId}`,
33066
+ name: `Terminal ${profile.label} - ${nodeLabel}`,
33067
+ config: {
33068
+ nodeId: node.id,
33069
+ profileId: profile.profileId,
33070
+ profileLabel: profile.label
33071
+ }
33072
+ }));
33073
+ });
33074
+ }
33024
33075
  function escapeXml(value) {
33025
33076
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
33026
33077
  }
@@ -33035,7 +33086,11 @@ async function renderTerminalJpeg(lines) {
33035
33086
  }
33036
33087
  //#endregion
33037
33088
  //#region src/terminal-camera-device.ts
33038
- var terminalCameraSchema = object({ nodeId: string().min(1) });
33089
+ var terminalCameraSchema = object({
33090
+ nodeId: string().min(1),
33091
+ profileId: string().min(1),
33092
+ profileLabel: string().min(1)
33093
+ });
33039
33094
  var relay = null;
33040
33095
  function installTerminalCameraRelay(next) {
33041
33096
  relay = next;
@@ -33054,18 +33109,19 @@ var TerminalCameraDevice = class extends BaseDevice {
33054
33109
  const activeRelay = relay;
33055
33110
  if (!activeRelay) throw new Error("terminal camera relay is unavailable");
33056
33111
  const nodeId = this.config.get("nodeId");
33057
- return (await activeRelay.listProfiles(nodeId)).map((profile) => ({
33058
- camStreamId: profile.profileId,
33112
+ const profileId = this.config.get("profileId");
33113
+ return [{
33114
+ camStreamId: profileId,
33059
33115
  kind: "pull-http",
33060
- url: activeRelay.streamUrl(nodeId, profile.profileId),
33116
+ url: activeRelay.streamUrl(nodeId, profileId),
33061
33117
  codec: "h264",
33062
33118
  resolution: {
33063
33119
  width: 960,
33064
33120
  height: 640
33065
33121
  },
33066
33122
  fps: 2,
33067
- label: profile.label
33068
- }));
33123
+ label: this.config.get("profileLabel")
33124
+ }];
33069
33125
  }
33070
33126
  setNodeOnline(online) {
33071
33127
  this.markOnline(online);
@@ -38237,7 +38293,12 @@ function createTerminalDataPlaneHandler(manager) {
38237
38293
  var ProfileIdSchema = string().trim().min(1).max(64).regex(/^[a-z][a-z0-9-]*$/, "must be a lowercase slug");
38238
38294
  var ConfiguredTerminalProfileSchema = object({
38239
38295
  enabled: boolean().default(true),
38240
- profileId: ProfileIdSchema.refine((id) => id !== "monitor" && id !== "shell", { message: "monitor and shell are reserved profile IDs" }),
38296
+ profileId: ProfileIdSchema.refine((id) => ![
38297
+ "monitor",
38298
+ "top",
38299
+ "glances",
38300
+ "shell"
38301
+ ].includes(id), { message: "monitor, top, glances and shell are reserved profile IDs" }),
38241
38302
  label: string().trim().min(1).max(100),
38242
38303
  description: string().trim().max(500).optional().default(""),
38243
38304
  executable: string().trim().min(1).max(1024).refine((value) => !value.includes("\0"), { message: "must not contain NUL bytes" }),
@@ -38252,17 +38313,36 @@ var ConfiguredTerminalProfileSchema = object({
38252
38313
  * surface.
38253
38314
  */
38254
38315
  function buildProfiles(options) {
38255
- const profiles = [{
38316
+ const profiles = [];
38317
+ if (options.btmEnabled !== false) profiles.push({
38256
38318
  profileId: "monitor",
38257
- label: "System Monitor",
38319
+ label: "BTM",
38258
38320
  description: "bottom (btm) — CPU, memory, network and process monitor",
38259
38321
  file: options.btmPath.trim().length > 0 ? options.btmPath.trim() : "btm",
38260
- args: [],
38261
- ...options.btmPath.trim().length === 0 ? { fallbacks: [{
38262
- file: "top",
38263
- args: []
38264
- }] } : {}
38265
- }];
38322
+ args: options.btmArgs ?? []
38323
+ });
38324
+ if (options.topEnabled !== false) profiles.push({
38325
+ profileId: "top",
38326
+ label: "Top",
38327
+ description: "The operating system process and resource monitor",
38328
+ file: options.topPath?.trim() || "top",
38329
+ args: options.topArgs ?? []
38330
+ });
38331
+ if (options.glancesEnabled !== false) {
38332
+ const configuredPath = options.glancesPath?.trim();
38333
+ const pythonPath = options.glancesPythonPath?.trim();
38334
+ profiles.push({
38335
+ profileId: "glances",
38336
+ label: "Glances",
38337
+ description: "Cross-platform curses monitor installed in CamStack embedded Python",
38338
+ file: configuredPath || pythonPath || "glances",
38339
+ args: configuredPath || !pythonPath ? options.glancesArgs ?? [] : [
38340
+ "-m",
38341
+ "glances",
38342
+ ...options.glancesArgs ?? []
38343
+ ]
38344
+ });
38345
+ }
38266
38346
  if (options.allowShell) profiles.push({
38267
38347
  profileId: "shell",
38268
38348
  label: "Shell (interactive)",
@@ -38335,6 +38415,15 @@ var TerminalSessionManager = class {
38335
38415
  this.opts = opts;
38336
38416
  this.profiles = buildProfiles({
38337
38417
  btmPath: opts.btmPath,
38418
+ btmEnabled: opts.btmEnabled,
38419
+ btmArgs: opts.btmArgs,
38420
+ topEnabled: opts.topEnabled,
38421
+ topPath: opts.topPath,
38422
+ topArgs: opts.topArgs,
38423
+ glancesEnabled: opts.glancesEnabled,
38424
+ glancesPath: opts.glancesPath,
38425
+ glancesArgs: opts.glancesArgs,
38426
+ glancesPythonPath: opts.glancesPythonPath,
38338
38427
  allowShell: opts.allowShell,
38339
38428
  shellPath: opts.shellPath,
38340
38429
  customProfiles: opts.customProfiles,
@@ -38588,6 +38677,14 @@ var TerminalSessionManager = class {
38588
38677
  //#region src/addon.ts
38589
38678
  var DEFAULTS = {
38590
38679
  btmPath: "",
38680
+ btmEnabled: true,
38681
+ btmArgs: [],
38682
+ topEnabled: true,
38683
+ topPath: "",
38684
+ topArgs: [],
38685
+ glancesEnabled: true,
38686
+ glancesPath: "",
38687
+ glancesArgs: [],
38591
38688
  allowShell: false,
38592
38689
  shellPath: "",
38593
38690
  maxSessions: 4,
@@ -38601,17 +38698,29 @@ var TerminalAddon = class extends BaseAddon {
38601
38698
  dataPlane = null;
38602
38699
  cameraRelay = null;
38603
38700
  cameraReconcileTimer = null;
38701
+ cameraProfilesByNode = /* @__PURE__ */ new Map();
38702
+ glancesPythonPath = "";
38604
38703
  constructor() {
38605
38704
  super({ ...DEFAULTS });
38606
38705
  }
38607
38706
  async onInitialize() {
38608
38707
  await warmNodePty();
38708
+ this.glancesPythonPath = await this.ctx.deps.ensurePython() ?? "";
38609
38709
  const manager = new TerminalSessionManager({
38610
38710
  spawn: createNodePtySpawner(),
38611
38711
  screenFactory: createXtermScreen,
38612
38712
  resolveBinary: resolveExecutable,
38613
38713
  logger: this.ctx.logger,
38614
38714
  btmPath: this.config.btmPath,
38715
+ btmEnabled: this.config.btmEnabled,
38716
+ btmArgs: this.config.btmArgs,
38717
+ topEnabled: this.config.topEnabled,
38718
+ topPath: this.config.topPath,
38719
+ topArgs: this.config.topArgs,
38720
+ glancesEnabled: this.config.glancesEnabled,
38721
+ glancesPath: this.config.glancesPath,
38722
+ glancesArgs: this.config.glancesArgs,
38723
+ glancesPythonPath: this.glancesPythonPath,
38615
38724
  allowShell: this.config.allowShell,
38616
38725
  shellPath: this.config.shellPath,
38617
38726
  maxSessions: this.config.maxSessions,
@@ -38656,6 +38765,15 @@ var TerminalAddon = class extends BaseAddon {
38656
38765
  async onConfigChanged() {
38657
38766
  this.manager?.reconfigureProfiles({
38658
38767
  btmPath: this.config.btmPath,
38768
+ btmEnabled: this.config.btmEnabled,
38769
+ btmArgs: this.config.btmArgs,
38770
+ topEnabled: this.config.topEnabled,
38771
+ topPath: this.config.topPath,
38772
+ topArgs: this.config.topArgs,
38773
+ glancesEnabled: this.config.glancesEnabled,
38774
+ glancesPath: this.config.glancesPath,
38775
+ glancesArgs: this.config.glancesArgs,
38776
+ glancesPythonPath: this.glancesPythonPath,
38659
38777
  allowShell: this.config.allowShell,
38660
38778
  shellPath: this.config.shellPath,
38661
38779
  maxSessions: this.config.maxSessions,
@@ -38680,6 +38798,44 @@ var TerminalAddon = class extends BaseAddon {
38680
38798
  const topology = await this.ctx.api.nodes.topology.query();
38681
38799
  if (topology.length === 0) throw new Error("cluster topology returned no nodes; refusing to withdraw declared cameras");
38682
38800
  const nodes = topology.filter((node) => typeof node.id === "string" && node.id.length > 0);
38801
+ const unavailableNodeIds = /* @__PURE__ */ new Set();
38802
+ await Promise.all(nodes.map(async (node) => {
38803
+ try {
38804
+ this.cameraProfilesByNode.set(node.id, await this.cameraRelay.listProfiles(node.id));
38805
+ } catch (error) {
38806
+ if (!this.cameraProfilesByNode.has(node.id)) {
38807
+ unavailableNodeIds.add(node.id);
38808
+ this.ctx.logger.warn("terminal profiles unavailable — preserving existing cameras for this node", { meta: {
38809
+ nodeId: node.id,
38810
+ error: error instanceof Error ? error.message : String(error)
38811
+ } });
38812
+ }
38813
+ }
38814
+ }));
38815
+ const cameraDeclarations = [...buildTerminalCameraDeclarations(nodes, this.cameraProfilesByNode)];
38816
+ if (unavailableNodeIds.size > 0) {
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
+ }
38683
38839
  const result = await new DeclaredDevices({
38684
38840
  logger: this.ctx.logger.child("camera-declaration"),
38685
38841
  addonId: this.ctx.id,
@@ -38698,12 +38854,12 @@ var TerminalAddon = class extends BaseAddon {
38698
38854
  }).reconcile({
38699
38855
  integrationName: TERMINAL_CAMERA_INTEGRATION,
38700
38856
  placement: "hub",
38701
- devices: nodes.map((node) => ({
38702
- stableId: `terminal-camera-${node.id}`,
38703
- name: node.isHub ? "Terminal Hub" : `Terminal ${node.name}`,
38857
+ devices: cameraDeclarations.map((camera) => ({
38858
+ stableId: camera.stableId,
38859
+ name: camera.name,
38704
38860
  type: DeviceType.Camera,
38705
38861
  DeviceClass: TerminalCameraDevice,
38706
- config: { nodeId: node.id },
38862
+ config: camera.config,
38707
38863
  role: "terminal-camera"
38708
38864
  }))
38709
38865
  });
@@ -38720,15 +38876,78 @@ var TerminalAddon = class extends BaseAddon {
38720
38876
  description: "Interactive terminal sessions in the Admin UI. Only pre-declared profiles can be opened; a free-form command is never accepted.",
38721
38877
  columns: 2,
38722
38878
  fields: [
38879
+ this.field({
38880
+ type: "boolean",
38881
+ key: "btmEnabled",
38882
+ label: "Enable BTM camera",
38883
+ default: true,
38884
+ perNode: true
38885
+ }),
38723
38886
  this.field({
38724
38887
  type: "text",
38725
38888
  key: "btmPath",
38726
- label: "Monitor binary",
38889
+ label: "BTM binary",
38727
38890
  description: "Path to the `btm` (bottom) executable. Leave empty to resolve from PATH.",
38728
38891
  placeholder: "btm",
38729
38892
  default: "",
38730
38893
  perNode: true
38731
38894
  }),
38895
+ this.field({
38896
+ type: "tags",
38897
+ key: "btmArgs",
38898
+ label: "BTM arguments",
38899
+ description: "Exact arguments passed to btm.",
38900
+ default: [],
38901
+ perNode: true
38902
+ }),
38903
+ this.field({
38904
+ type: "boolean",
38905
+ key: "topEnabled",
38906
+ label: "Enable Top camera",
38907
+ default: true,
38908
+ perNode: true
38909
+ }),
38910
+ this.field({
38911
+ type: "text",
38912
+ key: "topPath",
38913
+ label: "Top binary",
38914
+ placeholder: "top",
38915
+ default: "",
38916
+ perNode: true
38917
+ }),
38918
+ this.field({
38919
+ type: "tags",
38920
+ key: "topArgs",
38921
+ label: "Top arguments",
38922
+ description: "Exact arguments passed to top.",
38923
+ default: [],
38924
+ perNode: true
38925
+ }),
38926
+ this.field({
38927
+ type: "boolean",
38928
+ key: "glancesEnabled",
38929
+ label: "Enable Glances camera",
38930
+ description: "Glances is installed automatically into CamStack embedded Python.",
38931
+ default: true,
38932
+ perNode: true
38933
+ }),
38934
+ this.field({
38935
+ type: "text",
38936
+ key: "glancesPath",
38937
+ label: "Glances binary override",
38938
+ description: "Optional executable override. Empty uses the automatically managed Python package.",
38939
+ placeholder: "glances",
38940
+ default: "",
38941
+ perNode: true
38942
+ }),
38943
+ this.field({
38944
+ type: "tags",
38945
+ key: "glancesArgs",
38946
+ label: "Glances arguments",
38947
+ description: "Exact arguments passed to Glances.",
38948
+ default: [],
38949
+ perNode: true
38950
+ }),
38732
38951
  this.field({
38733
38952
  type: "number",
38734
38953
  key: "maxSessions",
@@ -38760,7 +38979,7 @@ var TerminalAddon = class extends BaseAddon {
38760
38979
  }, {
38761
38980
  id: "terminal-profiles",
38762
38981
  title: "Custom profiles",
38763
- description: "Define allowlisted programs available from the Terminal page on this node. Session requests only carry the profile ID; commands cannot be overridden by clients.",
38982
+ description: "Each enabled profile creates its own Terminal camera on this node and is available from the Terminal page. Session requests only carry the profile ID; commands cannot be overridden by clients.",
38764
38983
  columns: 1,
38765
38984
  fields: [this.field({
38766
38985
  type: "editable-array",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-terminal",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Interactive terminal sessions (pty + xterm) as a CamStack addon",
5
5
  "keywords": [
6
6
  "camstack",
@@ -34,8 +34,11 @@
34
34
  "category": "system",
35
35
  "name": "Terminal",
36
36
  "version": "0.1.0",
37
- "description": "Interactive terminal sessions on any cluster node, plus one virtual Terminal camera per node. Commands remain bound to administrator-configured profiles.",
37
+ "description": "Interactive terminal sessions on any cluster node, with one virtual camera for each configured profile. Commands remain bound to administrator-configured profiles.",
38
38
  "entry": "./dist/index.js",
39
+ "python": {
40
+ "requirements": "./python/requirements.txt"
41
+ },
39
42
  "icon": "assets/icon.svg",
40
43
  "color": "#0ea5e9",
41
44
  "instanceMode": "unique",
@@ -53,7 +56,8 @@
53
56
  },
54
57
  "files": [
55
58
  "dist",
56
- "assets"
59
+ "assets",
60
+ "python"
57
61
  ],
58
62
  "scripts": {
59
63
  "build": "vite build",
@@ -0,0 +1 @@
1
+ glances>=4.3,<5