quoxcli 0.3.1 → 0.4.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/lib/api/endpoints/actions.js +15 -0
- package/lib/api/endpoints/admin.js +20 -0
- package/lib/api/endpoints/blog.js +16 -0
- package/lib/api/endpoints/chat.js +7 -0
- package/lib/api/endpoints/contactsq.js +4 -0
- package/lib/api/endpoints/devServerRegistry.js +25 -0
- package/lib/api/endpoints/engagement.js +26 -0
- package/lib/api/endpoints/evidence.js +20 -0
- package/lib/api/endpoints/hat.js +14 -0
- package/lib/api/endpoints/index.js +12 -0
- package/lib/api/endpoints/loops.js +26 -0
- package/lib/api/endpoints/mon.js +173 -0
- package/lib/api/endpoints/objectives.js +6 -0
- package/lib/api/endpoints/quoxseo.js +6 -0
- package/lib/api/endpoints/server.js +21 -0
- package/lib/api/endpoints/setup.js +6 -0
- package/lib/api/endpoints/v1.js +34 -0
- package/lib/commands/actions.js +81 -11
- package/lib/commands/agent.js +11 -4
- package/lib/commands/agentAttach.js +46 -7
- package/lib/commands/approvals.js +5 -0
- package/lib/commands/ask.js +3 -1
- package/lib/commands/blog.js +110 -0
- package/lib/commands/chat.js +127 -28
- package/lib/commands/chatCompanion.js +27 -0
- package/lib/commands/chatState.js +45 -0
- package/lib/commands/claude.js +3 -32
- package/lib/commands/contacts.js +85 -4
- package/lib/commands/contactsSendAt.js +21 -0
- package/lib/commands/engagement.js +90 -0
- package/lib/commands/evidence.js +154 -0
- package/lib/commands/ext.js +23 -0
- package/lib/commands/fleet.js +25 -0
- package/lib/commands/hat.js +76 -0
- package/lib/commands/image.js +68 -0
- package/lib/commands/kuma.js +1050 -0
- package/lib/commands/kumaSla.js +174 -0
- package/lib/commands/licence.js +39 -1
- package/lib/commands/loop.js +291 -0
- package/lib/commands/objective.js +48 -1
- package/lib/commands/org.js +21 -4
- package/lib/commands/provider.js +58 -0
- package/lib/commands/quoxseo.js +31 -0
- package/lib/commands/research.js +79 -0
- package/lib/commands/rows.js +9 -0
- package/lib/commands/run.js +18 -1
- package/lib/commands/scope.js +2 -2
- package/lib/commands/server.js +113 -0
- package/lib/commands/shortcut.js +41 -0
- package/lib/commands/skills.js +20 -4
- package/lib/commands/vm.js +2 -2
- package/lib/commands/youtube.js +172 -0
- package/lib/core/actionItems.js +115 -0
- package/lib/core/agentAlias.js +2 -0
- package/lib/core/agentFamilies.js +7 -0
- package/lib/core/client.js +10 -2
- package/lib/core/commandRegistrars.js +9 -0
- package/lib/core/config.js +1 -1
- package/lib/core/findingReconcile.js +90 -0
- package/lib/core/global-flags.js +1 -1
- package/lib/core/governedSession.js +1 -3
- package/lib/core/liveSessionsReport.js +1 -1
- package/lib/core/loopGlyph.js +97 -0
- package/lib/core/offload.js +36 -7
- package/lib/core/output.js +7 -7
- package/lib/core/sessionHeartbeat.js +12 -8
- package/lib/core/shortcut.js +83 -0
- package/lib/core/version.js +1 -1
- package/lib/core/vm.js +5 -0
- package/lib/dragon/companionView.js +34 -0
- package/lib/dragon/frames.js +118 -0
- package/lib/dragon/jobs.js +123 -0
- package/lib/dragon/renderWorker.js +15 -0
- package/lib/dragon/select.js +72 -0
- package/lib/evidence/bundleExport.js +47 -0
- package/lib/evidence/bundleVerify.js +77 -0
- package/lib/quoxon/EXTENSIONS.md +23 -0
- package/lib/quoxon/api.js +192 -6
- package/lib/quoxon/app.js +655 -89
- package/lib/quoxon/approvalFacts.js +8 -6
- package/lib/quoxon/approvalSentence.js +44 -0
- package/lib/quoxon/approvalsView.js +63 -4
- package/lib/quoxon/commandShort.js +15 -3
- package/lib/quoxon/commands.js +75 -10
- package/lib/quoxon/countdownLine.js +10 -1
- package/lib/quoxon/deck.js +30 -0
- package/lib/quoxon/demo.autoplay.js +1 -1
- package/lib/quoxon/demo.js +3 -1
- package/lib/quoxon/dividerAocl.js +4 -3
- package/lib/quoxon/dividerRule.js +3 -3
- package/lib/quoxon/dragonCompanion.js +255 -0
- package/lib/quoxon/dragonModes.js +1 -0
- package/lib/quoxon/dragonMoments.js +82 -0
- package/lib/quoxon/dragonReads.js +68 -0
- package/lib/quoxon/dragonView.js +2 -2
- package/lib/quoxon/events.js +24 -3
- package/lib/quoxon/extensions.js +276 -0
- package/lib/quoxon/inboxView.js +79 -0
- package/lib/quoxon/installCli.js +79 -0
- package/lib/quoxon/keys.js +2 -2
- package/lib/quoxon/loopCard.js +456 -0
- package/lib/quoxon/loopCards.js +180 -0
- package/lib/quoxon/loopStream.js +87 -0
- package/lib/quoxon/main.js +12 -3
- package/lib/quoxon/mouse.js +12 -1
- package/lib/quoxon/objectiveOffer.js +82 -0
- package/lib/quoxon/objects/index.js +22 -4
- package/lib/quoxon/objects/model.js +33 -0
- package/lib/quoxon/objects/proof.js +12 -4
- package/lib/quoxon/onboarding/shortcutOffer.js +28 -0
- package/lib/quoxon/onboarding/view.js +1 -1
- package/lib/quoxon/orgSwitch.js +27 -1
- package/lib/quoxon/planSteps.js +51 -0
- package/lib/quoxon/print.js +6 -3
- package/lib/quoxon/qdragonFly.js +7 -6
- package/lib/quoxon/qmotion.js +17 -0
- package/lib/quoxon/render.js +130 -33
- package/lib/quoxon/roster.js +26 -12
- package/lib/quoxon/rows.js +244 -1
- package/lib/quoxon/serverChip.js +42 -0
- package/lib/quoxon/session.js +10 -0
- package/lib/quoxon/slash/cliCommand.js +42 -1
- package/lib/quoxon/slash/cliLeaves.json +76 -1
- package/lib/quoxon/slash/evidenceCmd.js +34 -0
- package/lib/quoxon/slash/palette.js +1 -1
- package/lib/quoxon/slash/qpeCmd.js +35 -0
- package/lib/quoxon/slash/restart.js +25 -1
- package/lib/quoxon/slash/resume.js +18 -1
- package/lib/quoxon/slash/todoCmd.js +122 -0
- package/lib/quoxon/suggestCommand.js +33 -2
- package/lib/quoxon/toolCards.js +30 -0
- package/lib/quoxon/whatsnew.jsonl +45 -0
- package/lib/quoxon/windowOrg.js +11 -1
- package/lib/server/devServers.js +137 -0
- package/package.json +2 -1
- package/vendor/dragonlab/SOURCE +4 -0
- package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
- package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
- package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
- package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
- package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
- package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
- package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
- package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
- package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
- package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
- package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
- package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
- package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
- package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
- package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
- package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
- package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
- package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
- package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
- package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
- package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
- package/vendor/dragonlab/design/seer/v1/seer-master.svg +53 -0
- package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
- package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
- package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
- package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
- package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
- package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
- package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
- package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
- package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
- package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
- package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -0
- package/vendor/dragonlab/factory/svg-rig.mjs +63 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +88 -0
- package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
- package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
- package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
- package/vendor/dragonlab/lib/power-effects.js +294 -0
- package/vendor/dragonlab/lib/power-registry.js +74 -0
- package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
- package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -0
|
@@ -0,0 +1,1050 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Uptime Kuma commands: read-only view of the monitors behind a Kuma status page.
|
|
3
|
+
*
|
|
4
|
+
* With no slug (and no --all) status|down|uptime read the ORG's own Kuma (vault credential, GET /api/v1/kuma/status),
|
|
5
|
+
* the same live set VIGIL sees; the header line shows the source. A slug or --all asks for that public status page.
|
|
6
|
+
* quox kuma status [slug] every monitor, grouped, with state/ping/uptime
|
|
7
|
+
* quox kuma down [slug] only monitors that are not up (exit 3 when any)
|
|
8
|
+
* quox kuma uptime [slug] 24h/30d uptime table, worst first
|
|
9
|
+
* quox kuma refresh [slug] ask the collector to re-scrape the status page(s)
|
|
10
|
+
* quox kuma monitors list|get|add|edit|pause|resume|remove manage monitors (writes need org admin + human approval)
|
|
11
|
+
* quox kuma incidents [--open] Kuma alerts that became Quox incidents (NQ-KUMA-4)
|
|
12
|
+
* quox kuma hooks list|create|delete per-org signed alert hook Kuma posts to (org admin)
|
|
13
|
+
* quox kuma diagnose <incidentId>|--monitor <id> why is it down: outside-in + governed host checks (NQ-KUMA-5)
|
|
14
|
+
* quox kuma sla [--month|--from --to] | list | verify <id|file> signed, verifiable uptime reports (NQ-KUMA-7)
|
|
15
|
+
* quox kuma maintenance list|open|end planned work does not page people (NQ-KUMA-8; open/end are approval-gated)
|
|
16
|
+
* quox kuma status-incident draft|publish|resolve customer-safe status-page comms (NQ-KUMA-8; publish/resolve are approval-gated)
|
|
17
|
+
*
|
|
18
|
+
* Backend (existing collector routes, no collector changes):
|
|
19
|
+
* GET /api/v1/monitoring/config configured Kuma URL + status-page slugs
|
|
20
|
+
* GET /api/v1/monitoring/uptime-kuma/status/:slug monitors (cached, scannedAt says how old)
|
|
21
|
+
* POST /api/v1/monitoring/uptime-kuma/refresh background re-scrape of all configured pages
|
|
22
|
+
*
|
|
23
|
+
* Honesty: a non-2xx or a body with success:false is an ERROR, reported with the
|
|
24
|
+
* verb, endpoint and status. It is never rendered as "0 monitors".
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { resolveEndpoint } from '../api/endpoints.js';
|
|
28
|
+
import { line as glyphLine } from '../core/quoxglyph.js';
|
|
29
|
+
import { resolveActing } from '../core/actingAs.js';
|
|
30
|
+
import { resolveOrgId } from './helpers.js';
|
|
31
|
+
import { execResultFailed, execResultPending, pendingBodyOfError } from './agentAttach.js';
|
|
32
|
+
import { registerKumaSla } from './kumaSla.js';
|
|
33
|
+
|
|
34
|
+
const STATE = { 1: 'up', 0: 'down', 2: 'pending', 3: 'maintenance' };
|
|
35
|
+
export const stateOf = (m) => (m && typeof m.state === 'string' ? m.state : m && Object.prototype.hasOwnProperty.call(STATE, m.status) ? STATE[m.status] : 'unknown');
|
|
36
|
+
|
|
37
|
+
const SETUP_HINT =
|
|
38
|
+
'Uptime Kuma is not configured on this collector.\n'
|
|
39
|
+
+ ' Check: quox mon config get\n'
|
|
40
|
+
+ ' Fix: set the Kuma URL and a status page slug in the dashboard monitoring settings\n'
|
|
41
|
+
+ ' (PUT /api/v1/monitoring/config/uptime-kuma with url and statusPageSlug; instance admin).\n'
|
|
42
|
+
+ ' There is no `quox mon config set` verb yet.';
|
|
43
|
+
|
|
44
|
+
class KumaFail extends Error {
|
|
45
|
+
constructor(message, exitCode = 1) { super(message); this.exitCode = exitCode; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function failFrom(verb, ep, err) {
|
|
49
|
+
const status = err && (err.status ?? err.statusCode);
|
|
50
|
+
const msg = (err && err.message) || String(err);
|
|
51
|
+
return new KumaFail(`quox kuma ${verb}: ${ep.method} ${ep.path} failed${status ? ` with HTTP ${status}` : ''}: ${msg}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const pct = (v) => {
|
|
55
|
+
const n = Number(v);
|
|
56
|
+
return Number.isFinite(n) ? n : null;
|
|
57
|
+
};
|
|
58
|
+
const fmtPct = (v) => { const n = pct(v); return n === null ? '-' : `${n.toFixed(2)}%`; };
|
|
59
|
+
const fmtPing = (v) => (v === null || v === undefined || v === '' || !Number.isFinite(Number(v)) ? '-' : `${Math.round(Number(v))}ms`);
|
|
60
|
+
|
|
61
|
+
function pad(rows) {
|
|
62
|
+
const w = [];
|
|
63
|
+
for (const r of rows) r.forEach((c, i) => { w[i] = Math.max(w[i] || 0, [...String(c)].length); });
|
|
64
|
+
return rows.map((r) => r.map((c, i) => String(c) + ' '.repeat(w[i] - [...String(c)].length)).join(' ').trimEnd());
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${Number(n) === 1 ? one : many}`;
|
|
68
|
+
|
|
69
|
+
/** Compact duration for relative times: 45s, 4m, 2h 35m, 3d 4h. */
|
|
70
|
+
export function relDuration(ms) {
|
|
71
|
+
const s = Math.max(0, Math.floor(ms / 1000));
|
|
72
|
+
if (s < 60) return `${s}s`;
|
|
73
|
+
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
|
74
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
|
|
75
|
+
return `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Wrap text to width; continuation lines get the indent. Never breaks inside a word unless the word alone is too long. */
|
|
79
|
+
export function wrapText(text, width, indent = '') {
|
|
80
|
+
const lines = [];
|
|
81
|
+
let cur = '';
|
|
82
|
+
for (const word of String(text).split(/\s+/).filter(Boolean)) {
|
|
83
|
+
if (cur && [...cur].length + 1 + [...word].length > width) { lines.push(cur); cur = indent + word; } else cur = cur ? `${cur} ${word}` : word;
|
|
84
|
+
}
|
|
85
|
+
if (cur) lines.push(cur);
|
|
86
|
+
return lines;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const hasTz = (s) => /(?:Z|[+-]\d\d:?\d\d)$/i.test(String(s));
|
|
90
|
+
/** A Kuma time as `YYYY-MM-DD HH:MM UTC`. A zoneless string is converted when its zone is known, else labelled as Kuma server time. */
|
|
91
|
+
export function fmtWhen(value, tz) {
|
|
92
|
+
if (value === null || value === undefined || value === '') return '-';
|
|
93
|
+
const str = String(value);
|
|
94
|
+
if (hasTz(str)) {
|
|
95
|
+
const d = new Date(str);
|
|
96
|
+
return Number.isNaN(d.getTime()) ? str : `${d.toISOString().slice(0, 16).replace('T', ' ')} UTC`;
|
|
97
|
+
}
|
|
98
|
+
const m = /^(\d{4})-(\d\d)-(\d\d)[T ](\d\d):(\d\d)/.exec(str);
|
|
99
|
+
if (m && tz) {
|
|
100
|
+
try {
|
|
101
|
+
const asUtc = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]);
|
|
102
|
+
const parts = new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).formatToParts(new Date(asUtc));
|
|
103
|
+
const g = (t) => Number(parts.find((x) => x.type === t).value);
|
|
104
|
+
const shown = Date.UTC(g('year'), g('month') - 1, g('day'), g('hour'), g('minute'));
|
|
105
|
+
return `${new Date(asUtc - (shown - asUtc)).toISOString().slice(0, 16).replace('T', ' ')} UTC`;
|
|
106
|
+
} catch { /* unknown zone: fall through to the label */ }
|
|
107
|
+
}
|
|
108
|
+
return `${str} (Kuma server time)`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function registerKumaCommands(program, ctx) {
|
|
112
|
+
const { client, auth } = ctx;
|
|
113
|
+
const jsonOut = () => program.opts().output === 'json';
|
|
114
|
+
|
|
115
|
+
const kuma = program
|
|
116
|
+
.command('kuma')
|
|
117
|
+
.description('Uptime Kuma. Bare `quox kuma` runs status. Verbs: status, down, uptime, refresh, monitors, incidents, diagnose, coverage, sla, maintenance, status-incident, hooks')
|
|
118
|
+
.addHelpText('after', '\nExample: quox kuma\nExit codes: 0 ok, 1 error or Kuma not configured, 3 `quox kuma down` found monitors not up.');
|
|
119
|
+
const exampled = new Set([kuma]);
|
|
120
|
+
const example = (cmd, line) => { exampled.add(cmd); return cmd.addHelpText('after', `\nExample: ${line}`); };
|
|
121
|
+
|
|
122
|
+
/** Returns { slugs: [...] } or throws KumaFail (unconfigured / HTTP error). */
|
|
123
|
+
async function configuredSlugs(verb) {
|
|
124
|
+
const ep = resolveEndpoint('mon.config.get');
|
|
125
|
+
let data;
|
|
126
|
+
try {
|
|
127
|
+
data = await client.get(ep.path, { service: ep.service });
|
|
128
|
+
} catch (err) { throw failFrom(verb, ep, err); }
|
|
129
|
+
if (!data || data.success === false) {
|
|
130
|
+
throw new KumaFail(`quox kuma ${verb}: ${ep.method} ${ep.path} returned an error body: ${(data && data.error) || 'no body'}`);
|
|
131
|
+
}
|
|
132
|
+
const k = data.services && data.services['uptime-kuma'];
|
|
133
|
+
const pages = (k && Array.isArray(k.statusPages) ? k.statusPages : []).map((p) => p.slug).filter(Boolean);
|
|
134
|
+
if (!pages.length && k && k.statusPageSlug) pages.push(k.statusPageSlug);
|
|
135
|
+
if (!k || !k.configured || !pages.length) {
|
|
136
|
+
throw new KumaFail(`${SETUP_HINT}${k && k.configured && !pages.length ? '\n (a URL is set but no status page slug is configured)' : ''}`);
|
|
137
|
+
}
|
|
138
|
+
return pages;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function fetchPage(verb, slug) {
|
|
142
|
+
const ep = resolveEndpoint('mon.kuma.status.get', { slug });
|
|
143
|
+
let data;
|
|
144
|
+
try {
|
|
145
|
+
data = await client.get(ep.path, { service: ep.service });
|
|
146
|
+
} catch (err) { throw failFrom(verb, ep, err); }
|
|
147
|
+
if (!data || data.success === false || !Array.isArray(data.monitors)) {
|
|
148
|
+
const why = data && data.error ? data.error : 'response had no monitors array';
|
|
149
|
+
if (/not configured/i.test(why)) throw new KumaFail(SETUP_HINT);
|
|
150
|
+
throw new KumaFail(`quox kuma ${verb}: ${ep.method} ${ep.path} returned an error body: ${why}`);
|
|
151
|
+
}
|
|
152
|
+
return { slug, ...data };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** NQ-KUMA-3: the org's live Kuma. Returns a page-shaped object, or null to use the legacy status page. */
|
|
156
|
+
async function fetchLive(verb) {
|
|
157
|
+
const ep = resolveEndpoint('mon.kuma.live.get');
|
|
158
|
+
let data;
|
|
159
|
+
try {
|
|
160
|
+
data = await client.get(ep.path, { service: ep.service });
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if ((err && (err.status ?? err.statusCode)) === 404) return null; // older collector: no live route
|
|
163
|
+
throw failFrom(verb, ep, err);
|
|
164
|
+
}
|
|
165
|
+
if (data && data.success === false) {
|
|
166
|
+
throw new KumaFail(`quox kuma ${verb}: ${ep.method} ${ep.path} returned an error body: ${data.error || 'no body'}`);
|
|
167
|
+
}
|
|
168
|
+
if (!data || !Array.isArray(data.monitors)) return null;
|
|
169
|
+
const slug = data.source === 'vault' ? 'vault' : `status_page${data.slug ? ` ${data.slug}` : ''}`;
|
|
170
|
+
return { ...data, slug };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Resolve slug(s) then fetch each page. */
|
|
174
|
+
async function loadPages(verb, slugArg, all) {
|
|
175
|
+
auth.requireAuth();
|
|
176
|
+
if (!slugArg && !all) {
|
|
177
|
+
const live = await fetchLive(verb);
|
|
178
|
+
if (live) return [live];
|
|
179
|
+
}
|
|
180
|
+
let slugs;
|
|
181
|
+
if (slugArg) slugs = [slugArg];
|
|
182
|
+
else {
|
|
183
|
+
const configured = await configuredSlugs(verb);
|
|
184
|
+
slugs = all ? configured : [configured[0]];
|
|
185
|
+
}
|
|
186
|
+
const pages = [];
|
|
187
|
+
for (const s of slugs) pages.push(await fetchPage(verb, s));
|
|
188
|
+
return pages;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function guard(verb, fn) {
|
|
192
|
+
return async (...args) => {
|
|
193
|
+
try { await fn(...args); } catch (err) {
|
|
194
|
+
if (!(err instanceof KumaFail)) throw err;
|
|
195
|
+
console.error(err.message);
|
|
196
|
+
process.exitCode = err.exitCode || 1;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const summaryOf = (ms) => {
|
|
202
|
+
const c = { up: 0, down: 0, pending: 0, maintenance: 0, unknown: 0 };
|
|
203
|
+
for (const m of ms) c[stateOf(m)]++;
|
|
204
|
+
return c;
|
|
205
|
+
};
|
|
206
|
+
const summaryText = (ms) => {
|
|
207
|
+
const c = summaryOf(ms);
|
|
208
|
+
const parts = [`${c.up}/${ms.length} up`];
|
|
209
|
+
for (const k of ['down', 'pending', 'maintenance', 'unknown']) if (c[k]) parts.push(`${c[k]} ${k}`);
|
|
210
|
+
return parts.join(' · ');
|
|
211
|
+
};
|
|
212
|
+
const nameOf = (m) => m.name || `monitor ${m.id}`;
|
|
213
|
+
// A snapshot older than STALE_AFTER_MS is not current state: say how old, and how to get a fresh one.
|
|
214
|
+
const STALE_AFTER_MS = 10 * 60 * 1000;
|
|
215
|
+
const ageText = (ms) => (ms >= 86400000 ? `${Math.floor(ms / 86400000)}d` : ms >= 3600000 ? `${Math.floor(ms / 3600000)}h` : `${Math.floor(ms / 60000)}m`);
|
|
216
|
+
const age = (p) => {
|
|
217
|
+
if (!p.scannedAt) return '';
|
|
218
|
+
const ms = Date.now() - Date.parse(p.scannedAt);
|
|
219
|
+
const old = Number.isFinite(ms) && ms > STALE_AFTER_MS;
|
|
220
|
+
const why = p.stale ? `, ${p.source ? 'status page snapshot is old' : `last refresh failed: ${p.error}`}` : '';
|
|
221
|
+
return `${p.source ? `source ${p.source}${p.fallbackReason ? ' (org has no Kuma credential in the vault)' : ''}, ` : ''}${p.cached ? 'cached' : 'fresh'} ${p.scannedAt}${old || p.stale ? ` ⚠ STALE: ${old ? `${ageText(ms)} old` : 'refresh failed'}${why}; this is not current state, run \`quox kuma refresh\`` : ''}`;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
function printMonitors(page, monitors) {
|
|
225
|
+
const anyGroup = monitors.some((m) => m.group);
|
|
226
|
+
const has30d = monitors.some((m) => pct(m.uptime30d) !== null);
|
|
227
|
+
const byGroup = new Map();
|
|
228
|
+
for (const m of monitors) {
|
|
229
|
+
const g = m.group || 'ungrouped';
|
|
230
|
+
if (!byGroup.has(g)) byGroup.set(g, []);
|
|
231
|
+
byGroup.get(g).push(m);
|
|
232
|
+
}
|
|
233
|
+
for (const [g, ms] of byGroup) {
|
|
234
|
+
if (anyGroup) console.log(`\n${g}`); else console.log('');
|
|
235
|
+
const ind = anyGroup ? ' ' : '';
|
|
236
|
+
const rows = [[`${ind}STATE`, 'MONITOR', 'PING', '24H', ...(has30d ? ['30D'] : [])]];
|
|
237
|
+
for (const m of ms) rows.push([`${ind}${stateOf(m)}`, nameOf(m), fmtPing(m.ping), fmtPct(m.uptime24h), ...(has30d ? [fmtPct(m.uptime30d)] : [])]);
|
|
238
|
+
console.log(pad(rows).join('\n'));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const statusAction = guard('status', async (slugArg, options) => {
|
|
243
|
+
const pages = await loadPages('status', slugArg, options.all);
|
|
244
|
+
if (jsonOut()) { console.log(JSON.stringify(options.all || pages.length > 1 ? pages : pages[0], null, 2)); return; }
|
|
245
|
+
for (const p of pages) {
|
|
246
|
+
const st = summaryOf(p.monitors).down ? 'fail' : 'ok';
|
|
247
|
+
console.log(glyphLine('kuma', `status ${p.slug}`, { result: `${summaryText(p.monitors)}${p.kumaVersion ? ` · Kuma ${p.kumaVersion}` : ''}`, status: st }));
|
|
248
|
+
if (age(p)) console.log(age(p));
|
|
249
|
+
if (!p.monitors.length) console.log('Status page has no monitors.');
|
|
250
|
+
else printMonitors(p, p.monitors);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
example(kuma
|
|
254
|
+
.command('status')
|
|
255
|
+
.description('Every monitor, grouped, with state, ping and 24h/30d uptime')
|
|
256
|
+
.argument('[slug]', 'status page slug (default: first configured)')
|
|
257
|
+
.option('--all', 'show every configured status page')
|
|
258
|
+
.action(statusAction), 'quox kuma status');
|
|
259
|
+
// Bare `quox kuma` is the fast path: the same as `quox kuma status`.
|
|
260
|
+
kuma.action(async () => { await statusAction(undefined, {}); });
|
|
261
|
+
|
|
262
|
+
example(kuma
|
|
263
|
+
.command('down')
|
|
264
|
+
.description('Only monitors that are not up. Exit code 0 when all up, 3 when any is not up')
|
|
265
|
+
.argument('[slug]', 'status page slug (default: first configured)')
|
|
266
|
+
.option('--all', 'check every configured status page')
|
|
267
|
+
.action(guard('down', async (slugArg, options) => {
|
|
268
|
+
const pages = await loadPages('down', slugArg, options.all);
|
|
269
|
+
const notUp = pages.flatMap((p) => p.monitors.filter((m) => stateOf(m) !== 'up').map((m) => ({ ...m, page: p.slug })));
|
|
270
|
+
if (jsonOut()) console.log(JSON.stringify({ down: notUp, checked: pages.map((p) => p.slug) }, null, 2));
|
|
271
|
+
else {
|
|
272
|
+
const total = pages.reduce((n, p) => n + p.monitors.length, 0);
|
|
273
|
+
console.log(glyphLine('kuma', `down ${pages.map((p) => p.slug).join(',')}`, {
|
|
274
|
+
result: notUp.length ? `${notUp.length} of ${total} not up` : `all ${total} up`, status: notUp.length ? 'fail' : 'ok',
|
|
275
|
+
}));
|
|
276
|
+
if (notUp.length) {
|
|
277
|
+
const anyGroup = notUp.some((m) => m.group);
|
|
278
|
+
const rows = [['STATE', 'MONITOR', ...(anyGroup ? ['GROUP'] : []), 'MESSAGE']];
|
|
279
|
+
for (const m of notUp) rows.push([stateOf(m), nameOf(m), ...(anyGroup ? [m.group || '-'] : []), String(m.message || '-').replace(/\s+/g, ' ').slice(0, 80)]);
|
|
280
|
+
console.log(pad(rows).join('\n'));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (notUp.length) process.exitCode = 3;
|
|
284
|
+
})), 'quox kuma down');
|
|
285
|
+
|
|
286
|
+
example(kuma
|
|
287
|
+
.command('uptime')
|
|
288
|
+
.description('Per-monitor 24h/30d uptime, worst first')
|
|
289
|
+
.argument('[slug]', 'status page slug (default: first configured)')
|
|
290
|
+
.option('--all', 'include every configured status page')
|
|
291
|
+
.option('--below <pct>', 'only monitors whose 24h or 30d uptime is below this percentage')
|
|
292
|
+
.action(guard('uptime', async (slugArg, options) => {
|
|
293
|
+
let below = null;
|
|
294
|
+
if (options.below !== undefined) {
|
|
295
|
+
below = Number(options.below);
|
|
296
|
+
if (!Number.isFinite(below)) throw new KumaFail(`quox kuma uptime: --below needs a number, got "${options.below}"`);
|
|
297
|
+
}
|
|
298
|
+
const pages = await loadPages('uptime', slugArg, options.all);
|
|
299
|
+
let list = pages.flatMap((p) => p.monitors.map((m) => ({ ...m, page: p.slug })));
|
|
300
|
+
const worst = (m) => Math.min(pct(m.uptime24h) ?? 101, pct(m.uptime30d) ?? 101);
|
|
301
|
+
if (below !== null) list = list.filter((m) => worst(m) < below);
|
|
302
|
+
list.sort((a, b) => worst(a) - worst(b) || nameOf(a).localeCompare(nameOf(b)));
|
|
303
|
+
if (jsonOut()) { console.log(JSON.stringify(list, null, 2)); return; }
|
|
304
|
+
console.log(glyphLine('kuma', `uptime ${pages.map((p) => p.slug).join(',')}`, {
|
|
305
|
+
result: below !== null ? `${list.length} below ${below}%` : plural(list.length, 'monitor'),
|
|
306
|
+
status: list.length && below !== null ? 'warn' : 'ok',
|
|
307
|
+
}));
|
|
308
|
+
if (!list.length) { console.log(below !== null ? `No monitors below ${below}%.` : 'Status page has no monitors.'); return; }
|
|
309
|
+
const has30d = list.some((m) => pct(m.uptime30d) !== null);
|
|
310
|
+
const rows = [['24H', ...(has30d ? ['30D'] : []), 'STATE', 'MONITOR']];
|
|
311
|
+
for (const m of list) rows.push([fmtPct(m.uptime24h), ...(has30d ? [fmtPct(m.uptime30d)] : []), stateOf(m), nameOf(m)]);
|
|
312
|
+
console.log(pad(rows).join('\n'));
|
|
313
|
+
})), 'quox kuma uptime --below 99.9');
|
|
314
|
+
|
|
315
|
+
example(kuma
|
|
316
|
+
.command('refresh')
|
|
317
|
+
.description('Bust the live-state cache and show fresh monitor state (also re-scrapes any configured public status pages)')
|
|
318
|
+
.argument('[slug]', 'accepted for symmetry; the collector refreshes every configured public status page')
|
|
319
|
+
, 'quox kuma refresh')
|
|
320
|
+
.action(async () => {
|
|
321
|
+
// Not wrapped in guard(): the leaf-methods scanner reads this handler's own
|
|
322
|
+
// source, and the client.post below must stay visible so refresh is classed a write.
|
|
323
|
+
try {
|
|
324
|
+
auth.requireAuth();
|
|
325
|
+
// 1. the org's live state, bypassing the per-org 30s cache (the same set `quox kuma status` reads)
|
|
326
|
+
const liveEp = resolveEndpoint('mon.kuma.live.get', { fresh: true });
|
|
327
|
+
let live = null;
|
|
328
|
+
try {
|
|
329
|
+
const d = await client.get(liveEp.path, { service: liveEp.service });
|
|
330
|
+
if (d && d.success === false) throw new KumaFail(`quox kuma refresh: ${liveEp.method} ${liveEp.path} returned an error body: ${d.error || 'no body'}`);
|
|
331
|
+
if (d && Array.isArray(d.monitors)) live = { ...d, slug: d.source === 'vault' ? 'vault' : `status_page${d.slug ? ` ${d.slug}` : ''}` };
|
|
332
|
+
} catch (err) {
|
|
333
|
+
if (err instanceof KumaFail) throw err;
|
|
334
|
+
if ((err && (err.status ?? err.statusCode)) !== 404) throw failFrom('refresh', liveEp, err); // 404: older collector, no live route
|
|
335
|
+
}
|
|
336
|
+
// 2. legacy public status pages: only mentioned when any are configured
|
|
337
|
+
const ep = resolveEndpoint('mon.kuma.refresh.create');
|
|
338
|
+
let data;
|
|
339
|
+
try {
|
|
340
|
+
data = await client.post(ep.path, { service: ep.service, body: {} });
|
|
341
|
+
} catch (err) { throw failFrom('refresh', ep, err); }
|
|
342
|
+
if (!data || data.success === false) {
|
|
343
|
+
throw new KumaFail(`quox kuma refresh: ${ep.method} ${ep.path} returned an error body: ${(data && data.error) || 'no body'}`);
|
|
344
|
+
}
|
|
345
|
+
const pages = Array.isArray(data.refreshing) ? data.refreshing : [];
|
|
346
|
+
if (jsonOut()) { console.log(JSON.stringify(live ? { live, ...data } : data, null, 2)); return; }
|
|
347
|
+
if (live) {
|
|
348
|
+
console.log(glyphLine('kuma', 'refresh', { result: `${summaryText(live.monitors)} · fresh ${live.scannedAt || ''}`.trim(), status: summaryOf(live.monitors).down ? 'fail' : 'ok' }));
|
|
349
|
+
if (pages.length) console.log(`Also queued a re-scrape of public status pages: ${pages.join(', ')} (background).`);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (!pages.length) throw new KumaFail(SETUP_HINT);
|
|
353
|
+
console.log(glyphLine('kuma', 'refresh', { result: `${pages.length} page(s) queued: ${pages.join(', ')}`, status: 'busy' }));
|
|
354
|
+
console.log('Runs in the background; re-run `quox kuma status` in a few seconds.');
|
|
355
|
+
} catch (err) {
|
|
356
|
+
if (!(err instanceof KumaFail)) throw err;
|
|
357
|
+
console.error(err.message);
|
|
358
|
+
process.exitCode = err.exitCode || 1;
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// ---- monitors: manage the monitors themselves (collector /api/v1/kuma/monitors) ----
|
|
363
|
+
const monitors = kuma
|
|
364
|
+
.command('monitors')
|
|
365
|
+
.description('List, inspect and (org admin, human approved) change Uptime Kuma monitors')
|
|
366
|
+
.addHelpText('after', '\nWrites are held for human approval (HTTP 202); approve in the inbox, then `quox approvals result <approval_id>`.\nAgents use VIGIL\'s kuma_* tools instead of these verbs.');
|
|
367
|
+
|
|
368
|
+
const targetOf = (m) => (m.target || (m.url && !/^https?:\/\/?$/i.test(m.url) ? m.url : null) || (m.hostname ? `${m.hostname}${m.port ? `:${m.port}` : ''}` : '-'));
|
|
369
|
+
const MON_TYPES = ['http', 'keyword', 'port', 'ping', 'dns'];
|
|
370
|
+
|
|
371
|
+
/** Turn a thrown API error into a KumaFail with the verb, endpoint, status and a useful hint. */
|
|
372
|
+
function monFail(verb, ep, err, id) {
|
|
373
|
+
const status = err && (err.status ?? err.statusCode);
|
|
374
|
+
const body = (err && err.body) || {};
|
|
375
|
+
const code = body.error_code || body.code || (err && err.code);
|
|
376
|
+
const msg = (err && err.message) || String(err);
|
|
377
|
+
const head = `quox kuma monitors ${verb}: ${ep.method} ${ep.path} failed${status ? ` with HTTP ${status}` : ''}`;
|
|
378
|
+
if (status === 403 && (code === 'admin_required' || /admin role required/i.test(msg))) {
|
|
379
|
+
return new KumaFail(`${head}: writes need an org admin or owner. Ask one to run this, or have an agent use VIGIL's kuma_* tools.`);
|
|
380
|
+
}
|
|
381
|
+
if (status === 403 && (code === 'org_required' || /organisation required/i.test(msg))) {
|
|
382
|
+
return new KumaFail(`${head}: no organisation on this login. Run \`quox login\` against an org account.`);
|
|
383
|
+
}
|
|
384
|
+
if (status === 422 || code === 'not_configured') {
|
|
385
|
+
return new KumaFail(`${head}: Uptime Kuma admin access is not configured for this org (${msg}).\n Set the Kuma admin credentials in the dashboard monitoring settings (vault), then retry.`);
|
|
386
|
+
}
|
|
387
|
+
if (status === 404) return new KumaFail(`${head}: ${/not found/i.test(msg) ? msg : `monitor ${id || ''} not found`.replace(' ', ' ')}. List them with \`quox kuma monitors\`.`);
|
|
388
|
+
return new KumaFail(`${head}: ${msg}${id ? ` (monitor ${id})` : ''}`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Print the result of a write. 202 = held for approval, never "done". */
|
|
392
|
+
function reportWrite(verb, ep, data, okLine) {
|
|
393
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
394
|
+
if (data && data.status === 'awaiting_approval') {
|
|
395
|
+
const id = data.approval_id || 'unknown';
|
|
396
|
+
console.log(`◐ awaiting approval ${id}: approve in the inbox${data.inbox_item_id ? ` (item ${data.inbox_item_id})` : ''}, then check with \`quox approvals result ${id}\``);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (!data || data.success === false || data.error) {
|
|
400
|
+
throw new KumaFail(`quox kuma monitors ${verb}: ${ep.method} ${ep.path} returned an error body: ${(data && data.error) || 'no body'}`);
|
|
401
|
+
}
|
|
402
|
+
console.log(glyphLine('kuma', `monitors ${verb}`, { result: okLine(data), status: 'ok' }));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Map CLI flags to allowlisted Kuma monitor fields (adminClient FIELD_RULES). Only given flags appear. */
|
|
406
|
+
function fieldsFrom(verb, o) {
|
|
407
|
+
const f = {};
|
|
408
|
+
const num = (flag, v) => {
|
|
409
|
+
if (!/^\d+$/.test(String(v))) throw new KumaFail(`quox kuma monitors ${verb}: --${flag} needs a whole number, got "${v}"`);
|
|
410
|
+
return Number(v);
|
|
411
|
+
};
|
|
412
|
+
if (o.name !== undefined) f.name = o.name;
|
|
413
|
+
if (o.type !== undefined) {
|
|
414
|
+
if (!MON_TYPES.includes(o.type)) throw new KumaFail(`quox kuma monitors ${verb}: --type must be one of ${MON_TYPES.join(', ')}, got "${o.type}"`);
|
|
415
|
+
f.type = o.type;
|
|
416
|
+
}
|
|
417
|
+
if (o.url !== undefined) f.url = o.url;
|
|
418
|
+
if (o.hostname !== undefined) f.hostname = o.hostname;
|
|
419
|
+
if (o.port !== undefined) f.port = num('port', o.port);
|
|
420
|
+
if (o.interval !== undefined) f.interval = num('interval', o.interval);
|
|
421
|
+
if (o.keyword !== undefined) f.keyword = o.keyword;
|
|
422
|
+
return f;
|
|
423
|
+
}
|
|
424
|
+
const withFlags = (cmd) => cmd
|
|
425
|
+
.option('--type <type>', `monitor type: ${MON_TYPES.join('|')}`)
|
|
426
|
+
.option('--url <url>', 'http(s) URL (http, keyword)')
|
|
427
|
+
.option('--hostname <host>', 'host name or IP (port, ping, dns)')
|
|
428
|
+
.option('--port <n>', 'TCP port (port, dns)')
|
|
429
|
+
.option('--interval <seconds>', 'check interval, 20..86400')
|
|
430
|
+
.option('--keyword <text>', 'text that must appear (keyword)');
|
|
431
|
+
|
|
432
|
+
monitors
|
|
433
|
+
.command('list')
|
|
434
|
+
.description('Every monitor: id, active, type, name, target, interval')
|
|
435
|
+
.action(guard('monitors list', async () => {
|
|
436
|
+
auth.requireAuth();
|
|
437
|
+
const ep = resolveEndpoint('mon.kuma.monitors.list');
|
|
438
|
+
let data;
|
|
439
|
+
try { data = await client.get(ep.path, { service: ep.service }); } catch (err) { throw monFail('list', ep, err); }
|
|
440
|
+
if (!data || data.success === false || data.error || !Array.isArray(data.monitors)) {
|
|
441
|
+
throw new KumaFail(`quox kuma monitors list: ${ep.method} ${ep.path} returned an error body: ${(data && data.error) || 'response had no monitors array'}`);
|
|
442
|
+
}
|
|
443
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
444
|
+
console.log(glyphLine('kuma', 'monitors list', { result: `${data.monitors.length} monitors${data.kumaVersion ? ` · Kuma ${data.kumaVersion}` : ''}`, status: 'ok' }));
|
|
445
|
+
if (!data.monitors.length) return;
|
|
446
|
+
const rows = [['ID', 'ACTIVE', 'TYPE', 'NAME', 'TARGET', 'INTERVAL']];
|
|
447
|
+
for (const m of data.monitors) {
|
|
448
|
+
rows.push([m.id ?? '-', m.active === null || m.active === undefined ? '-' : (m.active ? 'yes' : 'no'), m.type || '-', m.name || '-', targetOf(m), m.interval ? `${m.interval}s` : '-']);
|
|
449
|
+
}
|
|
450
|
+
console.log(pad(rows).join('\n'));
|
|
451
|
+
}));
|
|
452
|
+
|
|
453
|
+
monitors
|
|
454
|
+
.command('get')
|
|
455
|
+
.description('One monitor in full')
|
|
456
|
+
.argument('<id>', 'monitor id')
|
|
457
|
+
.action(guard('monitors get', async (id) => {
|
|
458
|
+
auth.requireAuth();
|
|
459
|
+
const ep = resolveEndpoint('mon.kuma.monitors.get', { id });
|
|
460
|
+
let data;
|
|
461
|
+
try { data = await client.get(ep.path, { service: ep.service }); } catch (err) { throw monFail('get', ep, err, id); }
|
|
462
|
+
if (!data || data.success === false || data.error || !data.monitor) {
|
|
463
|
+
throw new KumaFail(`quox kuma monitors get: ${ep.method} ${ep.path} returned an error body: ${(data && data.error) || 'response had no monitor'}`);
|
|
464
|
+
}
|
|
465
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
466
|
+
const m = data.monitor;
|
|
467
|
+
console.log(glyphLine('kuma', `monitors get ${id}`, { result: m.name || `monitor ${id}`, status: 'ok' }));
|
|
468
|
+
const rows = [['ID', m.id], ['NAME', m.name], ['TYPE', m.type], ['ACTIVE', m.active === null || m.active === undefined ? '-' : (m.active ? 'yes' : 'no')], ['TARGET', targetOf(m)], ['INTERVAL', m.interval ? `${m.interval}s` : '-']];
|
|
469
|
+
if (Array.isArray(m.tags) && m.tags.length) rows.push(['TAGS', m.tags.map((t) => (t.value ? `${t.name}:${t.value}` : t.name)).join(', ')]);
|
|
470
|
+
console.log(pad(rows.map(([k, v]) => [k, v ?? '-'])).join('\n'));
|
|
471
|
+
}));
|
|
472
|
+
|
|
473
|
+
// The add/edit/pause/resume/remove actions are NOT wrapped in guard(): the leaf-methods scanner
|
|
474
|
+
// reads each handler's own source, so every mutating client call must stay visible in its action.
|
|
475
|
+
const fail = (err) => {
|
|
476
|
+
if (!(err instanceof KumaFail)) throw err;
|
|
477
|
+
console.error(err.message);
|
|
478
|
+
process.exitCode = err.exitCode || 1;
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
// ---- attached-agent writes ----
|
|
482
|
+
// A session attached as an agent (quox agent attach) acts as that agent for plain verbs. The /api/v1/kuma write
|
|
483
|
+
// routes are human-admin only (they 403 an agent), so an attached session files the same governed kuma_* tool
|
|
484
|
+
// call `quox agent exec <agent> --tool kuma_...` makes: it comes back pending with an approval id.
|
|
485
|
+
async function actingAgent() {
|
|
486
|
+
const who = ctx.resolveActing ? await ctx.resolveActing() : await resolveActing({ explicitProfile: Boolean(ctx.config && ctx.config._explicitProfile) });
|
|
487
|
+
return (who && who.name) || null;
|
|
488
|
+
}
|
|
489
|
+
const wholeId = (verb, v) => {
|
|
490
|
+
if (!/^\d+$/.test(String(v))) throw new KumaFail(`quox kuma ${verb}: the id must be a whole number, got "${v}"`);
|
|
491
|
+
return Number(v);
|
|
492
|
+
};
|
|
493
|
+
const awaitingLine = (id) => `◐ awaiting approval ${id}: a person approves it in the inbox; check with quox approvals result ${id}`;
|
|
494
|
+
/** Run a governed kuma_* tool as the attached agent and report it like a human write. Returns the tool result. */
|
|
495
|
+
async function agentTool(agentName, verb, tool, input, okLine) {
|
|
496
|
+
const ep = resolveEndpoint('v1.tools.execute');
|
|
497
|
+
let data;
|
|
498
|
+
try {
|
|
499
|
+
data = await client.post(ep.path, { service: ep.service, body: { tool_name: tool, input, org_id: resolveOrgId(auth, program) } });
|
|
500
|
+
} catch (err) {
|
|
501
|
+
// The policy layer answers a held write with HTTP 403 + {pending:true, approvalId}: the approval is filed.
|
|
502
|
+
data = pendingBodyOfError(err);
|
|
503
|
+
if (!data) {
|
|
504
|
+
const status = err && (err.status ?? err.statusCode);
|
|
505
|
+
throw new KumaFail(`quox kuma ${verb}: ${ep.method} ${ep.path} (${tool} as ${agentName}) failed${status ? ` with HTTP ${status}` : ''}: ${(err && err.message) || err}`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
const pendingId = execResultPending(data);
|
|
509
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); if (!pendingId && execResultFailed(data)) process.exitCode = 1; return data; }
|
|
510
|
+
if (pendingId) { console.log(awaitingLine(pendingId)); return data; }
|
|
511
|
+
if (execResultFailed(data) || !data || data.error) {
|
|
512
|
+
throw new KumaFail(`quox kuma ${verb}: ${tool} as ${agentName} failed: ${(data && (data.error || data.message || data.error_code)) || 'tool reported failure'}`);
|
|
513
|
+
}
|
|
514
|
+
if (okLine) console.log(glyphLine('kuma', verb, { result: okLine(data), status: 'ok' }));
|
|
515
|
+
return data;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
withFlags(monitors.command('add').description('Add a monitor (org admin; held for human approval)')
|
|
519
|
+
.option('--name <name>', 'monitor name (required)'))
|
|
520
|
+
.action(async (options) => {
|
|
521
|
+
try {
|
|
522
|
+
auth.requireAuth();
|
|
523
|
+
if (!options.name) throw new KumaFail('quox kuma monitors add: --name is required');
|
|
524
|
+
if (!options.type) throw new KumaFail(`quox kuma monitors add: --type is required (${MON_TYPES.join('|')})`);
|
|
525
|
+
const monitor = fieldsFrom('add', options);
|
|
526
|
+
const agentName = await actingAgent();
|
|
527
|
+
if (agentName) { await agentTool(agentName, 'monitors add', 'kuma_monitor_add', { monitor: { name: options.name, ...monitor } }, (d) => `added ${(d.monitor && d.monitor.name) || monitor.name}`); return; }
|
|
528
|
+
const ep = resolveEndpoint('mon.kuma.monitors.create');
|
|
529
|
+
let data;
|
|
530
|
+
try { data = await client.post(ep.path, { service: ep.service, body: { monitor } }); } catch (err) { throw monFail('add', ep, err); }
|
|
531
|
+
reportWrite('add', ep, data, (d) => `added ${d.name || monitor.name}${d.id ? ` (id ${d.id})` : ''}`);
|
|
532
|
+
} catch (err) { fail(err); }
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
withFlags(monitors.command('edit').description('Change a monitor; only the flags you give are changed (org admin; held for approval)')
|
|
536
|
+
.argument('<id>', 'monitor id')
|
|
537
|
+
.option('--name <name>', 'new monitor name'))
|
|
538
|
+
.action(async (id, options) => {
|
|
539
|
+
try {
|
|
540
|
+
auth.requireAuth();
|
|
541
|
+
const changes = fieldsFrom('edit', options);
|
|
542
|
+
if (!Object.keys(changes).length) throw new KumaFail('quox kuma monitors edit: give at least one of --name --type --url --hostname --port --interval --keyword');
|
|
543
|
+
const agentName = await actingAgent();
|
|
544
|
+
if (agentName) { await agentTool(agentName, 'monitors edit', 'kuma_monitor_edit', { id: wholeId('monitors edit', id), changes }, () => `edited monitor ${id}`); return; }
|
|
545
|
+
const ep = resolveEndpoint('mon.kuma.monitors.update', { id });
|
|
546
|
+
let data;
|
|
547
|
+
try { data = await client.patch(ep.path, { service: ep.service, body: { changes } }); } catch (err) { throw monFail('edit', ep, err, id); }
|
|
548
|
+
reportWrite('edit', ep, data, (d) => `edited ${d.name || `monitor ${id}`} (id ${d.id ?? id})`);
|
|
549
|
+
} catch (err) { fail(err); }
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
monitors
|
|
553
|
+
.command('pause')
|
|
554
|
+
.description('Pause a monitor (org admin; held for human approval)')
|
|
555
|
+
.argument('<id>', 'monitor id')
|
|
556
|
+
.action(async (id) => {
|
|
557
|
+
try {
|
|
558
|
+
auth.requireAuth();
|
|
559
|
+
const agentName = await actingAgent();
|
|
560
|
+
if (agentName) { await agentTool(agentName, 'monitors pause', 'kuma_monitor_pause', { id: wholeId('monitors pause', id) }, () => `paused monitor ${id}`); return; }
|
|
561
|
+
const ep = resolveEndpoint('mon.kuma.monitors.pause', { id });
|
|
562
|
+
let data;
|
|
563
|
+
try { data = await client.post(ep.path, { service: ep.service, body: {} }); } catch (err) { throw monFail('pause', ep, err, id); }
|
|
564
|
+
reportWrite('pause', ep, data, (d) => `paused ${d.name || `monitor ${id}`}`);
|
|
565
|
+
} catch (err) { fail(err); }
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
monitors
|
|
569
|
+
.command('resume')
|
|
570
|
+
.description('Resume a paused monitor (org admin; held for human approval)')
|
|
571
|
+
.argument('<id>', 'monitor id')
|
|
572
|
+
.action(async (id) => {
|
|
573
|
+
try {
|
|
574
|
+
auth.requireAuth();
|
|
575
|
+
const agentName = await actingAgent();
|
|
576
|
+
if (agentName) { await agentTool(agentName, 'monitors resume', 'kuma_monitor_resume', { id: wholeId('monitors resume', id) }, () => `resumed monitor ${id}`); return; }
|
|
577
|
+
const ep = resolveEndpoint('mon.kuma.monitors.resume', { id });
|
|
578
|
+
let data;
|
|
579
|
+
try { data = await client.post(ep.path, { service: ep.service, body: {} }); } catch (err) { throw monFail('resume', ep, err, id); }
|
|
580
|
+
reportWrite('resume', ep, data, (d) => `resumed ${d.name || `monitor ${id}`}`);
|
|
581
|
+
} catch (err) { fail(err); }
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
monitors
|
|
585
|
+
.command('remove')
|
|
586
|
+
.description('Delete a monitor (org admin; held for human approval)')
|
|
587
|
+
.argument('<id>', 'monitor id')
|
|
588
|
+
.action(async (id) => {
|
|
589
|
+
try {
|
|
590
|
+
auth.requireAuth();
|
|
591
|
+
const agentName = await actingAgent();
|
|
592
|
+
if (agentName) { await agentTool(agentName, 'monitors remove', 'kuma_monitor_delete', { id: wholeId('monitors remove', id) }, () => `removed monitor ${id}`); return; }
|
|
593
|
+
const ep = resolveEndpoint('mon.kuma.monitors.delete', { id });
|
|
594
|
+
let data;
|
|
595
|
+
try { data = await client.delete(ep.path, { service: ep.service }); } catch (err) { throw monFail('remove', ep, err, id); }
|
|
596
|
+
reportWrite('remove', ep, data, (d) => `removed ${d.name || `monitor ${id}`}`);
|
|
597
|
+
} catch (err) { fail(err); }
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
// ---- incidents + alert hooks (NQ-KUMA-4) ----
|
|
601
|
+
const ageOf = (s) => {
|
|
602
|
+
if (s === null || s === undefined) return '-';
|
|
603
|
+
if (s < 60) return `${s}s`;
|
|
604
|
+
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
|
605
|
+
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
|
|
606
|
+
};
|
|
607
|
+
const hookFail = (verb, ep, err, id) => {
|
|
608
|
+
const status = err && (err.status ?? err.statusCode);
|
|
609
|
+
const code = err && err.body && err.body.error_code;
|
|
610
|
+
const msg = (err && err.message) || String(err);
|
|
611
|
+
const head = `quox kuma ${verb}: ${ep.method} ${ep.path} failed${status ? ` with HTTP ${status}` : ''}`;
|
|
612
|
+
if (status === 403 && (code === 'admin_required' || /admin role required/i.test(msg))) return new KumaFail(`${head}: managing alert hooks needs an org admin or owner.`);
|
|
613
|
+
if (status === 404) return new KumaFail(`${head}: no such hook${id ? ` (${id})` : ''} in this org. List them with \`quox kuma hooks list\`.`);
|
|
614
|
+
return new KumaFail(`${head}: ${msg}`);
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
kuma
|
|
618
|
+
.command('incidents')
|
|
619
|
+
.description('Kuma alerts that became Quox incidents: when down, when back, how long')
|
|
620
|
+
.option('--open', 'only outages still going')
|
|
621
|
+
.option('--limit <n>', 'max rows (default 50, max 200)')
|
|
622
|
+
.action(guard('incidents', async (options) => {
|
|
623
|
+
auth.requireAuth();
|
|
624
|
+
const ep = resolveEndpoint('mon.kuma.incidents.list', { open: !!options.open, limit: options.limit });
|
|
625
|
+
let data;
|
|
626
|
+
try { data = await client.get(ep.path, { service: ep.service }); } catch (err) { throw hookFail('incidents', ep, err); }
|
|
627
|
+
if (!data || data.success === false || data.error || !Array.isArray(data.incidents)) {
|
|
628
|
+
throw new KumaFail(`quox kuma incidents: ${ep.method} ${ep.path} returned an error body: ${(data && data.error) || 'response had no incidents array'}`);
|
|
629
|
+
}
|
|
630
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
631
|
+
const openN = data.incidents.filter((i) => i.open).length;
|
|
632
|
+
console.log(glyphLine('kuma', 'incidents', { result: `${data.incidents.length} shown · ${openN} open`, status: openN ? 'warn' : 'ok' }));
|
|
633
|
+
if (!data.incidents.length) return;
|
|
634
|
+
// Relative times (the ISO stays in --json). The OPENED cell is one token (2h35m) so the table columns stay scannable.
|
|
635
|
+
const now = Date.now();
|
|
636
|
+
const sinceOpen = (i) => { const t = Date.parse(i.opened_at); return Number.isFinite(t) ? relDuration(now - t) : null; };
|
|
637
|
+
const rows = [['STATE', 'MONITOR', 'TARGET', 'OPENED AGO', 'DURATION', 'EVENTS', 'LAST MESSAGE']];
|
|
638
|
+
for (const i of data.incidents) {
|
|
639
|
+
const ago = sinceOpen(i);
|
|
640
|
+
const downFor = i.close_reason === 'monitor_deleted' ? 'closed: monitor deleted' : i.open ? (ago ? `ongoing, down for ${ago}` : 'ongoing') : ageOf(i.duration_s);
|
|
641
|
+
rows.push([i.open ? 'DOWN' : (i.close_reason === 'monitor_deleted' ? 'closed' : 'resolved'), i.monitor_name || i.monitor_id, i.target || '-', ago ? `${ago.replace(/ /g, '')}` : (i.opened_at || '-'), downFor, i.events ?? '-', i.last_msg || '-']);
|
|
642
|
+
}
|
|
643
|
+
console.log(pad(rows).join('\n'));
|
|
644
|
+
// NQ-KUMA-5: the why, under the table, for every incident VIGIL has looked at.
|
|
645
|
+
const diagnosed = data.incidents.filter((i) => i.diagnosis && i.diagnosis.summary);
|
|
646
|
+
if (diagnosed.length) {
|
|
647
|
+
console.log('');
|
|
648
|
+
const width = Math.max(40, process.stdout.columns || 100);
|
|
649
|
+
for (const i of diagnosed) {
|
|
650
|
+
const head = `why ${i.id} `;
|
|
651
|
+
const tag = ` [${i.diagnosis.verdict}]`;
|
|
652
|
+
const full = `${head}${i.diagnosis.summary}${tag}`;
|
|
653
|
+
if ([...full].length <= width) { console.log(full); continue; }
|
|
654
|
+
const lines = wrapText(`${i.diagnosis.summary}${tag}`, width - head.length);
|
|
655
|
+
lines.forEach((l, n) => console.log(n === 0 ? `${head}${l}` : `${' '.repeat(head.length)}${l}`));
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}));
|
|
659
|
+
|
|
660
|
+
const printDiagnosis = (d, verb) => {
|
|
661
|
+
console.log(glyphLine('kuma', verb, { result: d.verdict, status: d.verdict === 'not_reproducible' ? 'warn' : 'fail' }));
|
|
662
|
+
console.log(d.summary);
|
|
663
|
+
const insideNote = {
|
|
664
|
+
ran: `inside checks ran on ${d.inside && d.inside.host}`,
|
|
665
|
+
no_host: 'outside-in only (the target is not a managed fleet host)',
|
|
666
|
+
needs_approval: 'outside-in only (host checks need a human approval)',
|
|
667
|
+
refused: 'outside-in only (the fleet boundary refused the host checks)',
|
|
668
|
+
unavailable: 'outside-in only (the fleet boundary was not available)',
|
|
669
|
+
}[d.inside && d.inside.status] || '';
|
|
670
|
+
if (insideNote) console.log(insideNote);
|
|
671
|
+
const rows = [['CHECK', 'OK', 'MS', 'DETAIL']];
|
|
672
|
+
for (const c of d.checks || []) rows.push([c.name, c.ok ? 'yes' : 'NO', c.ms, c.detail]);
|
|
673
|
+
if (rows.length > 1) { console.log(''); console.log(pad(rows).join('\n')); }
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
kuma
|
|
677
|
+
.command('diagnose')
|
|
678
|
+
.description('Why is it down: outside-in checks (DNS, TCP, HTTP, TLS, keyword) plus read-only host checks when the target is a managed fleet host')
|
|
679
|
+
.argument('[incidentId]', 'incident id from `quox kuma incidents`')
|
|
680
|
+
.option('--monitor <id>', 'diagnose the newest open incident of this monitor')
|
|
681
|
+
.action(guard('diagnose', async (incidentId, options) => {
|
|
682
|
+
auth.requireAuth();
|
|
683
|
+
if (!incidentId && !options.monitor) throw new KumaFail('quox kuma diagnose: give an incident id (quox kuma incidents) or --monitor <id>.');
|
|
684
|
+
const ep = resolveEndpoint('mon.kuma.diagnose.create');
|
|
685
|
+
const body = incidentId ? { incident_id: incidentId } : { monitor_id: String(options.monitor) };
|
|
686
|
+
let data;
|
|
687
|
+
try { data = await client.post(ep.path, { service: ep.service, body }); } catch (err) {
|
|
688
|
+
const status = err && (err.status ?? err.statusCode);
|
|
689
|
+
if (status === 404) throw new KumaFail(`quox kuma diagnose: ${ep.method} ${ep.path} failed with HTTP 404: no such incident${incidentId ? ` (${incidentId})` : ` for monitor ${options.monitor}`} in this org. List them with \`quox kuma incidents\`.`);
|
|
690
|
+
throw hookFail('diagnose', ep, err);
|
|
691
|
+
}
|
|
692
|
+
if (!data || data.success === false || data.error || !data.diagnosis) {
|
|
693
|
+
throw new KumaFail(`quox kuma diagnose: ${ep.method} ${ep.path} returned an error body: ${(data && data.error) || 'response had no diagnosis'}`);
|
|
694
|
+
}
|
|
695
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
696
|
+
printDiagnosis(data.diagnosis, 'diagnose');
|
|
697
|
+
}));
|
|
698
|
+
|
|
699
|
+
// ---- coverage audit (NQ-KUMA-6) ----
|
|
700
|
+
const coverage = kuma
|
|
701
|
+
.command('coverage')
|
|
702
|
+
.description('What is not watched: fleet hosts, verified domains and the Quox URL against your Kuma monitors ("9 of 14 watched")');
|
|
703
|
+
|
|
704
|
+
const UNWATCHED_CAP = 20;
|
|
705
|
+
coverage.option('--all', 'list every unwatched item (default: the first 20)');
|
|
706
|
+
example(coverage
|
|
707
|
+
.command('show', { isDefault: true })
|
|
708
|
+
.description('The coverage audit: watched, unwatched, orphan monitors')
|
|
709
|
+
.option('--all', 'list every unwatched item (default: the first 20)')
|
|
710
|
+
.action(guard('coverage', async (options, cmd) => {
|
|
711
|
+
const showAll = Boolean((options && options.all) || coverage.opts().all);
|
|
712
|
+
auth.requireAuth();
|
|
713
|
+
const ep = resolveEndpoint('mon.kuma.coverage.get');
|
|
714
|
+
let data;
|
|
715
|
+
try { data = await client.get(ep.path, { service: ep.service }); } catch (err) { throw hookFail('coverage', ep, err); }
|
|
716
|
+
if (!data || data.success === false || data.error || !Array.isArray(data.unwatched)) {
|
|
717
|
+
throw new KumaFail(`quox kuma coverage: ${ep.method} ${ep.path} returned an error body: ${(data && data.error) || 'response had no coverage'}`);
|
|
718
|
+
}
|
|
719
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
720
|
+
console.log(glyphLine('kuma', 'coverage', { result: data.headline, status: data.unwatched.length || data.partial ? 'warn' : 'ok' }));
|
|
721
|
+
for (const e of data.sourceErrors || []) console.log(`could not read ${e.source}: ${e.error}`);
|
|
722
|
+
if (data.unwatched.length) {
|
|
723
|
+
console.log('');
|
|
724
|
+
console.log('NOT WATCHED');
|
|
725
|
+
const shown = showAll ? data.unwatched : data.unwatched.slice(0, UNWATCHED_CAP);
|
|
726
|
+
console.log(pad([['KIND', 'NAME', 'ADDRESS', 'SUGGESTED'], ...shown.map((i) => [i.kind, i.name, i.address, `${i.suggested.type} ${i.suggested.url || i.suggested.hostname}`])]).join('\n'));
|
|
727
|
+
if (shown.length < data.unwatched.length) console.log(`and ${data.unwatched.length - shown.length} more (quox kuma coverage --all)`);
|
|
728
|
+
console.log('Cover them: quox kuma coverage propose <name...> (each one waits for a human approval)');
|
|
729
|
+
}
|
|
730
|
+
if (Array.isArray(data.invalid) && data.invalid.length) {
|
|
731
|
+
console.log('');
|
|
732
|
+
console.log('BAD INVENTORY ADDRESS (cannot be monitored; fix the host record, nothing is suggested)');
|
|
733
|
+
console.log(pad([['KIND', 'NAME', 'ADDRESS', 'WHY'], ...data.invalid.map((i) => [i.kind, i.name, i.address || '-', i.reason])]).join('\n'));
|
|
734
|
+
}
|
|
735
|
+
if (data.watched.length) {
|
|
736
|
+
console.log('');
|
|
737
|
+
console.log('WATCHED');
|
|
738
|
+
console.log(pad([['KIND', 'NAME', 'MONITORS'], ...data.watched.map((i) => [i.kind, i.name, i.monitors.map((m) => m.name).join(', ')])]).join('\n'));
|
|
739
|
+
}
|
|
740
|
+
if (data.orphans.length) {
|
|
741
|
+
console.log('');
|
|
742
|
+
console.log('MONITORS MATCHING NOTHING IN THE INVENTORY (informational)');
|
|
743
|
+
console.log(pad([['ID', 'NAME', 'TYPE', 'TARGET'], ...data.orphans.map((m) => [m.id, m.name, m.type, m.target || '-'])]).join('\n'));
|
|
744
|
+
}
|
|
745
|
+
})), 'quox kuma coverage --all');
|
|
746
|
+
|
|
747
|
+
example(coverage
|
|
748
|
+
.command('propose')
|
|
749
|
+
.description('File an approval-gated add request for each named unwatched item (org admin; nothing is added until approved)')
|
|
750
|
+
.argument('<name...>', 'item names from `quox kuma coverage`')
|
|
751
|
+
.option('--dry-run', 'only list what would be proposed')
|
|
752
|
+
.action(async (names, options) => {
|
|
753
|
+
try {
|
|
754
|
+
auth.requireAuth();
|
|
755
|
+
const agentName = await actingAgent();
|
|
756
|
+
const ep = resolveEndpoint('mon.kuma.coverage.propose');
|
|
757
|
+
let data;
|
|
758
|
+
if (agentName) {
|
|
759
|
+
// kuma_coverage_propose files one approval per item itself; its result has the route's shape.
|
|
760
|
+
const t = await client.post(resolveEndpoint('v1.tools.execute').path, { service: 'collector', body: { tool_name: 'kuma_coverage_propose', input: { items: names, ...(options.dryRun ? { dry_run: true } : {}) }, org_id: resolveOrgId(auth, program) } });
|
|
761
|
+
if (execResultFailed(t) || !t || t.error) throw new KumaFail(`quox kuma coverage propose: kuma_coverage_propose as ${agentName} failed: ${(t && (t.error || t.message || t.error_code)) || 'tool reported failure'}`);
|
|
762
|
+
data = t;
|
|
763
|
+
} else {
|
|
764
|
+
try { data = await client.post(ep.path, { service: ep.service, body: { items: names, ...(options.dryRun ? { dry_run: true } : {}) } }); } catch (err) { throw hookFail('coverage propose', ep, err); }
|
|
765
|
+
}
|
|
766
|
+
if (!data || data.success === false || data.error) throw new KumaFail(`quox kuma coverage propose: ${ep.method} ${ep.path} returned an error body: ${(data && data.error) || 'no body'}`);
|
|
767
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
768
|
+
if (data.dryRun) {
|
|
769
|
+
console.log(glyphLine('kuma', 'coverage propose', { result: `dry run: ${(data.would_propose || []).length} would be proposed`, status: 'ok' }));
|
|
770
|
+
for (const i of data.would_propose || []) console.log(`${i.kind} ${i.name} -> ${i.suggested.type} ${i.suggested.url || i.suggested.hostname}`);
|
|
771
|
+
} else {
|
|
772
|
+
const n = (data.proposals || []).filter((p) => p.status === 'awaiting_approval').length;
|
|
773
|
+
console.log(glyphLine('kuma', 'coverage propose', { result: `${n} awaiting approval`, status: n ? 'ok' : 'warn' }));
|
|
774
|
+
for (const p of data.proposals || []) console.log(`${p.name} ${p.status}${p.approval_id ? ` (${p.approval_id})` : ''}${p.error ? ` ${p.error}` : ''}`);
|
|
775
|
+
if (n) console.log('Approve them in the inbox; nothing is monitored until you do.');
|
|
776
|
+
}
|
|
777
|
+
if (data.truncated) console.log(`Capped at ${data.cap} per call; run it again for the rest.`);
|
|
778
|
+
for (const r of data.refusedBadAddress || []) console.log(`Refused ${r.name}: ${r.reason} (${r.address || 'no address'}). Fix the inventory record first.`);
|
|
779
|
+
if ((data.notUnwatched || []).length) console.log(`Not on the unwatched list (already watched or unknown): ${data.notUnwatched.join(', ')}`);
|
|
780
|
+
} catch (err) { fail(err); }
|
|
781
|
+
}), 'quox kuma coverage propose cache01 --dry-run');
|
|
782
|
+
// NQ-KUMA-7: quox kuma sla [list|verify]
|
|
783
|
+
registerKumaSla(kuma, { client, auth, jsonOut, guard, KumaFail, pad });
|
|
784
|
+
|
|
785
|
+
// ---- maintenance windows + customer-safe status-page incidents (NQ-KUMA-8) ----
|
|
786
|
+
// Like the monitor writes, these actions are not wrapped in guard(): the leaf-methods scanner reads each
|
|
787
|
+
// handler's own source, so every mutating client call stays visible in its action.
|
|
788
|
+
function p8Fail(verb, ep, err) {
|
|
789
|
+
const status = err && (err.status ?? err.statusCode);
|
|
790
|
+
const body = (err && err.body) || {};
|
|
791
|
+
const code = body.error_code || body.code || (err && err.code);
|
|
792
|
+
const msg = body.error || (err && err.message) || String(err);
|
|
793
|
+
const head = `quox kuma ${verb}: ${ep.method} ${ep.path} failed${status ? ` with HTTP ${status}` : ''}`;
|
|
794
|
+
if (status === 403 && (code === 'admin_required' || /admin role required/i.test(msg))) return new KumaFail(`${head}: this needs an org admin or owner.`);
|
|
795
|
+
if (status === 404) return new KumaFail(`${head}: ${msg}`);
|
|
796
|
+
if (status === 422 || code === 'not_configured') return new KumaFail(`${head}: Uptime Kuma admin access is not configured for this org (${msg}).`);
|
|
797
|
+
return new KumaFail(`${head}: ${msg}`);
|
|
798
|
+
}
|
|
799
|
+
function p8Report(verb, ep, data, okLine) {
|
|
800
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
801
|
+
if (data && data.status === 'awaiting_approval') {
|
|
802
|
+
const id = data.approval_id || 'unknown';
|
|
803
|
+
console.log(`◐ awaiting approval ${id}: approve in the inbox${data.inbox_item_id ? ` (item ${data.inbox_item_id})` : ''}, then check with \`quox approvals result ${id}\``);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
if (!data || data.success === false || data.error) throw new KumaFail(`quox kuma ${verb}: ${ep.method} ${ep.path} returned an error body: ${(data && data.error) || 'no body'}`);
|
|
807
|
+
console.log(glyphLine('kuma', verb, { result: okLine(data), status: 'ok' }));
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const maintenance = kuma
|
|
811
|
+
.command('maintenance')
|
|
812
|
+
.description('Maintenance windows: planned work does not page people (open and end are org admin, human approved)')
|
|
813
|
+
.addHelpText('after', '\nDeploy hook (one line, before the deploy; the window still waits for a human approval):\n'
|
|
814
|
+
+ ' quox kuma maintenance open --for 15 --monitors all --title "Deploy"\n'
|
|
815
|
+
+ 'Monitors: all, a comma list of monitor ids (3,7), or tag:<name>. A window is at most 1440 minutes.\n'
|
|
816
|
+
+ 'End early: quox kuma maintenance end <id> (ids from `quox kuma maintenance list`)');
|
|
817
|
+
|
|
818
|
+
maintenance
|
|
819
|
+
.command('list', { isDefault: true })
|
|
820
|
+
.description('The maintenance windows: id, status, start, end')
|
|
821
|
+
.action(guard('maintenance list', async () => {
|
|
822
|
+
auth.requireAuth();
|
|
823
|
+
const ep = resolveEndpoint('mon.kuma.maintenance.list');
|
|
824
|
+
let data;
|
|
825
|
+
try { data = await client.get(ep.path, { service: ep.service }); } catch (err) { throw p8Fail('maintenance list', ep, err); }
|
|
826
|
+
if (!data || data.success === false || data.error || !Array.isArray(data.windows)) {
|
|
827
|
+
throw new KumaFail(`quox kuma maintenance list: ${ep.method} ${ep.path} returned an error body: ${(data && data.error) || 'response had no windows array'}`);
|
|
828
|
+
}
|
|
829
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
830
|
+
console.log(glyphLine('kuma', 'maintenance list', { result: `${data.windows.length} windows`, status: 'ok' }));
|
|
831
|
+
if (!data.windows.length) return;
|
|
832
|
+
// Times in UTC. Kuma returns server-local strings: converted when the zone is known, labelled "Kuma server time" when not.
|
|
833
|
+
const tzOf = (w) => w.timezone || data.timezone || null;
|
|
834
|
+
console.log(pad([['ID', 'STATE', 'TITLE', 'START', 'END'], ...data.windows.map((w) => [w.id ?? '-', w.state || w.status || (w.active === false ? 'inactive' : '-'), w.title || '-', fmtWhen(w.start, tzOf(w)), fmtWhen(w.end, tzOf(w))])]).join('\n'));
|
|
835
|
+
}));
|
|
836
|
+
|
|
837
|
+
maintenance
|
|
838
|
+
.command('open')
|
|
839
|
+
.description('Open a one-off window over monitors for N minutes (org admin; held for human approval)')
|
|
840
|
+
.requiredOption('--title <text>', 'window title')
|
|
841
|
+
.requiredOption('--for <minutes>', 'how long, in minutes from now (1..1440)')
|
|
842
|
+
.requiredOption('--monitors <list>', 'all | comma list of monitor ids | tag:<name>')
|
|
843
|
+
.option('--start <iso>', 'start at this ISO time instead of now')
|
|
844
|
+
.action(async (options) => {
|
|
845
|
+
try {
|
|
846
|
+
auth.requireAuth();
|
|
847
|
+
if (!/^\d+$/.test(String(options.for))) throw new KumaFail(`quox kuma maintenance open: --for needs a whole number of minutes, got "${options.for}"`);
|
|
848
|
+
const sel = String(options.monitors).trim();
|
|
849
|
+
let monitors;
|
|
850
|
+
if (sel === 'all') monitors = 'all';
|
|
851
|
+
else if (/^tag:./.test(sel)) monitors = { tag: sel.slice(4) };
|
|
852
|
+
else if (/^\d+(,\d+)*$/.test(sel)) monitors = sel.split(',').map(Number);
|
|
853
|
+
else throw new KumaFail(`quox kuma maintenance open: --monitors must be all, a comma list of ids (3,7) or tag:<name>, got "${sel}"`);
|
|
854
|
+
const body = { title: options.title, minutes: Number(options.for), monitors, ...(options.start ? { start: options.start } : {}) };
|
|
855
|
+
const agentName = await actingAgent();
|
|
856
|
+
if (agentName) { await agentTool(agentName, 'maintenance open', 'kuma_maintenance_open', body, () => `window open for ${options.for} minutes`); return; }
|
|
857
|
+
const ep = resolveEndpoint('mon.kuma.maintenance.open');
|
|
858
|
+
let data;
|
|
859
|
+
try { data = await client.post(ep.path, { service: ep.service, body }); } catch (err) { throw p8Fail('maintenance open', ep, err); }
|
|
860
|
+
p8Report('maintenance open', ep, data, (d) => `window ${d.maintenance && d.maintenance.id != null ? d.maintenance.id : ''} open for ${options.for} minutes over ${d.maintenance && d.maintenance.monitor_ids ? plural(d.maintenance.monitor_ids.length, 'monitor') : '? monitors'}`);
|
|
861
|
+
} catch (err) { fail(err); }
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
maintenance
|
|
865
|
+
.command('end')
|
|
866
|
+
.description('End a maintenance window early (org admin; held for human approval)')
|
|
867
|
+
.argument('<id>', 'window id from `quox kuma maintenance list`')
|
|
868
|
+
.action(async (id) => {
|
|
869
|
+
try {
|
|
870
|
+
auth.requireAuth();
|
|
871
|
+
const agentName = await actingAgent();
|
|
872
|
+
if (agentName) { await agentTool(agentName, 'maintenance end', 'kuma_maintenance_end', { id: wholeId('maintenance end', id) }, () => `window ${id} ended`); return; }
|
|
873
|
+
const ep = resolveEndpoint('mon.kuma.maintenance.end', { id });
|
|
874
|
+
let data;
|
|
875
|
+
try { data = await client.post(ep.path, { service: ep.service, body: {} }); } catch (err) { throw p8Fail('maintenance end', ep, err); }
|
|
876
|
+
p8Report('maintenance end', ep, data, () => `window ${id} ended`);
|
|
877
|
+
} catch (err) { fail(err); }
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
const statusIncident = kuma
|
|
881
|
+
.command('status-incident')
|
|
882
|
+
.description('Customer-safe status-page comms for an outage: draft, publish, resolve (publish and resolve are org admin, human approved)');
|
|
883
|
+
|
|
884
|
+
/** --slug, else the first configured status page. */
|
|
885
|
+
async function slugOrDefault(verb, slug) {
|
|
886
|
+
if (slug) return slug;
|
|
887
|
+
return (await configuredSlugs(verb))[0];
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
statusIncident
|
|
891
|
+
.command('draft')
|
|
892
|
+
.description('The plain-words notice for an incident (no hostnames, IPs or internal detail); nothing is posted')
|
|
893
|
+
.argument('<incidentId>', 'incident id from `quox kuma incidents`')
|
|
894
|
+
.action(guard('status-incident draft', async (incidentId) => {
|
|
895
|
+
auth.requireAuth();
|
|
896
|
+
const ep = resolveEndpoint('mon.kuma.statusIncident.draft', { id: incidentId });
|
|
897
|
+
let data;
|
|
898
|
+
try { data = await client.get(ep.path, { service: ep.service }); } catch (err) { throw p8Fail('status-incident draft', ep, err); }
|
|
899
|
+
if (!data || data.success === false || data.error || !data.draft) {
|
|
900
|
+
throw new KumaFail(`quox kuma status-incident draft: ${ep.method} ${ep.path} returned an error body: ${(data && data.error) || 'response had no draft'}`);
|
|
901
|
+
}
|
|
902
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
903
|
+
console.log(glyphLine('kuma', 'status-incident draft', { result: `${data.draft.style} · ${data.draft.open ? 'outage' : 'recovered'}`, status: 'ok' }));
|
|
904
|
+
console.log(`${data.draft.title}\n\n${data.draft.content}\n\nPost it: quox kuma status-incident publish ${incidentId} (held for a human approval)`);
|
|
905
|
+
}));
|
|
906
|
+
|
|
907
|
+
statusIncident
|
|
908
|
+
.command('publish')
|
|
909
|
+
.description('Post the drafted notice to a PUBLIC status page (org admin; held for human approval, HIGH risk)')
|
|
910
|
+
.argument('<incidentId>', 'incident id from `quox kuma incidents`')
|
|
911
|
+
.option('--slug <slug>', 'status page slug (default: the first configured page)')
|
|
912
|
+
.action(async (incidentId, options) => {
|
|
913
|
+
try {
|
|
914
|
+
auth.requireAuth();
|
|
915
|
+
const slug = await slugOrDefault('status-incident publish', options.slug);
|
|
916
|
+
const agentName = await actingAgent();
|
|
917
|
+
if (agentName) { await agentTool(agentName, 'status-incident publish', 'kuma_status_incident_publish', { incident_id: incidentId, slug }, () => `posted to ${slug}`); return; }
|
|
918
|
+
const ep = resolveEndpoint('mon.kuma.statusIncident.publish', { id: incidentId });
|
|
919
|
+
let data;
|
|
920
|
+
try { data = await client.post(ep.path, { service: ep.service, body: { slug } }); } catch (err) { throw p8Fail('status-incident publish', ep, err); }
|
|
921
|
+
p8Report('status-incident publish', ep, data, (d) => `posted to ${slug}${d.status_incident && d.status_incident.title ? `: ${d.status_incident.title}` : ''}`);
|
|
922
|
+
} catch (err) { fail(err); }
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
statusIncident
|
|
926
|
+
.command('resolve')
|
|
927
|
+
.description('Take the pinned notice down from a PUBLIC status page (org admin; held for human approval, HIGH risk)')
|
|
928
|
+
.option('--slug <slug>', 'status page slug (default: the first configured page)')
|
|
929
|
+
.action(async (options) => {
|
|
930
|
+
try {
|
|
931
|
+
auth.requireAuth();
|
|
932
|
+
const slug = await slugOrDefault('status-incident resolve', options.slug);
|
|
933
|
+
const agentName = await actingAgent();
|
|
934
|
+
if (agentName) { await agentTool(agentName, 'status-incident resolve', 'kuma_status_incident_resolve', { slug }, () => `notice on ${slug} taken down`); return; }
|
|
935
|
+
const ep = resolveEndpoint('mon.kuma.statusIncident.resolve');
|
|
936
|
+
let data;
|
|
937
|
+
try { data = await client.post(ep.path, { service: ep.service, body: { slug } }); } catch (err) { throw p8Fail('status-incident resolve', ep, err); }
|
|
938
|
+
p8Report('status-incident resolve', ep, data, () => `notice on ${slug} taken down`);
|
|
939
|
+
} catch (err) { fail(err); }
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
const hooks = kuma
|
|
943
|
+
.command('hooks')
|
|
944
|
+
.description('The per-org signed hook Uptime Kuma posts alerts to (list, create, delete; org admin)');
|
|
945
|
+
|
|
946
|
+
hooks
|
|
947
|
+
.command('list')
|
|
948
|
+
.description('This org\'s alert hooks (never shows secrets)')
|
|
949
|
+
.action(guard('hooks list', async () => {
|
|
950
|
+
auth.requireAuth();
|
|
951
|
+
const ep = resolveEndpoint('mon.kuma.hooks.list');
|
|
952
|
+
let data;
|
|
953
|
+
try { data = await client.get(ep.path, { service: ep.service }); } catch (err) { throw hookFail('hooks list', ep, err); }
|
|
954
|
+
if (!data || data.success === false || data.error || !Array.isArray(data.hooks)) {
|
|
955
|
+
throw new KumaFail(`quox kuma hooks list: ${ep.method} ${ep.path} returned an error body: ${(data && data.error) || 'response had no hooks array'}`);
|
|
956
|
+
}
|
|
957
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
958
|
+
console.log(glyphLine('kuma', 'hooks list', { result: plural(data.hooks.length, 'hook'), status: 'ok' }));
|
|
959
|
+
if (!data.hooks.length) { console.log('No hooks yet. Create one: quox kuma hooks create'); return; }
|
|
960
|
+
const rows = [['HOOK', 'LABEL', 'CREATED', 'LAST USED', 'PATH']];
|
|
961
|
+
for (const h of data.hooks) rows.push([h.hookId, h.label || '-', h.created_at || '-', h.last_used_at || 'never', h.path || '-']);
|
|
962
|
+
console.log(pad(rows).join('\n'));
|
|
963
|
+
}));
|
|
964
|
+
|
|
965
|
+
hooks
|
|
966
|
+
.command('create')
|
|
967
|
+
.description('Create an alert hook; the secret is shown ONCE (org admin)')
|
|
968
|
+
.option('--label <text>', 'a name for this hook')
|
|
969
|
+
.action(async (options) => {
|
|
970
|
+
try {
|
|
971
|
+
auth.requireAuth();
|
|
972
|
+
const ep = resolveEndpoint('mon.kuma.hooks.create');
|
|
973
|
+
let data;
|
|
974
|
+
try { data = await client.post(ep.path, { service: ep.service, body: options.label ? { label: options.label } : {} }); } catch (err) { throw hookFail('hooks create', ep, err); }
|
|
975
|
+
if (!data || data.success === false || data.error || !data.hookId || !data.secret) {
|
|
976
|
+
throw new KumaFail(`quox kuma hooks create: ${ep.method} ${ep.path} returned an error body: ${(data && data.error) || 'response had no hook'}`);
|
|
977
|
+
}
|
|
978
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
979
|
+
console.log(glyphLine('kuma', 'hooks create', { result: data.hookId, status: 'ok' }));
|
|
980
|
+
console.log([
|
|
981
|
+
`URL path : ${data.path} (append to your Quox collector base URL)`,
|
|
982
|
+
`Secret : ${data.secret}`,
|
|
983
|
+
'',
|
|
984
|
+
'The secret is shown ONCE and is stored only as a hash. Copy it now.',
|
|
985
|
+
'',
|
|
986
|
+
'Set it up in Uptime Kuma:',
|
|
987
|
+
' 1. Settings > Notifications > Setup Notification',
|
|
988
|
+
' 2. Notification Type: Webhook',
|
|
989
|
+
' 3. Post URL: <collector base URL>' + data.path,
|
|
990
|
+
' 4. Request Body: Preset - application/json (POST JSON)',
|
|
991
|
+
' 5. Additional Headers: {"X-Quox-Kuma-Secret": "' + data.secret + '"}',
|
|
992
|
+
' 6. Save, press Test (answers 200), then enable it on your monitors (or Default enabled).',
|
|
993
|
+
].join('\n'));
|
|
994
|
+
} catch (err) { fail(err); }
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
hooks
|
|
998
|
+
.command('delete')
|
|
999
|
+
.description('Delete an alert hook; Kuma posts to it are refused from then on (org admin)')
|
|
1000
|
+
.argument('<hookId>', 'hook id from `quox kuma hooks list`')
|
|
1001
|
+
.action(async (hookId) => {
|
|
1002
|
+
try {
|
|
1003
|
+
auth.requireAuth();
|
|
1004
|
+
const ep = resolveEndpoint('mon.kuma.hooks.delete', { id: hookId });
|
|
1005
|
+
let data;
|
|
1006
|
+
try { data = await client.delete(ep.path, { service: ep.service }); } catch (err) { throw hookFail('hooks delete', ep, err, hookId); }
|
|
1007
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
1008
|
+
if (!data || data.success === false || data.error) throw new KumaFail(`quox kuma hooks delete: ${ep.method} ${ep.path} returned an error body: ${(data && data.error) || 'no body'}`);
|
|
1009
|
+
console.log(glyphLine('kuma', 'hooks delete', { result: `deleted ${hookId}`, status: 'ok' }));
|
|
1010
|
+
} catch (err) { fail(err); }
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
// Every verb's --help carries one example line (the ones above were added inline).
|
|
1014
|
+
const EXAMPLES = {
|
|
1015
|
+
monitors: 'quox kuma monitors list',
|
|
1016
|
+
'monitors list': 'quox kuma monitors list',
|
|
1017
|
+
'monitors get': 'quox kuma monitors get 4',
|
|
1018
|
+
'monitors add': 'quox kuma monitors add --name api --type http --url https://api.example.test',
|
|
1019
|
+
'monitors edit': 'quox kuma monitors edit 4 --interval 60',
|
|
1020
|
+
'monitors pause': 'quox kuma monitors pause 4',
|
|
1021
|
+
'monitors resume': 'quox kuma monitors resume 4',
|
|
1022
|
+
'monitors remove': 'quox kuma monitors remove 4',
|
|
1023
|
+
incidents: 'quox kuma incidents --open',
|
|
1024
|
+
diagnose: 'quox kuma diagnose --monitor 7',
|
|
1025
|
+
coverage: 'quox kuma coverage',
|
|
1026
|
+
sla: 'quox kuma sla --month 2026-09',
|
|
1027
|
+
'sla list': 'quox kuma sla list',
|
|
1028
|
+
'sla verify': 'quox kuma sla verify <report-id>',
|
|
1029
|
+
maintenance: 'quox kuma maintenance list',
|
|
1030
|
+
'maintenance list': 'quox kuma maintenance list',
|
|
1031
|
+
'maintenance open': 'quox kuma maintenance open --for 15 --monitors all --title "Deploy"',
|
|
1032
|
+
'maintenance end': 'quox kuma maintenance end 5',
|
|
1033
|
+
'status-incident': 'quox kuma status-incident draft <incident-id>',
|
|
1034
|
+
'status-incident draft': 'quox kuma status-incident draft <incident-id>',
|
|
1035
|
+
'status-incident publish': 'quox kuma status-incident publish <incident-id>',
|
|
1036
|
+
'status-incident resolve': 'quox kuma status-incident resolve',
|
|
1037
|
+
hooks: 'quox kuma hooks list',
|
|
1038
|
+
'hooks list': 'quox kuma hooks list',
|
|
1039
|
+
'hooks create': 'quox kuma hooks create --label alerts',
|
|
1040
|
+
'hooks delete': 'quox kuma hooks delete <hook-id>',
|
|
1041
|
+
};
|
|
1042
|
+
(function addExamples(cmd, path) {
|
|
1043
|
+
for (const c of cmd.commands) {
|
|
1044
|
+
const key = [...path, c.name()].join(' ');
|
|
1045
|
+
if (!exampled.has(c) && EXAMPLES[key]) example(c, EXAMPLES[key]);
|
|
1046
|
+
addExamples(c, [...path, c.name()]);
|
|
1047
|
+
}
|
|
1048
|
+
}(kuma, []));
|
|
1049
|
+
|
|
1050
|
+
}
|