Color fleet monitor rows: green operational, yellow degraded, red down
Some checks are pending
offline / test (push) Waiting to run

Paused or unhealthy replicas mark the service degraded even if the
tree-node floor is still met. Unmanaged NATS with no process is down.
This commit is contained in:
George Lambert 2026-09-11 13:15:52 -04:00
parent dd99ce1c64
commit 6fbc808bb1
4 changed files with 79 additions and 5 deletions

View file

@ -15,10 +15,13 @@
table { width:100%; border-collapse:collapse; background:#fff; } 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, 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); } 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; } .pill { font:700 10px system-ui; letter-spacing:.06em; text-transform:uppercase; padding:.12rem .35rem; border-radius:4px; }
.good { background:#d4efe6; color:var(--ok); } .good { background:#c8efd4; color:var(--ok); }
.bad { background:#f8d4d4; color:var(--err); } .bad { background:#f3c0c0; color:var(--err); }
.warn { background:#f5e6c8; color:var(--warn); } .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; } 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; } code { font:12px ui-monospace,Menlo,monospace; }
.inst { font:12px ui-monospace,Menlo,monospace; margin:.15rem 0; } .inst { font:12px ui-monospace,Menlo,monospace; margin:.15rem 0; }
@ -32,7 +35,7 @@
<main> <main>
<p id="meta"></p> <p id="meta"></p>
<table> <table>
<thead><tr><th>Service</th><th>Config</th><th>min/max</th><th>available</th><th>instances</th><th>actions</th></tr></thead> <thead><tr><th>Service</th><th>State</th><th>Config</th><th>min/max</th><th>available</th><th>instances</th><th>actions</th></tr></thead>
<tbody id="rows"></tbody> <tbody id="rows"></tbody>
</table> </table>
</main> </main>
@ -44,6 +47,9 @@ async function draw() {
document.getElementById('meta').textContent = 'probe ' + (s.monitor?.t || '—') + ' · NATS ' + (s.nats?.url || ''); document.getElementById('meta').textContent = 'probe ' + (s.monitor?.t || '—') + ' · NATS ' + (s.nats?.url || '');
const tb = document.getElementById('rows'); const tb = document.getElementById('rows');
tb.innerHTML = Object.values(s.services).map((sv) => { 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 ? '<span class="pill bad">below floor</span>' : (sv.keepFloor ? '<span class="pill good">floor ok</span>' : ''); const floor = sv.belowFloor ? '<span class="pill bad">below floor</span>' : (sv.keepFloor ? '<span class="pill good">floor ok</span>' : '');
const inst = (sv.instances || []).map((i) => { const inst = (sv.instances || []).map((i) => {
const st = !i.pid ? 'dead' : i.paused ? 'paused' : i.healthy === false ? 'unhealthy' : 'up'; const st = !i.pid ? 'dead' : i.paused ? 'paused' : i.healthy === false ? 'unhealthy' : 'up';
@ -54,8 +60,9 @@ async function draw() {
<button onclick="act('/api/instances/${i.id}/restart')">restart</button> <button onclick="act('/api/instances/${i.id}/restart')">restart</button>
</div>`; </div>`;
}).join('') || '<span style="color:#5b6d78">none</span>'; }).join('') || '<span style="color:#5b6d78">none</span>';
return `<tr> return `<tr class="state-${health}">
<td><strong>${sv.id}</strong><br><span style="color:#5b6d78">${sv.title || ''}</span></td> <td><strong>${sv.id}</strong><br><span style="color:#5b6d78">${sv.title || ''}</span></td>
<td><span class="pill ${healthCls}">${healthLabel}</span></td>
<td><code>${sv.configPath || ''}</code></td> <td><code>${sv.configPath || ''}</code></td>
<td>${sv.min} / ${sv.max} ${floor}</td> <td>${sv.min} / ${sv.max} ${floor}</td>
<td>${sv.available} running=${sv.running} paused=${sv.paused}</td> <td>${sv.available} running=${sv.running} paused=${sv.paused}</td>

View file

@ -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';
}

View file

@ -9,6 +9,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { loadFleet } from './load.js'; import { loadFleet } from './load.js';
import { httpProbe, postControl } from './health.js'; import { httpProbe, postControl } from './health.js';
import { classifyService } from './classify.js';
const WORKER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'worker.js'); const WORKER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'worker.js');
@ -284,6 +285,7 @@ export class Supervisor {
restarts: i.restarts, restarts: i.restarts,
})), })),
}; };
services[spec.id].health = classifyService(services[spec.id]);
} }
return { return {
control: this.loaded.control, control: this.loaded.control,

View file

@ -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');
});
});