diff --git a/packages/verae-fleet/public/index.html b/packages/verae-fleet/public/index.html
index 460731f..263cd1d 100644
--- a/packages/verae-fleet/public/index.html
+++ b/packages/verae-fleet/public/index.html
@@ -15,10 +15,13 @@
table { width:100%; border-collapse:collapse; background:#fff; }
th, td { text-align:left; padding:.4rem .5rem; border-bottom:1px solid var(--line); font-size:13px; }
th { font-size:11px; letter-spacing:.06em; text-transform:uppercase; color:var(--muted); }
+ tr.state-operational { background:#e3f6e8; }
+ tr.state-degraded { background:#fff3bf; }
+ tr.state-down { background:#f9d4d4; }
.pill { font:700 10px system-ui; letter-spacing:.06em; text-transform:uppercase; padding:.12rem .35rem; border-radius:4px; }
- .good { background:#d4efe6; color:var(--ok); }
- .bad { background:#f8d4d4; color:var(--err); }
- .warn { background:#f5e6c8; color:var(--warn); }
+ .good { background:#c8efd4; color:var(--ok); }
+ .bad { background:#f3c0c0; color:var(--err); }
+ .warn { background:#ffe08a; color:var(--warn); }
button { margin-right:.25rem; font:650 12px system-ui; border:1px solid var(--line); background:#fff; border-radius:5px; padding:.25rem .45rem; cursor:pointer; }
code { font:12px ui-monospace,Menlo,monospace; }
.inst { font:12px ui-monospace,Menlo,monospace; margin:.15rem 0; }
@@ -32,7 +35,7 @@
- | Service | Config | min/max | available | instances | actions |
+ | Service | State | Config | min/max | available | instances | actions |
@@ -44,6 +47,9 @@ async function draw() {
document.getElementById('meta').textContent = 'probe ' + (s.monitor?.t || '—') + ' · NATS ' + (s.nats?.url || '');
const tb = document.getElementById('rows');
tb.innerHTML = Object.values(s.services).map((sv) => {
+ const health = sv.health || 'down';
+ const healthLabel = health === 'operational' ? 'operational' : health === 'degraded' ? 'degraded' : 'not operational';
+ const healthCls = health === 'operational' ? 'good' : health === 'degraded' ? 'warn' : 'bad';
const floor = sv.belowFloor ? 'below floor' : (sv.keepFloor ? 'floor ok' : '');
const inst = (sv.instances || []).map((i) => {
const st = !i.pid ? 'dead' : i.paused ? 'paused' : i.healthy === false ? 'unhealthy' : 'up';
@@ -54,8 +60,9 @@ async function draw() {
`;
}).join('') || 'none';
- return `
+ return `
${sv.id} ${sv.title || ''} |
+ ${healthLabel} |
${sv.configPath || ''} |
${sv.min} / ${sv.max} ${floor} |
${sv.available} running=${sv.running} paused=${sv.paused} |
diff --git a/packages/verae-fleet/src/classify.js b/packages/verae-fleet/src/classify.js
new file mode 100644
index 0000000..3313b7a
--- /dev/null
+++ b/packages/verae-fleet/src/classify.js
@@ -0,0 +1,29 @@
+/**
+ * Row health: operational | degraded | down
+ * @module classify
+ */
+
+/**
+ * @param {{
+ * min?: number,
+ * available?: number,
+ * running?: number,
+ * paused?: number,
+ * managed?: boolean,
+ * instances?: Array<{ pid?: number|null, paused?: boolean, healthy?: boolean }>
+ * }} sv
+ * @returns {'operational'|'degraded'|'down'}
+ */
+export function classifyService(sv) {
+ const min = sv.min ?? 0;
+ const available = sv.available ?? 0;
+ const running = sv.running ?? 0;
+ const paused = sv.paused ?? 0;
+ const inst = sv.instances || [];
+ const unhealthy = inst.filter((i) => i.pid && i.healthy === false && !i.paused).length;
+
+ if (min === 0 && running === 0) return 'operational';
+ if (available === 0 && min > 0) return 'down';
+ if (available >= min && paused === 0 && unhealthy === 0) return 'operational';
+ return 'degraded';
+}
diff --git a/packages/verae-fleet/src/supervisor.js b/packages/verae-fleet/src/supervisor.js
index 94ec6c1..81f6d00 100644
--- a/packages/verae-fleet/src/supervisor.js
+++ b/packages/verae-fleet/src/supervisor.js
@@ -9,6 +9,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadFleet } from './load.js';
import { httpProbe, postControl } from './health.js';
+import { classifyService } from './classify.js';
const WORKER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'worker.js');
@@ -284,6 +285,7 @@ export class Supervisor {
restarts: i.restarts,
})),
};
+ services[spec.id].health = classifyService(services[spec.id]);
}
return {
control: this.loaded.control,
diff --git a/packages/verae-fleet/test/classify.test.js b/packages/verae-fleet/test/classify.test.js
new file mode 100644
index 0000000..7b5b083
--- /dev/null
+++ b/packages/verae-fleet/test/classify.test.js
@@ -0,0 +1,36 @@
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { classifyService } from '../src/classify.js';
+
+describe('classifyService', () => {
+ it('operational when available meets min and none paused', () => {
+ assert.equal(
+ classifyService({ min: 3, available: 3, running: 3, paused: 0, instances: [{ pid: 1, healthy: true }] }),
+ 'operational',
+ );
+ });
+
+ it('degraded when a replica is paused even if floor is still met', () => {
+ assert.equal(
+ classifyService({
+ min: 3,
+ available: 3,
+ running: 4,
+ paused: 1,
+ instances: [
+ { pid: 1, paused: true, healthy: false },
+ { pid: 2, paused: false, healthy: true },
+ ],
+ }),
+ 'degraded',
+ );
+ });
+
+ it('not operational when min>0 and nothing available', () => {
+ assert.equal(classifyService({ min: 1, available: 0, running: 0, paused: 0, instances: [] }), 'down');
+ });
+
+ it('optional idle (min 0, not running) is operational', () => {
+ assert.equal(classifyService({ min: 0, available: 0, running: 0, paused: 0 }), 'operational');
+ });
+});