happyuptime 1.0.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/README.md +263 -0
- package/dist/index.js +956 -0
- package/package.json +58 -0
package/README.md
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# happyuptime
|
|
2
|
+
|
|
3
|
+
The official CLI for [Happy Uptime](https://happyuptime.com) — monitor uptime, manage incidents, and check site speed from your terminal.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g happyuptime
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or use without installing:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx happyuptime status
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# 1. Authenticate with your API key
|
|
21
|
+
happy login --api-key hu_your_key_here
|
|
22
|
+
|
|
23
|
+
# 2. Check your monitors
|
|
24
|
+
happy status
|
|
25
|
+
|
|
26
|
+
# 3. Speed test any URL
|
|
27
|
+
happy speed-test https://example.com
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Get an API key from your [Happy Uptime dashboard](https://happyuptime.com/dashboard/settings) under Settings > API Keys.
|
|
31
|
+
|
|
32
|
+
## Commands
|
|
33
|
+
|
|
34
|
+
### Authentication
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
happy login --api-key hu_xxx # Save API key
|
|
38
|
+
happy whoami # Show current auth status
|
|
39
|
+
happy logout # Remove saved credentials
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
You can also set the `HAPPYUPTIME_API_KEY` environment variable instead of using `happy login`.
|
|
43
|
+
|
|
44
|
+
### Status Overview
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
happy status # Quick overview of all monitors
|
|
48
|
+
happy status --json # JSON output for scripting
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Example output:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
● All systems operational
|
|
55
|
+
|
|
56
|
+
Monitors
|
|
57
|
+
● Production API
|
|
58
|
+
● Marketing Site
|
|
59
|
+
▲ Staging API degraded
|
|
60
|
+
○ Dev Server (paused)
|
|
61
|
+
|
|
62
|
+
10 monitors | 8 up | 0 down | 1 degraded
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Monitors
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
happy monitors list # List all monitors
|
|
69
|
+
happy monitors list --status down # Filter by status
|
|
70
|
+
happy monitors list --type http # Filter by type
|
|
71
|
+
happy monitors get <id> # Show monitor details
|
|
72
|
+
|
|
73
|
+
happy monitors create \
|
|
74
|
+
--name "Production API" \
|
|
75
|
+
--url https://api.example.com/health \
|
|
76
|
+
--type http \
|
|
77
|
+
--interval 60 \
|
|
78
|
+
--regions us-east,eu-west # Create a monitor
|
|
79
|
+
|
|
80
|
+
happy monitors pause <id> # Pause monitoring
|
|
81
|
+
happy monitors resume <id> # Resume monitoring
|
|
82
|
+
happy monitors delete <id> # Delete (with confirmation)
|
|
83
|
+
happy monitors check https://example.com # Quick-check a URL
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Incidents
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
happy incidents list # List incidents
|
|
90
|
+
happy incidents list --status investigating # Filter by status
|
|
91
|
+
happy incidents get <id> # Show incident detail + timeline
|
|
92
|
+
|
|
93
|
+
happy incidents create "API is slow" \
|
|
94
|
+
--severity major \
|
|
95
|
+
--message "Investigating elevated latency" # Create incident
|
|
96
|
+
|
|
97
|
+
happy incidents update <id> \
|
|
98
|
+
--status identified \
|
|
99
|
+
--message "Found root cause: database" # Add status update
|
|
100
|
+
|
|
101
|
+
happy incidents resolve <id> \
|
|
102
|
+
--message "Fix deployed, monitoring" # Resolve incident
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Alerts
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
happy alerts list # List alert channels
|
|
109
|
+
|
|
110
|
+
happy alerts create \
|
|
111
|
+
--name "Slack Engineering" \
|
|
112
|
+
--type slack \
|
|
113
|
+
--webhook-url https://hooks.slack.com/... # Create channel
|
|
114
|
+
|
|
115
|
+
happy alerts delete <id> # Delete channel
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Speed Test
|
|
119
|
+
|
|
120
|
+
Test any URL from 6 global regions with timing waterfall:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
happy speed-test https://example.com
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Output includes a waterfall visualization:
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
Speed Test: https://example.com
|
|
130
|
+
Avg: 245ms Min: 180ms Max: 420ms
|
|
131
|
+
|
|
132
|
+
┌──────────┬────────┬──────┬───────┬─────┬─────────┬─────┬──────┐
|
|
133
|
+
│ Region │ Status │ Code │ Total │ DNS │ Connect │ TLS │ TTFB │
|
|
134
|
+
├──────────┼────────┼──────┼───────┼─────┼─────────┼─────┼──────┤
|
|
135
|
+
│ us-east │ up │ 200 │ 180ms │ 12ms│ 25ms │ 35ms│ 108ms│
|
|
136
|
+
│ eu-west │ up │ 200 │ 245ms │ 15ms│ 45ms │ 52ms│ 133ms│
|
|
137
|
+
└──────────┴────────┴──────┴───────┴─────┴─────────┴─────┴──────┘
|
|
138
|
+
|
|
139
|
+
Waterfall
|
|
140
|
+
us-east ██████████████████████ 180ms
|
|
141
|
+
eu-west ████████████████████████████ 245ms
|
|
142
|
+
|
|
143
|
+
■ DNS ■ Connect ■ TLS ■ TTFB ■ Transfer
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
No authentication required for speed tests.
|
|
147
|
+
|
|
148
|
+
### Config-as-Code
|
|
149
|
+
|
|
150
|
+
Manage monitors declaratively with `happyuptime.yml`:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
happy config pull # Export current monitors to YAML
|
|
154
|
+
happy config push # Sync YAML to Happy Uptime
|
|
155
|
+
happy config push --dry-run # Preview changes without applying
|
|
156
|
+
happy config validate # Validate YAML syntax
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Example `happyuptime.yml`:
|
|
160
|
+
|
|
161
|
+
```yaml
|
|
162
|
+
monitors:
|
|
163
|
+
- name: Production API
|
|
164
|
+
url: https://api.example.com/health
|
|
165
|
+
type: http
|
|
166
|
+
interval: 60
|
|
167
|
+
regions:
|
|
168
|
+
- us-east
|
|
169
|
+
- eu-west
|
|
170
|
+
- ap-southeast
|
|
171
|
+
tags:
|
|
172
|
+
- production
|
|
173
|
+
- critical
|
|
174
|
+
|
|
175
|
+
- name: Marketing Site
|
|
176
|
+
url: https://example.com
|
|
177
|
+
type: http
|
|
178
|
+
interval: 300
|
|
179
|
+
regions:
|
|
180
|
+
- us-east
|
|
181
|
+
- eu-west
|
|
182
|
+
|
|
183
|
+
- name: Database Backup
|
|
184
|
+
type: heartbeat
|
|
185
|
+
interval: 3600
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Environment variable substitution is supported:
|
|
189
|
+
|
|
190
|
+
```yaml
|
|
191
|
+
monitors:
|
|
192
|
+
- name: API
|
|
193
|
+
url: ${API_URL}/health
|
|
194
|
+
headers:
|
|
195
|
+
Authorization: Bearer ${API_TOKEN}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Environment Variables
|
|
199
|
+
|
|
200
|
+
| Variable | Description |
|
|
201
|
+
|---|---|
|
|
202
|
+
| `HAPPYUPTIME_API_KEY` | API key (alternative to `happy login`) |
|
|
203
|
+
| `HAPPYUPTIME_API_URL` | Custom API base URL (default: `https://happyuptime.com`) |
|
|
204
|
+
|
|
205
|
+
## JSON Output
|
|
206
|
+
|
|
207
|
+
All commands support `--json` for machine-readable output:
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
happy status --json | jq '.monitors | length'
|
|
211
|
+
happy monitors list --json | jq '.data[] | select(.status == "down")'
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## CI/CD Usage
|
|
215
|
+
|
|
216
|
+
Use in GitHub Actions or other CI pipelines:
|
|
217
|
+
|
|
218
|
+
```yaml
|
|
219
|
+
# .github/workflows/deploy.yml
|
|
220
|
+
- name: Check monitors after deploy
|
|
221
|
+
run: |
|
|
222
|
+
npx happyuptime status --json
|
|
223
|
+
env:
|
|
224
|
+
HAPPYUPTIME_API_KEY: ${{ secrets.HAPPYUPTIME_API_KEY }}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
```yaml
|
|
228
|
+
# Create incident on deploy failure
|
|
229
|
+
- name: Report deploy failure
|
|
230
|
+
if: failure()
|
|
231
|
+
run: |
|
|
232
|
+
npx happyuptime incidents create "Deploy failed" \
|
|
233
|
+
--severity major \
|
|
234
|
+
--message "Deploy to production failed in CI"
|
|
235
|
+
env:
|
|
236
|
+
HAPPYUPTIME_API_KEY: ${{ secrets.HAPPYUPTIME_API_KEY }}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Config File
|
|
240
|
+
|
|
241
|
+
Credentials are stored in `~/.happyuptime/config.json`:
|
|
242
|
+
|
|
243
|
+
```json
|
|
244
|
+
{
|
|
245
|
+
"apiKey": "hu_xxx",
|
|
246
|
+
"apiUrl": "https://happyuptime.com"
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## Requirements
|
|
251
|
+
|
|
252
|
+
- Node.js 18+
|
|
253
|
+
|
|
254
|
+
## Links
|
|
255
|
+
|
|
256
|
+
- [Happy Uptime](https://happyuptime.com)
|
|
257
|
+
- [API Documentation](https://happyuptime.com/docs)
|
|
258
|
+
- [Dashboard](https://happyuptime.com/dashboard)
|
|
259
|
+
- [GitHub](https://github.com/seangeng/happyuptime)
|
|
260
|
+
|
|
261
|
+
## License
|
|
262
|
+
|
|
263
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,956 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import chalk6 from 'chalk';
|
|
4
|
+
import { writeFileSync, existsSync, readFileSync, mkdirSync } from 'fs';
|
|
5
|
+
import { homedir } from 'os';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import Table from 'cli-table3';
|
|
8
|
+
|
|
9
|
+
// src/lib/constants.ts
|
|
10
|
+
var CONFIG_DIR = ".happyuptime";
|
|
11
|
+
var CONFIG_FILE = "config.json";
|
|
12
|
+
var VERSION = "1.0.0";
|
|
13
|
+
function configPath() {
|
|
14
|
+
return join(homedir(), CONFIG_DIR, CONFIG_FILE);
|
|
15
|
+
}
|
|
16
|
+
function configDir() {
|
|
17
|
+
return join(homedir(), CONFIG_DIR);
|
|
18
|
+
}
|
|
19
|
+
function loadConfig() {
|
|
20
|
+
const path = configPath();
|
|
21
|
+
if (!existsSync(path)) return {};
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
24
|
+
} catch {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function saveConfig(config) {
|
|
29
|
+
const dir = configDir();
|
|
30
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
31
|
+
writeFileSync(configPath(), JSON.stringify(config, null, 2) + "\n");
|
|
32
|
+
}
|
|
33
|
+
function getApiKey() {
|
|
34
|
+
const envKey = process.env.HAPPYUPTIME_API_KEY;
|
|
35
|
+
if (envKey) return envKey;
|
|
36
|
+
const config = loadConfig();
|
|
37
|
+
if (config.apiKey) return config.apiKey;
|
|
38
|
+
console.error(
|
|
39
|
+
"No API key found. Run `happy login --api-key hu_xxx` or set HAPPYUPTIME_API_KEY."
|
|
40
|
+
);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
function getApiUrl() {
|
|
44
|
+
const config = loadConfig();
|
|
45
|
+
return config.apiUrl || process.env.HAPPYUPTIME_API_URL || "https://happyuptime.com";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// src/lib/api.ts
|
|
49
|
+
var ApiError = class extends Error {
|
|
50
|
+
constructor(status, code, message) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.status = status;
|
|
53
|
+
this.code = code;
|
|
54
|
+
this.name = "ApiError";
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
async function api(path, opts = {}) {
|
|
58
|
+
const baseUrl = getApiUrl();
|
|
59
|
+
const url = new URL(path, baseUrl);
|
|
60
|
+
if (opts.params) {
|
|
61
|
+
for (const [key, val] of Object.entries(opts.params)) {
|
|
62
|
+
if (val !== void 0) url.searchParams.set(key, String(val));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const headers = {
|
|
66
|
+
"Content-Type": "application/json",
|
|
67
|
+
"User-Agent": "happyuptime-cli/1.0.0"
|
|
68
|
+
};
|
|
69
|
+
if (!opts.noAuth) {
|
|
70
|
+
headers["Authorization"] = `Bearer ${getApiKey()}`;
|
|
71
|
+
}
|
|
72
|
+
const res = await fetch(url.toString(), {
|
|
73
|
+
method: opts.method || "GET",
|
|
74
|
+
headers,
|
|
75
|
+
body: opts.body ? JSON.stringify(opts.body) : void 0
|
|
76
|
+
});
|
|
77
|
+
if (!res.ok) {
|
|
78
|
+
let errBody = {};
|
|
79
|
+
try {
|
|
80
|
+
errBody = await res.json();
|
|
81
|
+
} catch {
|
|
82
|
+
}
|
|
83
|
+
throw new ApiError(
|
|
84
|
+
res.status,
|
|
85
|
+
errBody.error?.code || "unknown",
|
|
86
|
+
errBody.error?.message || `HTTP ${res.status}`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return res.json();
|
|
90
|
+
}
|
|
91
|
+
function table(headers, rows, opts) {
|
|
92
|
+
const t = new Table({
|
|
93
|
+
head: headers.map((h) => chalk6.bold.cyan(h)),
|
|
94
|
+
style: { head: [], border: ["gray"] },
|
|
95
|
+
...{}
|
|
96
|
+
});
|
|
97
|
+
for (const row of rows) {
|
|
98
|
+
t.push(row.map(String));
|
|
99
|
+
}
|
|
100
|
+
console.log(t.toString());
|
|
101
|
+
}
|
|
102
|
+
function statusColor(status) {
|
|
103
|
+
switch (status.toLowerCase()) {
|
|
104
|
+
case "up":
|
|
105
|
+
case "operational":
|
|
106
|
+
case "resolved":
|
|
107
|
+
return chalk6.green(status);
|
|
108
|
+
case "degraded":
|
|
109
|
+
case "monitoring":
|
|
110
|
+
case "identified":
|
|
111
|
+
case "minor":
|
|
112
|
+
return chalk6.yellow(status);
|
|
113
|
+
case "down":
|
|
114
|
+
case "investigating":
|
|
115
|
+
case "major":
|
|
116
|
+
case "critical":
|
|
117
|
+
return chalk6.red(status);
|
|
118
|
+
case "paused":
|
|
119
|
+
return chalk6.gray(status);
|
|
120
|
+
default:
|
|
121
|
+
return status;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function kv(label, value) {
|
|
125
|
+
console.log(` ${chalk6.gray(label + ":")} ${value ?? chalk6.dim("\u2014")}`);
|
|
126
|
+
}
|
|
127
|
+
function heading(text) {
|
|
128
|
+
console.log(chalk6.bold("\n" + text));
|
|
129
|
+
}
|
|
130
|
+
function success(text) {
|
|
131
|
+
console.log(chalk6.green("\u2713") + " " + text);
|
|
132
|
+
}
|
|
133
|
+
function warn(text) {
|
|
134
|
+
console.log(chalk6.yellow("!") + " " + text);
|
|
135
|
+
}
|
|
136
|
+
function error(text) {
|
|
137
|
+
console.error(chalk6.red("\u2717") + " " + text);
|
|
138
|
+
}
|
|
139
|
+
function formatMs(ms) {
|
|
140
|
+
if (ms < 1e3) return `${Math.round(ms)}ms`;
|
|
141
|
+
return `${(ms / 1e3).toFixed(2)}s`;
|
|
142
|
+
}
|
|
143
|
+
function formatUptime(pct) {
|
|
144
|
+
const s = pct.toFixed(2) + "%";
|
|
145
|
+
if (pct >= 99.9) return chalk6.green(s);
|
|
146
|
+
if (pct >= 99) return chalk6.yellow(s);
|
|
147
|
+
return chalk6.red(s);
|
|
148
|
+
}
|
|
149
|
+
function timeAgo(dateStr) {
|
|
150
|
+
const diff = Date.now() - new Date(dateStr).getTime();
|
|
151
|
+
const mins = Math.floor(diff / 6e4);
|
|
152
|
+
if (mins < 1) return "just now";
|
|
153
|
+
if (mins < 60) return `${mins}m ago`;
|
|
154
|
+
const hours = Math.floor(mins / 60);
|
|
155
|
+
if (hours < 24) return `${hours}h ago`;
|
|
156
|
+
const days = Math.floor(hours / 24);
|
|
157
|
+
return `${days}d ago`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// src/commands/login.ts
|
|
161
|
+
var loginCommand = new Command("login").description("Authenticate with Happy Uptime").option("--api-key <key>", "API key (starts with hu_)").option("--api-url <url>", "Custom API URL (default: https://happyuptime.com)").action(async (opts) => {
|
|
162
|
+
if (!opts.apiKey) {
|
|
163
|
+
error("Please provide an API key: happy login --api-key hu_xxx");
|
|
164
|
+
console.log(
|
|
165
|
+
chalk6.dim("\nCreate one at https://happyuptime.com/dashboard/settings")
|
|
166
|
+
);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
if (!opts.apiKey.startsWith("hu_")) {
|
|
170
|
+
error("Invalid API key format. Keys start with hu_");
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
const config = loadConfig();
|
|
174
|
+
config.apiKey = opts.apiKey;
|
|
175
|
+
if (opts.apiUrl) config.apiUrl = opts.apiUrl;
|
|
176
|
+
saveConfig(config);
|
|
177
|
+
try {
|
|
178
|
+
const res = await api(
|
|
179
|
+
"/api/v1/analytics/summary"
|
|
180
|
+
);
|
|
181
|
+
success(
|
|
182
|
+
`Authenticated! ${res.data.monitors.total} monitors found.`
|
|
183
|
+
);
|
|
184
|
+
} catch (e) {
|
|
185
|
+
const apiErr = e;
|
|
186
|
+
if (apiErr.status === 401) {
|
|
187
|
+
warn("Key saved but could not verify \u2014 check that it's valid.");
|
|
188
|
+
} else {
|
|
189
|
+
success("API key saved.");
|
|
190
|
+
warn("Could not reach API to verify.");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
console.log(chalk6.dim(`Config saved to ~/.happyuptime/config.json`));
|
|
194
|
+
});
|
|
195
|
+
var whoamiCommand = new Command("whoami").description("Show current auth status").action(async () => {
|
|
196
|
+
try {
|
|
197
|
+
const key = getApiKey();
|
|
198
|
+
const url = getApiUrl();
|
|
199
|
+
const masked = key.slice(0, 7) + "..." + key.slice(-4);
|
|
200
|
+
heading("Happy Uptime CLI");
|
|
201
|
+
kv("API Key", masked);
|
|
202
|
+
kv("API URL", url);
|
|
203
|
+
const res = await api(
|
|
204
|
+
"/api/v1/analytics/summary"
|
|
205
|
+
);
|
|
206
|
+
kv("Monitors", `${res.data.monitors.total} total`);
|
|
207
|
+
console.log();
|
|
208
|
+
} catch {
|
|
209
|
+
error("Could not fetch account info. Check your API key.");
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
var logoutCommand = new Command("logout").description("Remove saved credentials").action(() => {
|
|
214
|
+
saveConfig({});
|
|
215
|
+
success("Logged out. API key removed from ~/.happyuptime/config.json");
|
|
216
|
+
});
|
|
217
|
+
var monitorsCommand = new Command("monitors").description("Manage monitors").addCommand(listMonitors()).addCommand(getMonitor()).addCommand(createMonitor()).addCommand(deleteMonitor()).addCommand(pauseMonitor()).addCommand(resumeMonitor()).addCommand(checkMonitor());
|
|
218
|
+
function listMonitors() {
|
|
219
|
+
return new Command("list").alias("ls").description("List all monitors").option("-s, --status <status>", "Filter by status (up, down, degraded)").option("-t, --type <type>", "Filter by type (http, ping, tcp, dns, keyword, heartbeat)").option("--tag <tag>", "Filter by tag").option("-p, --page <n>", "Page number", "1").option("--per-page <n>", "Results per page", "25").option("--json", "Output raw JSON").action(async (opts) => {
|
|
220
|
+
try {
|
|
221
|
+
const res = await api(
|
|
222
|
+
"/api/v1/monitors",
|
|
223
|
+
{ params: { status: opts.status, type: opts.type, tag: opts.tag, page: opts.page, per_page: opts.perPage } }
|
|
224
|
+
);
|
|
225
|
+
if (opts.json) {
|
|
226
|
+
console.log(JSON.stringify(res, null, 2));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (res.data.length === 0) {
|
|
230
|
+
warn("No monitors found.");
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
heading(`Monitors (${res.pagination.total} total)`);
|
|
234
|
+
table(
|
|
235
|
+
["ID", "Name", "Type", "Status", "Interval", "URL"],
|
|
236
|
+
res.data.map((m) => [
|
|
237
|
+
chalk6.dim(m.id.slice(0, 8)),
|
|
238
|
+
m.name,
|
|
239
|
+
m.type,
|
|
240
|
+
m.paused ? statusColor("paused") : statusColor(m.status),
|
|
241
|
+
`${m.interval_seconds}s`,
|
|
242
|
+
m.url?.length > 40 ? m.url.slice(0, 37) + "..." : m.url || "\u2014"
|
|
243
|
+
])
|
|
244
|
+
);
|
|
245
|
+
if (res.pagination.total_pages > 1) {
|
|
246
|
+
console.log(
|
|
247
|
+
chalk6.dim(` Page ${res.pagination.page}/${res.pagination.total_pages}`)
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
} catch (e) {
|
|
251
|
+
handleError(e);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
function getMonitor() {
|
|
256
|
+
return new Command("get").description("Show monitor details").argument("<id>", "Monitor ID").option("--json", "Output raw JSON").action(async (id, opts) => {
|
|
257
|
+
try {
|
|
258
|
+
const res = await api(
|
|
259
|
+
`/api/v1/monitors/${id}`
|
|
260
|
+
);
|
|
261
|
+
if (opts.json) {
|
|
262
|
+
console.log(JSON.stringify(res, null, 2));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const m = res.data;
|
|
266
|
+
heading(m.name);
|
|
267
|
+
kv("ID", m.id);
|
|
268
|
+
kv("Type", m.type);
|
|
269
|
+
kv("URL", m.url);
|
|
270
|
+
kv("Status", statusColor(m.paused ? "paused" : m.status));
|
|
271
|
+
kv("Interval", `${m.interval_seconds}s`);
|
|
272
|
+
kv("Regions", m.regions?.join(", ") || "\u2014");
|
|
273
|
+
if (m.uptime_30d !== void 0) {
|
|
274
|
+
kv("Uptime (30d)", formatUptime(m.uptime_30d));
|
|
275
|
+
}
|
|
276
|
+
kv("Created", timeAgo(m.created_at));
|
|
277
|
+
console.log();
|
|
278
|
+
} catch (e) {
|
|
279
|
+
handleError(e);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
function createMonitor() {
|
|
284
|
+
return new Command("create").description("Create a new monitor").requiredOption("-n, --name <name>", "Monitor name").requiredOption("-u, --url <url>", "URL to monitor").option("-t, --type <type>", "Monitor type", "http").option("-m, --method <method>", "HTTP method", "GET").option("-i, --interval <seconds>", "Check interval in seconds", "60").option("--timeout <ms>", "Timeout in milliseconds", "30000").option("-r, --regions <regions>", "Comma-separated regions", "us-east,eu-west").option("--tag <tags>", "Comma-separated tags").option("--json", "Output raw JSON").action(async (opts) => {
|
|
285
|
+
try {
|
|
286
|
+
const body = {
|
|
287
|
+
name: opts.name,
|
|
288
|
+
url: opts.url,
|
|
289
|
+
type: opts.type,
|
|
290
|
+
method: opts.method,
|
|
291
|
+
interval_seconds: parseInt(opts.interval),
|
|
292
|
+
timeout_ms: parseInt(opts.timeout),
|
|
293
|
+
regions: opts.regions.split(",").map((r) => r.trim())
|
|
294
|
+
};
|
|
295
|
+
if (opts.tag) body.tags = opts.tag.split(",").map((t) => t.trim());
|
|
296
|
+
const res = await api("/api/v1/monitors", {
|
|
297
|
+
method: "POST",
|
|
298
|
+
body
|
|
299
|
+
});
|
|
300
|
+
if (opts.json) {
|
|
301
|
+
console.log(JSON.stringify(res, null, 2));
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
success(`Monitor "${res.data.name}" created (${res.data.id})`);
|
|
305
|
+
} catch (e) {
|
|
306
|
+
handleError(e);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
function deleteMonitor() {
|
|
311
|
+
return new Command("delete").alias("rm").description("Delete a monitor").argument("<id>", "Monitor ID").option("-y, --yes", "Skip confirmation").action(async (id, opts) => {
|
|
312
|
+
if (!opts.yes) {
|
|
313
|
+
const { default: inquirer } = await import('inquirer');
|
|
314
|
+
const { confirm } = await inquirer.prompt([
|
|
315
|
+
{ type: "confirm", name: "confirm", message: `Delete monitor ${id}?`, default: false }
|
|
316
|
+
]);
|
|
317
|
+
if (!confirm) return;
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
await api(`/api/v1/monitors/${id}`, { method: "DELETE" });
|
|
321
|
+
success(`Monitor ${id} deleted.`);
|
|
322
|
+
} catch (e) {
|
|
323
|
+
handleError(e);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
function pauseMonitor() {
|
|
328
|
+
return new Command("pause").description("Pause a monitor").argument("<id>", "Monitor ID").action(async (id) => {
|
|
329
|
+
try {
|
|
330
|
+
await api(`/api/v1/monitors/${id}`, {
|
|
331
|
+
method: "PUT",
|
|
332
|
+
body: { paused: true }
|
|
333
|
+
});
|
|
334
|
+
success(`Monitor ${id} paused.`);
|
|
335
|
+
} catch (e) {
|
|
336
|
+
handleError(e);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
function resumeMonitor() {
|
|
341
|
+
return new Command("resume").description("Resume a paused monitor").argument("<id>", "Monitor ID").action(async (id) => {
|
|
342
|
+
try {
|
|
343
|
+
await api(`/api/v1/monitors/${id}`, {
|
|
344
|
+
method: "PUT",
|
|
345
|
+
body: { paused: false }
|
|
346
|
+
});
|
|
347
|
+
success(`Monitor ${id} resumed.`);
|
|
348
|
+
} catch (e) {
|
|
349
|
+
handleError(e);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
function checkMonitor() {
|
|
354
|
+
return new Command("check").description("Quick-check a URL from multiple regions").argument("<url>", "URL to check").option("--json", "Output raw JSON").action(async (url, opts) => {
|
|
355
|
+
const ora = (await import('ora')).default;
|
|
356
|
+
const spinner = ora(`Testing ${url}...`).start();
|
|
357
|
+
try {
|
|
358
|
+
const res = await api("/api/speed-test/run", {
|
|
359
|
+
method: "POST",
|
|
360
|
+
body: { url },
|
|
361
|
+
noAuth: true
|
|
362
|
+
});
|
|
363
|
+
spinner.stop();
|
|
364
|
+
if (opts.json) {
|
|
365
|
+
console.log(JSON.stringify(res, null, 2));
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
heading(`Results for ${url}`);
|
|
369
|
+
table(
|
|
370
|
+
["Region", "Status", "Code", "Total", "DNS", "Connect", "TLS", "TTFB"],
|
|
371
|
+
res.results.map((r) => [
|
|
372
|
+
r.region,
|
|
373
|
+
statusColor(r.status),
|
|
374
|
+
r.statusCode || "\u2014",
|
|
375
|
+
formatMs(r.responseTimeMs),
|
|
376
|
+
formatMs(r.timingDnsMs),
|
|
377
|
+
formatMs(r.timingConnectMs),
|
|
378
|
+
formatMs(r.timingTlsMs),
|
|
379
|
+
formatMs(r.timingTtfbMs)
|
|
380
|
+
])
|
|
381
|
+
);
|
|
382
|
+
if (res.id) {
|
|
383
|
+
console.log(
|
|
384
|
+
chalk6.dim(`
|
|
385
|
+
Share: https://happyuptime.com/speed-test?id=${res.id}`)
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
} catch (e) {
|
|
389
|
+
spinner.stop();
|
|
390
|
+
handleError(e);
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
function handleError(e) {
|
|
395
|
+
if (e instanceof ApiError) {
|
|
396
|
+
error(`${e.message} (${e.status})`);
|
|
397
|
+
} else if (e instanceof Error) {
|
|
398
|
+
error(e.message);
|
|
399
|
+
}
|
|
400
|
+
process.exit(1);
|
|
401
|
+
}
|
|
402
|
+
var statusCommand = new Command("status").description("Quick overview of all monitors").option("--json", "Output raw JSON").action(async (opts) => {
|
|
403
|
+
try {
|
|
404
|
+
const [summary, monitors] = await Promise.all([
|
|
405
|
+
api("/api/v1/analytics/summary"),
|
|
406
|
+
api("/api/v1/monitors", {
|
|
407
|
+
params: { per_page: 100 }
|
|
408
|
+
})
|
|
409
|
+
]);
|
|
410
|
+
if (opts.json) {
|
|
411
|
+
console.log(JSON.stringify({ summary: summary.data, monitors: monitors.data }, null, 2));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const s = summary.data;
|
|
415
|
+
const allUp = s.monitors.down === 0 && s.monitors.degraded === 0;
|
|
416
|
+
console.log();
|
|
417
|
+
if (allUp) {
|
|
418
|
+
console.log(chalk6.green.bold(" \u25CF All systems operational"));
|
|
419
|
+
} else {
|
|
420
|
+
if (s.monitors.down > 0) {
|
|
421
|
+
console.log(chalk6.red.bold(` \u25CF ${s.monitors.down} monitor${s.monitors.down > 1 ? "s" : ""} down`));
|
|
422
|
+
}
|
|
423
|
+
if (s.monitors.degraded > 0) {
|
|
424
|
+
console.log(chalk6.yellow.bold(` \u25B2 ${s.monitors.degraded} monitor${s.monitors.degraded > 1 ? "s" : ""} degraded`));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (s.active_incidents > 0) {
|
|
428
|
+
console.log(chalk6.red(` ! ${s.active_incidents} active incident${s.active_incidents > 1 ? "s" : ""}`));
|
|
429
|
+
}
|
|
430
|
+
heading("Monitors");
|
|
431
|
+
const sorted = [...monitors.data].sort((a, b) => {
|
|
432
|
+
const order = { down: 0, degraded: 1, up: 2 };
|
|
433
|
+
return (order[a.status] ?? 3) - (order[b.status] ?? 3);
|
|
434
|
+
});
|
|
435
|
+
for (const m of sorted) {
|
|
436
|
+
if (m.paused) {
|
|
437
|
+
console.log(` ${chalk6.gray("\u25CB")} ${chalk6.gray(m.name)} ${chalk6.dim("(paused)")}`);
|
|
438
|
+
} else if (m.status === "up") {
|
|
439
|
+
console.log(` ${chalk6.green("\u25CF")} ${m.name}`);
|
|
440
|
+
} else if (m.status === "degraded") {
|
|
441
|
+
console.log(` ${chalk6.yellow("\u25B2")} ${m.name} ${chalk6.yellow("degraded")}`);
|
|
442
|
+
} else {
|
|
443
|
+
console.log(` ${chalk6.red("\u25CF")} ${m.name} ${chalk6.red("DOWN")}`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
console.log(
|
|
447
|
+
chalk6.dim(`
|
|
448
|
+
${s.monitors.total} monitors | ${s.monitors.up} up | ${s.monitors.down} down | ${s.monitors.degraded} degraded
|
|
449
|
+
`)
|
|
450
|
+
);
|
|
451
|
+
} catch (e) {
|
|
452
|
+
if (e instanceof Error) error(e.message);
|
|
453
|
+
process.exit(1);
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
var incidentsCommand = new Command("incidents").description("Manage incidents").addCommand(listIncidents()).addCommand(getIncident()).addCommand(createIncident()).addCommand(updateIncident()).addCommand(resolveIncident());
|
|
457
|
+
function listIncidents() {
|
|
458
|
+
return new Command("list").alias("ls").description("List incidents").option("-s, --status <status>", "Filter (investigating, identified, monitoring, resolved)").option("--severity <severity>", "Filter (critical, major, minor)").option("-p, --page <n>", "Page number", "1").option("--json", "Output raw JSON").action(async (opts) => {
|
|
459
|
+
try {
|
|
460
|
+
const res = await api(
|
|
461
|
+
"/api/v1/incidents",
|
|
462
|
+
{ params: { status: opts.status, severity: opts.severity, page: opts.page } }
|
|
463
|
+
);
|
|
464
|
+
if (opts.json) {
|
|
465
|
+
console.log(JSON.stringify(res, null, 2));
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (res.data.length === 0) {
|
|
469
|
+
success("No incidents found.");
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
heading(`Incidents (${res.pagination.total})`);
|
|
473
|
+
table(
|
|
474
|
+
["ID", "Title", "Status", "Severity", "Started"],
|
|
475
|
+
res.data.map((i) => [
|
|
476
|
+
chalk6.dim(i.id.slice(0, 8)),
|
|
477
|
+
i.title.length > 40 ? i.title.slice(0, 37) + "..." : i.title,
|
|
478
|
+
statusColor(i.status),
|
|
479
|
+
statusColor(i.severity),
|
|
480
|
+
timeAgo(i.started_at || i.created_at)
|
|
481
|
+
])
|
|
482
|
+
);
|
|
483
|
+
} catch (e) {
|
|
484
|
+
handleError2(e);
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
function getIncident() {
|
|
489
|
+
return new Command("get").description("Show incident details").argument("<id>", "Incident ID").option("--json", "Output raw JSON").action(async (id, opts) => {
|
|
490
|
+
try {
|
|
491
|
+
const res = await api(`/api/v1/incidents/${id}`);
|
|
492
|
+
if (opts.json) {
|
|
493
|
+
console.log(JSON.stringify(res, null, 2));
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const inc = res.data;
|
|
497
|
+
heading(inc.title);
|
|
498
|
+
kv("ID", inc.id);
|
|
499
|
+
kv("Status", statusColor(inc.status));
|
|
500
|
+
kv("Severity", statusColor(inc.severity));
|
|
501
|
+
kv("Started", inc.started_at || inc.created_at);
|
|
502
|
+
if (inc.resolved_at) kv("Resolved", inc.resolved_at);
|
|
503
|
+
if (inc.affected_monitors?.length) {
|
|
504
|
+
kv(
|
|
505
|
+
"Affected",
|
|
506
|
+
inc.affected_monitors.map((m) => m.name).join(", ")
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
if (inc.updates?.length) {
|
|
510
|
+
heading("Timeline");
|
|
511
|
+
for (const u of inc.updates) {
|
|
512
|
+
console.log(
|
|
513
|
+
` ${chalk6.dim(timeAgo(u.created_at))} ${statusColor(u.status)} \u2014 ${u.message}`
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
console.log();
|
|
518
|
+
} catch (e) {
|
|
519
|
+
handleError2(e);
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
function createIncident() {
|
|
524
|
+
return new Command("create").description("Create a new incident").argument("<title>", "Incident title").option("-s, --severity <severity>", "Severity (critical, major, minor)", "major").option("--status <status>", "Initial status", "investigating").option("-m, --message <message>", "Initial status update message").option("--json", "Output raw JSON").action(async (title, opts) => {
|
|
525
|
+
try {
|
|
526
|
+
const body = {
|
|
527
|
+
title,
|
|
528
|
+
severity: opts.severity,
|
|
529
|
+
status: opts.status
|
|
530
|
+
};
|
|
531
|
+
if (opts.message) body.message = opts.message;
|
|
532
|
+
const res = await api("/api/v1/incidents", {
|
|
533
|
+
method: "POST",
|
|
534
|
+
body
|
|
535
|
+
});
|
|
536
|
+
if (opts.json) {
|
|
537
|
+
console.log(JSON.stringify(res, null, 2));
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
success(`Incident created: ${res.data.id}`);
|
|
541
|
+
} catch (e) {
|
|
542
|
+
handleError2(e);
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
function updateIncident() {
|
|
547
|
+
return new Command("update").description("Add a status update to an incident").argument("<id>", "Incident ID").requiredOption("-m, --message <message>", "Update message").option("-s, --status <status>", "New status (investigating, identified, monitoring)").option("--json", "Output raw JSON").action(async (id, opts) => {
|
|
548
|
+
try {
|
|
549
|
+
const body = { message: opts.message };
|
|
550
|
+
if (opts.status) body.status = opts.status;
|
|
551
|
+
const res = await api(`/api/v1/incidents/${id}/updates`, {
|
|
552
|
+
method: "POST",
|
|
553
|
+
body
|
|
554
|
+
});
|
|
555
|
+
if (opts.json) {
|
|
556
|
+
console.log(JSON.stringify(res, null, 2));
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
success("Incident updated.");
|
|
560
|
+
} catch (e) {
|
|
561
|
+
handleError2(e);
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
function resolveIncident() {
|
|
566
|
+
return new Command("resolve").description("Resolve an incident").argument("<id>", "Incident ID").option("-m, --message <message>", "Resolution message").option("--json", "Output raw JSON").action(async (id, opts) => {
|
|
567
|
+
try {
|
|
568
|
+
const body = {};
|
|
569
|
+
if (opts.message) body.message = opts.message;
|
|
570
|
+
const res = await api(`/api/v1/incidents/${id}/resolve`, {
|
|
571
|
+
method: "POST",
|
|
572
|
+
body
|
|
573
|
+
});
|
|
574
|
+
if (opts.json) {
|
|
575
|
+
console.log(JSON.stringify(res, null, 2));
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
success(`Incident ${id} resolved.`);
|
|
579
|
+
} catch (e) {
|
|
580
|
+
handleError2(e);
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
function handleError2(e) {
|
|
585
|
+
if (e instanceof ApiError) error(`${e.message} (${e.status})`);
|
|
586
|
+
else if (e instanceof Error) error(e.message);
|
|
587
|
+
process.exit(1);
|
|
588
|
+
}
|
|
589
|
+
var speedTestCommand = new Command("speed-test").description("Test any URL from 6 global regions").argument("<url>", "URL to test").option("--json", "Output raw JSON").action(async (url, opts) => {
|
|
590
|
+
const ora = (await import('ora')).default;
|
|
591
|
+
const spinner = ora(`Testing ${url} from 6 regions...`).start();
|
|
592
|
+
try {
|
|
593
|
+
const res = await api("/api/speed-test/run", {
|
|
594
|
+
method: "POST",
|
|
595
|
+
body: { url },
|
|
596
|
+
noAuth: true
|
|
597
|
+
});
|
|
598
|
+
spinner.stop();
|
|
599
|
+
if (opts.json) {
|
|
600
|
+
console.log(JSON.stringify(res, null, 2));
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
heading(`Speed Test: ${url}`);
|
|
604
|
+
const times = res.results.filter((r) => r.status === "up").map((r) => r.responseTimeMs);
|
|
605
|
+
if (times.length > 0) {
|
|
606
|
+
const avg = times.reduce((a, b) => a + b, 0) / times.length;
|
|
607
|
+
const min = Math.min(...times);
|
|
608
|
+
const max = Math.max(...times);
|
|
609
|
+
console.log(
|
|
610
|
+
` ${chalk6.dim("Avg:")} ${formatMs(avg)} ${chalk6.dim("Min:")} ${formatMs(min)} ${chalk6.dim("Max:")} ${formatMs(max)}`
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
console.log();
|
|
614
|
+
table(
|
|
615
|
+
["Region", "Status", "Code", "Total", "DNS", "Connect", "TLS", "TTFB", "Transfer"],
|
|
616
|
+
res.results.map((r) => [
|
|
617
|
+
r.region,
|
|
618
|
+
r.errorMessage ? chalk6.red("fail") : statusColor(r.status),
|
|
619
|
+
r.statusCode || "\u2014",
|
|
620
|
+
formatMs(r.responseTimeMs),
|
|
621
|
+
formatMs(r.timingDnsMs),
|
|
622
|
+
formatMs(r.timingConnectMs),
|
|
623
|
+
formatMs(r.timingTlsMs),
|
|
624
|
+
formatMs(r.timingTtfbMs),
|
|
625
|
+
formatMs(r.timingTransferMs)
|
|
626
|
+
])
|
|
627
|
+
);
|
|
628
|
+
heading("Waterfall");
|
|
629
|
+
const maxTime = Math.max(...res.results.map((r) => r.responseTimeMs), 1);
|
|
630
|
+
for (const r of res.results) {
|
|
631
|
+
const barLen = Math.round(r.responseTimeMs / maxTime * 40);
|
|
632
|
+
const dnsBar = Math.max(1, Math.round(r.timingDnsMs / maxTime * 40));
|
|
633
|
+
const connectBar = Math.max(1, Math.round(r.timingConnectMs / maxTime * 40));
|
|
634
|
+
const tlsBar = Math.max(1, Math.round(r.timingTlsMs / maxTime * 40));
|
|
635
|
+
const ttfbBar = Math.max(1, Math.round(r.timingTtfbMs / maxTime * 40));
|
|
636
|
+
const transferBar = Math.max(0, barLen - dnsBar - connectBar - tlsBar - ttfbBar);
|
|
637
|
+
const bar = chalk6.blue("\u2588".repeat(dnsBar)) + chalk6.cyan("\u2588".repeat(connectBar)) + chalk6.magenta("\u2588".repeat(tlsBar)) + chalk6.yellow("\u2588".repeat(ttfbBar)) + chalk6.green("\u2588".repeat(transferBar));
|
|
638
|
+
console.log(` ${r.region.padEnd(15)} ${bar} ${formatMs(r.responseTimeMs)}`);
|
|
639
|
+
}
|
|
640
|
+
console.log(
|
|
641
|
+
chalk6.dim(
|
|
642
|
+
`
|
|
643
|
+
${chalk6.blue("\u25A0")} DNS ${chalk6.cyan("\u25A0")} Connect ${chalk6.magenta("\u25A0")} TLS ${chalk6.yellow("\u25A0")} TTFB ${chalk6.green("\u25A0")} Transfer`
|
|
644
|
+
)
|
|
645
|
+
);
|
|
646
|
+
const sslResult = res.results.find((r) => r.sslExpiryDays !== void 0);
|
|
647
|
+
if (sslResult?.sslExpiryDays) {
|
|
648
|
+
const days = sslResult.sslExpiryDays;
|
|
649
|
+
const sslColor = days > 30 ? chalk6.green : days > 7 ? chalk6.yellow : chalk6.red;
|
|
650
|
+
console.log(`
|
|
651
|
+
${chalk6.dim("SSL expiry:")} ${sslColor(days + " days")}`);
|
|
652
|
+
}
|
|
653
|
+
if (res.id) {
|
|
654
|
+
console.log(
|
|
655
|
+
chalk6.dim(`
|
|
656
|
+
Share: https://happyuptime.com/speed-test?id=${res.id}`)
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
console.log();
|
|
660
|
+
} catch (e) {
|
|
661
|
+
spinner.stop();
|
|
662
|
+
if (e instanceof ApiError) error(`${e.message} (${e.status})`);
|
|
663
|
+
else if (e instanceof Error) error(e.message);
|
|
664
|
+
process.exit(1);
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
var CONFIG_FILENAME = "happyuptime.yml";
|
|
668
|
+
var configCommand = new Command("config").description("Config-as-code (happyuptime.yml)").addCommand(configPull()).addCommand(configPush()).addCommand(configValidate());
|
|
669
|
+
function configPull() {
|
|
670
|
+
return new Command("pull").description("Generate happyuptime.yml from current monitors").option("-o, --output <file>", "Output file", CONFIG_FILENAME).action(async (opts) => {
|
|
671
|
+
const ora = (await import('ora')).default;
|
|
672
|
+
const spinner = ora("Fetching monitors...").start();
|
|
673
|
+
try {
|
|
674
|
+
const res = await api("/api/v1/monitors", {
|
|
675
|
+
params: { per_page: 200 }
|
|
676
|
+
});
|
|
677
|
+
spinner.stop();
|
|
678
|
+
const yaml = (await import('yaml')).default;
|
|
679
|
+
const config = {
|
|
680
|
+
monitors: res.data.map((m) => {
|
|
681
|
+
const mon = {
|
|
682
|
+
name: m.name,
|
|
683
|
+
url: m.url,
|
|
684
|
+
type: m.type,
|
|
685
|
+
interval: m.interval_seconds,
|
|
686
|
+
regions: m.regions
|
|
687
|
+
};
|
|
688
|
+
if (m.method && m.method !== "GET") mon.method = m.method;
|
|
689
|
+
if (m.timeout_ms !== 3e4) mon.timeout = m.timeout_ms;
|
|
690
|
+
if (m.tags?.length) mon.tags = m.tags;
|
|
691
|
+
return mon;
|
|
692
|
+
})
|
|
693
|
+
};
|
|
694
|
+
const content = yaml.stringify(config, { indent: 2 });
|
|
695
|
+
writeFileSync(opts.output, content);
|
|
696
|
+
success(`Config written to ${opts.output} (${res.data.length} monitors)`);
|
|
697
|
+
} catch (e) {
|
|
698
|
+
spinner.stop();
|
|
699
|
+
handleError3(e);
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
function configPush() {
|
|
704
|
+
return new Command("push").description("Sync happyuptime.yml to Happy Uptime").option("-f, --file <file>", "Config file path", CONFIG_FILENAME).option("-y, --yes", "Skip confirmation").option("--dry-run", "Show what would change without applying").action(async (opts) => {
|
|
705
|
+
if (!existsSync(opts.file)) {
|
|
706
|
+
error(`${opts.file} not found. Run \`happy config pull\` first.`);
|
|
707
|
+
process.exit(1);
|
|
708
|
+
}
|
|
709
|
+
const yaml = (await import('yaml')).default;
|
|
710
|
+
let config;
|
|
711
|
+
try {
|
|
712
|
+
const raw = readFileSync(opts.file, "utf-8");
|
|
713
|
+
const substituted = raw.replace(/\$\{(\w+)\}/g, (_, name) => {
|
|
714
|
+
const val = process.env[name];
|
|
715
|
+
if (!val) {
|
|
716
|
+
warn(`Environment variable ${name} is not set`);
|
|
717
|
+
return "";
|
|
718
|
+
}
|
|
719
|
+
return val;
|
|
720
|
+
});
|
|
721
|
+
config = yaml.parse(substituted);
|
|
722
|
+
} catch (e) {
|
|
723
|
+
error(`Invalid YAML: ${e.message}`);
|
|
724
|
+
process.exit(1);
|
|
725
|
+
}
|
|
726
|
+
if (!config.monitors?.length) {
|
|
727
|
+
warn("No monitors defined in config.");
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
const ora = (await import('ora')).default;
|
|
731
|
+
const spinner = ora("Comparing with remote...").start();
|
|
732
|
+
try {
|
|
733
|
+
const existing = await api("/api/v1/monitors", {
|
|
734
|
+
params: { per_page: 200 }
|
|
735
|
+
});
|
|
736
|
+
spinner.stop();
|
|
737
|
+
const existingByName = new Map(existing.data.map((m) => [m.name, m]));
|
|
738
|
+
const toCreate = [];
|
|
739
|
+
const toUpdate = [];
|
|
740
|
+
for (const mon of config.monitors) {
|
|
741
|
+
const ex = existingByName.get(mon.name);
|
|
742
|
+
if (!ex) {
|
|
743
|
+
toCreate.push(mon);
|
|
744
|
+
} else {
|
|
745
|
+
const changes = {};
|
|
746
|
+
if (mon.url && mon.url !== ex.url) changes.url = mon.url;
|
|
747
|
+
if (mon.type && mon.type !== ex.type) changes.type = mon.type;
|
|
748
|
+
if (mon.interval && mon.interval !== ex.interval_seconds)
|
|
749
|
+
changes.interval_seconds = mon.interval;
|
|
750
|
+
if (mon.regions && JSON.stringify(mon.regions) !== JSON.stringify(ex.regions))
|
|
751
|
+
changes.regions = mon.regions;
|
|
752
|
+
if (Object.keys(changes).length > 0) {
|
|
753
|
+
toUpdate.push({ id: ex.id, body: changes });
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
console.log();
|
|
758
|
+
if (toCreate.length === 0 && toUpdate.length === 0) {
|
|
759
|
+
success("Everything is in sync.");
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
if (toCreate.length > 0) {
|
|
763
|
+
console.log(chalk6.green(` + ${toCreate.length} monitor(s) to create`));
|
|
764
|
+
for (const m of toCreate) console.log(chalk6.green(` + ${m.name}`));
|
|
765
|
+
}
|
|
766
|
+
if (toUpdate.length > 0) {
|
|
767
|
+
console.log(chalk6.yellow(` ~ ${toUpdate.length} monitor(s) to update`));
|
|
768
|
+
}
|
|
769
|
+
if (opts.dryRun) {
|
|
770
|
+
console.log(chalk6.dim("\n (dry run \u2014 no changes applied)"));
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
if (!opts.yes) {
|
|
774
|
+
const { default: inquirer } = await import('inquirer');
|
|
775
|
+
const { confirm } = await inquirer.prompt([
|
|
776
|
+
{ type: "confirm", name: "confirm", message: "Apply changes?", default: true }
|
|
777
|
+
]);
|
|
778
|
+
if (!confirm) return;
|
|
779
|
+
}
|
|
780
|
+
const applySpinner = ora("Applying...").start();
|
|
781
|
+
let created = 0;
|
|
782
|
+
let updated = 0;
|
|
783
|
+
for (const mon of toCreate) {
|
|
784
|
+
await api("/api/v1/monitors", {
|
|
785
|
+
method: "POST",
|
|
786
|
+
body: {
|
|
787
|
+
name: mon.name,
|
|
788
|
+
url: mon.url,
|
|
789
|
+
type: mon.type || "http",
|
|
790
|
+
method: mon.method || "GET",
|
|
791
|
+
interval_seconds: mon.interval || 60,
|
|
792
|
+
timeout_ms: mon.timeout || 3e4,
|
|
793
|
+
regions: mon.regions || ["us-east", "eu-west"],
|
|
794
|
+
tags: mon.tags,
|
|
795
|
+
headers: mon.headers,
|
|
796
|
+
assertions: mon.assertions
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
created++;
|
|
800
|
+
}
|
|
801
|
+
for (const { id, body } of toUpdate) {
|
|
802
|
+
await api(`/api/v1/monitors/${id}`, { method: "PUT", body });
|
|
803
|
+
updated++;
|
|
804
|
+
}
|
|
805
|
+
applySpinner.stop();
|
|
806
|
+
success(
|
|
807
|
+
`Done. ${created} created, ${updated} updated.`
|
|
808
|
+
);
|
|
809
|
+
} catch (e) {
|
|
810
|
+
spinner.stop();
|
|
811
|
+
handleError3(e);
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
function configValidate() {
|
|
816
|
+
return new Command("validate").description("Validate happyuptime.yml syntax").option("-f, --file <file>", "Config file path", CONFIG_FILENAME).action(async (opts) => {
|
|
817
|
+
if (!existsSync(opts.file)) {
|
|
818
|
+
error(`${opts.file} not found.`);
|
|
819
|
+
process.exit(1);
|
|
820
|
+
}
|
|
821
|
+
const yaml = (await import('yaml')).default;
|
|
822
|
+
try {
|
|
823
|
+
const raw = readFileSync(opts.file, "utf-8");
|
|
824
|
+
const config = yaml.parse(raw);
|
|
825
|
+
let errors = 0;
|
|
826
|
+
if (!config.monitors || !Array.isArray(config.monitors)) {
|
|
827
|
+
error("Missing or invalid 'monitors' array.");
|
|
828
|
+
errors++;
|
|
829
|
+
} else {
|
|
830
|
+
for (let i = 0; i < config.monitors.length; i++) {
|
|
831
|
+
const m = config.monitors[i];
|
|
832
|
+
if (!m.name) {
|
|
833
|
+
error(`monitors[${i}]: missing 'name'`);
|
|
834
|
+
errors++;
|
|
835
|
+
}
|
|
836
|
+
if (!m.url && m.type !== "heartbeat") {
|
|
837
|
+
error(`monitors[${i}] (${m.name || "unnamed"}): missing 'url'`);
|
|
838
|
+
errors++;
|
|
839
|
+
}
|
|
840
|
+
if (m.type && !["http", "ping", "tcp", "dns", "keyword", "heartbeat"].includes(m.type)) {
|
|
841
|
+
error(`monitors[${i}] (${m.name}): invalid type '${m.type}'`);
|
|
842
|
+
errors++;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
if (errors === 0) {
|
|
847
|
+
success(
|
|
848
|
+
`${opts.file} is valid (${config.monitors?.length || 0} monitors defined)`
|
|
849
|
+
);
|
|
850
|
+
} else {
|
|
851
|
+
error(`${errors} error(s) found.`);
|
|
852
|
+
process.exit(1);
|
|
853
|
+
}
|
|
854
|
+
} catch (e) {
|
|
855
|
+
error(`Parse error: ${e.message}`);
|
|
856
|
+
process.exit(1);
|
|
857
|
+
}
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
function handleError3(e) {
|
|
861
|
+
if (e instanceof ApiError) error(`${e.message} (${e.status})`);
|
|
862
|
+
else if (e instanceof Error) error(e.message);
|
|
863
|
+
process.exit(1);
|
|
864
|
+
}
|
|
865
|
+
var alertsCommand = new Command("alerts").description("Manage alert channels").addCommand(listAlerts()).addCommand(createAlert()).addCommand(deleteAlert());
|
|
866
|
+
function listAlerts() {
|
|
867
|
+
return new Command("list").alias("ls").description("List alert channels").option("--json", "Output raw JSON").action(async (opts) => {
|
|
868
|
+
try {
|
|
869
|
+
const res = await api("/api/v1/alerts/channels");
|
|
870
|
+
if (opts.json) {
|
|
871
|
+
console.log(JSON.stringify(res, null, 2));
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
if (res.data.length === 0) {
|
|
875
|
+
warn("No alert channels configured.");
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
heading("Alert Channels");
|
|
879
|
+
table(
|
|
880
|
+
["ID", "Name", "Type", "Default", "Created"],
|
|
881
|
+
res.data.map((ch) => [
|
|
882
|
+
chalk6.dim(ch.id.slice(0, 8)),
|
|
883
|
+
ch.name,
|
|
884
|
+
ch.type,
|
|
885
|
+
ch.is_default ? chalk6.green("yes") : "no",
|
|
886
|
+
timeAgo(ch.created_at)
|
|
887
|
+
])
|
|
888
|
+
);
|
|
889
|
+
} catch (e) {
|
|
890
|
+
handleError4(e);
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
function createAlert() {
|
|
895
|
+
return new Command("create").description("Create an alert channel").requiredOption("-n, --name <name>", "Channel name").requiredOption("-t, --type <type>", "Type (slack, discord, webhook, email, telegram)").option("--webhook-url <url>", "Webhook URL (for slack, discord, webhook)").option("--email <email>", "Email address (for email type)").option("--default", "Set as default channel").option("--json", "Output raw JSON").action(async (opts) => {
|
|
896
|
+
try {
|
|
897
|
+
const config = {};
|
|
898
|
+
if (opts.webhookUrl) config.webhook_url = opts.webhookUrl;
|
|
899
|
+
if (opts.email) config.email = opts.email;
|
|
900
|
+
const res = await api("/api/v1/alerts/channels", {
|
|
901
|
+
method: "POST",
|
|
902
|
+
body: {
|
|
903
|
+
name: opts.name,
|
|
904
|
+
type: opts.type,
|
|
905
|
+
config,
|
|
906
|
+
is_default: opts.default || false
|
|
907
|
+
}
|
|
908
|
+
});
|
|
909
|
+
if (opts.json) {
|
|
910
|
+
console.log(JSON.stringify(res, null, 2));
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
success(`Alert channel "${res.data.name}" created (${res.data.id})`);
|
|
914
|
+
} catch (e) {
|
|
915
|
+
handleError4(e);
|
|
916
|
+
}
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
function deleteAlert() {
|
|
920
|
+
return new Command("delete").alias("rm").description("Delete an alert channel").argument("<id>", "Channel ID").option("-y, --yes", "Skip confirmation").action(async (id, opts) => {
|
|
921
|
+
if (!opts.yes) {
|
|
922
|
+
const { default: inquirer } = await import('inquirer');
|
|
923
|
+
const { confirm } = await inquirer.prompt([
|
|
924
|
+
{ type: "confirm", name: "confirm", message: `Delete alert channel ${id}?`, default: false }
|
|
925
|
+
]);
|
|
926
|
+
if (!confirm) return;
|
|
927
|
+
}
|
|
928
|
+
try {
|
|
929
|
+
await api(`/api/v1/alerts/channels/${id}`, { method: "DELETE" });
|
|
930
|
+
success(`Alert channel ${id} deleted.`);
|
|
931
|
+
} catch (e) {
|
|
932
|
+
handleError4(e);
|
|
933
|
+
}
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
function handleError4(e) {
|
|
937
|
+
if (e instanceof ApiError) error(`${e.message} (${e.status})`);
|
|
938
|
+
else if (e instanceof Error) error(e.message);
|
|
939
|
+
process.exit(1);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// src/index.ts
|
|
943
|
+
var program = new Command().name("happy").description("Happy Uptime CLI \u2014 monitor uptime, manage incidents, check speed").version(VERSION, "-v, --version");
|
|
944
|
+
program.addCommand(loginCommand);
|
|
945
|
+
program.addCommand(logoutCommand);
|
|
946
|
+
program.addCommand(whoamiCommand);
|
|
947
|
+
program.addCommand(statusCommand);
|
|
948
|
+
program.addCommand(monitorsCommand);
|
|
949
|
+
program.addCommand(incidentsCommand);
|
|
950
|
+
program.addCommand(alertsCommand);
|
|
951
|
+
program.addCommand(speedTestCommand);
|
|
952
|
+
program.addCommand(configCommand);
|
|
953
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
954
|
+
console.error(err);
|
|
955
|
+
process.exit(1);
|
|
956
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "happyuptime",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI for Happy Uptime — monitor uptime, manage incidents, and check site speed from the terminal.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"happyuptime": "dist/index.js",
|
|
9
|
+
"happy": "dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsup",
|
|
17
|
+
"dev": "tsup --watch",
|
|
18
|
+
"prepublishOnly": "npm run build"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"uptime",
|
|
22
|
+
"monitoring",
|
|
23
|
+
"status-page",
|
|
24
|
+
"cli",
|
|
25
|
+
"devops",
|
|
26
|
+
"incident-management",
|
|
27
|
+
"speed-test",
|
|
28
|
+
"alerting",
|
|
29
|
+
"sla"
|
|
30
|
+
],
|
|
31
|
+
"author": "Happy Uptime <hello@happyuptime.com>",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"homepage": "https://happyuptime.com",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/seangeng/happyuptime.git",
|
|
37
|
+
"directory": "cli"
|
|
38
|
+
},
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/seangeng/happyuptime/issues"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=18"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"chalk": "^5.4.1",
|
|
47
|
+
"cli-table3": "^0.6.5",
|
|
48
|
+
"commander": "^13.1.0",
|
|
49
|
+
"inquirer": "^12.6.0",
|
|
50
|
+
"ora": "^8.2.0",
|
|
51
|
+
"yaml": "^2.7.1"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/node": "^22.15.0",
|
|
55
|
+
"tsup": "^8.4.0",
|
|
56
|
+
"typescript": "^5.9.3"
|
|
57
|
+
}
|
|
58
|
+
}
|