@camstack/addon-pipeline-orchestrator 1.2.636 → 1.2.638

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/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.638] - 2026-10-28
11
+
12
+ ### Changed
13
+
14
+ - Node alerts name the node by its display name and id
15
+
16
+ ## [1.2.637] - 2026-10-28
17
+
18
+ ### Changed
19
+
20
+ - Raises an alert when a node joining or leaving leaves cameras that a manual rebalance would move, with a one-click Rebalance now
21
+
10
22
  ## [1.2.636] - 2026-10-28
11
23
 
12
24
  ### Changed
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.2.438",
21
+ version: "1.2.440",
22
22
  scope: ["default"],
23
23
  loaded: !1,
24
24
  from: "addon_pipeline_orchestrator_widgets",
@@ -36,7 +36,7 @@ async function r() {
36
36
  }
37
37
  },
38
38
  "@camstack/types": {
39
- version: "1.2.438",
39
+ version: "1.2.440",
40
40
  scope: "default",
41
41
  shareConfig: {
42
42
  singleton: !0,
package/dist/index.js CHANGED
@@ -17661,9 +17661,21 @@ var AlertSeveritySchema = _enum([
17661
17661
  "error",
17662
17662
  "critical"
17663
17663
  ]);
17664
+ /**
17665
+ * An action that RUNS something instead of going somewhere: the admin UI calls
17666
+ * the addon's custom action (`addons.custom`) with exactly these fields. The
17667
+ * producer owns the meaning; the UI knows nothing about the addon or the kind.
17668
+ */
17669
+ var AlertActionInvokeSchema = object({
17670
+ addonId: string().min(1),
17671
+ action: string().min(1),
17672
+ input: record(string(), unknown()).optional()
17673
+ });
17674
+ /** An action has `href` (a link) OR `invoke` (a button that runs a custom action). */
17664
17675
  var AlertActionSchema = object({
17665
17676
  label: string(),
17666
17677
  href: string().optional(),
17678
+ invoke: AlertActionInvokeSchema.optional(),
17667
17679
  /**
17668
17680
  * How `href` is followed. Absent / `app`: an admin-ui route (client-side).
17669
17681
  * `server`: a route the HUB serves (an addon HTTP route such as an OAuth
@@ -96457,8 +96469,17 @@ var NodeStressStatsOutputSchema = object({
96457
96469
  at: number()
96458
96470
  })).readonly()
96459
96471
  });
96472
+ var RebalanceNowOutputSchema = object({
96473
+ migrated: number(),
96474
+ skipped: number()
96475
+ });
96460
96476
  var pipelineOrchestratorActions = defineCustomActions({
96461
96477
  dumpState: customAction(_void(), OrchestratorDiagnosticsSchema),
96478
+ /**
96479
+ * A manual rebalance (margin 0) — the action behind the "Camera placement can
96480
+ * be rebalanced" alert. Closes that alert as completed, or marks it failed.
96481
+ */
96482
+ rebalanceNow: customAction(_void(), RebalanceNowOutputSchema, { auth: "admin" }),
96462
96483
  nodeStressStats: customAction(NodeStressStatsInputSchema, NodeStressStatsOutputSchema, { auth: "admin" })
96463
96484
  });
96464
96485
  /**
@@ -99150,6 +99171,8 @@ var PlacementService = class {
99150
99171
  const talliedLoads = () => loads.map((load) => tallyLoad(load, attachedDelta.get(load.nodeId) ?? 0, deviceDelta.get(load.nodeId)));
99151
99172
  let migrated = 0;
99152
99173
  const skipped = [];
99174
+ const moves = [];
99175
+ const dryRun = opts?.dryRun === true;
99153
99176
  for (const [deviceId, config] of this.deps.ledger.configEntries()) {
99154
99177
  if (opts?.onlyDeviceIds !== void 0 && !opts.onlyDeviceIds.has(deviceId)) continue;
99155
99178
  if (this.deps.isIdleSessionCamera(deviceId)) continue;
@@ -99168,7 +99191,7 @@ var PlacementService = class {
99168
99191
  });
99169
99192
  if (!decision) continue;
99170
99193
  if (decision.kind === "pending") {
99171
- this.handlePendingDecision(deviceId, decision.reason);
99194
+ if (!dryRun) this.handlePendingDecision(deviceId, decision.reason);
99172
99195
  continue;
99173
99196
  }
99174
99197
  if (current && current.agentNodeId === decision.agentNodeId) continue;
@@ -99178,6 +99201,16 @@ var PlacementService = class {
99178
99201
  const targetLoad = tallied.find((l) => l.nodeId === decision.agentNodeId);
99179
99202
  if (currentLoad && targetLoad && capacityImprovement(currentLoad, targetLoad, nodeWeights) < minImprovement) continue;
99180
99203
  }
99204
+ if (dryRun) {
99205
+ if (current) bumpAttached(current.agentNodeId, -1, config.deviceKey);
99206
+ bumpAttached(decision.agentNodeId, 1, config.deviceKey);
99207
+ moves.push({
99208
+ deviceId,
99209
+ fromNodeId: current?.agentNodeId ?? null,
99210
+ toNodeId: decision.agentNodeId
99211
+ });
99212
+ continue;
99213
+ }
99181
99214
  const placement = await this.resolvePlacement({
99182
99215
  deviceId,
99183
99216
  nodeId: decision.agentNodeId,
@@ -99220,11 +99253,28 @@ var PlacementService = class {
99220
99253
  dispatchGuard.dispose();
99221
99254
  }
99222
99255
  migrated++;
99256
+ moves.push({
99257
+ deviceId,
99258
+ fromNodeId: current?.agentNodeId ?? null,
99259
+ toNodeId: decision.agentNodeId
99260
+ });
99261
+ }
99262
+ if (dryRun) {
99263
+ ctx.logger.debug("rebalance: plan", { meta: {
99264
+ minImprovement,
99265
+ wouldMove: moves.length
99266
+ } });
99267
+ return {
99268
+ migrated: 0,
99269
+ skipped,
99270
+ moves
99271
+ };
99223
99272
  }
99224
99273
  ctx.logger.info("rebalance: migrated cameras", { meta: { migrated } });
99225
99274
  return {
99226
99275
  migrated,
99227
- skipped
99276
+ skipped,
99277
+ moves
99228
99278
  };
99229
99279
  }
99230
99280
  getPipelineAssignments() {
@@ -106915,6 +106965,157 @@ async function buildOrchestratorControllers(deps) {
106915
106965
  };
106916
106966
  }
106917
106967
  //#endregion
106968
+ //#region src/rebalance-suggestion.ts
106969
+ var REBALANCE_SUGGESTED_ALERT_ID = "pipeline-orchestrator:rebalance-suggested";
106970
+ var NODE_NOT_USED_ALERT_PREFIX = "pipeline-orchestrator:node-not-used:";
106971
+ /** The alert category the Alert Center files both alerts under. */
106972
+ var ALERT_CATEGORY = "system";
106973
+ var DEFAULT_SUGGESTION_DEBOUNCE_MS = 9e4;
106974
+ var VERB = {
106975
+ joined: "joined",
106976
+ left: "left",
106977
+ capabilities: "changed its pipeline roles"
106978
+ };
106979
+ var RebalanceSuggestion = class {
106980
+ deps;
106981
+ /** `name (id)` when a name distinct from the id is known, else the id. */
106982
+ async label(nodeId) {
106983
+ const name = await this.deps.nodeName(nodeId);
106984
+ return name === null || name.length === 0 || name === nodeId ? nodeId : `${name} (${nodeId})`;
106985
+ }
106986
+ timer = null;
106987
+ lastNote = null;
106988
+ notUsedRaised = /* @__PURE__ */ new Set();
106989
+ disposed = false;
106990
+ constructor(deps) {
106991
+ this.deps = deps;
106992
+ }
106993
+ /** A node event: (re)arm the single evaluation. Forked-process ids are not nodes. */
106994
+ noteNodeEvent(note) {
106995
+ if (this.disposed || note.nodeId.includes("/")) return;
106996
+ this.lastNote = note;
106997
+ if (this.timer !== null) clearTimeout(this.timer);
106998
+ this.timer = setTimeout(() => {
106999
+ this.timer = null;
107000
+ this.evaluate();
107001
+ }, this.deps.debounceMs);
107002
+ }
107003
+ dispose() {
107004
+ this.disposed = true;
107005
+ if (this.timer !== null) clearTimeout(this.timer);
107006
+ this.timer = null;
107007
+ }
107008
+ async evaluate() {
107009
+ await this.evaluateSuggestion();
107010
+ await this.evaluateUnusedNodes();
107011
+ }
107012
+ async evaluateSuggestion() {
107013
+ const { logger } = this.deps;
107014
+ let manual;
107015
+ let sweep;
107016
+ try {
107017
+ manual = await this.deps.plan(0);
107018
+ sweep = await this.deps.plan(AUTO_REBALANCE_MIN_IMPROVEMENT);
107019
+ } catch (err) {
107020
+ logger.warn("rebalance suggestion: plan failed — alert left as it is", { meta: { error: err instanceof Error ? err.message : String(err) } });
107021
+ return;
107022
+ }
107023
+ const sweepIds = new Set(sweep.moves.map((m) => m.deviceId));
107024
+ const onlyManual = manual.moves.filter((m) => !sweepIds.has(m.deviceId));
107025
+ if (onlyManual.length === 0) {
107026
+ logger.debug("rebalance suggestion: nothing beyond the auto sweep — closing the alert", { meta: {
107027
+ manualMoves: manual.moves.length,
107028
+ sweepMoves: sweep.moves.length
107029
+ } });
107030
+ await this.complete(REBALANCE_SUGGESTED_ALERT_ID);
107031
+ return;
107032
+ }
107033
+ const note = this.lastNote;
107034
+ const cause = note === null ? "The cluster changed" : `Node ${await this.label(note.nodeId)} ${VERB[note.kind]}`;
107035
+ const count = manual.moves.length;
107036
+ const now = this.deps.now();
107037
+ logger.info("rebalance suggestion: raising the alert", { meta: {
107038
+ manualMoves: count,
107039
+ beyondSweep: onlyManual.length
107040
+ } });
107041
+ await this.emit({
107042
+ id: REBALANCE_SUGGESTED_ALERT_ID,
107043
+ category: ALERT_CATEGORY,
107044
+ severity: "warning",
107045
+ title: "Camera placement can be rebalanced",
107046
+ message: `${cause}. ${String(count)} ${count === 1 ? "camera" : "cameras"} would move to even out the load; the automatic balancing will not do it on its own.`,
107047
+ action: {
107048
+ label: "Rebalance now",
107049
+ invoke: {
107050
+ addonId: this.deps.addonId,
107051
+ action: "rebalanceNow"
107052
+ }
107053
+ },
107054
+ status: "active",
107055
+ read: false,
107056
+ createdAt: now,
107057
+ updatedAt: now,
107058
+ source: {
107059
+ type: "addon",
107060
+ id: this.deps.addonId
107061
+ }
107062
+ });
107063
+ }
107064
+ async evaluateUnusedNodes() {
107065
+ const enabled = new Set(this.deps.enabledNodes());
107066
+ const unused = new Set(this.deps.runnerNodes().filter((id) => !enabled.has(id)));
107067
+ for (const nodeId of unused) {
107068
+ const id = `${NODE_NOT_USED_ALERT_PREFIX}${nodeId}`;
107069
+ if (this.notUsedRaised.has(id)) continue;
107070
+ this.notUsedRaised.add(id);
107071
+ this.deps.logger.info("rebalance suggestion: node online but not used for detection", { tags: { nodeId } });
107072
+ const now = this.deps.now();
107073
+ await this.emit({
107074
+ id,
107075
+ category: ALERT_CATEGORY,
107076
+ severity: "info",
107077
+ title: "Node is not used for detection",
107078
+ message: `Node ${await this.label(nodeId)} is online but detection is not enabled on it, so no camera will be placed there.`,
107079
+ action: {
107080
+ label: "Open node",
107081
+ href: `/system/pipeline?view=nodes&node=${encodeURIComponent(nodeId)}`,
107082
+ navigation: "app"
107083
+ },
107084
+ status: "active",
107085
+ read: false,
107086
+ createdAt: now,
107087
+ updatedAt: now,
107088
+ source: {
107089
+ type: "node",
107090
+ id: nodeId
107091
+ }
107092
+ });
107093
+ }
107094
+ for (const id of [...this.notUsedRaised]) {
107095
+ const nodeId = id.slice(36);
107096
+ if (unused.has(nodeId)) continue;
107097
+ this.notUsedRaised.delete(id);
107098
+ await this.complete(id);
107099
+ }
107100
+ }
107101
+ async emit(alert) {
107102
+ await this.deps.alerts.emit(alert).catch((err) => {
107103
+ this.deps.logger.warn("rebalance suggestion: could not raise an alert", { meta: {
107104
+ alertId: alert.id,
107105
+ error: err instanceof Error ? err.message : String(err)
107106
+ } });
107107
+ });
107108
+ }
107109
+ async complete(alertId) {
107110
+ await this.deps.alerts.complete(alertId).catch((err) => {
107111
+ this.deps.logger.debug("rebalance suggestion: close failed (alert may not exist)", { meta: {
107112
+ alertId,
107113
+ error: err instanceof Error ? err.message : String(err)
107114
+ } });
107115
+ });
107116
+ }
107117
+ };
107118
+ //#endregion
106918
107119
  //#region src/settings-ui-schemas.ts
106919
107120
  /** Build the addon-level schema sections (cluster roles + crop + balancer + failover). */
106920
107121
  function buildGlobalSettingsSections(options) {
@@ -108711,6 +108912,9 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
108711
108912
  * Constructed right after `loadShed`. Nulled at the end of `onShutdown`.
108712
108913
  */
108713
108914
  placement = null;
108915
+ /** The debounced "placement can be rebalanced" evaluation; see `rebalance-suggestion.ts`. */
108916
+ rebalanceSuggestion = null;
108917
+ unsubSuggestionEvents = [];
108714
108918
  /** EventBus unsubscribes for agent online/offline events. */
108715
108919
  unsubAgentOnline = null;
108716
108920
  unsubAgentOffline = null;
@@ -108984,6 +109188,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
108984
109188
  this.localNodeId = controllers.localNodeId;
108985
109189
  this.unsubAgentOnline = controllers.unsubAgentOnline;
108986
109190
  this.unsubAgentOffline = controllers.unsubAgentOffline;
109191
+ this.startRebalanceSuggestion();
108987
109192
  this.unsubOrchestratorSubscriptions = controllers.unsubOrchestratorSubscriptions;
108988
109193
  this.pendingRetryTimer = controllers.pendingRetryTimer;
108989
109194
  this.autoRebalanceTimer = controllers.autoRebalanceTimer;
@@ -109027,6 +109232,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
109027
109232
  customActions: pipelineOrchestratorActions,
109028
109233
  actionHandlers: {
109029
109234
  dumpState: async () => this.dumpDiagnostics(),
109235
+ rebalanceNow: async () => this.rebalanceNow(),
109030
109236
  nodeStressStats: async (input) => {
109031
109237
  const lts = this.nodeStressLts;
109032
109238
  const moves = this.nodeStress?.historyView() ?? [];
@@ -109109,6 +109315,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
109109
109315
  this.detectionWiring = null;
109110
109316
  this.unsubAgentOffline?.();
109111
109317
  this.unsubAgentOffline = null;
109318
+ this.stopRebalanceSuggestion();
109112
109319
  this.effectiveSharesService?.stop();
109113
109320
  this.unsubAgentOnline?.();
109114
109321
  this.unsubAgentOnline = null;
@@ -109175,6 +109382,101 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
109175
109382
  async rebalance(opts) {
109176
109383
  return this.placement.rebalance(opts);
109177
109384
  }
109385
+ /**
109386
+ * The `rebalanceNow` custom action: a manual rebalance, and the verdict on
109387
+ * the suggestion alert that offered it. Every branch is logged.
109388
+ */
109389
+ async rebalanceNow() {
109390
+ const alertId = REBALANCE_SUGGESTED_ALERT_ID;
109391
+ try {
109392
+ const result = await this.rebalance({ minImprovement: 0 });
109393
+ const message = `${String(result.migrated)} ${result.migrated === 1 ? "camera" : "cameras"} moved`;
109394
+ this.ctx.logger.info("rebalanceNow: done", { meta: {
109395
+ migrated: result.migrated,
109396
+ skipped: result.skipped.length
109397
+ } });
109398
+ await this.ctx.api.alerts.update.mutate({
109399
+ alertId,
109400
+ patch: {
109401
+ status: "completed",
109402
+ message,
109403
+ updatedAt: Date.now()
109404
+ }
109405
+ }).catch((err) => this.ctx.logger.warn("rebalanceNow: could not close the suggestion alert", { meta: { error: errMsg$2(err) } }));
109406
+ return {
109407
+ migrated: result.migrated,
109408
+ skipped: result.skipped.length
109409
+ };
109410
+ } catch (err) {
109411
+ const reason = errMsg$2(err);
109412
+ this.ctx.logger.error("rebalanceNow: failed", { meta: { error: reason } });
109413
+ await this.ctx.api.alerts.update.mutate({
109414
+ alertId,
109415
+ patch: {
109416
+ status: "failed",
109417
+ message: `Rebalance failed: ${reason}`,
109418
+ updatedAt: Date.now()
109419
+ }
109420
+ }).catch((e) => this.ctx.logger.warn("rebalanceNow: could not mark the suggestion alert failed", { meta: { error: errMsg$2(e) } }));
109421
+ throw err;
109422
+ }
109423
+ }
109424
+ startRebalanceSuggestion() {
109425
+ const ctx = this.ctx;
109426
+ const suggestion = new RebalanceSuggestion({
109427
+ addonId: "pipeline-orchestrator",
109428
+ logger: ctx.logger,
109429
+ debounceMs: DEFAULT_SUGGESTION_DEBOUNCE_MS,
109430
+ now: () => Date.now(),
109431
+ plan: (minImprovement) => this.rebalance({
109432
+ minImprovement,
109433
+ dryRun: true
109434
+ }),
109435
+ enabledNodes: () => this.topology?.enabledNodes ?? [],
109436
+ runnerNodes: () => this.topology?.collectRunnerNodeIds() ?? [],
109437
+ nodeName: async (nodeId) => {
109438
+ try {
109439
+ return (await ctx.api.nodes.directory.query()).nodes.find((n) => n.id === nodeId)?.name ?? null;
109440
+ } catch {
109441
+ return null;
109442
+ }
109443
+ },
109444
+ alerts: {
109445
+ emit: (alert) => ctx.api.alerts.emit.mutate(alert),
109446
+ complete: (alertId) => ctx.api.alerts.update.mutate({
109447
+ alertId,
109448
+ patch: {
109449
+ status: "completed",
109450
+ updatedAt: Date.now()
109451
+ }
109452
+ })
109453
+ }
109454
+ });
109455
+ this.rebalanceSuggestion = suggestion;
109456
+ this.unsubSuggestionEvents = [
109457
+ ctx.eventBus.subscribe({ category: EventCategory.AgentOnline }, (event) => suggestion.noteNodeEvent({
109458
+ kind: "joined",
109459
+ nodeId: event.data.agentId
109460
+ })),
109461
+ ctx.eventBus.subscribe({ category: EventCategory.AgentOffline }, (event) => suggestion.noteNodeEvent({
109462
+ kind: "left",
109463
+ nodeId: event.data.agentId
109464
+ })),
109465
+ ctx.eventBus.subscribe({ category: EventCategory.AgentUnregistered }, (event) => {
109466
+ const agentId = event.data["agentId"];
109467
+ if (typeof agentId === "string") suggestion.noteNodeEvent({
109468
+ kind: "left",
109469
+ nodeId: agentId
109470
+ });
109471
+ })
109472
+ ];
109473
+ }
109474
+ stopRebalanceSuggestion() {
109475
+ for (const unsub of this.unsubSuggestionEvents) unsub();
109476
+ this.unsubSuggestionEvents = [];
109477
+ this.rebalanceSuggestion?.dispose();
109478
+ this.rebalanceSuggestion = null;
109479
+ }
109178
109480
  getPipelineAssignments() {
109179
109481
  return this.placement.getPipelineAssignments();
109180
109482
  }
@@ -109530,6 +109832,10 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
109530
109832
  this.topology.addKnownRunnerNode(input.agentNodeId);
109531
109833
  this.reconcile.schedulePendingRetry();
109532
109834
  if (input.detect !== void 0) this.noteShareChange(`node-detect:${input.agentNodeId}`);
109835
+ this.rebalanceSuggestion?.noteNodeEvent({
109836
+ kind: "capabilities",
109837
+ nodeId: input.agentNodeId
109838
+ });
109533
109839
  return { success: true };
109534
109840
  }
109535
109841
  /**
package/dist/index.mjs CHANGED
@@ -17633,9 +17633,21 @@ var AlertSeveritySchema = _enum([
17633
17633
  "error",
17634
17634
  "critical"
17635
17635
  ]);
17636
+ /**
17637
+ * An action that RUNS something instead of going somewhere: the admin UI calls
17638
+ * the addon's custom action (`addons.custom`) with exactly these fields. The
17639
+ * producer owns the meaning; the UI knows nothing about the addon or the kind.
17640
+ */
17641
+ var AlertActionInvokeSchema = object({
17642
+ addonId: string().min(1),
17643
+ action: string().min(1),
17644
+ input: record(string(), unknown()).optional()
17645
+ });
17646
+ /** An action has `href` (a link) OR `invoke` (a button that runs a custom action). */
17636
17647
  var AlertActionSchema = object({
17637
17648
  label: string(),
17638
17649
  href: string().optional(),
17650
+ invoke: AlertActionInvokeSchema.optional(),
17639
17651
  /**
17640
17652
  * How `href` is followed. Absent / `app`: an admin-ui route (client-side).
17641
17653
  * `server`: a route the HUB serves (an addon HTTP route such as an OAuth
@@ -96429,8 +96441,17 @@ var NodeStressStatsOutputSchema = object({
96429
96441
  at: number()
96430
96442
  })).readonly()
96431
96443
  });
96444
+ var RebalanceNowOutputSchema = object({
96445
+ migrated: number(),
96446
+ skipped: number()
96447
+ });
96432
96448
  var pipelineOrchestratorActions = defineCustomActions({
96433
96449
  dumpState: customAction(_void(), OrchestratorDiagnosticsSchema),
96450
+ /**
96451
+ * A manual rebalance (margin 0) — the action behind the "Camera placement can
96452
+ * be rebalanced" alert. Closes that alert as completed, or marks it failed.
96453
+ */
96454
+ rebalanceNow: customAction(_void(), RebalanceNowOutputSchema, { auth: "admin" }),
96434
96455
  nodeStressStats: customAction(NodeStressStatsInputSchema, NodeStressStatsOutputSchema, { auth: "admin" })
96435
96456
  });
96436
96457
  /**
@@ -99122,6 +99143,8 @@ var PlacementService = class {
99122
99143
  const talliedLoads = () => loads.map((load) => tallyLoad(load, attachedDelta.get(load.nodeId) ?? 0, deviceDelta.get(load.nodeId)));
99123
99144
  let migrated = 0;
99124
99145
  const skipped = [];
99146
+ const moves = [];
99147
+ const dryRun = opts?.dryRun === true;
99125
99148
  for (const [deviceId, config] of this.deps.ledger.configEntries()) {
99126
99149
  if (opts?.onlyDeviceIds !== void 0 && !opts.onlyDeviceIds.has(deviceId)) continue;
99127
99150
  if (this.deps.isIdleSessionCamera(deviceId)) continue;
@@ -99140,7 +99163,7 @@ var PlacementService = class {
99140
99163
  });
99141
99164
  if (!decision) continue;
99142
99165
  if (decision.kind === "pending") {
99143
- this.handlePendingDecision(deviceId, decision.reason);
99166
+ if (!dryRun) this.handlePendingDecision(deviceId, decision.reason);
99144
99167
  continue;
99145
99168
  }
99146
99169
  if (current && current.agentNodeId === decision.agentNodeId) continue;
@@ -99150,6 +99173,16 @@ var PlacementService = class {
99150
99173
  const targetLoad = tallied.find((l) => l.nodeId === decision.agentNodeId);
99151
99174
  if (currentLoad && targetLoad && capacityImprovement(currentLoad, targetLoad, nodeWeights) < minImprovement) continue;
99152
99175
  }
99176
+ if (dryRun) {
99177
+ if (current) bumpAttached(current.agentNodeId, -1, config.deviceKey);
99178
+ bumpAttached(decision.agentNodeId, 1, config.deviceKey);
99179
+ moves.push({
99180
+ deviceId,
99181
+ fromNodeId: current?.agentNodeId ?? null,
99182
+ toNodeId: decision.agentNodeId
99183
+ });
99184
+ continue;
99185
+ }
99153
99186
  const placement = await this.resolvePlacement({
99154
99187
  deviceId,
99155
99188
  nodeId: decision.agentNodeId,
@@ -99192,11 +99225,28 @@ var PlacementService = class {
99192
99225
  dispatchGuard.dispose();
99193
99226
  }
99194
99227
  migrated++;
99228
+ moves.push({
99229
+ deviceId,
99230
+ fromNodeId: current?.agentNodeId ?? null,
99231
+ toNodeId: decision.agentNodeId
99232
+ });
99233
+ }
99234
+ if (dryRun) {
99235
+ ctx.logger.debug("rebalance: plan", { meta: {
99236
+ minImprovement,
99237
+ wouldMove: moves.length
99238
+ } });
99239
+ return {
99240
+ migrated: 0,
99241
+ skipped,
99242
+ moves
99243
+ };
99195
99244
  }
99196
99245
  ctx.logger.info("rebalance: migrated cameras", { meta: { migrated } });
99197
99246
  return {
99198
99247
  migrated,
99199
- skipped
99248
+ skipped,
99249
+ moves
99200
99250
  };
99201
99251
  }
99202
99252
  getPipelineAssignments() {
@@ -106887,6 +106937,157 @@ async function buildOrchestratorControllers(deps) {
106887
106937
  };
106888
106938
  }
106889
106939
  //#endregion
106940
+ //#region src/rebalance-suggestion.ts
106941
+ var REBALANCE_SUGGESTED_ALERT_ID = "pipeline-orchestrator:rebalance-suggested";
106942
+ var NODE_NOT_USED_ALERT_PREFIX = "pipeline-orchestrator:node-not-used:";
106943
+ /** The alert category the Alert Center files both alerts under. */
106944
+ var ALERT_CATEGORY = "system";
106945
+ var DEFAULT_SUGGESTION_DEBOUNCE_MS = 9e4;
106946
+ var VERB = {
106947
+ joined: "joined",
106948
+ left: "left",
106949
+ capabilities: "changed its pipeline roles"
106950
+ };
106951
+ var RebalanceSuggestion = class {
106952
+ deps;
106953
+ /** `name (id)` when a name distinct from the id is known, else the id. */
106954
+ async label(nodeId) {
106955
+ const name = await this.deps.nodeName(nodeId);
106956
+ return name === null || name.length === 0 || name === nodeId ? nodeId : `${name} (${nodeId})`;
106957
+ }
106958
+ timer = null;
106959
+ lastNote = null;
106960
+ notUsedRaised = /* @__PURE__ */ new Set();
106961
+ disposed = false;
106962
+ constructor(deps) {
106963
+ this.deps = deps;
106964
+ }
106965
+ /** A node event: (re)arm the single evaluation. Forked-process ids are not nodes. */
106966
+ noteNodeEvent(note) {
106967
+ if (this.disposed || note.nodeId.includes("/")) return;
106968
+ this.lastNote = note;
106969
+ if (this.timer !== null) clearTimeout(this.timer);
106970
+ this.timer = setTimeout(() => {
106971
+ this.timer = null;
106972
+ this.evaluate();
106973
+ }, this.deps.debounceMs);
106974
+ }
106975
+ dispose() {
106976
+ this.disposed = true;
106977
+ if (this.timer !== null) clearTimeout(this.timer);
106978
+ this.timer = null;
106979
+ }
106980
+ async evaluate() {
106981
+ await this.evaluateSuggestion();
106982
+ await this.evaluateUnusedNodes();
106983
+ }
106984
+ async evaluateSuggestion() {
106985
+ const { logger } = this.deps;
106986
+ let manual;
106987
+ let sweep;
106988
+ try {
106989
+ manual = await this.deps.plan(0);
106990
+ sweep = await this.deps.plan(AUTO_REBALANCE_MIN_IMPROVEMENT);
106991
+ } catch (err) {
106992
+ logger.warn("rebalance suggestion: plan failed — alert left as it is", { meta: { error: err instanceof Error ? err.message : String(err) } });
106993
+ return;
106994
+ }
106995
+ const sweepIds = new Set(sweep.moves.map((m) => m.deviceId));
106996
+ const onlyManual = manual.moves.filter((m) => !sweepIds.has(m.deviceId));
106997
+ if (onlyManual.length === 0) {
106998
+ logger.debug("rebalance suggestion: nothing beyond the auto sweep — closing the alert", { meta: {
106999
+ manualMoves: manual.moves.length,
107000
+ sweepMoves: sweep.moves.length
107001
+ } });
107002
+ await this.complete(REBALANCE_SUGGESTED_ALERT_ID);
107003
+ return;
107004
+ }
107005
+ const note = this.lastNote;
107006
+ const cause = note === null ? "The cluster changed" : `Node ${await this.label(note.nodeId)} ${VERB[note.kind]}`;
107007
+ const count = manual.moves.length;
107008
+ const now = this.deps.now();
107009
+ logger.info("rebalance suggestion: raising the alert", { meta: {
107010
+ manualMoves: count,
107011
+ beyondSweep: onlyManual.length
107012
+ } });
107013
+ await this.emit({
107014
+ id: REBALANCE_SUGGESTED_ALERT_ID,
107015
+ category: ALERT_CATEGORY,
107016
+ severity: "warning",
107017
+ title: "Camera placement can be rebalanced",
107018
+ message: `${cause}. ${String(count)} ${count === 1 ? "camera" : "cameras"} would move to even out the load; the automatic balancing will not do it on its own.`,
107019
+ action: {
107020
+ label: "Rebalance now",
107021
+ invoke: {
107022
+ addonId: this.deps.addonId,
107023
+ action: "rebalanceNow"
107024
+ }
107025
+ },
107026
+ status: "active",
107027
+ read: false,
107028
+ createdAt: now,
107029
+ updatedAt: now,
107030
+ source: {
107031
+ type: "addon",
107032
+ id: this.deps.addonId
107033
+ }
107034
+ });
107035
+ }
107036
+ async evaluateUnusedNodes() {
107037
+ const enabled = new Set(this.deps.enabledNodes());
107038
+ const unused = new Set(this.deps.runnerNodes().filter((id) => !enabled.has(id)));
107039
+ for (const nodeId of unused) {
107040
+ const id = `${NODE_NOT_USED_ALERT_PREFIX}${nodeId}`;
107041
+ if (this.notUsedRaised.has(id)) continue;
107042
+ this.notUsedRaised.add(id);
107043
+ this.deps.logger.info("rebalance suggestion: node online but not used for detection", { tags: { nodeId } });
107044
+ const now = this.deps.now();
107045
+ await this.emit({
107046
+ id,
107047
+ category: ALERT_CATEGORY,
107048
+ severity: "info",
107049
+ title: "Node is not used for detection",
107050
+ message: `Node ${await this.label(nodeId)} is online but detection is not enabled on it, so no camera will be placed there.`,
107051
+ action: {
107052
+ label: "Open node",
107053
+ href: `/system/pipeline?view=nodes&node=${encodeURIComponent(nodeId)}`,
107054
+ navigation: "app"
107055
+ },
107056
+ status: "active",
107057
+ read: false,
107058
+ createdAt: now,
107059
+ updatedAt: now,
107060
+ source: {
107061
+ type: "node",
107062
+ id: nodeId
107063
+ }
107064
+ });
107065
+ }
107066
+ for (const id of [...this.notUsedRaised]) {
107067
+ const nodeId = id.slice(36);
107068
+ if (unused.has(nodeId)) continue;
107069
+ this.notUsedRaised.delete(id);
107070
+ await this.complete(id);
107071
+ }
107072
+ }
107073
+ async emit(alert) {
107074
+ await this.deps.alerts.emit(alert).catch((err) => {
107075
+ this.deps.logger.warn("rebalance suggestion: could not raise an alert", { meta: {
107076
+ alertId: alert.id,
107077
+ error: err instanceof Error ? err.message : String(err)
107078
+ } });
107079
+ });
107080
+ }
107081
+ async complete(alertId) {
107082
+ await this.deps.alerts.complete(alertId).catch((err) => {
107083
+ this.deps.logger.debug("rebalance suggestion: close failed (alert may not exist)", { meta: {
107084
+ alertId,
107085
+ error: err instanceof Error ? err.message : String(err)
107086
+ } });
107087
+ });
107088
+ }
107089
+ };
107090
+ //#endregion
106890
107091
  //#region src/settings-ui-schemas.ts
106891
107092
  /** Build the addon-level schema sections (cluster roles + crop + balancer + failover). */
106892
107093
  function buildGlobalSettingsSections(options) {
@@ -108683,6 +108884,9 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
108683
108884
  * Constructed right after `loadShed`. Nulled at the end of `onShutdown`.
108684
108885
  */
108685
108886
  placement = null;
108887
+ /** The debounced "placement can be rebalanced" evaluation; see `rebalance-suggestion.ts`. */
108888
+ rebalanceSuggestion = null;
108889
+ unsubSuggestionEvents = [];
108686
108890
  /** EventBus unsubscribes for agent online/offline events. */
108687
108891
  unsubAgentOnline = null;
108688
108892
  unsubAgentOffline = null;
@@ -108956,6 +109160,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
108956
109160
  this.localNodeId = controllers.localNodeId;
108957
109161
  this.unsubAgentOnline = controllers.unsubAgentOnline;
108958
109162
  this.unsubAgentOffline = controllers.unsubAgentOffline;
109163
+ this.startRebalanceSuggestion();
108959
109164
  this.unsubOrchestratorSubscriptions = controllers.unsubOrchestratorSubscriptions;
108960
109165
  this.pendingRetryTimer = controllers.pendingRetryTimer;
108961
109166
  this.autoRebalanceTimer = controllers.autoRebalanceTimer;
@@ -108999,6 +109204,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
108999
109204
  customActions: pipelineOrchestratorActions,
109000
109205
  actionHandlers: {
109001
109206
  dumpState: async () => this.dumpDiagnostics(),
109207
+ rebalanceNow: async () => this.rebalanceNow(),
109002
109208
  nodeStressStats: async (input) => {
109003
109209
  const lts = this.nodeStressLts;
109004
109210
  const moves = this.nodeStress?.historyView() ?? [];
@@ -109081,6 +109287,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
109081
109287
  this.detectionWiring = null;
109082
109288
  this.unsubAgentOffline?.();
109083
109289
  this.unsubAgentOffline = null;
109290
+ this.stopRebalanceSuggestion();
109084
109291
  this.effectiveSharesService?.stop();
109085
109292
  this.unsubAgentOnline?.();
109086
109293
  this.unsubAgentOnline = null;
@@ -109147,6 +109354,101 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
109147
109354
  async rebalance(opts) {
109148
109355
  return this.placement.rebalance(opts);
109149
109356
  }
109357
+ /**
109358
+ * The `rebalanceNow` custom action: a manual rebalance, and the verdict on
109359
+ * the suggestion alert that offered it. Every branch is logged.
109360
+ */
109361
+ async rebalanceNow() {
109362
+ const alertId = REBALANCE_SUGGESTED_ALERT_ID;
109363
+ try {
109364
+ const result = await this.rebalance({ minImprovement: 0 });
109365
+ const message = `${String(result.migrated)} ${result.migrated === 1 ? "camera" : "cameras"} moved`;
109366
+ this.ctx.logger.info("rebalanceNow: done", { meta: {
109367
+ migrated: result.migrated,
109368
+ skipped: result.skipped.length
109369
+ } });
109370
+ await this.ctx.api.alerts.update.mutate({
109371
+ alertId,
109372
+ patch: {
109373
+ status: "completed",
109374
+ message,
109375
+ updatedAt: Date.now()
109376
+ }
109377
+ }).catch((err) => this.ctx.logger.warn("rebalanceNow: could not close the suggestion alert", { meta: { error: errMsg$2(err) } }));
109378
+ return {
109379
+ migrated: result.migrated,
109380
+ skipped: result.skipped.length
109381
+ };
109382
+ } catch (err) {
109383
+ const reason = errMsg$2(err);
109384
+ this.ctx.logger.error("rebalanceNow: failed", { meta: { error: reason } });
109385
+ await this.ctx.api.alerts.update.mutate({
109386
+ alertId,
109387
+ patch: {
109388
+ status: "failed",
109389
+ message: `Rebalance failed: ${reason}`,
109390
+ updatedAt: Date.now()
109391
+ }
109392
+ }).catch((e) => this.ctx.logger.warn("rebalanceNow: could not mark the suggestion alert failed", { meta: { error: errMsg$2(e) } }));
109393
+ throw err;
109394
+ }
109395
+ }
109396
+ startRebalanceSuggestion() {
109397
+ const ctx = this.ctx;
109398
+ const suggestion = new RebalanceSuggestion({
109399
+ addonId: "pipeline-orchestrator",
109400
+ logger: ctx.logger,
109401
+ debounceMs: DEFAULT_SUGGESTION_DEBOUNCE_MS,
109402
+ now: () => Date.now(),
109403
+ plan: (minImprovement) => this.rebalance({
109404
+ minImprovement,
109405
+ dryRun: true
109406
+ }),
109407
+ enabledNodes: () => this.topology?.enabledNodes ?? [],
109408
+ runnerNodes: () => this.topology?.collectRunnerNodeIds() ?? [],
109409
+ nodeName: async (nodeId) => {
109410
+ try {
109411
+ return (await ctx.api.nodes.directory.query()).nodes.find((n) => n.id === nodeId)?.name ?? null;
109412
+ } catch {
109413
+ return null;
109414
+ }
109415
+ },
109416
+ alerts: {
109417
+ emit: (alert) => ctx.api.alerts.emit.mutate(alert),
109418
+ complete: (alertId) => ctx.api.alerts.update.mutate({
109419
+ alertId,
109420
+ patch: {
109421
+ status: "completed",
109422
+ updatedAt: Date.now()
109423
+ }
109424
+ })
109425
+ }
109426
+ });
109427
+ this.rebalanceSuggestion = suggestion;
109428
+ this.unsubSuggestionEvents = [
109429
+ ctx.eventBus.subscribe({ category: EventCategory.AgentOnline }, (event) => suggestion.noteNodeEvent({
109430
+ kind: "joined",
109431
+ nodeId: event.data.agentId
109432
+ })),
109433
+ ctx.eventBus.subscribe({ category: EventCategory.AgentOffline }, (event) => suggestion.noteNodeEvent({
109434
+ kind: "left",
109435
+ nodeId: event.data.agentId
109436
+ })),
109437
+ ctx.eventBus.subscribe({ category: EventCategory.AgentUnregistered }, (event) => {
109438
+ const agentId = event.data["agentId"];
109439
+ if (typeof agentId === "string") suggestion.noteNodeEvent({
109440
+ kind: "left",
109441
+ nodeId: agentId
109442
+ });
109443
+ })
109444
+ ];
109445
+ }
109446
+ stopRebalanceSuggestion() {
109447
+ for (const unsub of this.unsubSuggestionEvents) unsub();
109448
+ this.unsubSuggestionEvents = [];
109449
+ this.rebalanceSuggestion?.dispose();
109450
+ this.rebalanceSuggestion = null;
109451
+ }
109150
109452
  getPipelineAssignments() {
109151
109453
  return this.placement.getPipelineAssignments();
109152
109454
  }
@@ -109502,6 +109804,10 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
109502
109804
  this.topology.addKnownRunnerNode(input.agentNodeId);
109503
109805
  this.reconcile.schedulePendingRetry();
109504
109806
  if (input.detect !== void 0) this.noteShareChange(`node-detect:${input.agentNodeId}`);
109807
+ this.rebalanceSuggestion?.noteNodeEvent({
109808
+ kind: "capabilities",
109809
+ nodeId: input.agentNodeId
109810
+ });
109505
109811
  return { success: true };
109506
109812
  }
109507
109813
  /**
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_orchestrator_widgets-DwR2APoE.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_orchestrator_widgets-Hya6JCSL.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-pipeline-orchestrator",
3
- "version": "1.2.636",
3
+ "version": "1.2.638",
4
4
  "description": "Hub-side camera-to-agent load balancer — tracks runner capacity and dispatches attachCamera calls to the optimal pipeline-runner instance",
5
5
  "keywords": [
6
6
  "camstack",