@camstack/addon-pipeline-orchestrator 1.2.636 → 1.2.637

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,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.637] - 2026-10-28
11
+
12
+ ### Changed
13
+
14
+ - Raises an alert when a node joining or leaving leaves cameras that a manual rebalance would move, with a one-click Rebalance now
15
+
10
16
  ## [1.2.636] - 2026-10-28
11
17
 
12
18
  ### 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.439",
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.439",
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,152 @@ 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
+ timer = null;
106982
+ lastNote = null;
106983
+ notUsedRaised = /* @__PURE__ */ new Set();
106984
+ disposed = false;
106985
+ constructor(deps) {
106986
+ this.deps = deps;
106987
+ }
106988
+ /** A node event: (re)arm the single evaluation. Forked-process ids are not nodes. */
106989
+ noteNodeEvent(note) {
106990
+ if (this.disposed || note.nodeId.includes("/")) return;
106991
+ this.lastNote = note;
106992
+ if (this.timer !== null) clearTimeout(this.timer);
106993
+ this.timer = setTimeout(() => {
106994
+ this.timer = null;
106995
+ this.evaluate();
106996
+ }, this.deps.debounceMs);
106997
+ }
106998
+ dispose() {
106999
+ this.disposed = true;
107000
+ if (this.timer !== null) clearTimeout(this.timer);
107001
+ this.timer = null;
107002
+ }
107003
+ async evaluate() {
107004
+ await this.evaluateSuggestion();
107005
+ await this.evaluateUnusedNodes();
107006
+ }
107007
+ async evaluateSuggestion() {
107008
+ const { logger } = this.deps;
107009
+ let manual;
107010
+ let sweep;
107011
+ try {
107012
+ manual = await this.deps.plan(0);
107013
+ sweep = await this.deps.plan(AUTO_REBALANCE_MIN_IMPROVEMENT);
107014
+ } catch (err) {
107015
+ logger.warn("rebalance suggestion: plan failed — alert left as it is", { meta: { error: err instanceof Error ? err.message : String(err) } });
107016
+ return;
107017
+ }
107018
+ const sweepIds = new Set(sweep.moves.map((m) => m.deviceId));
107019
+ const onlyManual = manual.moves.filter((m) => !sweepIds.has(m.deviceId));
107020
+ if (onlyManual.length === 0) {
107021
+ logger.debug("rebalance suggestion: nothing beyond the auto sweep — closing the alert", { meta: {
107022
+ manualMoves: manual.moves.length,
107023
+ sweepMoves: sweep.moves.length
107024
+ } });
107025
+ await this.complete(REBALANCE_SUGGESTED_ALERT_ID);
107026
+ return;
107027
+ }
107028
+ const note = this.lastNote;
107029
+ const cause = note === null ? "The cluster changed" : `Node ${note.nodeId} ${VERB[note.kind]}`;
107030
+ const count = manual.moves.length;
107031
+ const now = this.deps.now();
107032
+ logger.info("rebalance suggestion: raising the alert", { meta: {
107033
+ manualMoves: count,
107034
+ beyondSweep: onlyManual.length
107035
+ } });
107036
+ await this.emit({
107037
+ id: REBALANCE_SUGGESTED_ALERT_ID,
107038
+ category: ALERT_CATEGORY,
107039
+ severity: "warning",
107040
+ title: "Camera placement can be rebalanced",
107041
+ 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.`,
107042
+ action: {
107043
+ label: "Rebalance now",
107044
+ invoke: {
107045
+ addonId: this.deps.addonId,
107046
+ action: "rebalanceNow"
107047
+ }
107048
+ },
107049
+ status: "active",
107050
+ read: false,
107051
+ createdAt: now,
107052
+ updatedAt: now,
107053
+ source: {
107054
+ type: "addon",
107055
+ id: this.deps.addonId
107056
+ }
107057
+ });
107058
+ }
107059
+ async evaluateUnusedNodes() {
107060
+ const enabled = new Set(this.deps.enabledNodes());
107061
+ const unused = new Set(this.deps.runnerNodes().filter((id) => !enabled.has(id)));
107062
+ for (const nodeId of unused) {
107063
+ const id = `${NODE_NOT_USED_ALERT_PREFIX}${nodeId}`;
107064
+ if (this.notUsedRaised.has(id)) continue;
107065
+ this.notUsedRaised.add(id);
107066
+ this.deps.logger.info("rebalance suggestion: node online but not used for detection", { tags: { nodeId } });
107067
+ const now = this.deps.now();
107068
+ await this.emit({
107069
+ id,
107070
+ category: ALERT_CATEGORY,
107071
+ severity: "info",
107072
+ title: "Node is not used for detection",
107073
+ message: `Node ${nodeId} is online but detection is not enabled on it, so no camera will be placed there.`,
107074
+ action: {
107075
+ label: "Open node",
107076
+ href: `/system/pipeline?view=nodes&node=${encodeURIComponent(nodeId)}`,
107077
+ navigation: "app"
107078
+ },
107079
+ status: "active",
107080
+ read: false,
107081
+ createdAt: now,
107082
+ updatedAt: now,
107083
+ source: {
107084
+ type: "node",
107085
+ id: nodeId
107086
+ }
107087
+ });
107088
+ }
107089
+ for (const id of [...this.notUsedRaised]) {
107090
+ const nodeId = id.slice(36);
107091
+ if (unused.has(nodeId)) continue;
107092
+ this.notUsedRaised.delete(id);
107093
+ await this.complete(id);
107094
+ }
107095
+ }
107096
+ async emit(alert) {
107097
+ await this.deps.alerts.emit(alert).catch((err) => {
107098
+ this.deps.logger.warn("rebalance suggestion: could not raise an alert", { meta: {
107099
+ alertId: alert.id,
107100
+ error: err instanceof Error ? err.message : String(err)
107101
+ } });
107102
+ });
107103
+ }
107104
+ async complete(alertId) {
107105
+ await this.deps.alerts.complete(alertId).catch((err) => {
107106
+ this.deps.logger.debug("rebalance suggestion: close failed (alert may not exist)", { meta: {
107107
+ alertId,
107108
+ error: err instanceof Error ? err.message : String(err)
107109
+ } });
107110
+ });
107111
+ }
107112
+ };
107113
+ //#endregion
106918
107114
  //#region src/settings-ui-schemas.ts
106919
107115
  /** Build the addon-level schema sections (cluster roles + crop + balancer + failover). */
106920
107116
  function buildGlobalSettingsSections(options) {
@@ -108711,6 +108907,9 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
108711
108907
  * Constructed right after `loadShed`. Nulled at the end of `onShutdown`.
108712
108908
  */
108713
108909
  placement = null;
108910
+ /** The debounced "placement can be rebalanced" evaluation; see `rebalance-suggestion.ts`. */
108911
+ rebalanceSuggestion = null;
108912
+ unsubSuggestionEvents = [];
108714
108913
  /** EventBus unsubscribes for agent online/offline events. */
108715
108914
  unsubAgentOnline = null;
108716
108915
  unsubAgentOffline = null;
@@ -108984,6 +109183,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
108984
109183
  this.localNodeId = controllers.localNodeId;
108985
109184
  this.unsubAgentOnline = controllers.unsubAgentOnline;
108986
109185
  this.unsubAgentOffline = controllers.unsubAgentOffline;
109186
+ this.startRebalanceSuggestion();
108987
109187
  this.unsubOrchestratorSubscriptions = controllers.unsubOrchestratorSubscriptions;
108988
109188
  this.pendingRetryTimer = controllers.pendingRetryTimer;
108989
109189
  this.autoRebalanceTimer = controllers.autoRebalanceTimer;
@@ -109027,6 +109227,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
109027
109227
  customActions: pipelineOrchestratorActions,
109028
109228
  actionHandlers: {
109029
109229
  dumpState: async () => this.dumpDiagnostics(),
109230
+ rebalanceNow: async () => this.rebalanceNow(),
109030
109231
  nodeStressStats: async (input) => {
109031
109232
  const lts = this.nodeStressLts;
109032
109233
  const moves = this.nodeStress?.historyView() ?? [];
@@ -109109,6 +109310,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
109109
109310
  this.detectionWiring = null;
109110
109311
  this.unsubAgentOffline?.();
109111
109312
  this.unsubAgentOffline = null;
109313
+ this.stopRebalanceSuggestion();
109112
109314
  this.effectiveSharesService?.stop();
109113
109315
  this.unsubAgentOnline?.();
109114
109316
  this.unsubAgentOnline = null;
@@ -109175,6 +109377,94 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
109175
109377
  async rebalance(opts) {
109176
109378
  return this.placement.rebalance(opts);
109177
109379
  }
109380
+ /**
109381
+ * The `rebalanceNow` custom action: a manual rebalance, and the verdict on
109382
+ * the suggestion alert that offered it. Every branch is logged.
109383
+ */
109384
+ async rebalanceNow() {
109385
+ const alertId = REBALANCE_SUGGESTED_ALERT_ID;
109386
+ try {
109387
+ const result = await this.rebalance({ minImprovement: 0 });
109388
+ const message = `${String(result.migrated)} ${result.migrated === 1 ? "camera" : "cameras"} moved`;
109389
+ this.ctx.logger.info("rebalanceNow: done", { meta: {
109390
+ migrated: result.migrated,
109391
+ skipped: result.skipped.length
109392
+ } });
109393
+ await this.ctx.api.alerts.update.mutate({
109394
+ alertId,
109395
+ patch: {
109396
+ status: "completed",
109397
+ message,
109398
+ updatedAt: Date.now()
109399
+ }
109400
+ }).catch((err) => this.ctx.logger.warn("rebalanceNow: could not close the suggestion alert", { meta: { error: errMsg$2(err) } }));
109401
+ return {
109402
+ migrated: result.migrated,
109403
+ skipped: result.skipped.length
109404
+ };
109405
+ } catch (err) {
109406
+ const reason = errMsg$2(err);
109407
+ this.ctx.logger.error("rebalanceNow: failed", { meta: { error: reason } });
109408
+ await this.ctx.api.alerts.update.mutate({
109409
+ alertId,
109410
+ patch: {
109411
+ status: "failed",
109412
+ message: `Rebalance failed: ${reason}`,
109413
+ updatedAt: Date.now()
109414
+ }
109415
+ }).catch((e) => this.ctx.logger.warn("rebalanceNow: could not mark the suggestion alert failed", { meta: { error: errMsg$2(e) } }));
109416
+ throw err;
109417
+ }
109418
+ }
109419
+ startRebalanceSuggestion() {
109420
+ const ctx = this.ctx;
109421
+ const suggestion = new RebalanceSuggestion({
109422
+ addonId: "pipeline-orchestrator",
109423
+ logger: ctx.logger,
109424
+ debounceMs: DEFAULT_SUGGESTION_DEBOUNCE_MS,
109425
+ now: () => Date.now(),
109426
+ plan: (minImprovement) => this.rebalance({
109427
+ minImprovement,
109428
+ dryRun: true
109429
+ }),
109430
+ enabledNodes: () => this.topology?.enabledNodes ?? [],
109431
+ runnerNodes: () => this.topology?.collectRunnerNodeIds() ?? [],
109432
+ alerts: {
109433
+ emit: (alert) => ctx.api.alerts.emit.mutate(alert),
109434
+ complete: (alertId) => ctx.api.alerts.update.mutate({
109435
+ alertId,
109436
+ patch: {
109437
+ status: "completed",
109438
+ updatedAt: Date.now()
109439
+ }
109440
+ })
109441
+ }
109442
+ });
109443
+ this.rebalanceSuggestion = suggestion;
109444
+ this.unsubSuggestionEvents = [
109445
+ ctx.eventBus.subscribe({ category: EventCategory.AgentOnline }, (event) => suggestion.noteNodeEvent({
109446
+ kind: "joined",
109447
+ nodeId: event.data.agentId
109448
+ })),
109449
+ ctx.eventBus.subscribe({ category: EventCategory.AgentOffline }, (event) => suggestion.noteNodeEvent({
109450
+ kind: "left",
109451
+ nodeId: event.data.agentId
109452
+ })),
109453
+ ctx.eventBus.subscribe({ category: EventCategory.AgentUnregistered }, (event) => {
109454
+ const agentId = event.data["agentId"];
109455
+ if (typeof agentId === "string") suggestion.noteNodeEvent({
109456
+ kind: "left",
109457
+ nodeId: agentId
109458
+ });
109459
+ })
109460
+ ];
109461
+ }
109462
+ stopRebalanceSuggestion() {
109463
+ for (const unsub of this.unsubSuggestionEvents) unsub();
109464
+ this.unsubSuggestionEvents = [];
109465
+ this.rebalanceSuggestion?.dispose();
109466
+ this.rebalanceSuggestion = null;
109467
+ }
109178
109468
  getPipelineAssignments() {
109179
109469
  return this.placement.getPipelineAssignments();
109180
109470
  }
@@ -109530,6 +109820,10 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
109530
109820
  this.topology.addKnownRunnerNode(input.agentNodeId);
109531
109821
  this.reconcile.schedulePendingRetry();
109532
109822
  if (input.detect !== void 0) this.noteShareChange(`node-detect:${input.agentNodeId}`);
109823
+ this.rebalanceSuggestion?.noteNodeEvent({
109824
+ kind: "capabilities",
109825
+ nodeId: input.agentNodeId
109826
+ });
109533
109827
  return { success: true };
109534
109828
  }
109535
109829
  /**
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,152 @@ 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
+ timer = null;
106954
+ lastNote = null;
106955
+ notUsedRaised = /* @__PURE__ */ new Set();
106956
+ disposed = false;
106957
+ constructor(deps) {
106958
+ this.deps = deps;
106959
+ }
106960
+ /** A node event: (re)arm the single evaluation. Forked-process ids are not nodes. */
106961
+ noteNodeEvent(note) {
106962
+ if (this.disposed || note.nodeId.includes("/")) return;
106963
+ this.lastNote = note;
106964
+ if (this.timer !== null) clearTimeout(this.timer);
106965
+ this.timer = setTimeout(() => {
106966
+ this.timer = null;
106967
+ this.evaluate();
106968
+ }, this.deps.debounceMs);
106969
+ }
106970
+ dispose() {
106971
+ this.disposed = true;
106972
+ if (this.timer !== null) clearTimeout(this.timer);
106973
+ this.timer = null;
106974
+ }
106975
+ async evaluate() {
106976
+ await this.evaluateSuggestion();
106977
+ await this.evaluateUnusedNodes();
106978
+ }
106979
+ async evaluateSuggestion() {
106980
+ const { logger } = this.deps;
106981
+ let manual;
106982
+ let sweep;
106983
+ try {
106984
+ manual = await this.deps.plan(0);
106985
+ sweep = await this.deps.plan(AUTO_REBALANCE_MIN_IMPROVEMENT);
106986
+ } catch (err) {
106987
+ logger.warn("rebalance suggestion: plan failed — alert left as it is", { meta: { error: err instanceof Error ? err.message : String(err) } });
106988
+ return;
106989
+ }
106990
+ const sweepIds = new Set(sweep.moves.map((m) => m.deviceId));
106991
+ const onlyManual = manual.moves.filter((m) => !sweepIds.has(m.deviceId));
106992
+ if (onlyManual.length === 0) {
106993
+ logger.debug("rebalance suggestion: nothing beyond the auto sweep — closing the alert", { meta: {
106994
+ manualMoves: manual.moves.length,
106995
+ sweepMoves: sweep.moves.length
106996
+ } });
106997
+ await this.complete(REBALANCE_SUGGESTED_ALERT_ID);
106998
+ return;
106999
+ }
107000
+ const note = this.lastNote;
107001
+ const cause = note === null ? "The cluster changed" : `Node ${note.nodeId} ${VERB[note.kind]}`;
107002
+ const count = manual.moves.length;
107003
+ const now = this.deps.now();
107004
+ logger.info("rebalance suggestion: raising the alert", { meta: {
107005
+ manualMoves: count,
107006
+ beyondSweep: onlyManual.length
107007
+ } });
107008
+ await this.emit({
107009
+ id: REBALANCE_SUGGESTED_ALERT_ID,
107010
+ category: ALERT_CATEGORY,
107011
+ severity: "warning",
107012
+ title: "Camera placement can be rebalanced",
107013
+ 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.`,
107014
+ action: {
107015
+ label: "Rebalance now",
107016
+ invoke: {
107017
+ addonId: this.deps.addonId,
107018
+ action: "rebalanceNow"
107019
+ }
107020
+ },
107021
+ status: "active",
107022
+ read: false,
107023
+ createdAt: now,
107024
+ updatedAt: now,
107025
+ source: {
107026
+ type: "addon",
107027
+ id: this.deps.addonId
107028
+ }
107029
+ });
107030
+ }
107031
+ async evaluateUnusedNodes() {
107032
+ const enabled = new Set(this.deps.enabledNodes());
107033
+ const unused = new Set(this.deps.runnerNodes().filter((id) => !enabled.has(id)));
107034
+ for (const nodeId of unused) {
107035
+ const id = `${NODE_NOT_USED_ALERT_PREFIX}${nodeId}`;
107036
+ if (this.notUsedRaised.has(id)) continue;
107037
+ this.notUsedRaised.add(id);
107038
+ this.deps.logger.info("rebalance suggestion: node online but not used for detection", { tags: { nodeId } });
107039
+ const now = this.deps.now();
107040
+ await this.emit({
107041
+ id,
107042
+ category: ALERT_CATEGORY,
107043
+ severity: "info",
107044
+ title: "Node is not used for detection",
107045
+ message: `Node ${nodeId} is online but detection is not enabled on it, so no camera will be placed there.`,
107046
+ action: {
107047
+ label: "Open node",
107048
+ href: `/system/pipeline?view=nodes&node=${encodeURIComponent(nodeId)}`,
107049
+ navigation: "app"
107050
+ },
107051
+ status: "active",
107052
+ read: false,
107053
+ createdAt: now,
107054
+ updatedAt: now,
107055
+ source: {
107056
+ type: "node",
107057
+ id: nodeId
107058
+ }
107059
+ });
107060
+ }
107061
+ for (const id of [...this.notUsedRaised]) {
107062
+ const nodeId = id.slice(36);
107063
+ if (unused.has(nodeId)) continue;
107064
+ this.notUsedRaised.delete(id);
107065
+ await this.complete(id);
107066
+ }
107067
+ }
107068
+ async emit(alert) {
107069
+ await this.deps.alerts.emit(alert).catch((err) => {
107070
+ this.deps.logger.warn("rebalance suggestion: could not raise an alert", { meta: {
107071
+ alertId: alert.id,
107072
+ error: err instanceof Error ? err.message : String(err)
107073
+ } });
107074
+ });
107075
+ }
107076
+ async complete(alertId) {
107077
+ await this.deps.alerts.complete(alertId).catch((err) => {
107078
+ this.deps.logger.debug("rebalance suggestion: close failed (alert may not exist)", { meta: {
107079
+ alertId,
107080
+ error: err instanceof Error ? err.message : String(err)
107081
+ } });
107082
+ });
107083
+ }
107084
+ };
107085
+ //#endregion
106890
107086
  //#region src/settings-ui-schemas.ts
