@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.
- package/LICENSE +21 -0
- package/README.md +139 -2
- package/dist/bin/incidents.js +40 -0
- package/dist/src/cli.js +106 -0
- package/dist/src/config.js +75 -0
- package/dist/src/core/adapters.js +58 -0
- package/dist/src/core/age.js +33 -0
- package/dist/src/core/catalogue.js +102 -0
- package/dist/src/core/index.js +8 -0
- package/dist/src/core/parse.js +118 -0
- package/dist/src/core/read.js +52 -0
- package/dist/src/core/registry.js +54 -0
- package/dist/src/core/severity.js +14 -0
- package/dist/src/core/types.js +1 -0
- package/dist/src/labels.js +29 -0
- package/dist/src/mock.js +117 -0
- package/dist/src/mode.js +2 -0
- package/dist/src/render.js +111 -0
- package/dist/src/source.js +35 -0
- package/dist/src/ui/app.js +179 -0
- package/package.json +60 -4
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { Box, Text, useInput, useStdout } from "ink";
|
|
4
|
+
import { PAGE_CHROME, Page, SelectableRow, Spinner, colors, useAppKeys, useListCursor, } from "@kud/ink-ui";
|
|
5
|
+
import { ageOf, isClear, whenLabel, worstFirst, } from "../core/index.js";
|
|
6
|
+
import { declaredText, statusLabel, wordLabel } from "../labels.js";
|
|
7
|
+
const TONE = {
|
|
8
|
+
error: colors.error,
|
|
9
|
+
warning: colors.warning,
|
|
10
|
+
info: colors.info,
|
|
11
|
+
success: colors.success,
|
|
12
|
+
muted: colors.muted,
|
|
13
|
+
};
|
|
14
|
+
const MAX_DETAIL_ROWS = 12;
|
|
15
|
+
const FOOTER_AND_COUNTER_ROWS = 3;
|
|
16
|
+
const keepHistory = (previous, next) => previous?.historyLoaded && !next.historyLoaded
|
|
17
|
+
? {
|
|
18
|
+
...next,
|
|
19
|
+
recent: previous.recent,
|
|
20
|
+
historyLoaded: true,
|
|
21
|
+
historyFailed: previous.historyFailed,
|
|
22
|
+
}
|
|
23
|
+
: next;
|
|
24
|
+
const OverviewRow = ({ row, active, nameWidth, }) => {
|
|
25
|
+
if (!row.record)
|
|
26
|
+
return (_jsx(SelectableRow, { active: active, children: _jsxs(Box, { children: [_jsx(Box, { width: 16, flexShrink: 0, children: _jsx(Text, { color: colors.muted, children: "\u25CC checking" }) }), _jsx(Box, { width: nameWidth, flexShrink: 0, children: _jsx(Text, { children: row.service.name }) })] }) }));
|
|
27
|
+
const label = statusLabel(row.record.indicator);
|
|
28
|
+
const loud = row.record.indicator !== "none" && row.record.indicator !== "link";
|
|
29
|
+
return (_jsx(SelectableRow, { active: active, children: _jsxs(Box, { children: [_jsx(Box, { width: 16, flexShrink: 0, children: _jsx(Text, { color: TONE[label.tone], children: label.text }) }), _jsx(Box, { width: nameWidth, flexShrink: 0, children: _jsx(Text, { bold: loud, children: row.service.name }) }), _jsx(Text, { bold: loud, color: loud ? undefined : colors.muted, wrap: "truncate-end", children: declaredText(row.record) })] }) }));
|
|
30
|
+
};
|
|
31
|
+
const Overview = ({ rows, cursor, height, note, }) => {
|
|
32
|
+
const nameWidth = Math.max(...rows.map((r) => r.service.name.length), 7) + 2;
|
|
33
|
+
const start = Math.max(0, Math.min(cursor - Math.floor(height / 2), rows.length - height));
|
|
34
|
+
return (_jsxs(Box, { flexDirection: "column", children: [rows.slice(start, start + height).map((row, i) => (_jsx(OverviewRow, { row: row, active: start + i === cursor, nameWidth: nameWidth }, row.service.id))), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: colors.muted, children: "Declared status only: green is not proof your calls work." }), note ? _jsx(Text, { color: colors.muted, children: note }) : null] })] }));
|
|
35
|
+
};
|
|
36
|
+
const Detail = ({ record, busy, }) => {
|
|
37
|
+
if (!record)
|
|
38
|
+
return (_jsx(Box, { children: _jsx(Spinner, { label: "Reading the status page\u2026" }) }));
|
|
39
|
+
const status = statusLabel(record.indicator);
|
|
40
|
+
const broken = record.components.filter((c) => c.status !== "operational");
|
|
41
|
+
const healthy = record.components.length - broken.length;
|
|
42
|
+
const happening = [
|
|
43
|
+
...broken.map((c) => ({
|
|
44
|
+
word: wordLabel(c.status),
|
|
45
|
+
what: c.name,
|
|
46
|
+
when: "now",
|
|
47
|
+
})),
|
|
48
|
+
...record.active.map((i) => {
|
|
49
|
+
const since = ageOf(i.date);
|
|
50
|
+
return {
|
|
51
|
+
word: wordLabel(i.impact),
|
|
52
|
+
what: i.title,
|
|
53
|
+
when: since ? `since ${since}` : "",
|
|
54
|
+
};
|
|
55
|
+
}),
|
|
56
|
+
];
|
|
57
|
+
const current = new Set(record.active.flatMap((i) => [i.link, i.title]).filter(Boolean));
|
|
58
|
+
const earlier = record.recent
|
|
59
|
+
.filter((i) => !current.has(i.link) && !current.has(i.title))
|
|
60
|
+
.slice(0, 7);
|
|
61
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: TONE[status.tone], children: status.text }), _jsx(Text, { bold: true, children: ` ${record.description} ` }), _jsx(Text, { color: colors.muted, children: record.url.replace(/^https:\/\//, "") })] }), happening.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Box, { width: 14, flexShrink: 0, children: _jsx(Text, { color: colors.muted, children: "Status" }) }), _jsx(Box, { width: 44, flexShrink: 0, children: _jsx(Text, { color: colors.muted, children: "What" }) }), _jsx(Text, { color: colors.muted, children: "When" })] }), happening.slice(0, MAX_DETAIL_ROWS).map((h, i) => (_jsxs(Box, { children: [_jsx(Box, { width: 14, flexShrink: 0, children: _jsx(Text, { color: TONE[h.word.tone], children: h.word.text }) }), _jsx(Box, { width: 44, flexShrink: 0, children: _jsx(Text, { wrap: "truncate-end", children: h.what }) }), _jsx(Text, { children: h.when })] }, i))), happening.length > MAX_DETAIL_ROWS ? (_jsx(Text, { color: colors.muted, children: `+${happening.length - MAX_DETAIL_ROWS} more` })) : null] })) : null, broken.length === 0 && healthy > 0 ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.muted, children: `${healthy} components operational` }) })) : null, earlier.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Box, { width: 58, flexShrink: 0, children: _jsx(Text, { color: colors.muted, children: "Earlier" }) }), _jsx(Text, { color: colors.muted, children: "When" })] }), earlier.map((i, n) => (_jsxs(Box, { children: [_jsx(Box, { width: 58, flexShrink: 0, children: _jsx(Text, { color: colors.muted, wrap: "truncate-end", children: i.title }) }), _jsx(Text, { color: colors.muted, children: whenLabel(i.date) })] }, n)))] })) : null, busy && !record.historyLoaded ? (_jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: "Loading history\u2026" }) })) : null, record.historyFailed ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.muted, children: "History unavailable: its feed could not be read." }) })) : null, record.note ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.muted, children: record.note }) })) : null] }));
|
|
62
|
+
};
|
|
63
|
+
export const App = ({ source, initialDetail, }) => {
|
|
64
|
+
const { stdout } = useStdout();
|
|
65
|
+
const [records, setRecords] = useState({});
|
|
66
|
+
const [loading, setLoading] = useState(true);
|
|
67
|
+
const [updatedAt, setUpdatedAt] = useState(null);
|
|
68
|
+
const [detailId, setDetailId] = useState(initialDetail ?? null);
|
|
69
|
+
const [detailBusy, setDetailBusy] = useState(false);
|
|
70
|
+
const loadRef = useRef(null);
|
|
71
|
+
const detailRef = useRef(null);
|
|
72
|
+
const load = useCallback(() => {
|
|
73
|
+
loadRef.current?.abort();
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
loadRef.current = controller;
|
|
76
|
+
setLoading(true);
|
|
77
|
+
source
|
|
78
|
+
.readAll({
|
|
79
|
+
signal: controller.signal,
|
|
80
|
+
onRecord: (record) => setRecords((prev) => ({
|
|
81
|
+
...prev,
|
|
82
|
+
[record.id]: keepHistory(prev[record.id], record),
|
|
83
|
+
})),
|
|
84
|
+
})
|
|
85
|
+
.then(() => {
|
|
86
|
+
setLoading(false);
|
|
87
|
+
setUpdatedAt(new Date());
|
|
88
|
+
})
|
|
89
|
+
.catch(() => undefined);
|
|
90
|
+
}, [source]);
|
|
91
|
+
const loadDetail = useCallback((id) => {
|
|
92
|
+
const service = source.services.find((s) => s.id === id);
|
|
93
|
+
if (!service)
|
|
94
|
+
return;
|
|
95
|
+
detailRef.current?.abort();
|
|
96
|
+
const controller = new AbortController();
|
|
97
|
+
detailRef.current = controller;
|
|
98
|
+
setDetailBusy(true);
|
|
99
|
+
source
|
|
100
|
+
.readDetail(service, { signal: controller.signal })
|
|
101
|
+
.then((record) => {
|
|
102
|
+
setRecords((prev) => ({ ...prev, [id]: record }));
|
|
103
|
+
setDetailBusy(false);
|
|
104
|
+
})
|
|
105
|
+
.catch(() => undefined);
|
|
106
|
+
}, [source]);
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
load();
|
|
109
|
+
if (initialDetail)
|
|
110
|
+
loadDetail(initialDetail);
|
|
111
|
+
return () => {
|
|
112
|
+
loadRef.current?.abort();
|
|
113
|
+
detailRef.current?.abort();
|
|
114
|
+
};
|
|
115
|
+
}, []);
|
|
116
|
+
const rows = useMemo(() => {
|
|
117
|
+
const known = worstFirst(Object.values(records));
|
|
118
|
+
const pending = source.services.filter((s) => !(s.id in records));
|
|
119
|
+
return [
|
|
120
|
+
...known.map((record) => ({
|
|
121
|
+
record,
|
|
122
|
+
service: source.services.find((s) => s.id === record.id),
|
|
123
|
+
})),
|
|
124
|
+
...pending.map((service) => ({ service })),
|
|
125
|
+
];
|
|
126
|
+
}, [records, source]);
|
|
127
|
+
const { cursor } = useListCursor(rows.length, {
|
|
128
|
+
vimKeys: true,
|
|
129
|
+
isActive: detailId === null,
|
|
130
|
+
});
|
|
131
|
+
const openDetail = (id) => {
|
|
132
|
+
setDetailId(id);
|
|
133
|
+
if (!records[id]?.historyLoaded)
|
|
134
|
+
loadDetail(id);
|
|
135
|
+
};
|
|
136
|
+
const closeDetail = () => {
|
|
137
|
+
detailRef.current?.abort();
|
|
138
|
+
setDetailBusy(false);
|
|
139
|
+
setDetailId(null);
|
|
140
|
+
};
|
|
141
|
+
useAppKeys({
|
|
142
|
+
onBack: () => {
|
|
143
|
+
if (detailId === null)
|
|
144
|
+
return false;
|
|
145
|
+
closeDetail();
|
|
146
|
+
return true;
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
useInput((input, key) => {
|
|
150
|
+
if (input === "r")
|
|
151
|
+
return detailId ? loadDetail(detailId) : load();
|
|
152
|
+
if (key.return && detailId === null && rows[cursor])
|
|
153
|
+
openDetail(rows[cursor].service.id);
|
|
154
|
+
});
|
|
155
|
+
const trouble = Object.values(records).filter((r) => !isClear(r.indicator));
|
|
156
|
+
const status = loading
|
|
157
|
+
? {
|
|
158
|
+
text: Object.keys(records).length ? "↻ refreshing…" : "↻ checking…",
|
|
159
|
+
tone: "busy",
|
|
160
|
+
}
|
|
161
|
+
: {
|
|
162
|
+
text: `updated ${updatedAt?.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }) ?? ""}`,
|
|
163
|
+
tone: "quiet",
|
|
164
|
+
};
|
|
165
|
+
const alert = trouble.length
|
|
166
|
+
? `${trouble.length} declaring trouble`
|
|
167
|
+
: undefined;
|
|
168
|
+
const critical = trouble.some((r) => ["major", "critical"].includes(r.indicator));
|
|
169
|
+
if (detailId !== null) {
|
|
170
|
+
const service = source.services.find((s) => s.id === detailId);
|
|
171
|
+
return (_jsx(Page, { icon: "\u25C6", title: "Incidents", scope: service?.name, status: status, alert: alert, critical: critical, page: "nested", help: false, hints: [["r", "refresh"]], children: _jsx(Detail, { record: records[detailId], busy: detailBusy }) }));
|
|
172
|
+
}
|
|
173
|
+
const height = Math.max(3, (stdout?.rows || 30) - PAGE_CHROME - FOOTER_AND_COUNTER_ROWS - 4);
|
|
174
|
+
return (_jsx(Page, { icon: "\u25C6", title: "Incidents", count: source.services.length, noun: "service", status: status, alert: alert, critical: critical, counter: rows.length ? `${cursor + 1} of ${rows.length}` : undefined, page: "root", help: false, hints: [
|
|
175
|
+
["↑↓", "move"],
|
|
176
|
+
["⏎", "open"],
|
|
177
|
+
["r", "refresh"],
|
|
178
|
+
], children: _jsx(Overview, { rows: rows, cursor: cursor, height: height, note: source.note }) }));
|
|
179
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kud/incidents-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Which of the services you depend on are declaring trouble right now: vendor status pages in one terminal view.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"incidents": "dist/bin/incidents.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"dev": "tsx bin/incidents.ts",
|
|
14
|
+
"build": "tsc -p tsconfig.build.json",
|
|
15
|
+
"typecheck": "tsc --noEmit",
|
|
16
|
+
"lint": "eslint .",
|
|
17
|
+
"format": "prettier --write .",
|
|
18
|
+
"format:check": "prettier --check .",
|
|
19
|
+
"test": "node --import tsx --test 'test/**/*.test.ts' 'test/**/*.test.tsx'",
|
|
20
|
+
"prepublishOnly": "npm run build"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"status",
|
|
24
|
+
"statuspage",
|
|
25
|
+
"incidents",
|
|
26
|
+
"outage",
|
|
27
|
+
"cli",
|
|
28
|
+
"tui",
|
|
29
|
+
"terminal"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "https://github.com/kud/incidents-cli"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=22"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@kud/ink-ui": "0.44.0",
|
|
44
|
+
"chalk": "5.6.2",
|
|
45
|
+
"commander": "14.0.0",
|
|
46
|
+
"ink": "7.1.0",
|
|
47
|
+
"js-yaml": "4.3.2",
|
|
48
|
+
"react": "19.2.7"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@eslint/js": "10.0.1",
|
|
52
|
+
"@types/js-yaml": "4.0.9",
|
|
53
|
+
"@types/node": "24.3.1",
|
|
54
|
+
"@types/react": "19.2.17",
|
|
55
|
+
"eslint": "10.14.0",
|
|
56
|
+
"ink-testing-library": "4.0.0",
|
|
57
|
+
"prettier": "3.10.1",
|
|
58
|
+
"tsx": "4.20.5",
|
|
59
|
+
"typescript": "5.9.2",
|
|
60
|
+
"typescript-eslint": "8.72.2"
|
|
61
|
+
}
|
|
62
|
+
}
|