@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,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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }