@kud/incidents-cli 0.0.0-stage → 0.1.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.
@@ -0,0 +1,118 @@
1
+ import { age } from "./age.js";
2
+ import { SEVERITY } from "./severity.js";
3
+ const tag = (block, name) => block
4
+ .match(new RegExp(`<${name}>(?:<!\\[CDATA\\[)?([\\s\\S]*?)(?:\\]\\]>)?<\\/${name}>`))?.[1]
5
+ ?.trim() ?? "";
6
+ export const parseRss = (xml) => [...xml.matchAll(/<item>([\s\S]*?)<\/item>/g)].map(([, block]) => ({
7
+ title: tag(block, "title"),
8
+ link: tag(block, "link"),
9
+ date: tag(block, "pubDate"),
10
+ }));
11
+ const timeOf = (raw) => new Date(raw).getTime();
12
+ export const newestFirst = (items) => [...items].sort((a, b) => {
13
+ const ta = timeOf(a.date);
14
+ const tb = timeOf(b.date);
15
+ if (Number.isNaN(ta) && Number.isNaN(tb))
16
+ return 0;
17
+ if (Number.isNaN(ta))
18
+ return 1;
19
+ if (Number.isNaN(tb))
20
+ return -1;
21
+ return tb - ta;
22
+ });
23
+ export const fromStatuspageSummary = (summary) => ({
24
+ indicator: (summary?.status?.indicator ?? "unknown"),
25
+ description: summary?.status?.description ?? "No description",
26
+ components: (summary?.components ?? [])
27
+ .filter((c) => !c.group && !/^Visit /i.test(c.name))
28
+ .map((c) => ({ name: c.name, status: c.status })),
29
+ active: (summary?.incidents ?? []).map((i) => ({
30
+ title: i.name,
31
+ link: i.shortlink ?? "",
32
+ date: i.started_at ?? i.created_at ?? "",
33
+ impact: i.impact,
34
+ })),
35
+ });
36
+ export const fromFeed = (items, { now = Date.now(), windowHours = 6 } = {}) => {
37
+ const latest = newestFirst(items)[0];
38
+ if (!latest)
39
+ return { indicator: "none", description: "No incidents posted" };
40
+ const since = now - timeOf(latest.date);
41
+ if (Number.isNaN(since))
42
+ return { indicator: "unknown", description: "Feed has no dates" };
43
+ return since < windowHours * 3_600_000
44
+ ? {
45
+ indicator: "minor",
46
+ description: `Posted ${age(since)} ago: ${latest.title}`,
47
+ }
48
+ : { indicator: "none", description: `Last post ${age(since)} ago` };
49
+ };
50
+ const SLACK_IMPACT = {
51
+ outage: "major",
52
+ incident: "minor",
53
+ notice: "minor",
54
+ };
55
+ export const fromSlack = (current) => {
56
+ const incidents = current?.active_incidents ?? [];
57
+ if (current?.status === "ok" || incidents.length === 0)
58
+ return {
59
+ indicator: "none",
60
+ description: "All systems operational",
61
+ active: [],
62
+ };
63
+ const indicator = incidents
64
+ .map((i) => SLACK_IMPACT[i.type ?? ""] ?? "minor")
65
+ .sort((a, b) => SEVERITY[b] - SEVERITY[a])[0];
66
+ return {
67
+ indicator,
68
+ description: incidents[0].title,
69
+ components: [...new Set(incidents.flatMap((i) => i.services ?? []))].map((name) => ({
70
+ name,
71
+ status: indicator === "major" ? "major_outage" : "degraded_performance",
72
+ })),
73
+ active: incidents.map((i) => ({
74
+ title: i.title,
75
+ link: i.url ?? "",
76
+ date: i.date_created ?? "",
77
+ })),
78
+ };
79
+ };
80
+ const AWS_STATUS = {
81
+ "1": "minor",
82
+ "2": "minor",
83
+ "3": "major",
84
+ };
85
+ export const fromAws = (events, { regions } = {}) => {
86
+ const regionOf = (e) => String(e.arn ?? "").split(":")[3] || "global";
87
+ const wanted = regions?.length ? new Set(regions.map(String)) : null;
88
+ const open = (events ?? []).filter((e) => !wanted || wanted.has(regionOf(e)) || regionOf(e) === "global");
89
+ if (open.length === 0)
90
+ return {
91
+ indicator: "none",
92
+ description: wanted
93
+ ? `No open events in ${[...wanted].join(", ")}`
94
+ : "No open events",
95
+ };
96
+ const level = (e) => AWS_STATUS[String(e.event_log?.at(-1)?.status ?? e.status)] ?? "minor";
97
+ const indicator = open.map(level).sort((a, b) => SEVERITY[b] - SEVERITY[a])[0];
98
+ const first = open[0];
99
+ return {
100
+ indicator,
101
+ description: `${first.service_name}, ${first.region_name}: ${first.summary}` +
102
+ (open.length > 1 ? ` (+${open.length - 1} more)` : ""),
103
+ components: open.map((e) => ({
104
+ name: `${e.service_name} (${regionOf(e)})`,
105
+ status: level(e) === "major" ? "major_outage" : "degraded_performance",
106
+ })),
107
+ active: open.map((e) => ({
108
+ title: `${e.service_name}, ${e.region_name}: ${e.summary}`,
109
+ link: "https://health.aws.amazon.com/health/status",
110
+ date: e.date ? new Date(Number(e.date) * 1000).toISOString() : "",
111
+ })),
112
+ };
113
+ };
114
+ export const decodeUtf16 = (buf) => {
115
+ const bytes = new Uint8Array(buf);
116
+ const le = bytes[0] === 0xff && bytes[1] === 0xfe;
117
+ return new TextDecoder(le ? "utf-16le" : "utf-16be").decode(bytes);
118
+ };
@@ -0,0 +1,52 @@
1
+ import { ADAPTERS } from "./adapters.js";
2
+ const failureReason = (error) => {
3
+ const e = error;
4
+ return e?.name === "TimeoutError" ? "timed out" : (e?.message ?? "failed");
5
+ };
6
+ export const readService = async (service, opts = {}) => {
7
+ const base = {
8
+ id: service.id,
9
+ name: service.name,
10
+ url: service.url ?? "",
11
+ kind: service.kind,
12
+ fetched: "ok",
13
+ indicator: "unknown",
14
+ description: "",
15
+ components: [],
16
+ active: [],
17
+ recent: [],
18
+ historyLoaded: false,
19
+ historyFailed: false,
20
+ note: service.note ?? "",
21
+ };
22
+ const adapter = ADAPTERS[service.kind];
23
+ if (!adapter)
24
+ return {
25
+ ...base,
26
+ fetched: "failed",
27
+ description: `Unknown kind '${service.kind}'`,
28
+ };
29
+ try {
30
+ const adapted = await adapter(service, opts);
31
+ return {
32
+ ...base,
33
+ ...adapted,
34
+ historyLoaded: Boolean(opts.detail) && service.kind !== "link",
35
+ };
36
+ }
37
+ catch (error) {
38
+ if (opts.signal?.aborted)
39
+ throw opts.signal.reason;
40
+ return {
41
+ ...base,
42
+ fetched: "failed",
43
+ description: `Could not read the page (${failureReason(error)})`,
44
+ };
45
+ }
46
+ };
47
+ export const readAll = async (services, { onRecord, ...opts } = {}) => Promise.all(services.map(async (service) => {
48
+ const record = await readService(service, opts);
49
+ if (!opts.signal?.aborted)
50
+ onRecord?.(record);
51
+ return record;
52
+ }));
@@ -0,0 +1,54 @@
1
+ import { CATALOGUE } from "./catalogue.js";
2
+ export const RESERVED_WORDS = ["list", "services", "help"];
3
+ const KINDS = ["statuspage", "rss", "slack", "aws", "link"];
4
+ export class ConfigError extends Error {
5
+ }
6
+ export const mergeServices = (base = {}, over = {}) => {
7
+ const merged = { ...base };
8
+ for (const [id, fields] of Object.entries(over ?? {})) {
9
+ merged[id] = { ...(merged[id] ?? {}), ...(fields ?? {}) };
10
+ }
11
+ return merged;
12
+ };
13
+ const finalise = (raw) => Object.fromEntries(Object.entries(raw)
14
+ .filter(([, svc]) => svc && svc.enabled !== false)
15
+ .map(([id, svc]) => [
16
+ id,
17
+ { id, name: id, aliases: [], ...svc },
18
+ ]));
19
+ export const mergeRegistry = (base = {}, profile = {}) => finalise(mergeServices(base, profile));
20
+ const problemsOf = (svc) => {
21
+ const words = [svc.id, ...svc.aliases.map(String)].map((w) => w.toLowerCase());
22
+ const reserved = words.filter((w) => RESERVED_WORDS.includes(w));
23
+ if (svc.kind === undefined)
24
+ return [
25
+ ...reserved.map((w) => `'${w}' is a reserved word`),
26
+ "is not in the catalogue and has no kind",
27
+ ];
28
+ return [
29
+ ...reserved.map((w) => `'${w}' is a reserved word`),
30
+ ...(KINDS.includes(svc.kind)
31
+ ? []
32
+ : [`kind '${svc.kind}' is not one of ${KINDS.join(", ")}`]),
33
+ ...(["statuspage", "rss", "link"].includes(svc.kind) &&
34
+ !/^https:\/\//.test(svc.url ?? "")
35
+ ? ["needs an https url"]
36
+ : []),
37
+ ...(svc.kind === "rss" && !svc.feed ? ["needs a feed"] : []),
38
+ ];
39
+ };
40
+ export const selectServices = (selected, catalogue = CATALOGUE, source = "") => {
41
+ const registry = finalise(mergeServices(Object.fromEntries(Object.keys(selected).map((id) => [
42
+ id,
43
+ catalogue[id] ?? {},
44
+ ])), selected));
45
+ const problems = Object.values(registry).flatMap((svc) => problemsOf(svc).map((p) => `service '${svc.id}': ${p}`));
46
+ if (problems.length)
47
+ throw new ConfigError((source ? `${source}: ` : "") + problems.join("\n"));
48
+ return registry;
49
+ };
50
+ export const resolveService = (registry, word) => {
51
+ const w = word.toLowerCase();
52
+ return (registry[w] ??
53
+ Object.values(registry).find((svc) => svc.aliases.map((a) => String(a).toLowerCase()).includes(w)));
54
+ };
@@ -0,0 +1,14 @@
1
+ export const SEVERITY = {
2
+ critical: 6,
3
+ major: 5,
4
+ unknown: 4,
5
+ minor: 3,
6
+ maintenance: 2,
7
+ none: 1,
8
+ link: 0,
9
+ };
10
+ export const isClear = (indicator) => indicator === "none" || indicator === "link" || indicator === "maintenance";
11
+ export const severityOf = (indicator) => SEVERITY[indicator] ?? SEVERITY.unknown;
12
+ export const worstFirst = (records) => [...records].sort((a, b) => severityOf(b.indicator) - severityOf(a.indicator) ||
13
+ a.name.localeCompare(b.name));
14
+ export const exitCode = (records) => records.every((r) => isClear(r.indicator)) ? 0 : 1;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,29 @@
1
+ const STATUS = {
2
+ critical: { text: "● critical", tone: "error" },
3
+ major: { text: "● major", tone: "error" },
4
+ minor: { text: "● minor", tone: "warning" },
5
+ maintenance: { text: "◌ maintenance", tone: "info" },
6
+ unknown: { text: "? unreadable", tone: "warning" },
7
+ none: { text: "● ok", tone: "success" },
8
+ link: { text: "→ manual", tone: "muted" },
9
+ };
10
+ export const statusLabel = (indicator) => STATUS[indicator] ?? STATUS.link;
11
+ const WORDS = {
12
+ major_outage: { text: "outage", tone: "error" },
13
+ partial_outage: { text: "partial", tone: "error" },
14
+ under_maintenance: { text: "maintenance", tone: "info" },
15
+ critical: { text: "critical", tone: "error" },
16
+ major: { text: "major", tone: "error" },
17
+ minor: { text: "minor", tone: "warning" },
18
+ degraded_performance: { text: "degraded", tone: "warning" },
19
+ };
20
+ export const wordLabel = (status) => WORDS[status ?? ""] ?? { text: "active", tone: "warning" };
21
+ export const declaredText = (record) => {
22
+ if (record.indicator === "link")
23
+ return "no feed, open the page";
24
+ if (record.indicator !== "none")
25
+ return record.description;
26
+ if (record.kind === "rss")
27
+ return `quiet · ${record.description.charAt(0).toLowerCase()}${record.description.slice(1)}`;
28
+ return "operational";
29
+ };
@@ -0,0 +1,117 @@
1
+ const ago = (minutes) => new Date(Date.now() - minutes * 60_000).toISOString();
2
+ const base = (id, name) => ({
3
+ id,
4
+ name,
5
+ kind: "statuspage",
6
+ url: `https://status.${id}.example.com`,
7
+ aliases: [],
8
+ });
9
+ export const MOCK_SERVICES = [
10
+ base("acme-cloud", "Acme Cloud"),
11
+ base("example-cdn", "Example CDN"),
12
+ base("sample-registry", "Sample Registry"),
13
+ base("demo-maintenance", "Demo Maintenance"),
14
+ { ...base("demo-feed", "Demo Feed"), kind: "rss", aliases: ["feed"] },
15
+ base("placeholder-mail", "Placeholder Mail"),
16
+ { ...base("test-docs", "Test Docs"), kind: "link" },
17
+ ];
18
+ const record = (service, fields) => ({
19
+ id: service.id,
20
+ name: service.name,
21
+ url: service.url ?? "",
22
+ kind: service.kind,
23
+ fetched: "ok",
24
+ indicator: "none",
25
+ description: "All Systems Operational",
26
+ components: [],
27
+ active: [],
28
+ recent: [],
29
+ historyLoaded: false,
30
+ historyFailed: false,
31
+ note: "",
32
+ ...fields,
33
+ });
34
+ const history = (id) => [
35
+ {
36
+ title: "Delayed webhook delivery",
37
+ link: `https://status.${id}.example.com/incidents/h1`,
38
+ date: ago(3 * 24 * 60),
39
+ },
40
+ {
41
+ title: "Elevated latency in the EU region",
42
+ link: `https://status.${id}.example.com/incidents/h2`,
43
+ date: ago(11 * 24 * 60),
44
+ },
45
+ ];
46
+ export const mockRecord = (service, detail = false) => {
47
+ const recent = detail ? history(service.id) : [];
48
+ const flags = { historyLoaded: detail };
49
+ switch (service.id) {
50
+ case "acme-cloud":
51
+ return record(service, {
52
+ ...flags,
53
+ indicator: "major",
54
+ description: "Partial System Outage",
55
+ components: [
56
+ { name: "API", status: "major_outage" },
57
+ { name: "Dashboard", status: "degraded_performance" },
58
+ { name: "Webhooks", status: "operational" },
59
+ { name: "Storage", status: "operational" },
60
+ ],
61
+ active: [
62
+ {
63
+ title: "Elevated API error rates",
64
+ link: "https://status.acme-cloud.example.com/incidents/a1",
65
+ date: ago(72),
66
+ impact: "major",
67
+ },
68
+ ],
69
+ recent,
70
+ });
71
+ case "example-cdn":
72
+ return record(service, {
73
+ ...flags,
74
+ indicator: "minor",
75
+ description: "Minor Service Outage",
76
+ components: [{ name: "Edge cache", status: "degraded_performance" }],
77
+ recent,
78
+ });
79
+ case "demo-maintenance":
80
+ return record(service, {
81
+ ...flags,
82
+ indicator: "maintenance",
83
+ description: "Scheduled maintenance in progress",
84
+ components: [{ name: "Database", status: "under_maintenance" }],
85
+ recent,
86
+ });
87
+ case "demo-feed":
88
+ return record(service, {
89
+ ...flags,
90
+ description: "Last post 3h ago",
91
+ recent,
92
+ });
93
+ case "placeholder-mail":
94
+ return record(service, {
95
+ ...flags,
96
+ fetched: "failed",
97
+ indicator: "unknown",
98
+ description: "Could not read the page (timed out)",
99
+ });
100
+ case "test-docs":
101
+ return record(service, {
102
+ ...flags,
103
+ indicator: "link",
104
+ description: "No machine-readable status",
105
+ historyLoaded: false,
106
+ });
107
+ default:
108
+ return record(service, {
109
+ ...flags,
110
+ components: [
111
+ { name: "Install", status: "operational" },
112
+ { name: "Search", status: "operational" },
113
+ ],
114
+ recent,
115
+ });
116
+ }
117
+ };
@@ -0,0 +1,2 @@
1
+ const isSet = (value) => value !== undefined && value !== "" && value !== "0" && value !== "false";
2
+ export const canBeInteractive = ({ stdinTTY, stdoutTTY, env, json, }) => stdinTTY && stdoutTTY && !isSet(env.CI) && !json;
@@ -0,0 +1,111 @@
1
+ import { Chalk } from "chalk";
2
+ import { colors } from "@kud/ink-ui";
3
+ import { ageOf, whenLabel, worstFirst, } from "./core/index.js";
4
+ import { declaredText, statusLabel, wordLabel } from "./labels.js";
5
+ const ESC = String.fromCharCode(27);
6
+ const BEL = String.fromCharCode(7);
7
+ const ANSI = new RegExp(`${ESC}\\[[0-9;]*[a-zA-Z]`, "g");
8
+ const OSC8 = new RegExp(`${ESC}\\]8;;[^${BEL}${ESC}]*(?:${BEL}|${ESC}\\\\)`, "g");
9
+ export const stripEscapes = (s) => s.replace(OSC8, "").replace(ANSI, "");
10
+ export const visibleLength = (s) => [...stripEscapes(s)].length;
11
+ const pad = (s, width) => s + " ".repeat(Math.max(0, width - visibleLength(s)));
12
+ const toneColour = {
13
+ error: colors.error,
14
+ warning: colors.warning,
15
+ info: colors.info,
16
+ success: colors.success,
17
+ muted: colors.muted,
18
+ };
19
+ const styler = ({ color, links }) => {
20
+ const chalk = new Chalk({ level: color ? 3 : 0 });
21
+ return {
22
+ tone: (text, tone) => chalk.hex(toneColour[tone])(text),
23
+ muted: (text) => chalk.hex(colors.muted)(text),
24
+ bold: (text) => chalk.bold(text),
25
+ link: (text, url) => links && url ? `\u001B]8;;${url}\u001B\\${text}\u001B]8;;\u001B\\` : text,
26
+ };
27
+ };
28
+ const table = (rows, muted) => {
29
+ const widths = rows[0].map((_, i) => Math.max(...rows.map((r) => visibleLength(r[i] ?? ""))));
30
+ return rows.map((row, index) => {
31
+ const line = row.map((cell, i) => pad(cell, widths[i])).join(" ");
32
+ return ` ${index === 0 ? muted(line.trimEnd()) : line.trimEnd()}`;
33
+ });
34
+ };
35
+ export const renderOverview = (records, options) => {
36
+ const s = styler(options);
37
+ const rows = [["Status", "Service", "Declared"]].concat(worstFirst(records).map((r) => {
38
+ const status = s.tone(statusLabel(r.indicator).text, statusLabel(r.indicator).tone);
39
+ const name = s.link(r.name, r.url);
40
+ const declared = declaredText(r);
41
+ const loud = r.indicator !== "none" && r.indicator !== "link";
42
+ return [
43
+ status,
44
+ loud ? s.bold(name) : name,
45
+ loud ? s.bold(declared) : s.muted(declared),
46
+ ];
47
+ }));
48
+ return [
49
+ "",
50
+ ...table(rows, s.muted),
51
+ "",
52
+ s.muted(" Declared status only: green is not proof your calls work. incidents list <service> for detail."),
53
+ ...(options.note ? [s.muted(` ${options.note}`)] : []),
54
+ "",
55
+ ].join("\n");
56
+ };
57
+ const OPERATIONAL = "operational";
58
+ export const renderDetail = (record, options) => {
59
+ const s = styler(options);
60
+ const now = options.now ?? Date.now();
61
+ const status = statusLabel(record.indicator);
62
+ const broken = record.components.filter((c) => c.status !== OPERATIONAL);
63
+ const healthy = record.components.length - broken.length;
64
+ const happening = [
65
+ ...broken.map((c) => {
66
+ const w = wordLabel(c.status);
67
+ return [s.tone(w.text, w.tone), c.name, "now"];
68
+ }),
69
+ ...record.active.map((i) => {
70
+ const w = wordLabel(i.impact);
71
+ const since = ageOf(i.date, now);
72
+ return [
73
+ s.tone(w.text, w.tone),
74
+ s.link(i.title, i.link),
75
+ since ? `since ${since}` : "",
76
+ ];
77
+ }),
78
+ ];
79
+ const current = new Set(record.active.flatMap((i) => [i.link, i.title]).filter(Boolean));
80
+ const earlier = record.recent
81
+ .filter((i) => !current.has(i.link) && !current.has(i.title))
82
+ .slice(0, 7);
83
+ return [
84
+ "",
85
+ ` ${s.tone(status.text, status.tone)} ${record.description} ${s.muted(record.url.replace(/^https:\/\//, ""))}`,
86
+ "",
87
+ ...(happening.length
88
+ ? [...table([["Status", "What", "When"], ...happening], s.muted), ""]
89
+ : []),
90
+ ...(broken.length === 0 && healthy > 0
91
+ ? [s.muted(` ${healthy} components operational`), ""]
92
+ : []),
93
+ ...(earlier.length
94
+ ? [
95
+ ...table([
96
+ ["Earlier", "When"],
97
+ ...earlier.map((i) => [
98
+ s.muted(s.link(i.title, i.link)),
99
+ s.muted(whenLabel(i.date, now)),
100
+ ]),
101
+ ], s.muted),
102
+ "",
103
+ ]
104
+ : []),
105
+ ...(record.historyFailed
106
+ ? [s.muted(" History unavailable: its feed could not be read."), ""]
107
+ : []),
108
+ ...(record.note ? [s.muted(` ${record.note}`), ""] : []),
109
+ ...(options.note ? [s.muted(` ${options.note}`), ""] : []),
110
+ ].join("\n");
111
+ };
@@ -0,0 +1,35 @@
1
+ import { readAll, readService, } from "./core/index.js";
2
+ import { MOCK_SERVICES, mockRecord } from "./mock.js";
3
+ export const liveSource = (config, version, note) => {
4
+ const shared = {
5
+ timeout: config.timeout,
6
+ userAgent: `incidents-cli/${version}`,
7
+ };
8
+ return {
9
+ services: Object.values(config.services),
10
+ note,
11
+ readAll: ({ signal, onRecord }) => readAll(Object.values(config.services), { ...shared, signal, onRecord }),
12
+ readDetail: (service, { signal }) => readService(service, { ...shared, signal, detail: true }),
13
+ };
14
+ };
15
+ const settle = (ms, signal) => new Promise((resolve, reject) => {
16
+ const timer = setTimeout(resolve, ms);
17
+ signal?.addEventListener("abort", () => {
18
+ clearTimeout(timer);
19
+ reject(signal.reason);
20
+ }, { once: true });
21
+ });
22
+ export const mockSource = (delayMs = 120) => ({
23
+ services: MOCK_SERVICES,
24
+ note: null,
25
+ readAll: async ({ signal, onRecord }) => Promise.all(MOCK_SERVICES.map(async (service, index) => {
26
+ await settle(delayMs * (index + 1), signal);
27
+ const record = mockRecord(service);
28
+ onRecord?.(record);
29
+ return record;
30
+ })),
31
+ readDetail: async (service, { signal }) => {
32
+ await settle(delayMs, signal);
33
+ return mockRecord(service, true);
34
+ },
35
+ });