@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Erwann Mest
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,140 @@
|
|
|
1
|
-
#
|
|
1
|
+
# incidents-cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Which of the services you depend on are declaring trouble right now: vendor status pages in one terminal view.
|
|
4
|
+
|
|
5
|
+
It reads each vendor's own status page, worst first, and tells you what they have **declared**. A green row is not proof your calls work; a page it cannot read is shown as `? unreadable` and counts as not green, never as fine.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install -g @kud/incidents-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Node 22 or later.
|
|
14
|
+
|
|
15
|
+
## Use
|
|
16
|
+
|
|
17
|
+
| Command | What it does |
|
|
18
|
+
| -------------------------- | ------------------------------------------------------------------------ |
|
|
19
|
+
| `incidents` | Interactive view: live list, worst first, rows fill in as they answer |
|
|
20
|
+
| `incidents <service>` | Interactive view opened on that service's detail |
|
|
21
|
+
| `incidents list [service]` | One-shot static output: the overview, or one service's detail |
|
|
22
|
+
| `incidents services` | One service per line: id, name, aliases (comma-separated), tab-separated |
|
|
23
|
+
|
|
24
|
+
Keys in the interactive view: `↑↓` or `j k` move, `⏎` opens a service, `esc` or `⌫` goes back, `r` refreshes, `q` quits.
|
|
25
|
+
|
|
26
|
+
Add `--json` to any form for JSON records. With no TTY on stdin or stdout, or `CI` set, every form falls back to the static output (or JSON), so pipes, `$(...)` and cron never hang.
|
|
27
|
+
|
|
28
|
+
### Exit codes
|
|
29
|
+
|
|
30
|
+
| Code | Meaning |
|
|
31
|
+
| ---- | ----------------------------------------------------------- |
|
|
32
|
+
| 0 | All clear (maintenance and manual-link rows count as clear) |
|
|
33
|
+
| 1 | Something is degraded, or a page could not be read |
|
|
34
|
+
| 2 | Usage or configuration error |
|
|
35
|
+
|
|
36
|
+
The interactive view exits 0 on quit.
|
|
37
|
+
|
|
38
|
+
## Configuration
|
|
39
|
+
|
|
40
|
+
`${XDG_CONFIG_HOME:-~/.config}/incidents/config.yml`, then every `config.d/*.yml` merged over it by service id, in lexical order. `INCIDENTS_CONFIG=<file>` replaces both.
|
|
41
|
+
|
|
42
|
+
```yaml
|
|
43
|
+
timeout: 4000 # milliseconds per request, default 4000
|
|
44
|
+
services:
|
|
45
|
+
github: {} # take the built-in entry as it is
|
|
46
|
+
aws:
|
|
47
|
+
regions: [us-east-1, us-west-2] # override fields of a built-in entry
|
|
48
|
+
npm:
|
|
49
|
+
enabled: false # drop one
|
|
50
|
+
acme:
|
|
51
|
+
name: Acme # a full entry defines a new service
|
|
52
|
+
kind: statuspage
|
|
53
|
+
url: https://status.acme.example
|
|
54
|
+
aliases: [ac]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
With no config file the defaults are `github`, `npm`, `cloudflare` and `claude`, and one muted line names the config path.
|
|
58
|
+
|
|
59
|
+
`list`, `services` and `help` are reserved words and cannot be a service id or alias; loading fails with exit 2 and a message naming the file and the id. Any other configuration error also exits 2.
|
|
60
|
+
|
|
61
|
+
### Built-in services
|
|
62
|
+
|
|
63
|
+
`github`, `claude`, `openai`, `vercel`, `cloudflare`, `npm`, `docker`, `figma`, `linear`, `notion`, `gandi`, `todoist`, `bluesky`, `jira`, `confluence`, `bitbucket`, `sentry`, `harness`, `datadog`, `slack`, `aws`.
|
|
64
|
+
|
|
65
|
+
### Kinds
|
|
66
|
+
|
|
67
|
+
| Kind | Reads |
|
|
68
|
+
| ------------ | -------------------------------------------------------------------------------------------------------- |
|
|
69
|
+
| `statuspage` | Atlassian Statuspage v2: `<url>/api/v2/summary.json`, history from `<url>/history.rss` |
|
|
70
|
+
| `rss` | An incident feed only. Current state comes from how recent the last post is (`window`, hours, default 6) |
|
|
71
|
+
| `slack` | Slack's status API |
|
|
72
|
+
| `aws` | AWS Health's public event list; `regions` narrows it, global events always count |
|
|
73
|
+
| `link` | No machine-readable page: the row points at `url` and claims nothing |
|
|
74
|
+
|
|
75
|
+
Other fields: `aliases`, `note` (printed under the detail), `timeout` (per service, milliseconds).
|
|
76
|
+
|
|
77
|
+
## JSON records
|
|
78
|
+
|
|
79
|
+
`incidents list --json` prints an array, worst first. `incidents list <service> --json` prints one record, with its history loaded.
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"id": "github",
|
|
84
|
+
"name": "GitHub",
|
|
85
|
+
"url": "https://www.githubstatus.com",
|
|
86
|
+
"kind": "statuspage",
|
|
87
|
+
"fetched": "ok",
|
|
88
|
+
"indicator": "minor",
|
|
89
|
+
"description": "Partially Degraded Service",
|
|
90
|
+
"components": [{ "name": "API", "status": "degraded_performance" }],
|
|
91
|
+
"active": [
|
|
92
|
+
{
|
|
93
|
+
"title": "Elevated API errors",
|
|
94
|
+
"link": "https://stspg.io/abc",
|
|
95
|
+
"date": "2026-01-01T10:00:00Z",
|
|
96
|
+
"impact": "minor"
|
|
97
|
+
}
|
|
98
|
+
],
|
|
99
|
+
"recent": [{ "title": "Earlier incident", "link": "https://…", "date": "…" }],
|
|
100
|
+
"historyLoaded": true,
|
|
101
|
+
"historyFailed": false,
|
|
102
|
+
"note": ""
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
| Field | Meaning |
|
|
107
|
+
| --------------- | --------------------------------------------------------------------------------------------------------- |
|
|
108
|
+
| `fetched` | `ok`, or `failed` when the page could not be read |
|
|
109
|
+
| `indicator` | `critical`, `major`, `unknown`, `minor`, `maintenance`, `none` or `link`, worst first in that order |
|
|
110
|
+
| `components` | Components with their raw vendor status; `operational` ones are included |
|
|
111
|
+
| `active` | Incidents open now |
|
|
112
|
+
| `recent` | Past incidents, newest first; empty until history is loaded |
|
|
113
|
+
| `historyLoaded` | `true` once history was requested for this record |
|
|
114
|
+
| `historyFailed` | `true` when the history feed could not be read, so an empty `recent` is not a claim that nothing happened |
|
|
115
|
+
|
|
116
|
+
`unknown` means the page could not be read. It sorts above every healthy row and fails the exit code.
|
|
117
|
+
|
|
118
|
+
## Environment
|
|
119
|
+
|
|
120
|
+
| Variable | Effect |
|
|
121
|
+
| ------------------ | -------------------------------------------------- |
|
|
122
|
+
| `INCIDENTS_CONFIG` | A single config file, replacing the default lookup |
|
|
123
|
+
| `NO_COLOR` | Plain output |
|
|
124
|
+
| `CI` | Forces the static output |
|
|
125
|
+
|
|
126
|
+
Requests identify themselves as `incidents-cli/<version>`.
|
|
127
|
+
|
|
128
|
+
## Development
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
npm install
|
|
132
|
+
npm run dev -- --mock
|
|
133
|
+
npm test
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`--mock` swaps every data source for fixtures; `--screen <name>` opens straight on a screen (`--screen list` prints the names).
|
|
137
|
+
|
|
138
|
+
## Licence
|
|
139
|
+
|
|
140
|
+
MIT
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
import { list, open, run, services } from "../src/cli.js";
|
|
7
|
+
const resolveVersion = () => {
|
|
8
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
for (let i = 0; i < 6; i++) {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).version;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
dir = dirname(dir);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return "0.0.0";
|
|
18
|
+
};
|
|
19
|
+
const version = resolveVersion();
|
|
20
|
+
const program = new Command();
|
|
21
|
+
program
|
|
22
|
+
.name("incidents")
|
|
23
|
+
.description("Which of the services you depend on are declaring trouble right now")
|
|
24
|
+
.version(version)
|
|
25
|
+
.option("--json", "print JSON records, never the interactive view", false)
|
|
26
|
+
.option("--mock", "use built-in fixtures instead of live status pages", false)
|
|
27
|
+
.option("--screen <name>", "open directly on a screen (`list` prints them)")
|
|
28
|
+
.argument("[service]", "open the interactive view on one service")
|
|
29
|
+
.showHelpAfterError()
|
|
30
|
+
.action((word, flags) => run(() => open(word, flags, version)));
|
|
31
|
+
program
|
|
32
|
+
.command("list")
|
|
33
|
+
.argument("[service]", "one service's detail instead of the overview")
|
|
34
|
+
.description("print the status once and exit: 0 all clear, 1 degraded or unreadable")
|
|
35
|
+
.action((word, _flags, command) => run(() => list(word, command.optsWithGlobals(), version)));
|
|
36
|
+
program
|
|
37
|
+
.command("services")
|
|
38
|
+
.description("print configured service ids and aliases, one per line")
|
|
39
|
+
.action((_flags, command) => run(() => services(command.optsWithGlobals(), version)));
|
|
40
|
+
await program.parseAsync();
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { loadConfig, defaultsNote } from "./config.js";
|
|
2
|
+
import { ConfigError, exitCode, resolveService, worstFirst, } from "./core/index.js";
|
|
3
|
+
import { canBeInteractive } from "./mode.js";
|
|
4
|
+
import { renderDetail, renderOverview } from "./render.js";
|
|
5
|
+
import { liveSource, mockSource } from "./source.js";
|
|
6
|
+
export const SCREENS = ["overview", "detail"];
|
|
7
|
+
const makeContext = (flags, version) => {
|
|
8
|
+
const source = flags.mock
|
|
9
|
+
? mockSource()
|
|
10
|
+
: (() => {
|
|
11
|
+
const config = loadConfig();
|
|
12
|
+
return liveSource(config, version, defaultsNote(config));
|
|
13
|
+
})();
|
|
14
|
+
return {
|
|
15
|
+
source,
|
|
16
|
+
registry: Object.fromEntries(source.services.map((s) => [s.id, s])),
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
const write = (text) => {
|
|
20
|
+
process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
21
|
+
};
|
|
22
|
+
const fail = (message) => {
|
|
23
|
+
process.stderr.write(`incidents: ${message}\n`);
|
|
24
|
+
return 2;
|
|
25
|
+
};
|
|
26
|
+
const pretty = () => {
|
|
27
|
+
const color = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
|
|
28
|
+
return { color, links: color };
|
|
29
|
+
};
|
|
30
|
+
const unknownService = (word, registry) => fail(`no service '${word}'. Known: ${Object.keys(registry).sort().join(", ")}`);
|
|
31
|
+
const classic = async (word, flags, { source, registry }) => {
|
|
32
|
+
if (word !== undefined) {
|
|
33
|
+
const service = resolveService(registry, word);
|
|
34
|
+
if (!service)
|
|
35
|
+
return unknownService(word, registry);
|
|
36
|
+
const record = await source.readDetail(service, {});
|
|
37
|
+
write(flags.json
|
|
38
|
+
? JSON.stringify(record, null, 2)
|
|
39
|
+
: renderDetail(record, { ...pretty(), note: source.note }));
|
|
40
|
+
return exitCode([record]);
|
|
41
|
+
}
|
|
42
|
+
if (source.services.length === 0) {
|
|
43
|
+
write("No services configured.");
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
const records = worstFirst(await source.readAll({}));
|
|
47
|
+
write(flags.json
|
|
48
|
+
? JSON.stringify(records, null, 2)
|
|
49
|
+
: renderOverview(records, { ...pretty(), note: source.note }));
|
|
50
|
+
return exitCode(records);
|
|
51
|
+
};
|
|
52
|
+
const interactive = async (word, flags, { source, registry }) => {
|
|
53
|
+
const target = word !== undefined ? resolveService(registry, word) : undefined;
|
|
54
|
+
if (word !== undefined && !target)
|
|
55
|
+
return unknownService(word, registry);
|
|
56
|
+
const initialDetail = target?.id ??
|
|
57
|
+
(flags.screen === "detail" ? source.services[0]?.id : undefined);
|
|
58
|
+
const { render } = await import("ink");
|
|
59
|
+
const { createElement } = await import("react");
|
|
60
|
+
const { App } = await import("./ui/app.js");
|
|
61
|
+
const app = render(createElement(App, { source, initialDetail }));
|
|
62
|
+
await app.waitUntilExit();
|
|
63
|
+
return 0;
|
|
64
|
+
};
|
|
65
|
+
export const open = async (word, flags, version) => {
|
|
66
|
+
if (flags.screen === "list") {
|
|
67
|
+
write(SCREENS.join("\n"));
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
if (flags.screen !== undefined && !SCREENS.includes(flags.screen))
|
|
71
|
+
return fail(`no screen '${flags.screen}'. Known: ${SCREENS.join(", ")}`);
|
|
72
|
+
const context = makeContext(flags, version);
|
|
73
|
+
const tty = canBeInteractive({
|
|
74
|
+
stdinTTY: Boolean(process.stdin.isTTY),
|
|
75
|
+
stdoutTTY: Boolean(process.stdout.isTTY),
|
|
76
|
+
env: process.env,
|
|
77
|
+
json: Boolean(flags.json),
|
|
78
|
+
});
|
|
79
|
+
return tty
|
|
80
|
+
? interactive(word, flags, context)
|
|
81
|
+
: classic(word ??
|
|
82
|
+
(flags.screen === "detail"
|
|
83
|
+
? context.source.services[0]?.id
|
|
84
|
+
: undefined), flags, context);
|
|
85
|
+
};
|
|
86
|
+
export const list = async (word, flags, version) => classic(word, flags, makeContext(flags, version));
|
|
87
|
+
export const services = (flags, version) => {
|
|
88
|
+
const { source } = makeContext(flags, version);
|
|
89
|
+
write(source.services
|
|
90
|
+
.map((s) => [s.id, s.name, s.aliases.join(",")].join("\t"))
|
|
91
|
+
.join("\n"));
|
|
92
|
+
return 0;
|
|
93
|
+
};
|
|
94
|
+
export const run = async (action) => {
|
|
95
|
+
try {
|
|
96
|
+
process.exitCode = await action();
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
if (error instanceof ConfigError)
|
|
100
|
+
process.exitCode = fail(error.message);
|
|
101
|
+
else {
|
|
102
|
+
process.stderr.write(`incidents: ${error.message}\n`);
|
|
103
|
+
process.exitCode = 1;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { load } from "js-yaml";
|
|
5
|
+
import { ConfigError, DEFAULT_IDS, RESERVED_WORDS, DEFAULT_TIMEOUT_MS, mergeServices, selectServices, } from "./core/index.js";
|
|
6
|
+
const configHome = (env) => join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "incidents");
|
|
7
|
+
const readFile = (path) => {
|
|
8
|
+
try {
|
|
9
|
+
const parsed = load(readFileSync(path, "utf8"));
|
|
10
|
+
if (parsed === undefined || parsed === null)
|
|
11
|
+
return {};
|
|
12
|
+
if (typeof parsed !== "object" || Array.isArray(parsed))
|
|
13
|
+
throw new Error("expected a mapping at the top level");
|
|
14
|
+
return parsed;
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
throw new ConfigError(`${path}: ${error.message}`);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
const rejectReserved = (path, services) => {
|
|
21
|
+
for (const [id, fields] of Object.entries(services)) {
|
|
22
|
+
const aliases = Array.isArray(fields?.aliases) ? fields.aliases : [];
|
|
23
|
+
const word = [id, ...aliases]
|
|
24
|
+
.map(String)
|
|
25
|
+
.find((w) => RESERVED_WORDS.includes(w.toLowerCase()));
|
|
26
|
+
if (word)
|
|
27
|
+
throw new ConfigError(`${path}: service '${id}' uses the reserved word '${word}'`);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const filesFor = (env) => {
|
|
31
|
+
if (env.INCIDENTS_CONFIG) {
|
|
32
|
+
if (!existsSync(env.INCIDENTS_CONFIG))
|
|
33
|
+
throw new ConfigError(`INCIDENTS_CONFIG: no file at ${env.INCIDENTS_CONFIG}`);
|
|
34
|
+
return [env.INCIDENTS_CONFIG];
|
|
35
|
+
}
|
|
36
|
+
const dir = configHome(env);
|
|
37
|
+
const main = join(dir, "config.yml");
|
|
38
|
+
const dropIns = join(dir, "config.d");
|
|
39
|
+
return [
|
|
40
|
+
...(existsSync(main) ? [main] : []),
|
|
41
|
+
...(existsSync(dropIns)
|
|
42
|
+
? readdirSync(dropIns)
|
|
43
|
+
.filter((f) => f.endsWith(".yml"))
|
|
44
|
+
.sort()
|
|
45
|
+
.map((f) => join(dropIns, f))
|
|
46
|
+
: []),
|
|
47
|
+
];
|
|
48
|
+
};
|
|
49
|
+
export const defaultConfigPath = (env = process.env) => env.INCIDENTS_CONFIG || join(configHome(env), "config.yml");
|
|
50
|
+
export const loadConfig = (env = process.env) => {
|
|
51
|
+
const files = filesFor(env);
|
|
52
|
+
if (files.length === 0)
|
|
53
|
+
return {
|
|
54
|
+
services: selectServices(Object.fromEntries(DEFAULT_IDS.map((id) => [id, {}]))),
|
|
55
|
+
timeout: DEFAULT_TIMEOUT_MS,
|
|
56
|
+
source: "default",
|
|
57
|
+
path: defaultConfigPath(env),
|
|
58
|
+
};
|
|
59
|
+
const parsed = files.map(readFile);
|
|
60
|
+
parsed.forEach((file, i) => rejectReserved(files[i], file.services ?? {}));
|
|
61
|
+
const selected = parsed.reduce((acc, file) => mergeServices(acc, file.services ?? {}), {});
|
|
62
|
+
const timeout = parsed
|
|
63
|
+
.map((f) => f.timeout)
|
|
64
|
+
.filter((t) => typeof t === "number")
|
|
65
|
+
.at(-1) ?? DEFAULT_TIMEOUT_MS;
|
|
66
|
+
return {
|
|
67
|
+
services: selectServices(selected, undefined, files.join(", ")),
|
|
68
|
+
timeout,
|
|
69
|
+
source: "file",
|
|
70
|
+
path: files[0],
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
export const defaultsNote = (config) => config.source === "default"
|
|
74
|
+
? `No config at ${config.path.replace(homedir(), "~")}: showing ${DEFAULT_IDS.join(", ")}.`
|
|
75
|
+
: null;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { decodeUtf16, fromAws, fromFeed, fromSlack, fromStatuspageSummary, newestFirst, parseRss, } from "./parse.js";
|
|
2
|
+
export const DEFAULT_TIMEOUT_MS = 4000;
|
|
3
|
+
const fetchWith = async (url, service, opts) => {
|
|
4
|
+
const timeout = AbortSignal.timeout(service.timeout ?? opts.timeout ?? DEFAULT_TIMEOUT_MS);
|
|
5
|
+
const signal = opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout;
|
|
6
|
+
const res = await (opts.fetch ?? fetch)(url, {
|
|
7
|
+
signal,
|
|
8
|
+
headers: opts.userAgent ? { "user-agent": opts.userAgent } : {},
|
|
9
|
+
});
|
|
10
|
+
if (!res.ok)
|
|
11
|
+
throw new Error(`${res.status} from ${url}`);
|
|
12
|
+
return res;
|
|
13
|
+
};
|
|
14
|
+
const json = async (url, service, opts) => (await fetchWith(url, service, opts)).json();
|
|
15
|
+
const text = async (url, service, opts) => (await fetchWith(url, service, opts)).text();
|
|
16
|
+
const readHistory = async (url, service, opts) => {
|
|
17
|
+
try {
|
|
18
|
+
return {
|
|
19
|
+
recent: newestFirst(parseRss(await text(url, service, opts))),
|
|
20
|
+
historyFailed: false,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (opts.signal?.aborted)
|
|
25
|
+
throw error;
|
|
26
|
+
return { recent: [], historyFailed: true };
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
export const ADAPTERS = {
|
|
30
|
+
statuspage: async (service, opts) => {
|
|
31
|
+
const base = (service.url ?? "").replace(/\/$/, "");
|
|
32
|
+
const [summary, history] = await Promise.all([
|
|
33
|
+
json(`${base}/api/v2/summary.json`, service, opts),
|
|
34
|
+
opts.detail
|
|
35
|
+
? readHistory(`${base}/history.rss`, service, opts)
|
|
36
|
+
: { recent: [], historyFailed: false },
|
|
37
|
+
]);
|
|
38
|
+
return { ...fromStatuspageSummary(summary), ...history };
|
|
39
|
+
},
|
|
40
|
+
rss: async (service, opts) => {
|
|
41
|
+
const items = newestFirst(parseRss(await text(service.feed ?? "", service, opts)));
|
|
42
|
+
return {
|
|
43
|
+
...fromFeed(items, { windowHours: service.window ?? 6 }),
|
|
44
|
+
recent: items,
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
slack: async (service, opts) => fromSlack(await json("https://slack-status.com/api/v2.0.0/current", service, opts)),
|
|
48
|
+
aws: async (service, opts) => {
|
|
49
|
+
const res = await fetchWith("https://health.aws.amazon.com/public/currentevents", service, opts);
|
|
50
|
+
return fromAws(JSON.parse(decodeUtf16(await res.arrayBuffer())), {
|
|
51
|
+
regions: service.regions,
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
link: async () => ({
|
|
55
|
+
indicator: "link",
|
|
56
|
+
description: "No machine-readable status",
|
|
57
|
+
}),
|
|
58
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export const age = (ms) => {
|
|
2
|
+
const hours = Math.floor(ms / 3_600_000);
|
|
3
|
+
if (hours < 1)
|
|
4
|
+
return `${Math.max(1, Math.floor(ms / 60_000))}m`;
|
|
5
|
+
if (hours < 48)
|
|
6
|
+
return hours < 10 && ms % 3_600_000 >= 60_000
|
|
7
|
+
? `${hours}h ${Math.floor((ms % 3_600_000) / 60_000)}m`
|
|
8
|
+
: `${hours}h`;
|
|
9
|
+
return `${Math.floor(hours / 24)}d`;
|
|
10
|
+
};
|
|
11
|
+
export const ageOf = (raw, now = Date.now()) => {
|
|
12
|
+
const ms = now - new Date(raw).getTime();
|
|
13
|
+
return raw && !Number.isNaN(ms) ? age(Math.max(0, ms)) : "";
|
|
14
|
+
};
|
|
15
|
+
export const formatWhen = (raw) => {
|
|
16
|
+
const date = new Date(raw);
|
|
17
|
+
return Number.isNaN(date.getTime())
|
|
18
|
+
? raw
|
|
19
|
+
: date.toLocaleString(undefined, {
|
|
20
|
+
day: "numeric",
|
|
21
|
+
month: "short",
|
|
22
|
+
hour: "2-digit",
|
|
23
|
+
minute: "2-digit",
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
export const whenLabel = (raw, now = Date.now()) => {
|
|
27
|
+
const label = ageOf(raw, now);
|
|
28
|
+
if (!label)
|
|
29
|
+
return raw ? formatWhen(raw) : "";
|
|
30
|
+
return label.endsWith("d") && parseInt(label, 10) >= 7
|
|
31
|
+
? formatWhen(raw)
|
|
32
|
+
: `${label} ago`;
|
|
33
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export const CATALOGUE = {
|
|
2
|
+
github: {
|
|
3
|
+
name: "GitHub",
|
|
4
|
+
kind: "statuspage",
|
|
5
|
+
url: "https://www.githubstatus.com",
|
|
6
|
+
aliases: ["gh"],
|
|
7
|
+
note: "A green board is not a promise your calls succeed: scattered 5xx errors never reach an incident.",
|
|
8
|
+
},
|
|
9
|
+
claude: {
|
|
10
|
+
name: "Claude",
|
|
11
|
+
kind: "statuspage",
|
|
12
|
+
url: "https://status.claude.com",
|
|
13
|
+
aliases: ["anthropic"],
|
|
14
|
+
},
|
|
15
|
+
openai: {
|
|
16
|
+
name: "OpenAI",
|
|
17
|
+
kind: "statuspage",
|
|
18
|
+
url: "https://status.openai.com",
|
|
19
|
+
},
|
|
20
|
+
vercel: {
|
|
21
|
+
name: "Vercel",
|
|
22
|
+
kind: "statuspage",
|
|
23
|
+
url: "https://www.vercel-status.com",
|
|
24
|
+
},
|
|
25
|
+
cloudflare: {
|
|
26
|
+
name: "Cloudflare",
|
|
27
|
+
kind: "statuspage",
|
|
28
|
+
url: "https://www.cloudflarestatus.com",
|
|
29
|
+
aliases: ["cf"],
|
|
30
|
+
},
|
|
31
|
+
npm: { name: "npm", kind: "statuspage", url: "https://status.npmjs.org" },
|
|
32
|
+
docker: {
|
|
33
|
+
name: "Docker",
|
|
34
|
+
kind: "statuspage",
|
|
35
|
+
url: "https://www.dockerstatus.com",
|
|
36
|
+
},
|
|
37
|
+
figma: { name: "Figma", kind: "statuspage", url: "https://status.figma.com" },
|
|
38
|
+
linear: {
|
|
39
|
+
name: "Linear",
|
|
40
|
+
kind: "statuspage",
|
|
41
|
+
url: "https://linearstatus.com",
|
|
42
|
+
},
|
|
43
|
+
notion: {
|
|
44
|
+
name: "Notion",
|
|
45
|
+
kind: "statuspage",
|
|
46
|
+
url: "https://www.notion-status.com",
|
|
47
|
+
},
|
|
48
|
+
gandi: { name: "Gandi", kind: "statuspage", url: "https://status.gandi.net" },
|
|
49
|
+
todoist: {
|
|
50
|
+
name: "Todoist",
|
|
51
|
+
kind: "rss",
|
|
52
|
+
url: "https://status.todoist.net",
|
|
53
|
+
feed: "https://status.todoist.net/history.rss",
|
|
54
|
+
},
|
|
55
|
+
bluesky: {
|
|
56
|
+
name: "Bluesky",
|
|
57
|
+
kind: "rss",
|
|
58
|
+
url: "https://status.bsky.app",
|
|
59
|
+
feed: "https://status.bsky.app/history.rss",
|
|
60
|
+
aliases: ["bsky"],
|
|
61
|
+
},
|
|
62
|
+
jira: {
|
|
63
|
+
name: "Jira",
|
|
64
|
+
kind: "statuspage",
|
|
65
|
+
url: "https://jira-software.status.atlassian.com",
|
|
66
|
+
},
|
|
67
|
+
confluence: {
|
|
68
|
+
name: "Confluence",
|
|
69
|
+
kind: "statuspage",
|
|
70
|
+
url: "https://confluence.status.atlassian.com",
|
|
71
|
+
},
|
|
72
|
+
bitbucket: {
|
|
73
|
+
name: "Bitbucket",
|
|
74
|
+
kind: "statuspage",
|
|
75
|
+
url: "https://bitbucket.status.atlassian.com",
|
|
76
|
+
},
|
|
77
|
+
sentry: {
|
|
78
|
+
name: "Sentry",
|
|
79
|
+
kind: "statuspage",
|
|
80
|
+
url: "https://status.sentry.io",
|
|
81
|
+
},
|
|
82
|
+
harness: {
|
|
83
|
+
name: "Harness",
|
|
84
|
+
kind: "statuspage",
|
|
85
|
+
url: "https://status.harness.io",
|
|
86
|
+
},
|
|
87
|
+
datadog: {
|
|
88
|
+
name: "Datadog",
|
|
89
|
+
kind: "rss",
|
|
90
|
+
url: "https://status.datadoghq.com",
|
|
91
|
+
feed: "https://status.datadoghq.com/history.rss",
|
|
92
|
+
aliases: ["dd"],
|
|
93
|
+
},
|
|
94
|
+
slack: { name: "Slack", kind: "slack", url: "https://slack-status.com" },
|
|
95
|
+
aws: {
|
|
96
|
+
name: "AWS",
|
|
97
|
+
kind: "aws",
|
|
98
|
+
url: "https://health.aws.amazon.com/health/status",
|
|
99
|
+
note: "Every region counts unless `regions` narrows it; global events always do.",
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
export const DEFAULT_IDS = ["github", "npm", "cloudflare", "claude"];
|