106891
107087
  /** Build the addon-level schema sections (cluster roles + crop + balancer + failover). */
106892
107088
  function buildGlobalSettingsSections(options) {
@@ -108683,6 +108879,9 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
108683
108879
  * Constructed right after `loadShed`. Nulled at the end of `onShutdown`.
108684
108880
  */
108685
108881
  placement = null;
108882
+ /** The debounced "placement can be rebalanced" evaluation; see `rebalance-suggestion.ts`. */
108883
+ rebalanceSuggestion = null;
108884
+ unsubSuggestionEvents = [];
108686
108885
  /** EventBus unsubscribes for agent online/offline events. */
108687
108886
  unsubAgentOnline = null;
108688
108887
  unsubAgentOffline = null;
@@ -108956,6 +109155,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
108956
109155
  this.localNodeId = controllers.localNodeId;
108957
109156
  this.unsubAgentOnline = controllers.unsubAgentOnline;
108958
109157
  this.unsubAgentOffline = controllers.unsubAgentOffline;
109158
+ this.startRebalanceSuggestion();
108959
109159
  this.unsubOrchestratorSubscriptions = controllers.unsubOrchestratorSubscriptions;
108960
109160
  this.pendingRetryTimer = controllers.pendingRetryTimer;
108961
109161
  this.autoRebalanceTimer = controllers.autoRebalanceTimer;
@@ -108999,6 +109199,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
108999
109199
  customActions: pipelineOrchestratorActions,
109000
109200
  actionHandlers: {
109001
109201
  dumpState: async () => this.dumpDiagnostics(),
109202
+ rebalanceNow: async () => this.rebalanceNow(),
109002
109203
  nodeStressStats: async (input) => {
109003
109204
  const lts = this.nodeStressLts;
109004
109205
  const moves = this.nodeStress?.historyView() ?? [];
@@ -109081,6 +109282,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
109081
109282
  this.detectionWiring = null;
109082
109283
  this.unsubAgentOffline?.();
109083
109284
  this.unsubAgentOffline = null;
109285
+ this.stopRebalanceSuggestion();
109084
109286
  this.effectiveSharesService?.stop();
109085
109287
  this.unsubAgentOnline?.();
109086
109288
  this.unsubAgentOnline = null;
@@ -109147,6 +109349,94 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
109147
109349
  async rebalance(opts) {
109148
109350
  return this.placement.rebalance(opts);
109149
109351
  }
109352
+ /**
109353
+ * The `rebalanceNow` custom action: a manual rebalance, and the verdict on
109354
+ * the suggestion alert that offered it. Every branch is logged.
109355
+ */
109356
+ async rebalanceNow() {
109357
+ const alertId = REBALANCE_SUGGESTED_ALERT_ID;
109358
+ try {
109359
+ const result = await this.rebalance({ minImprovement: 0 });
109360
+ const message = `${String(result.migrated)} ${result.migrated === 1 ? "camera" : "cameras"} moved`;
109361
+ this.ctx.logger.info("rebalanceNow: done", { meta: {
109362
+ migrated: result.migrated,
109363
+ skipped: result.skipped.length
109364
+ } });
109365
+ await this.ctx.api.alerts.update.mutate({
109366
+ alertId,
109367
+ patch: {
109368
+ status: "completed",
109369
+ message,
109370
+ updatedAt: Date.now()
109371
+ }
109372
+ }).catch((err) => this.ctx.logger.warn("rebalanceNow: could not close the suggestion alert", { meta: { error: errMsg$2(err) } }));
109373
+ return {
109374
+ migrated: result.migrated,
109375
+ skipped: result.skipped.length
109376
+ };
109377
+ } catch (err) {
109378
+ const reason = errMsg$2(err);
109379
+ this.ctx.logger.error("rebalanceNow: failed", { meta: { error: reason } });
109380
+ await this.ctx.api.alerts.update.mutate({
109381
+ alertId,
109382
+ patch: {
109383
+ status: "failed",
109384
+ message: `Rebalance failed: ${reason}`,
109385
+ updatedAt: Date.now()
109386
+ }
109387
+ }).catch((e) => this.ctx.logger.warn("rebalanceNow: could not mark the suggestion alert failed", { meta: { error: errMsg$2(e) } }));
109388
+ throw err;
109389
+ }
109390
+ }
109391
+ startRebalanceSuggestion() {
109392
+ const ctx = this.ctx;
109393
+ const suggestion = new RebalanceSuggestion({
109394
+ addonId: "pipeline-orchestrator",
109395
+ logger: ctx.logger,
109396
+ debounceMs: DEFAULT_SUGGESTION_DEBOUNCE_MS,
109397
+ now: () => Date.now(),
109398
+ plan: (minImprovement) => this.rebalance({
109399
+ minImprovement,
109400
+ dryRun: true
109401
+ }),
109402
+ enabledNodes: () => this.topology?.enabledNodes ?? [],
109403
+ runnerNodes: () => this.topology?.collectRunnerNodeIds() ?? [],
109404
+ alerts: {
109405
+ emit: (alert) => ctx.api.alerts.emit.mutate(alert),
109406
+ complete: (alertId) => ctx.api.alerts.update.mutate({
109407
+ alertId,
109408
+ patch: {
109409
+ status: "completed",
109410
+ updatedAt: Date.now()
109411
+ }
109412
+ })
109413
+ }
109414
+ });
109415
+ this.rebalanceSuggestion = suggestion;
109416
+ this.unsubSuggestionEvents = [
109417
+ ctx.eventBus.subscribe({ category: EventCategory.AgentOnline }, (event) => suggestion.noteNodeEvent({
109418
+ kind: "joined",
109419
+ nodeId: event.data.agentId
109420
+ })),
109421
+ ctx.eventBus.subscribe({ category: EventCategory.AgentOffline }, (event) => suggestion.noteNodeEvent({
109422
+ kind: "left",
109423
+ nodeId: event.data.agentId
109424
+ })),
109425
+ ctx.eventBus.subscribe({ category: EventCategory.AgentUnregistered }, (event) => {
109426
+ const agentId = event.data["agentId"];
109427
+ if (typeof agentId === "string") suggestion.noteNodeEvent({
109428
+ kind: "left",
109429
+ nodeId: agentId
109430
+ });
109431
+ })
109432
+ ];
109433
+ }
109434
+ stopRebalanceSuggestion() {
109435
+ for (const unsub of this.unsubSuggestionEvents) unsub();
109436
+ this.unsubSuggestionEvents = [];
109437
+ this.rebalanceSuggestion?.dispose();
109438
+ this.rebalanceSuggestion = null;
109439
+ }
109150
109440
  getPipelineAssignments() {
109151
109441
  return this.placement.getPipelineAssignments();
109152
109442
  }
@@ -109502,6 +109792,10 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
109502
109792
  this.topology.addKnownRunnerNode(input.agentNodeId);
109503
109793
  this.reconcile.schedulePendingRetry();
109504
109794
  if (input.detect !== void 0) this.noteShareChange(`node-detect:${input.agentNodeId}`);
109795
+ this.rebalanceSuggestion?.noteNodeEvent({
109796
+ kind: "capabilities",
109797
+ nodeId: input.agentNodeId
109798
+ });
109505
109799
  return { success: true };
109506
109800
  }
109507
109801
  /**
@@ -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-3S_aIDgV.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.637",
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",