@camstack/addon-terminal 0.1.10 → 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
@@ -21149,6 +21149,7 @@ var FaceInfoSchema = object({
21149
21149
  var FaceFilterEnum = _enum([
21150
21150
  "unassigned",
21151
21151
  "recognized",
21152
+ "identified",
21152
21153
  "all"
21153
21154
  ]);
21154
21155
  var MediaFileLiteSchema$1 = object({
@@ -21177,6 +21178,8 @@ method(_void(), array(IdentitySchema).readonly()), method(object({ name: string(
21177
21178
  kind: "mutation",
21178
21179
  auth: "admin"
21179
21180
  }), method(object({
21181
+ /** Restrict to one camera. Absent keeps the cluster-wide gallery view. */
21182
+ deviceId: number().int().optional(),
21180
21183
  limit: number().int().positive().optional(),
21181
21184
  filter: FaceFilterEnum.optional(),
21182
21185
  /**
@@ -23406,6 +23409,10 @@ var OsdSourceSchema = discriminatedUnion("kind", [
23406
23409
  capName: string().min(1).max(64),
23407
23410
  /** Dot path inside the slice, e.g. `detected`, `value`, `mode`. */
23408
23411
  valuePath: string().min(1).max(64)
23412
+ }),
23413
+ object({
23414
+ kind: literal("latest-recognition"),
23415
+ recognition: _enum(["person", "plate"])
23409
23416
  })
23410
23417
  ]);
23411
23418
  var OsdSlotBindingSchema = object({
@@ -23511,6 +23518,15 @@ method(object({ deviceId: number().int() }), object({
23511
23518
  }), object({ success: literal(true) }), {
23512
23519
  kind: "mutation",
23513
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"
23514
23530
  }), method(object({
23515
23531
  deviceId: number().int(),
23516
23532
  slotId: string().min(1),
@@ -30204,6 +30220,12 @@ Object.freeze({
30204
30220
  addonId: null,
30205
30221
  access: "delete"
30206
30222
  },
30223
+ "osdManager.copyDeviceConfiguration": {
30224
+ capName: "osd-manager",
30225
+ capScope: "system",
30226
+ addonId: null,
30227
+ access: "create"
30228
+ },
30207
30229
  "osdManager.getConditionSupport": {
30208
30230
  capName: "osd-manager",
30209
30231
  capScope: "system",
@@ -33056,6 +33078,22 @@ function createNodePtySpawner() {
33056
33078
  async function warmNodePty() {
33057
33079
  await loadNodePty();
33058
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
+ }
33059
33097
  function escapeXml(value) {
33060
33098
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
33061
33099
  }
@@ -33070,7 +33108,11 @@ async function renderTerminalJpeg(lines) {
33070
33108
  }
33071
33109
  //#endregion
33072
33110
  //#region src/terminal-camera-device.ts
33073
- 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
+ });
33074
33116
  var relay = null;
33075
33117
  function installTerminalCameraRelay(next) {
33076
33118
  relay = next;
@@ -33089,18 +33131,19 @@ var TerminalCameraDevice = class extends BaseDevice {
33089
33131
  const activeRelay = relay;
33090
33132
  if (!activeRelay) throw new Error("terminal camera relay is unavailable");
33091
33133
  const nodeId = this.config.get("nodeId");
33092
- return (await activeRelay.listProfiles(nodeId)).map((profile) => ({
33093
- camStreamId: profile.profileId,
33134
+ const profileId = this.config.get("profileId");
33135
+ return [{
33136
+ camStreamId: profileId,
33094
33137
  kind: "pull-http",
33095
- url: activeRelay.streamUrl(nodeId, profile.profileId),
33138
+ url: activeRelay.streamUrl(nodeId, profileId),
33096
33139
  codec: "h264",
33097
33140
  resolution: {
33098
33141
  width: 960,
33099
33142
  height: 640
33100
33143
  },
33101
33144
  fps: 2,
33102
- label: profile.label
33103
- }));
33145
+ label: this.config.get("profileLabel")
33146
+ }];
33104
33147
  }
33105
33148
  setNodeOnline(online) {
33106
33149
  this.markOnline(online);
@@ -38272,7 +38315,12 @@ function createTerminalDataPlaneHandler(manager) {
38272
38315
  var ProfileIdSchema = string().trim().min(1).max(64).regex(/^[a-z][a-z0-9-]*$/, "must be a lowercase slug");
38273
38316
  var ConfiguredTerminalProfileSchema = object({
38274
38317
  enabled: boolean().default(true),
38275
- 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" }),
38276
38324
  label: string().trim().min(1).max(100),
38277
38325
  description: string().trim().max(500).optional().default(""),
38278
38326
  executable: string().trim().min(1).max(1024).refine((value) => !value.includes("\0"), { message: "must not contain NUL bytes" }),
@@ -38287,17 +38335,36 @@ var ConfiguredTerminalProfileSchema = object({
38287
38335
  * surface.
38288
38336
  */
38289
38337
  function buildProfiles(options) {
38290
- const profiles = [{
38338
+ const profiles = [];
38339
+ if (options.btmEnabled !== false) profiles.push({
38291
38340
  profileId: "monitor",
38292
- label: "System Monitor",
38341
+ label: "BTM",
38293
38342
  description: "bottom (btm) — CPU, memory, network and process monitor",
38294
38343
  file: options.btmPath.trim().length > 0 ? options.btmPath.trim() : "btm",
38295
- args: [],
38296
- ...options.btmPath.trim().length === 0 ? { fallbacks: [{
38297
- file: "top",
38298
- args: []
38299
- }] } : {}
38300
- }];
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
+ }
38301
38368
  if (options.allowShell) profiles.push({
38302
38369
  profileId: "shell",
38303
38370
  label: "Shell (interactive)",
@@ -38370,6 +38437,15 @@ var TerminalSessionManager = class {
38370
38437
  this.opts = opts;
38371
38438
  this.profiles = buildProfiles({
38372
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,
38373
38449
  allowShell: opts.allowShell,
38374
38450
  shellPath: opts.shellPath,
38375
38451
  customProfiles: opts.customProfiles,
@@ -38623,6 +38699,14 @@ var TerminalSessionManager = class {
38623
38699
  //#region src/addon.ts
38624
38700
  var DEFAULTS = {
38625
38701
  btmPath: "",
38702
+ btmEnabled: true,
38703
+ btmArgs: [],
38704
+ topEnabled: true,
38705
+ topPath: "",
38706
+ topArgs: [],
38707
+ glancesEnabled: true,
38708
+ glancesPath: "",
38709
+ glancesArgs: [],
38626
38710
  allowShell: false,
38627
38711
  shellPath: "",
38628
38712
  maxSessions: 4,
@@ -38636,17 +38720,29 @@ var TerminalAddon = class extends BaseAddon {
38636
38720
  dataPlane = null;
38637
38721
  cameraRelay = null;
38638
38722
  cameraReconcileTimer = null;
38723
+ cameraProfilesByNode = /* @__PURE__ */ new Map();
38724
+ glancesPythonPath = "";
38639
38725
  constructor() {
38640
38726
  super({ ...DEFAULTS });
38641
38727
  }
38642
38728
  async onInitialize() {
38643
38729
  await warmNodePty();
38730
+ this.glancesPythonPath = await this.ctx.deps.ensurePython() ?? "";
38644
38731
  const manager = new TerminalSessionManager({
38645
38732
  spawn: createNodePtySpawner(),
38646
38733
  screenFactory: createXtermScreen,
38647
38734
  resolveBinary: resolveExecutable,
38648
38735
  logger: this.ctx.logger,
38649
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,
38650
38746
  allowShell: this.config.allowShell,
38651
38747
  shellPath: this.config.shellPath,
38652
38748
  maxSessions: this.config.maxSessions,
@@ -38691,6 +38787,15 @@ var TerminalAddon = class extends BaseAddon {
38691
38787
  async onConfigChanged() {
38692
38788
  this.manager?.reconfigureProfiles({
38693
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,
38694
38799
  allowShell: this.config.allowShell,
38695
38800
  shellPath: this.config.shellPath,
38696
38801
  maxSessions: this.config.maxSessions,
@@ -38715,6 +38820,44 @@ var TerminalAddon = class extends BaseAddon {
38715
38820
  const topology = await this.ctx.api.nodes.topology.query();
38716
38821
  if (topology.length === 0) throw new Error("cluster topology returned no nodes; refusing to withdraw declared cameras");
38717
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
+ }
38718
38861
  const result = await new DeclaredDevices({
38719
38862
  logger: this.ctx.logger.child("camera-declaration"),
38720
38863
  addonId: this.ctx.id,
@@ -38733,12 +38876,12 @@ var TerminalAddon = class extends BaseAddon {
38733
38876
  }).reconcile({
38734
38877
  integrationName: TERMINAL_CAMERA_INTEGRATION,
38735
38878
  placement: "hub",
38736
- devices: nodes.map((node) => ({
38737
- stableId: `terminal-camera-${node.id}`,
38738
- name: node.isHub ? "Terminal Hub" : `Terminal ${node.name}`,
38879
+ devices: cameraDeclarations.map((camera) => ({
38880
+ stableId: camera.stableId,
38881
+ name: camera.name,
38739
38882
  type: DeviceType.Camera,
38740
38883
  DeviceClass: TerminalCameraDevice,
38741
- config: { nodeId: node.id },
38884
+ config: camera.config,
38742
38885
  role: "terminal-camera"
38743
38886
  }))
38744
38887
  });
@@ -38755,15 +38898,78 @@ var TerminalAddon = class extends BaseAddon {
38755
38898
  description: "Interactive terminal sessions in the Admin UI. Only pre-declared profiles can be opened; a free-form command is never accepted.",
38756
38899
  columns: 2,
38757
38900
  fields: [
38901
+ this.field({
38902
+ type: "boolean",
38903
+ key: "btmEnabled",
38904
+ label: "Enable BTM camera",
38905
+ default: true,
38906
+ perNode: true
38907
+ }),
38758
38908
  this.field({
38759
38909
  type: "text",
38760
38910
  key: "btmPath",
38761
- label: "Monitor binary",
38911
+ label: "BTM binary",
38762
38912
  description: "Path to the `btm` (bottom) executable. Leave empty to resolve from PATH.",
38763
38913
  placeholder: "btm",
38764
38914
  default: "",
38765
38915
  perNode: true
38766
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
+ }),
38767
38973
  this.field({
38768
38974
  type: "number",
38769
38975
  key: "maxSessions",
@@ -38795,7 +39001,7 @@ var TerminalAddon = class extends BaseAddon {
38795
39001
  }, {
38796
39002
  id: "terminal-profiles",
38797
39003
  title: "Custom profiles",
38798
- 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.",
38799
39005
  columns: 1,
38800
39006
  fields: [this.field({
38801
39007
  type: "editable-array",
package/dist/addon.mjs CHANGED
@@ -21127,6 +21127,7 @@ var FaceInfoSchema = object({
21127
21127
  var FaceFilterEnum = _enum([
21128
21128
  "unassigned",
21129
21129
  "recognized",
21130
+ "identified",
21130
21131
  "all"
21131
21132
  ]);
21132
21133
  var MediaFileLiteSchema$1 = object({
@@ -21155,6 +21156,8 @@ method(_void(), array(IdentitySchema).readonly()), method(object({ name: string(
21155
21156
  kind: "mutation",
21156
21157
  auth: "admin"
21157
21158
  }), method(object({
21159
+ /** Restrict to one camera. Absent keeps the cluster-wide gallery view. */
21160
+ deviceId: number().int().optional(),
21158
21161
  limit: number().int().positive().optional(),
21159
21162
  filter: FaceFilterEnum.optional(),
21160
21163
  /**
@@ -23384,6 +23387,10 @@ var OsdSourceSchema = discriminatedUnion("kind", [
23384
23387
  capName: string().min(1).max(64),
23385
23388
  /** Dot path inside the slice, e.g. `detected`, `value`, `mode`. */
23386
23389
  valuePath: string().min(1).max(64)
23390
+ }),
23391
+ object({
23392
+ kind: literal("latest-recognition"),
23393
+ recognition: _enum(["person", "plate"])
23387
23394
  })
23388
23395
  ]);
23389
23396
  var OsdSlotBindingSchema = object({
@@ -23489,6 +23496,15 @@ method(object({ deviceId: number().int() }), object({
23489
23496
  }), object({ success: literal(true) }), {
23490
23497
  kind: "mutation",
23491
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"
23492
23508
  }), method(object({
23493
23509
  deviceId: number().int(),
23494
23510
  slotId: string().min(1),
@@ -30182,6 +30198,12 @@ Object.freeze({
30182
30198
  addonId: null,
30183
30199
  access: "delete"
30184
30200
  },
30201
+ "osdManager.copyDeviceConfiguration": {
30202
+ capName: "osd-manager",
30203
+ capScope: "system",
30204
+ addonId: null,
30205
+ access: "create"
30206
+ },
30185
30207
  "osdManager.getConditionSupport": {
30186
30208
  capName: "osd-manager",
30187
30209
  capScope: "system",
@@ -33034,6 +33056,22 @@ function createNodePtySpawner() {
33034
33056
  async function warmNodePty() {
33035
33057
  await loadNodePty();
33036
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
+ }
33037
33075
  function escapeXml(value) {
33038
33076
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
33039
33077
  }
@@ -33048,7 +33086,11 @@ async function renderTerminalJpeg(lines) {
33048
33086
  }
33049
33087
  //#endregion
33050
33088
  //#region src/terminal-camera-device.ts
33051
- 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
+ });
33052
33094
  var relay = null;
33053
33095
  function installTerminalCameraRelay(next) {
33054
33096
  relay = next;
@@ -33067,18 +33109,19 @@ var TerminalCameraDevice = class extends BaseDevice {
33067
33109
  const activeRelay = relay;
33068
33110
  if (!activeRelay) throw new Error("terminal camera relay is unavailable");
33069
33111
  const nodeId = this.config.get("nodeId");
33070
- return (await activeRelay.listProfiles(nodeId)).map((profile) => ({
33071
- camStreamId: profile.profileId,
33112
+ const profileId = this.config.get("profileId");
33113
+ return [{
33114
+ camStreamId: profileId,
33072
33115
  kind: "pull-http",
33073
- url: activeRelay.streamUrl(nodeId, profile.profileId),
33116
+ url: activeRelay.streamUrl(nodeId, profileId),
33074
33117
  codec: "h264",
33075
33118
  resolution: {
33076
33119
  width: 960,
33077
33120
  height: 640
33078
33121
  },
33079
33122
  fps: 2,
33080
- label: profile.label
33081
- }));
33123
+ label: this.config.get("profileLabel")
33124
+ }];
33082
33125
  }
33083
33126
  setNodeOnline(online) {
33084
33127
  this.markOnline(online);
@@ -38250,7 +38293,12 @@ function createTerminalDataPlaneHandler(manager) {
38250
38293
  var ProfileIdSchema = string().trim().min(1).max(64).regex(/^[a-z][a-z0-9-]*$/, "must be a lowercase slug");
38251
38294
  var ConfiguredTerminalProfileSchema = object({
38252
38295
  enabled: boolean().default(true),
38253
- 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" }),
38254
38302
  label: string().trim().min(1).max(100),
38255
38303
  description: string().trim().max(500).optional().default(""),
38256
38304
  executable: string().trim().min(1).max(1024).refine((value) => !value.includes("\0"), { message: "must not contain NUL bytes" }),
@@ -38265,17 +38313,36 @@ var ConfiguredTerminalProfileSchema = object({
38265
38313
  * surface.
38266
38314
  */
38267
38315
  function buildProfiles(options) {
38268
- const profiles = [{
38316
+ const profiles = [];
38317
+ if (options.btmEnabled !== false) profiles.push({
38269
38318
  profileId: "monitor",
38270
- label: "System Monitor",
38319
+ label: "BTM",
38271
38320
  description: "bottom (btm) — CPU, memory, network and process monitor",
38272
38321
  file: options.btmPath.trim().length > 0 ? options.btmPath.trim() : "btm",
38273
- args: [],
38274
- ...options.btmPath.trim().length === 0 ? { fallbacks: [{
38275
- file: "top",
38276
- args: []
38277
- }] } : {}
38278
- }];
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
+ }
38279
38346
  if (options.allowShell) profiles.push({
38280
38347
  profileId: "shell",
38281
38348
  label: "Shell (interactive)",
@@ -38348,6 +38415,15 @@ var TerminalSessionManager = class {
38348
38415
  this.opts = opts;
38349
38416
  this.profiles = buildProfiles({
38350
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,
38351
38427
  allowShell: opts.allowShell,
38352
38428
  shellPath: opts.shellPath,
38353
38429
  customProfiles: opts.customProfiles,
@@ -38601,6 +38677,14 @@ var TerminalSessionManager = class {
38601
38677
  //#region src/addon.ts
38602
38678
  var DEFAULTS = {
38603
38679
  btmPath: "",
38680
+ btmEnabled: true,
38681
+ btmArgs: [],
38682
+ topEnabled: true,
38683
+ topPath: "",
38684
+ topArgs: [],
38685
+ glancesEnabled: true,
38686
+ glancesPath: "",
38687
+ glancesArgs: [],
38604
38688
  allowShell: false,
38605
38689
  shellPath: "",
38606
38690
  maxSessions: 4,
@@ -38614,17 +38698,29 @@ var TerminalAddon = class extends BaseAddon {
38614
38698
  dataPlane = null;
38615
38699
  cameraRelay = null;
38616
38700
  cameraReconcileTimer = null;
38701
+ cameraProfilesByNode = /* @__PURE__ */ new Map();
38702
+ glancesPythonPath = "";
38617
38703
  constructor() {
38618
38704
  super({ ...DEFAULTS });
38619
38705
  }
38620
38706
  async onInitialize() {
38621
38707
  await warmNodePty();
38708
+ this.glancesPythonPath = await this.ctx.deps.ensurePython() ?? "";
38622
38709
  const manager = new TerminalSessionManager({
38623
38710
  spawn: createNodePtySpawner(),
38624
38711
  screenFactory: createXtermScreen,
38625
38712
  resolveBinary: resolveExecutable,
38626
38713
  logger: this.ctx.logger,
38627
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,
38628
38724
  allowShell: this.config.allowShell,
38629
38725
  shellPath: this.config.shellPath,
38630
38726
  maxSessions: this.config.maxSessions,
@@ -38669,6 +38765,15 @@ var TerminalAddon = class extends BaseAddon {
38669
38765
  async onConfigChanged() {
38670
38766
  this.manager?.reconfigureProfiles({
38671
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,
38672
38777
  allowShell: this.config.allowShell,
38673
38778
  shellPath: this.config.shellPath,
38674
38779
  maxSessions: this.config.maxSessions,
@@ -38693,6 +38798,44 @@ var TerminalAddon = class extends BaseAddon {
38693
38798
  const topology = await this.ctx.api.nodes.topology.query();
38694
38799
  if (topology.length === 0) throw new Error("cluster topology returned no nodes; refusing to withdraw declared cameras");
38695
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
+ }
38696
38839
  const result = await new DeclaredDevices({
38697
38840
  logger: this.ctx.logger.child("camera-declaration"),
38698
38841
  addonId: this.ctx.id,
@@ -38711,12 +38854,12 @@ var TerminalAddon = class extends BaseAddon {
38711
38854
  }).reconcile({
38712
38855
  integrationName: TERMINAL_CAMERA_INTEGRATION,
38713
38856
  placement: "hub",
38714
- devices: nodes.map((node) => ({
38715
- stableId: `terminal-camera-${node.id}`,
38716
- name: node.isHub ? "Terminal Hub" : `Terminal ${node.name}`,
38857
+ devices: cameraDeclarations.map((camera) => ({
38858
+ stableId: camera.stableId,
38859
+ name: camera.name,
38717
38860
  type: DeviceType.Camera,
38718
38861
  DeviceClass: TerminalCameraDevice,
38719
- config: { nodeId: node.id },
38862
+ config: camera.config,
38720
38863
  role: "terminal-camera"
38721
38864
  }))
38722
38865
  });
@@ -38733,15 +38876,78 @@ var TerminalAddon = class extends BaseAddon {
38733
38876
  description: "Interactive terminal sessions in the Admin UI. Only pre-declared profiles can be opened; a free-form command is never accepted.",
38734
38877
  columns: 2,
38735
38878
  fields: [
38879
+ this.field({
38880
+ type: "boolean",
38881
+ key: "btmEnabled",
38882
+ label: "Enable BTM camera",
38883
+ default: true,
38884
+ perNode: true
38885
+ }),
38736
38886
  this.field({
38737
38887
  type: "text",
38738
38888
  key: "btmPath",
38739
- label: "Monitor binary",
38889
+ label: "BTM binary",
38740
38890
  description: "Path to the `btm` (bottom) executable. Leave empty to resolve from PATH.",
38741
38891
  placeholder: "btm",
38742
38892
  default: "",
38743
38893
  perNode: true
38744
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
+ }),
38745
38951
  this.field({
38746
38952
  type: "number",
38747
38953
  key: "maxSessions",
@@ -38773,7 +38979,7 @@ var TerminalAddon = class extends BaseAddon {
38773
38979
  }, {
38774
38980
  id: "terminal-profiles",
38775
38981
  title: "Custom profiles",
38776
- 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.",
38777
38983
  columns: 1,
38778
38984
  fields: [this.field({
38779
38985
  type: "editable-array",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-terminal",
3
- "version": "0.1.10",
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