@celilo/cli 0.21.0 → 0.22.0
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/CELILO_CORE_MODULES.md +5 -4
- package/package.json +4 -4
- package/src/cli/commands/events.test.ts +66 -0
- package/src/cli/commands/events.ts +72 -0
- package/src/cli/completion.ts +1 -0
- package/src/cli/index.ts +4 -0
- package/src/services/dns-registrations.ts +12 -0
- package/src/services/fleet-checks.test.ts +46 -0
- package/src/services/fleet-checks.ts +15 -6
package/CELILO_CORE_MODULES.md
CHANGED
|
@@ -14,19 +14,20 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
|
|
|
14
14
|
> the `@celilo/cli` npm package). Re-grep manifests if an entry looks stale.
|
|
15
15
|
|
|
16
16
|
> How the graph closes: a public app (`requires: public_web`) is served by **caddy**, which
|
|
17
|
-
> `requires: dns_registrar` (**namecheap**) to publish its name and `firewall` (**
|
|
18
|
-
> **iptables**) to open the port. Internal name resolution comes from a **dns_internal** provider
|
|
17
|
+
> `requires: dns_registrar` (**namecheap**) to publish its name and `firewall` (**axon** /
|
|
18
|
+
> **greenwave** / **iptables**) to open the port. Internal name resolution comes from a **dns_internal** provider
|
|
19
19
|
> (**knot-unbound-internal** / **technitium**). Identity comes from **authentik** (`idp`). That's
|
|
20
20
|
> the whole edge: DNS + firewall + ingress + identity, each a swappable provider module.
|
|
21
21
|
|
|
22
22
|
## Network fabric (DNS / firewall / DHCP)
|
|
23
23
|
|
|
24
|
-
- **
|
|
24
|
+
- **axon** — Axon Networks Q1000K ISP router driver (Brightspeed-branded); port-forwarding + public-IP discovery + DHCP DNS via the TR-181 CGI API. **provides:** `firewall`, `dhcp_server`. Fork of **greenwave** — identical protocol, differing only in the vendor extension prefix (`X_AXON_` vs `X_GWS_`/`X_LANTIQ_COM_`). Pick by device: Q1000K → **axon**, C4000XG → **greenwave**.
|
|
25
|
+
- **greenwave** — GreenWave C4000XG ISP router driver; port-forwarding + public-IP discovery via REST. **provides:** `firewall`, `dhcp_server`. Legacy device; new deployments on Axon hardware want **axon**.
|
|
25
26
|
- **iptables** — iptables firewall + NAT; cross-VLAN port exposure with recursive upstream delegation. Converge model: `exposeService` registers into the shared-core port-forward registry, then renders the complete ruleset and applies it atomically via `iptables-restore` (default-DROP FORWARD + coarse zone-tier matrix; SSH-free). **provides:** `firewall`.
|
|
26
27
|
- **knot-unbound-internal** — split-horizon internal DNS via Knot (authoritative) + Unbound (recursive); lightweight, plain apt, no .NET. **provides:** `dns_internal`. Ships a base-module-aspect (`modules/knot-unbound-internal/base-module-aspect/`).
|
|
27
28
|
- **technitium** — internal split-horizon DNS resolver + authoritative server (web UI + HTTP API); heavier alternative to knot-unbound. **provides:** `dns_internal`. Ships a base-module-aspect (`modules/technitium/base-module-aspect/`).
|
|
28
29
|
- **namecheap** — public DNS A-record management via Namecheap Dynamic DNS API (HTTP, no browser automation). A caller supplies a NAME and nothing else: the address is the source IP of celilo's own update, re-derived on every assert. Registering `<domain>` also claims `www.<domain>` and vice versa (best effort, reported back as `outputs.companion_fqdn` so the framework's `public_dns` check watches it — Namecheap answers `ErrCount 0` for `www` updates it does not apply). DDNS passwords are keyed by the **registrable domain**, never the FQDN. **provides:** `dns_registrar`.
|
|
29
|
-
- **wireguard** — owns the admin WireGuard tunnel on the firewall host: interface, listen port, peers (as records), and client subnet are module config rather than hand-maintained state. Exposes the listen port and **registers the client subnet as a trusted source**, so VPN reach into the managed zones is in the firewall registry and every converge re-emits it. Records `network.control-plane-vpn.subnet`, which the internal resolver's split-horizon view also consumes. Adopts a running tunnel in place (existing key and peers retained; `wg syncconf`, never `wg-quick down`) because that tunnel is the operator's recovery path. **requires:** `firewall` (and the provider must support trusted-source registration — `iptables` does
|
|
30
|
+
- **wireguard** — owns the admin WireGuard tunnel on the firewall host: interface, listen port, peers (as records), and client subnet are module config rather than hand-maintained state. Exposes the listen port and **registers the client subnet as a trusted source**, so VPN reach into the managed zones is in the firewall registry and every converge re-emits it. Records `network.control-plane-vpn.subnet`, which the internal resolver's split-horizon view also consumes. Adopts a running tunnel in place (existing key and peers retained; `wg syncconf`, never `wg-quick down`) because that tunnel is the operator's recovery path. **requires:** `firewall` (and the provider must support trusted-source registration — `iptables` does; the ISP-router drivers `greenwave` and `axon` do not).
|
|
30
31
|
|
|
31
32
|
## Public edge (ingress / identity)
|
|
32
33
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -57,10 +57,10 @@
|
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@aws-sdk/client-s3": "^3.1024.0",
|
|
60
|
-
"@celilo/capabilities": "^1.0.
|
|
60
|
+
"@celilo/capabilities": "^1.0.1",
|
|
61
61
|
"@celilo/cli-display": "^0.1.10",
|
|
62
|
-
"@celilo/core": "^0.
|
|
63
|
-
"@celilo/event-bus": "^0.
|
|
62
|
+
"@celilo/core": "^0.6.0",
|
|
63
|
+
"@celilo/event-bus": "^0.4.0",
|
|
64
64
|
"@clack/prompts": "^1.1.0",
|
|
65
65
|
"ajv": "^8.18.0",
|
|
66
66
|
"drizzle-orm": "^0.36.4",
|
|
@@ -4,10 +4,12 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { defineEvents, openBus } from '@celilo/event-bus';
|
|
6
6
|
import {
|
|
7
|
+
type FailedDeliveryReport,
|
|
7
8
|
handleEventsAck,
|
|
8
9
|
handleEventsDrain,
|
|
9
10
|
handleEventsEmit,
|
|
10
11
|
handleEventsFail,
|
|
12
|
+
handleEventsListFailed,
|
|
11
13
|
handleEventsListPending,
|
|
12
14
|
handleEventsListSubscribers,
|
|
13
15
|
handleEventsRepair,
|
|
@@ -262,3 +264,67 @@ describe('celilo events command handlers', () => {
|
|
|
262
264
|
expect(replies[0].payload).toEqual({ value: 'a.net' });
|
|
263
265
|
});
|
|
264
266
|
});
|
|
267
|
+
|
|
268
|
+
describe('celilo events list-failed', () => {
|
|
269
|
+
let dir: string;
|
|
270
|
+
let dbPath: string;
|
|
271
|
+
|
|
272
|
+
beforeEach(() => {
|
|
273
|
+
dir = mkdtempSync(join(tmpdir(), 'events-failed-test-'));
|
|
274
|
+
dbPath = join(dir, 'events.db');
|
|
275
|
+
process.env.EVENT_BUS_DB = dbPath;
|
|
276
|
+
const bus = openBus({ dbPath, events: defineEvents({}) });
|
|
277
|
+
const sub = bus.subscribe({ name: 'namecheap.ddns', pattern: 'ddns.*', handler: 'echo' });
|
|
278
|
+
for (let i = 0; i < 60; i++) {
|
|
279
|
+
const event = bus.emitRaw('ddns.refresh', { n: i });
|
|
280
|
+
bus.markFailed({ eventId: event.id, subscriberId: sub.id }, new Error('boom'), {
|
|
281
|
+
abandoned: true,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
bus.close();
|
|
285
|
+
});
|
|
286
|
+
afterEach(() => {
|
|
287
|
+
process.env.EVENT_BUS_DB = undefined;
|
|
288
|
+
try {
|
|
289
|
+
rmSync(dir, { recursive: true, force: true });
|
|
290
|
+
} catch {
|
|
291
|
+
/* ignore */
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// The whole point of the command: `total` is a COUNT, `shown` is the limit.
|
|
296
|
+
it('separates the true total from the capped sample', async () => {
|
|
297
|
+
const result = await handleEventsListFailed([], { limit: '5' });
|
|
298
|
+
expect(result.success).toBe(true);
|
|
299
|
+
if (!result.success) throw new Error('expected success');
|
|
300
|
+
const report = result.data as FailedDeliveryReport;
|
|
301
|
+
expect(report.total).toBe(60);
|
|
302
|
+
expect(report.shown).toBe(5);
|
|
303
|
+
expect(report.deliveries).toHaveLength(5);
|
|
304
|
+
expect(report.bySubscriber).toEqual([
|
|
305
|
+
{ subscriber: 'namecheap.ddns', count: 60, latestFinishedAt: expect.any(Number) },
|
|
306
|
+
]);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('names the subscriber and event type on every row', async () => {
|
|
310
|
+
const result = await handleEventsListFailed([], { limit: '1' });
|
|
311
|
+
if (!result.success) throw new Error('expected success');
|
|
312
|
+
const row = (result.data as FailedDeliveryReport).deliveries[0];
|
|
313
|
+
expect(row.subscriber).toBe('namecheap.ddns');
|
|
314
|
+
expect(row.eventType).toBe('ddns.refresh');
|
|
315
|
+
expect(row.status).toBe('abandoned');
|
|
316
|
+
expect(row.error).toBe('boom');
|
|
317
|
+
expect(row.finishedAt).toBeGreaterThan(0);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('--subscriber scopes both the rows and the total', async () => {
|
|
321
|
+
const mine = await handleEventsListFailed([], { subscriber: 'namecheap.ddns', limit: '3' });
|
|
322
|
+
if (!mine.success) throw new Error('expected success');
|
|
323
|
+
expect((mine.data as FailedDeliveryReport).total).toBe(60);
|
|
324
|
+
|
|
325
|
+
const other = await handleEventsListFailed([], { subscriber: 'nobody' });
|
|
326
|
+
if (!other.success) throw new Error('expected success');
|
|
327
|
+
expect((other.data as FailedDeliveryReport).total).toBe(0);
|
|
328
|
+
expect((other.data as FailedDeliveryReport).deliveries).toHaveLength(0);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* tail recent events
|
|
11
11
|
* list-subscribers persistent subscribers
|
|
12
12
|
* list-pending pending deliveries (subscriber fan-out — NOT questions)
|
|
13
|
+
* list-failed failed/abandoned deliveries + a TRUE total
|
|
13
14
|
* list-unanswered interview questions nobody has answered yet
|
|
14
15
|
* drain process pending deliveries once
|
|
15
16
|
* run long-running dispatcher (foreground; SIGINT to stop)
|
|
@@ -24,7 +25,9 @@
|
|
|
24
25
|
import { spawnSync } from 'node:child_process';
|
|
25
26
|
import {
|
|
26
27
|
BUS_VERSION,
|
|
28
|
+
type FailedBySubscriber,
|
|
27
29
|
defineEvents,
|
|
30
|
+
describeError,
|
|
28
31
|
drainOnce,
|
|
29
32
|
openBus,
|
|
30
33
|
recoverFromCrash,
|
|
@@ -246,6 +249,75 @@ export async function handleEventsListPending(
|
|
|
246
249
|
}
|
|
247
250
|
}
|
|
248
251
|
|
|
252
|
+
/** One failed/abandoned delivery, as `events list-failed` reports it. */
|
|
253
|
+
export interface FailedDelivery {
|
|
254
|
+
eventId: number;
|
|
255
|
+
eventType: string | null;
|
|
256
|
+
subscriber: string | null;
|
|
257
|
+
status: string;
|
|
258
|
+
attempts: number;
|
|
259
|
+
finishedAt: number | null;
|
|
260
|
+
error: string | null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** What `events list-failed` returns: the true total, the shape, and a sample. */
|
|
264
|
+
export interface FailedDeliveryReport {
|
|
265
|
+
total: number;
|
|
266
|
+
bySubscriber: FailedBySubscriber[];
|
|
267
|
+
shown: number;
|
|
268
|
+
deliveries: FailedDelivery[];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* `celilo events list-failed` — what has failed or been abandoned, who it was
|
|
273
|
+
* for, and why.
|
|
274
|
+
*
|
|
275
|
+
* The instrument celilo#623 lacked: `failedDeliveries` had exactly one caller
|
|
276
|
+
* (the doctor) and no operator surface at all, so the only signal was a count
|
|
277
|
+
* that was really a LIMIT. `total` here is a real COUNT — `deliveries` is the
|
|
278
|
+
* sample, capped by `--limit`, and `shown` says so.
|
|
279
|
+
*
|
|
280
|
+
* This is NOT `list-pending`, which reads deliveries still queued.
|
|
281
|
+
*/
|
|
282
|
+
export async function handleEventsListFailed(
|
|
283
|
+
_args: string[],
|
|
284
|
+
flags: Record<string, string | boolean>,
|
|
285
|
+
): Promise<CommandResult> {
|
|
286
|
+
const bus = openCliBus();
|
|
287
|
+
try {
|
|
288
|
+
const subscriber = typeof flags.subscriber === 'string' ? flags.subscriber : undefined;
|
|
289
|
+
const limit = flags.limit ? Number(flags.limit) : 50;
|
|
290
|
+
const { total, bySubscriber } = bus.failedDeliveryTotals();
|
|
291
|
+
// Subscriber/event names live in other tables; the delivery row has ids.
|
|
292
|
+
const named = new Map(
|
|
293
|
+
bus.db
|
|
294
|
+
.query<{ id: number; name: string }, []>('SELECT id, name FROM subscribers')
|
|
295
|
+
.all()
|
|
296
|
+
.map((r) => [r.id, r.name]),
|
|
297
|
+
);
|
|
298
|
+
const deliveries: FailedDelivery[] = bus.failedDeliveries({ limit, subscriber }).map((d) => ({
|
|
299
|
+
eventId: d.eventId,
|
|
300
|
+
eventType: bus.getEvent(d.eventId)?.type ?? null,
|
|
301
|
+
subscriber: named.get(d.subscriberId) ?? null,
|
|
302
|
+
status: d.status,
|
|
303
|
+
attempts: d.attempts,
|
|
304
|
+
finishedAt: d.finishedAt,
|
|
305
|
+
error: describeError(d.lastError),
|
|
306
|
+
}));
|
|
307
|
+
const report: FailedDeliveryReport = {
|
|
308
|
+
total: subscriber
|
|
309
|
+
? (bySubscriber.find((s) => s.subscriber === subscriber)?.count ?? 0)
|
|
310
|
+
: total,
|
|
311
|
+
bySubscriber,
|
|
312
|
+
shown: deliveries.length,
|
|
313
|
+
deliveries,
|
|
314
|
+
};
|
|
315
|
+
return jsonResult(report);
|
|
316
|
+
} finally {
|
|
317
|
+
bus.close();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
249
321
|
/** One unanswered interview question, as `events list-unanswered` reports it. */
|
|
250
322
|
export interface UnansweredInterview {
|
|
251
323
|
eventId: number;
|
package/src/cli/completion.ts
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
handleEventsEmit,
|
|
33
33
|
handleEventsFail,
|
|
34
34
|
handleEventsInstallDaemon,
|
|
35
|
+
handleEventsListFailed,
|
|
35
36
|
handleEventsListPending,
|
|
36
37
|
handleEventsListSubscribers,
|
|
37
38
|
handleEventsListUnanswered,
|
|
@@ -300,6 +301,7 @@ Subcommands:
|
|
|
300
301
|
list-subscribers List persistent bus subscribers
|
|
301
302
|
resync-subscriptions Rebuild subscribers from deployed modules' manifests (after a restore/migration)
|
|
302
303
|
list-pending [--subscriber] List pending deliveries
|
|
304
|
+
list-failed [--subscriber] List failed/abandoned deliveries with a true total
|
|
303
305
|
drain [--concurrency N] Process pending deliveries once and return
|
|
304
306
|
run [--poll-ms N] Run the long-running dispatcher (foreground)
|
|
305
307
|
emit <type> [<payload>] Emit an event (operator/test path)
|
|
@@ -1425,6 +1427,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1425
1427
|
return handleEventsResyncSubscriptions();
|
|
1426
1428
|
case 'list-pending':
|
|
1427
1429
|
return handleEventsListPending(parsed.args, parsed.flags);
|
|
1430
|
+
case 'list-failed':
|
|
1431
|
+
return handleEventsListFailed(parsed.args, parsed.flags);
|
|
1428
1432
|
case 'list-unanswered':
|
|
1429
1433
|
return handleEventsListUnanswered(parsed.args, parsed.flags);
|
|
1430
1434
|
case 'drain':
|
|
@@ -40,8 +40,20 @@ export interface DnsRegistrationRow {
|
|
|
40
40
|
* triggers for FK-cascaded deletes only when `recursive_triggers` is on,
|
|
41
41
|
* and reads are the only thing that consumes the ledger. Move it into a
|
|
42
42
|
* trigger if something ever reads these rows without going through here.
|
|
43
|
+
*
|
|
44
|
+
* It CHECKS before deleting, so the overwhelmingly common case (nothing
|
|
45
|
+
* orphaned) stays a pure read. The first version ran the DELETE
|
|
46
|
+
* unconditionally, which took a write lock on every list — including the
|
|
47
|
+
* refresh hook's, and `celilo dns registrations`. A read that quietly writes
|
|
48
|
+
* is a surprise on its own, and on SQLite it is a surprise that serialises
|
|
49
|
+
* against every other writer for no benefit.
|
|
43
50
|
*/
|
|
44
51
|
function pruneOrphanedRegistrations(db: DbClient): void {
|
|
52
|
+
const orphaned = sql`SELECT 1 FROM dns_registrations WHERE NOT EXISTS (
|
|
53
|
+
SELECT 1 FROM dns_registration_consumers c WHERE c.registration_id = dns_registrations.id
|
|
54
|
+
) LIMIT 1`;
|
|
55
|
+
if (!db.get(orphaned)) return;
|
|
56
|
+
|
|
45
57
|
db.run(
|
|
46
58
|
sql`DELETE FROM dns_registrations WHERE NOT EXISTS (
|
|
47
59
|
SELECT 1 FROM dns_registration_consumers c WHERE c.registration_id = dns_registrations.id
|
|
@@ -42,6 +42,19 @@ function seedHeartbeat(
|
|
|
42
42
|
);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/** Abandon `count` deliveries to one subscriber, oldest first. */
|
|
46
|
+
function seedFailed(bus: Bus, count: number): void {
|
|
47
|
+
const sub = bus.subscribe({ name: 'namecheap.ddns', pattern: 'ddns.refresh', handler: 'echo' });
|
|
48
|
+
for (let i = 0; i < count; i++) {
|
|
49
|
+
const event = bus.emitRaw('ddns.refresh', { n: i });
|
|
50
|
+
bus.markFailed(
|
|
51
|
+
{ eventId: event.id, subscriberId: sub.id },
|
|
52
|
+
new Error('handler timed out after 30000ms'),
|
|
53
|
+
{ abandoned: true },
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
45
58
|
/** Write a supervisor unit file so readInstalledUnit(scope) sees it. */
|
|
46
59
|
function installFakeUnit(home: string, scope: 'user' | 'system' = 'user', systemRoot = '/'): void {
|
|
47
60
|
const path = getDaemonUnitPath('linux', home, scope, systemRoot);
|
|
@@ -199,6 +212,39 @@ describe('checkDispatcher', () => {
|
|
|
199
212
|
expect(f.status).toBe('warn');
|
|
200
213
|
expect(f.detail.join(' ')).toContain('not emitting on schedule');
|
|
201
214
|
});
|
|
215
|
+
|
|
216
|
+
// celilo#623 — the old check read `failedDeliveries({ limit: 50 }).length`,
|
|
217
|
+
// so on celilo-mgr it printed a literal `50` that meant "at least 50" and
|
|
218
|
+
// read as an exact count. 137 > any limit anyone would pick.
|
|
219
|
+
it('reports the TRUE total of failed deliveries, not the read limit', () => {
|
|
220
|
+
seedHeartbeat(bus, { startedAt: now - MINUTE, lastHeartbeat: now - 1000 });
|
|
221
|
+
installFakeUnit(home);
|
|
222
|
+
seedFailed(bus, 137);
|
|
223
|
+
|
|
224
|
+
const f = checkDispatcher(bus, { now: now, home, platform: 'linux' });
|
|
225
|
+
expect(f.status).toBe('warn');
|
|
226
|
+
expect(f.detail.join(' ')).toContain('137 failed/abandoned delivery(ies) total');
|
|
227
|
+
expect(f.detail.join(' ')).not.toContain('50 failed');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// The stored error is double-wrapped for every row written before the
|
|
231
|
+
// serializeError fix; those live 90 days, so doctor must unwrap them.
|
|
232
|
+
it('renders the sample error text, not {"message":"[object Object]"}', () => {
|
|
233
|
+
seedHeartbeat(bus, { startedAt: now - MINUTE, lastHeartbeat: now - 1000 });
|
|
234
|
+
installFakeUnit(home);
|
|
235
|
+
seedFailed(bus, 1);
|
|
236
|
+
bus.db.run('UPDATE deliveries SET last_error = ?', [
|
|
237
|
+
JSON.stringify({
|
|
238
|
+
message: '[object Object]',
|
|
239
|
+
value: { message: 'handler exited with code 1' },
|
|
240
|
+
}),
|
|
241
|
+
]);
|
|
242
|
+
|
|
243
|
+
const f = checkDispatcher(bus, { now: now, home, platform: 'linux' });
|
|
244
|
+
const detail = f.detail.join(' ');
|
|
245
|
+
expect(detail).toContain('handler exited with code 1');
|
|
246
|
+
expect(detail).not.toContain('[object Object]');
|
|
247
|
+
});
|
|
202
248
|
});
|
|
203
249
|
|
|
204
250
|
describe('checkSubscribers + checkCapabilityProviders', () => {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* rendering + `--fix` orchestration lives in the doctor command.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import type
|
|
20
|
+
import { type Bus, describeError } from '@celilo/event-bus';
|
|
21
21
|
import { inArray } from 'drizzle-orm';
|
|
22
22
|
import { getModuleStoragePath } from '../config/paths';
|
|
23
23
|
import { type DbClient, findMigrationsFolder } from '../db/client';
|
|
@@ -343,12 +343,21 @@ export function checkDispatcher(bus: Bus, opts: DispatcherCheckOptions = {}): Fl
|
|
|
343
343
|
}
|
|
344
344
|
}
|
|
345
345
|
|
|
346
|
-
|
|
347
|
-
|
|
346
|
+
// A TRUE total, not `failedDeliveries().length` — that saturates at its LIMIT
|
|
347
|
+
// and printed a literal `50` on celilo-mgr that read as a count (celilo#623).
|
|
348
|
+
// The newest row dates the backlog: a big total whose newest entry is days old
|
|
349
|
+
// is drained history, not active bleeding.
|
|
350
|
+
const { total: failedTotal } = bus.failedDeliveryTotals();
|
|
351
|
+
if (failedTotal > 0) {
|
|
348
352
|
statuses.push('warn');
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
353
|
+
const newest = bus.failedDeliveries({ limit: 1 })[0];
|
|
354
|
+
const age = newest?.finishedAt
|
|
355
|
+
? `, most recent ${Math.round((now - newest.finishedAt) / 60000)}min ago`
|
|
356
|
+
: '';
|
|
357
|
+
detail.push(`${failedTotal} failed/abandoned delivery(ies) total${age}`);
|
|
358
|
+
const sample = describeError(newest?.lastError ?? null)?.split('\n')[0];
|
|
359
|
+
if (sample) detail.push(` example (newest, not the only one): ${sample}`);
|
|
360
|
+
remediations.push('`celilo events list-failed` to see them; re-emit/repair as needed');
|
|
352
361
|
}
|
|
353
362
|
|
|
354
363
|
const status = worst(statuses);
|