@hasna/uptime 0.1.0 → 0.1.1
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/CHANGELOG.md +27 -0
- package/README.md +25 -4
- package/dist/api.d.ts +9 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +422 -12
- package/dist/cli/index.js +499 -22
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +424 -12
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +424 -8
- package/dist/report.d.ts +49 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +274 -0
- package/dist/service.d.ts +7 -0
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +392 -9
- package/dist/store.d.ts +9 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +93 -7
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -2
package/dist/cli/index.js
CHANGED
|
@@ -2642,6 +2642,9 @@ async function runTcpCheck(monitor) {
|
|
|
2642
2642
|
});
|
|
2643
2643
|
}
|
|
2644
2644
|
|
|
2645
|
+
// src/service.ts
|
|
2646
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
2647
|
+
|
|
2645
2648
|
// src/store.ts
|
|
2646
2649
|
import { mkdirSync as mkdirSync2 } from "fs";
|
|
2647
2650
|
import { dirname } from "path";
|
|
@@ -2674,6 +2677,13 @@ var MAX_RETRY_COUNT = 10;
|
|
|
2674
2677
|
var MAX_RESULT_LIMIT = 1000;
|
|
2675
2678
|
|
|
2676
2679
|
// src/store.ts
|
|
2680
|
+
class StaleCheckResultError extends Error {
|
|
2681
|
+
constructor(message) {
|
|
2682
|
+
super(message);
|
|
2683
|
+
this.name = "StaleCheckResultError";
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2677
2687
|
class UptimeStore {
|
|
2678
2688
|
dbPath;
|
|
2679
2689
|
db;
|
|
@@ -2707,10 +2717,12 @@ class UptimeStore {
|
|
|
2707
2717
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
2708
2718
|
status TEXT NOT NULL DEFAULT 'unknown',
|
|
2709
2719
|
last_checked_at TEXT,
|
|
2720
|
+
revision INTEGER NOT NULL DEFAULT 1,
|
|
2710
2721
|
created_at TEXT NOT NULL,
|
|
2711
2722
|
updated_at TEXT NOT NULL
|
|
2712
2723
|
)
|
|
2713
2724
|
`);
|
|
2725
|
+
this.ensureColumn("monitors", "revision", "INTEGER NOT NULL DEFAULT 1");
|
|
2714
2726
|
this.db.run(`
|
|
2715
2727
|
CREATE TABLE IF NOT EXISTS check_results (
|
|
2716
2728
|
id TEXT PRIMARY KEY,
|
|
@@ -2736,8 +2748,17 @@ class UptimeStore {
|
|
|
2736
2748
|
reason TEXT
|
|
2737
2749
|
)
|
|
2738
2750
|
`);
|
|
2751
|
+
this.db.run(`
|
|
2752
|
+
CREATE TABLE IF NOT EXISTS check_leases (
|
|
2753
|
+
monitor_id TEXT PRIMARY KEY REFERENCES monitors(id) ON DELETE CASCADE,
|
|
2754
|
+
owner TEXT NOT NULL,
|
|
2755
|
+
leased_until TEXT NOT NULL,
|
|
2756
|
+
acquired_at TEXT NOT NULL
|
|
2757
|
+
)
|
|
2758
|
+
`);
|
|
2739
2759
|
this.db.run("CREATE INDEX IF NOT EXISTS idx_results_monitor_time ON check_results(monitor_id, checked_at DESC)");
|
|
2740
2760
|
this.db.run("CREATE INDEX IF NOT EXISTS idx_incidents_monitor_status ON incidents(monitor_id, status)");
|
|
2761
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_check_leases_until ON check_leases(leased_until)");
|
|
2741
2762
|
}
|
|
2742
2763
|
createMonitor(input) {
|
|
2743
2764
|
const normalized = normalizeCreateMonitor(input);
|
|
@@ -2757,14 +2778,15 @@ class UptimeStore {
|
|
|
2757
2778
|
enabled: normalized.enabled ?? true,
|
|
2758
2779
|
status: normalized.enabled === false ? "paused" : "unknown",
|
|
2759
2780
|
lastCheckedAt: null,
|
|
2781
|
+
revision: 1,
|
|
2760
2782
|
createdAt: now,
|
|
2761
2783
|
updatedAt: now
|
|
2762
2784
|
};
|
|
2763
2785
|
this.db.query(`INSERT INTO monitors (
|
|
2764
2786
|
id, name, kind, url, host, port, method, expected_status,
|
|
2765
2787
|
interval_seconds, timeout_ms, retry_count, enabled, status,
|
|
2766
|
-
last_checked_at, created_at, updated_at
|
|
2767
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(monitor.id, monitor.name, monitor.kind, monitor.url, monitor.host, monitor.port, monitor.method, monitor.expectedStatus, monitor.intervalSeconds, monitor.timeoutMs, monitor.retryCount, monitor.enabled ? 1 : 0, monitor.status, monitor.lastCheckedAt, monitor.createdAt, monitor.updatedAt);
|
|
2788
|
+
last_checked_at, revision, created_at, updated_at
|
|
2789
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(monitor.id, monitor.name, monitor.kind, monitor.url, monitor.host, monitor.port, monitor.method, monitor.expectedStatus, monitor.intervalSeconds, monitor.timeoutMs, monitor.retryCount, monitor.enabled ? 1 : 0, monitor.status, monitor.lastCheckedAt, monitor.revision, monitor.createdAt, monitor.updatedAt);
|
|
2768
2790
|
return monitor;
|
|
2769
2791
|
}
|
|
2770
2792
|
listMonitors(options = {}) {
|
|
@@ -2784,8 +2806,12 @@ class UptimeStore {
|
|
|
2784
2806
|
this.db.query(`UPDATE monitors SET
|
|
2785
2807
|
name = ?, kind = ?, url = ?, host = ?, port = ?, method = ?,
|
|
2786
2808
|
expected_status = ?, interval_seconds = ?, timeout_ms = ?,
|
|
2787
|
-
retry_count = ?, enabled = ?, status = ?, last_checked_at = ?,
|
|
2809
|
+
retry_count = ?, enabled = ?, status = ?, last_checked_at = ?,
|
|
2810
|
+
revision = revision + 1, updated_at = ?
|
|
2788
2811
|
WHERE id = ?`).run(next.name, next.kind, next.url, next.host, next.port, next.method, next.expectedStatus, next.intervalSeconds, next.timeoutMs, next.retryCount, next.enabled ? 1 : 0, next.status, next.lastCheckedAt, updatedAt, current.id);
|
|
2812
|
+
if (definitionChanged(current, next)) {
|
|
2813
|
+
this.closeOpenIncident(current.id, updatedAt);
|
|
2814
|
+
}
|
|
2789
2815
|
return this.getMonitor(current.id);
|
|
2790
2816
|
}
|
|
2791
2817
|
deleteMonitor(idOrName) {
|
|
@@ -2795,10 +2821,31 @@ class UptimeStore {
|
|
|
2795
2821
|
this.db.query("DELETE FROM monitors WHERE id = ?").run(current.id);
|
|
2796
2822
|
return true;
|
|
2797
2823
|
}
|
|
2824
|
+
acquireCheckLease(monitorId, owner, ttlMs) {
|
|
2825
|
+
const now = new Date;
|
|
2826
|
+
const nowIso = now.toISOString();
|
|
2827
|
+
const leasedUntil = new Date(now.getTime() + Math.max(1000, ttlMs)).toISOString();
|
|
2828
|
+
const tx = this.db.transaction(() => {
|
|
2829
|
+
this.db.query("DELETE FROM check_leases WHERE monitor_id = ? AND leased_until <= ?").run(monitorId, nowIso);
|
|
2830
|
+
this.db.query("INSERT OR IGNORE INTO check_leases (monitor_id, owner, leased_until, acquired_at) VALUES (?, ?, ?, ?)").run(monitorId, owner, leasedUntil, nowIso);
|
|
2831
|
+
const row = this.db.query("SELECT * FROM check_leases WHERE monitor_id = ?").get(monitorId);
|
|
2832
|
+
return row?.owner === owner;
|
|
2833
|
+
});
|
|
2834
|
+
return tx();
|
|
2835
|
+
}
|
|
2836
|
+
releaseCheckLease(monitorId, owner) {
|
|
2837
|
+
this.db.query("DELETE FROM check_leases WHERE monitor_id = ? AND owner = ?").run(monitorId, owner);
|
|
2838
|
+
}
|
|
2798
2839
|
recordCheckResult(input) {
|
|
2799
2840
|
const monitor = this.getMonitor(input.monitorId);
|
|
2800
2841
|
if (!monitor)
|
|
2801
2842
|
throw new Error(`Monitor not found: ${input.monitorId}`);
|
|
2843
|
+
if (input.expectedMonitorRevision !== undefined && monitor.revision !== input.expectedMonitorRevision) {
|
|
2844
|
+
throw new StaleCheckResultError(`Monitor changed while check was in progress: ${monitor.name}`);
|
|
2845
|
+
}
|
|
2846
|
+
if (!monitor.enabled) {
|
|
2847
|
+
throw new StaleCheckResultError(`Monitor was disabled while check was in progress: ${monitor.name}`);
|
|
2848
|
+
}
|
|
2802
2849
|
const checkedAt = input.checkedAt ?? new Date().toISOString();
|
|
2803
2850
|
const result = {
|
|
2804
2851
|
id: newId("chk"),
|
|
@@ -2811,6 +2858,15 @@ class UptimeStore {
|
|
|
2811
2858
|
attemptCount: Math.max(1, input.attemptCount)
|
|
2812
2859
|
};
|
|
2813
2860
|
const tx = this.db.transaction(() => {
|
|
2861
|
+
const current = this.db.query("SELECT * FROM monitors WHERE id = ?").get(result.monitorId);
|
|
2862
|
+
if (!current)
|
|
2863
|
+
throw new Error(`Monitor not found: ${result.monitorId}`);
|
|
2864
|
+
if (input.expectedMonitorRevision !== undefined && current.revision !== input.expectedMonitorRevision) {
|
|
2865
|
+
throw new StaleCheckResultError(`Monitor changed while check was in progress: ${current.name}`);
|
|
2866
|
+
}
|
|
2867
|
+
if (!current.enabled) {
|
|
2868
|
+
throw new StaleCheckResultError(`Monitor was disabled while check was in progress: ${current.name}`);
|
|
2869
|
+
}
|
|
2814
2870
|
this.db.query(`INSERT INTO check_results (
|
|
2815
2871
|
id, monitor_id, checked_at, status, latency_ms, status_code, error, attempt_count
|
|
2816
2872
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(result.id, result.monitorId, result.checkedAt, result.status, result.latencyMs, result.statusCode, result.error, result.attemptCount);
|
|
@@ -2858,10 +2914,14 @@ class UptimeStore {
|
|
|
2858
2914
|
down: monitors.filter((m) => m.status === "down").length,
|
|
2859
2915
|
paused: monitors.filter((m) => !m.enabled || m.status === "paused").length,
|
|
2860
2916
|
unknown: monitors.filter((m) => m.status === "unknown").length,
|
|
2861
|
-
openIncidents: this.
|
|
2917
|
+
openIncidents: this.countOpenIncidents()
|
|
2862
2918
|
}
|
|
2863
2919
|
};
|
|
2864
2920
|
}
|
|
2921
|
+
countOpenIncidents() {
|
|
2922
|
+
const row = this.db.query("SELECT COUNT(*) AS count FROM incidents WHERE status = 'open'").get();
|
|
2923
|
+
return Number(row?.count ?? 0);
|
|
2924
|
+
}
|
|
2865
2925
|
monitorSummary(monitor) {
|
|
2866
2926
|
const row = this.db.query(`SELECT
|
|
2867
2927
|
COUNT(*) as total,
|
|
@@ -2899,13 +2959,24 @@ class UptimeStore {
|
|
|
2899
2959
|
this.db.query("UPDATE incidents SET status = 'closed', closed_at = ?, recovery_check_id = ? WHERE id = ?").run(result.checkedAt, result.id, open.id);
|
|
2900
2960
|
}
|
|
2901
2961
|
}
|
|
2962
|
+
closeOpenIncident(monitorId, closedAt) {
|
|
2963
|
+
this.db.query("UPDATE incidents SET status = 'closed', closed_at = ? WHERE monitor_id = ? AND status = 'open'").run(closedAt, monitorId);
|
|
2964
|
+
}
|
|
2965
|
+
ensureColumn(table, name, definition) {
|
|
2966
|
+
const columns = this.db.query(`PRAGMA table_info(${table})`).all();
|
|
2967
|
+
if (!columns.some((column) => column.name === name)) {
|
|
2968
|
+
this.db.run(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2902
2971
|
}
|
|
2903
2972
|
function normalizeCreateMonitor(input) {
|
|
2904
2973
|
const name = input.name?.trim();
|
|
2905
2974
|
if (!name)
|
|
2906
2975
|
throw new Error("Monitor name is required");
|
|
2976
|
+
rejectControlCharacters(name, "Monitor name");
|
|
2907
2977
|
const method = normalizeMethod(input.method ?? "GET");
|
|
2908
2978
|
const expectedStatus = normalizeExpectedStatus(input.expectedStatus);
|
|
2979
|
+
const enabled = normalizeEnabled(input.enabled);
|
|
2909
2980
|
if (input.kind === "http") {
|
|
2910
2981
|
const url = normalizeHttpUrl(input.url);
|
|
2911
2982
|
return {
|
|
@@ -2917,12 +2988,13 @@ function normalizeCreateMonitor(input) {
|
|
|
2917
2988
|
intervalSeconds: boundedInteger(input.intervalSeconds ?? 60, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS),
|
|
2918
2989
|
timeoutMs: boundedInteger(input.timeoutMs ?? 5000, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
|
|
2919
2990
|
retryCount: boundedInteger(input.retryCount ?? 0, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT),
|
|
2920
|
-
enabled
|
|
2991
|
+
enabled
|
|
2921
2992
|
};
|
|
2922
2993
|
} else if (input.kind === "tcp") {
|
|
2923
2994
|
const host = input.host?.trim();
|
|
2924
2995
|
if (!host)
|
|
2925
2996
|
throw new Error("TCP monitors require host");
|
|
2997
|
+
rejectControlCharacters(host, "TCP host");
|
|
2926
2998
|
if (!Number.isInteger(input.port) || input.port <= 0 || input.port > 65535) {
|
|
2927
2999
|
throw new Error("TCP monitors require a port from 1 to 65535");
|
|
2928
3000
|
}
|
|
@@ -2936,12 +3008,15 @@ function normalizeCreateMonitor(input) {
|
|
|
2936
3008
|
intervalSeconds: boundedInteger(input.intervalSeconds ?? 60, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS),
|
|
2937
3009
|
timeoutMs: boundedInteger(input.timeoutMs ?? 5000, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
|
|
2938
3010
|
retryCount: boundedInteger(input.retryCount ?? 0, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT),
|
|
2939
|
-
enabled
|
|
3011
|
+
enabled
|
|
2940
3012
|
};
|
|
2941
3013
|
} else {
|
|
2942
3014
|
throw new Error("Monitor kind must be http or tcp");
|
|
2943
3015
|
}
|
|
2944
3016
|
}
|
|
3017
|
+
function definitionChanged(current, next) {
|
|
3018
|
+
return next.kind !== current.kind || next.url !== current.url || next.host !== current.host || next.port !== current.port || next.method !== current.method || next.expectedStatus !== current.expectedStatus;
|
|
3019
|
+
}
|
|
2945
3020
|
function normalizeUpdateMonitor(current, input, updatedAt) {
|
|
2946
3021
|
const merged = {
|
|
2947
3022
|
...current,
|
|
@@ -3006,6 +3081,18 @@ function normalizeExpectedStatus(value) {
|
|
|
3006
3081
|
}
|
|
3007
3082
|
return value;
|
|
3008
3083
|
}
|
|
3084
|
+
function normalizeEnabled(value) {
|
|
3085
|
+
if (value === undefined)
|
|
3086
|
+
return true;
|
|
3087
|
+
if (typeof value !== "boolean")
|
|
3088
|
+
throw new Error("enabled must be a boolean");
|
|
3089
|
+
return value;
|
|
3090
|
+
}
|
|
3091
|
+
function rejectControlCharacters(value, label) {
|
|
3092
|
+
if (/[\x00-\x1f\x7f-\x9f]/.test(value)) {
|
|
3093
|
+
throw new Error(`${label} must not contain control characters`);
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3009
3096
|
function monitorFromRow(row) {
|
|
3010
3097
|
return {
|
|
3011
3098
|
id: row.id,
|
|
@@ -3022,6 +3109,7 @@ function monitorFromRow(row) {
|
|
|
3022
3109
|
enabled: Boolean(row.enabled),
|
|
3023
3110
|
status: row.status,
|
|
3024
3111
|
lastCheckedAt: row.last_checked_at,
|
|
3112
|
+
revision: row.revision ?? 1,
|
|
3025
3113
|
createdAt: row.created_at,
|
|
3026
3114
|
updatedAt: row.updated_at
|
|
3027
3115
|
};
|
|
@@ -3070,10 +3158,281 @@ function round(value, places) {
|
|
|
3070
3158
|
return Math.round(value * factor) / factor;
|
|
3071
3159
|
}
|
|
3072
3160
|
|
|
3161
|
+
// src/report.ts
|
|
3162
|
+
var DEFAULT_MAILERY_API_URL = "http://localhost:3900";
|
|
3163
|
+
var DEFAULT_TELEPHONY_API_URL = "http://localhost:19451";
|
|
3164
|
+
var DEFAULT_LOGS_API_URL = "http://localhost:3460";
|
|
3165
|
+
var DEFAULT_TIMEOUT_MS = 15000;
|
|
3166
|
+
function buildUptimeReport(summary, options = {}) {
|
|
3167
|
+
const subject = options.subject ?? defaultSubject(summary);
|
|
3168
|
+
const lines = [
|
|
3169
|
+
subject,
|
|
3170
|
+
`Generated: ${summary.generatedAt}`,
|
|
3171
|
+
`Monitors: ${summary.totals.monitors} total, ${summary.totals.enabled} enabled, ${summary.totals.up} up, ${summary.totals.down} down, ${summary.totals.openIncidents} open incidents`,
|
|
3172
|
+
"",
|
|
3173
|
+
...summary.monitors.map(renderMonitorLine)
|
|
3174
|
+
];
|
|
3175
|
+
const text = lines.join(`
|
|
3176
|
+
`).trimEnd();
|
|
3177
|
+
const json = {
|
|
3178
|
+
kind: "open-uptime.report",
|
|
3179
|
+
generated_at: summary.generatedAt,
|
|
3180
|
+
subject,
|
|
3181
|
+
totals: summary.totals,
|
|
3182
|
+
monitors: summary.monitors
|
|
3183
|
+
};
|
|
3184
|
+
return {
|
|
3185
|
+
subject,
|
|
3186
|
+
generatedAt: summary.generatedAt,
|
|
3187
|
+
summary,
|
|
3188
|
+
text,
|
|
3189
|
+
html: `<pre>${escapeHtml(text)}</pre>`,
|
|
3190
|
+
json
|
|
3191
|
+
};
|
|
3192
|
+
}
|
|
3193
|
+
async function sendUptimeReport(summary, options = {}) {
|
|
3194
|
+
const report = buildUptimeReport(summary, options);
|
|
3195
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
3196
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
3197
|
+
const deliveries = [];
|
|
3198
|
+
if (options.email) {
|
|
3199
|
+
deliveries.push(await sendEmailReport(report, resolveEmailTarget(options.email), fetchImpl, timeoutMs));
|
|
3200
|
+
}
|
|
3201
|
+
if (options.sms) {
|
|
3202
|
+
const smsTarget = resolveSmsTarget(options.sms);
|
|
3203
|
+
const recipients = splitTargets(smsTarget.to);
|
|
3204
|
+
if (recipients.length === 0) {
|
|
3205
|
+
deliveries.push(await sendSmsReport(report, smsTarget, fetchImpl, timeoutMs));
|
|
3206
|
+
} else {
|
|
3207
|
+
for (const target of recipients) {
|
|
3208
|
+
deliveries.push(await sendSmsReport(report, { ...smsTarget, to: target }, fetchImpl, timeoutMs));
|
|
3209
|
+
}
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3212
|
+
if (options.logs) {
|
|
3213
|
+
deliveries.push(await sendLogsReport(report, resolveLogsTarget(options.logs), fetchImpl, timeoutMs));
|
|
3214
|
+
}
|
|
3215
|
+
return deliveries;
|
|
3216
|
+
}
|
|
3217
|
+
function defaultSubject(summary) {
|
|
3218
|
+
if (summary.totals.openIncidents > 0 || summary.totals.down > 0) {
|
|
3219
|
+
return `Open Uptime alert: ${summary.totals.down} down, ${summary.totals.openIncidents} open incidents`;
|
|
3220
|
+
}
|
|
3221
|
+
return `Open Uptime report: ${summary.totals.up}/${summary.totals.enabled} enabled monitors up`;
|
|
3222
|
+
}
|
|
3223
|
+
function renderMonitorLine(item) {
|
|
3224
|
+
const uptime = item.uptimePercent == null ? "-" : `${item.uptimePercent.toFixed(2)}%`;
|
|
3225
|
+
const latency = item.averageLatencyMs == null ? "-" : `${item.averageLatencyMs}ms`;
|
|
3226
|
+
const incident = item.openIncident ? ` open incident: ${item.openIncident.reason ?? "down"}` : "";
|
|
3227
|
+
return `- ${item.monitor.status.toUpperCase()} ${item.monitor.name} (${targetLabel(item)}): uptime ${uptime}, latency ${latency}${incident}`;
|
|
3228
|
+
}
|
|
3229
|
+
function targetLabel(item) {
|
|
3230
|
+
return item.monitor.kind === "http" ? item.monitor.url ?? "" : `${item.monitor.host}:${item.monitor.port}`;
|
|
3231
|
+
}
|
|
3232
|
+
function resolveEmailTarget(value) {
|
|
3233
|
+
const target = typeof value === "boolean" ? {} : value;
|
|
3234
|
+
return {
|
|
3235
|
+
apiUrl: target.apiUrl ?? env2("HASNA_MAILERY_API_URL", "MAILERY_API_URL") ?? DEFAULT_MAILERY_API_URL,
|
|
3236
|
+
sendKey: target.sendKey ?? env2("HASNA_MAILERY_SEND_KEY", "MAILERY_SEND_KEY", "ESK"),
|
|
3237
|
+
from: target.from ?? env2("HASNA_UPTIME_REPORT_EMAIL_FROM", "UPTIME_REPORT_EMAIL_FROM"),
|
|
3238
|
+
to: target.to ?? env2("HASNA_UPTIME_REPORT_EMAIL_TO", "UPTIME_REPORT_EMAIL_TO"),
|
|
3239
|
+
subject: target.subject,
|
|
3240
|
+
providerId: target.providerId ?? env2("HASNA_MAILERY_PROVIDER_ID", "MAILERY_PROVIDER_ID")
|
|
3241
|
+
};
|
|
3242
|
+
}
|
|
3243
|
+
function resolveSmsTarget(value) {
|
|
3244
|
+
const target = typeof value === "boolean" ? {} : value;
|
|
3245
|
+
return {
|
|
3246
|
+
apiUrl: target.apiUrl ?? env2("HASNA_TELEPHONY_API_URL", "TELEPHONY_API_URL") ?? DEFAULT_TELEPHONY_API_URL,
|
|
3247
|
+
from: target.from ?? env2("HASNA_UPTIME_REPORT_SMS_FROM", "UPTIME_REPORT_SMS_FROM"),
|
|
3248
|
+
to: target.to ?? env2("HASNA_UPTIME_REPORT_PHONE_TO", "UPTIME_REPORT_PHONE_TO")
|
|
3249
|
+
};
|
|
3250
|
+
}
|
|
3251
|
+
function resolveLogsTarget(value) {
|
|
3252
|
+
const target = typeof value === "boolean" ? {} : value;
|
|
3253
|
+
return {
|
|
3254
|
+
apiUrl: target.apiUrl ?? env2("HASNA_LOGS_URL", "LOGS_URL") ?? DEFAULT_LOGS_API_URL,
|
|
3255
|
+
apiKey: target.apiKey ?? env2("HASNA_LOGS_API_TOKEN", "LOGS_API_TOKEN", "HASNA_LOGS_API_KEY", "LOGS_API_KEY"),
|
|
3256
|
+
projectId: target.projectId ?? env2("HASNA_LOGS_PROJECT_ID", "LOGS_PROJECT_ID") ?? "open-uptime",
|
|
3257
|
+
environment: target.environment ?? env2("HASNA_ENV", "NODE_ENV"),
|
|
3258
|
+
service: target.service ?? "open-uptime"
|
|
3259
|
+
};
|
|
3260
|
+
}
|
|
3261
|
+
async function sendEmailReport(report, target, fetchImpl, timeoutMs) {
|
|
3262
|
+
if (!target.sendKey)
|
|
3263
|
+
return { channel: "email", ok: false, error: "Mailery send key is required" };
|
|
3264
|
+
if (!target.from)
|
|
3265
|
+
return { channel: "email", ok: false, error: "Email from address is required" };
|
|
3266
|
+
if (!hasTargets(target.to))
|
|
3267
|
+
return { channel: "email", ok: false, error: "Email recipient is required" };
|
|
3268
|
+
const body = {
|
|
3269
|
+
from: target.from,
|
|
3270
|
+
to: splitTargets(target.to),
|
|
3271
|
+
subject: target.subject ?? report.subject,
|
|
3272
|
+
text: report.text,
|
|
3273
|
+
html: report.html,
|
|
3274
|
+
provider_id: target.providerId
|
|
3275
|
+
};
|
|
3276
|
+
return requestJson("email", `${normalizeUrl(target.apiUrl ?? DEFAULT_MAILERY_API_URL)}/api/v1/send`, {
|
|
3277
|
+
method: "POST",
|
|
3278
|
+
headers: { authorization: `Bearer ${target.sendKey}` },
|
|
3279
|
+
body
|
|
3280
|
+
}, fetchImpl, timeoutMs, secretsForTarget(target));
|
|
3281
|
+
}
|
|
3282
|
+
async function sendSmsReport(report, target, fetchImpl, timeoutMs) {
|
|
3283
|
+
if (!hasTargets(target.to))
|
|
3284
|
+
return { channel: "sms", ok: false, error: "SMS recipient phone number is required" };
|
|
3285
|
+
return requestJson("sms", `${normalizeUrl(target.apiUrl ?? DEFAULT_TELEPHONY_API_URL)}/api/sms/send`, {
|
|
3286
|
+
method: "POST",
|
|
3287
|
+
body: {
|
|
3288
|
+
to: Array.isArray(target.to) ? target.to[0] : target.to,
|
|
3289
|
+
from: target.from,
|
|
3290
|
+
body: truncateSms(report.text)
|
|
3291
|
+
}
|
|
3292
|
+
}, fetchImpl, timeoutMs, secretsForTarget(target));
|
|
3293
|
+
}
|
|
3294
|
+
async function sendLogsReport(report, target, fetchImpl, timeoutMs) {
|
|
3295
|
+
const params = new URLSearchParams({
|
|
3296
|
+
format: "json",
|
|
3297
|
+
source: "structured",
|
|
3298
|
+
service: target.service ?? "open-uptime",
|
|
3299
|
+
project_id: target.projectId ?? "open-uptime"
|
|
3300
|
+
});
|
|
3301
|
+
if (target.environment)
|
|
3302
|
+
params.set("environment", target.environment);
|
|
3303
|
+
return requestJson("logs", `${normalizeUrl(target.apiUrl ?? DEFAULT_LOGS_API_URL)}/api/logs/structured?${params}`, {
|
|
3304
|
+
method: "POST",
|
|
3305
|
+
headers: target.apiKey ? { authorization: `Bearer ${target.apiKey}` } : undefined,
|
|
3306
|
+
body: {
|
|
3307
|
+
timestamp: report.generatedAt,
|
|
3308
|
+
level: report.summary.totals.down > 0 || report.summary.totals.openIncidents > 0 ? "warn" : "info",
|
|
3309
|
+
message: report.subject,
|
|
3310
|
+
report: report.json
|
|
3311
|
+
}
|
|
3312
|
+
}, fetchImpl, timeoutMs, secretsForTarget(target));
|
|
3313
|
+
}
|
|
3314
|
+
async function requestJson(channel, url, options, fetchImpl, timeoutMs, secrets = []) {
|
|
3315
|
+
const controller = new AbortController;
|
|
3316
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
3317
|
+
try {
|
|
3318
|
+
const response = await fetchImpl(url, {
|
|
3319
|
+
method: options.method,
|
|
3320
|
+
signal: controller.signal,
|
|
3321
|
+
headers: {
|
|
3322
|
+
"content-type": "application/json",
|
|
3323
|
+
accept: "application/json",
|
|
3324
|
+
...options.headers
|
|
3325
|
+
},
|
|
3326
|
+
body: JSON.stringify(options.body)
|
|
3327
|
+
});
|
|
3328
|
+
const text = await response.text();
|
|
3329
|
+
const data = parseMaybeJson(text);
|
|
3330
|
+
if (!response.ok) {
|
|
3331
|
+
return { channel, ok: false, status: response.status, error: errorFromResponse(data, response.statusText, secrets) };
|
|
3332
|
+
}
|
|
3333
|
+
return { channel, ok: true, status: response.status, id: redactOptional(idFromResponse(data), secrets) };
|
|
3334
|
+
} catch (error) {
|
|
3335
|
+
const message = error instanceof Error && error.name === "AbortError" ? "request timed out" : error instanceof Error ? error.message : String(error);
|
|
3336
|
+
return { channel, ok: false, error: redactSecrets(message, secrets) };
|
|
3337
|
+
} finally {
|
|
3338
|
+
clearTimeout(timer);
|
|
3339
|
+
}
|
|
3340
|
+
}
|
|
3341
|
+
function hasTargets(value) {
|
|
3342
|
+
return splitTargets(value).length > 0;
|
|
3343
|
+
}
|
|
3344
|
+
function splitTargets(value) {
|
|
3345
|
+
if (!value)
|
|
3346
|
+
return [];
|
|
3347
|
+
const values = Array.isArray(value) ? value : value.split(",");
|
|
3348
|
+
return values.map((item) => item.trim()).filter(Boolean);
|
|
3349
|
+
}
|
|
3350
|
+
function normalizeUrl(value) {
|
|
3351
|
+
const parsed = new URL(value.trim());
|
|
3352
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
3353
|
+
throw new Error("Integration API URL must use http or https");
|
|
3354
|
+
}
|
|
3355
|
+
return parsed.toString().replace(/\/$/, "");
|
|
3356
|
+
}
|
|
3357
|
+
function truncateSms(value) {
|
|
3358
|
+
return value.length > 1400 ? `${value.slice(0, 1397)}...` : value;
|
|
3359
|
+
}
|
|
3360
|
+
function parseMaybeJson(text) {
|
|
3361
|
+
if (!text.trim())
|
|
3362
|
+
return {};
|
|
3363
|
+
try {
|
|
3364
|
+
return JSON.parse(text);
|
|
3365
|
+
} catch {
|
|
3366
|
+
return { message: text };
|
|
3367
|
+
}
|
|
3368
|
+
}
|
|
3369
|
+
function idFromResponse(data) {
|
|
3370
|
+
if (!data || typeof data !== "object")
|
|
3371
|
+
return;
|
|
3372
|
+
const record = data;
|
|
3373
|
+
for (const key of ["id", "message_id", "event_id"]) {
|
|
3374
|
+
if (typeof record[key] === "string")
|
|
3375
|
+
return record[key];
|
|
3376
|
+
}
|
|
3377
|
+
return;
|
|
3378
|
+
}
|
|
3379
|
+
function errorFromResponse(data, fallback, secrets = []) {
|
|
3380
|
+
if (data && typeof data === "object") {
|
|
3381
|
+
const record = data;
|
|
3382
|
+
if (typeof record.error === "string")
|
|
3383
|
+
return redactSecrets(record.error, secrets);
|
|
3384
|
+
if (typeof record.message === "string")
|
|
3385
|
+
return redactSecrets(record.message, secrets);
|
|
3386
|
+
}
|
|
3387
|
+
return redactSecrets(fallback, secrets);
|
|
3388
|
+
}
|
|
3389
|
+
function env2(...keys) {
|
|
3390
|
+
for (const key of keys) {
|
|
3391
|
+
const value = process.env[key]?.trim();
|
|
3392
|
+
if (value)
|
|
3393
|
+
return value;
|
|
3394
|
+
}
|
|
3395
|
+
return;
|
|
3396
|
+
}
|
|
3397
|
+
function escapeHtml(value) {
|
|
3398
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
3399
|
+
}
|
|
3400
|
+
function secretsForTarget(target) {
|
|
3401
|
+
const values = new Set;
|
|
3402
|
+
for (const key of ["sendKey", "apiKey"]) {
|
|
3403
|
+
const value = target[key];
|
|
3404
|
+
if (typeof value === "string" && value.trim())
|
|
3405
|
+
values.add(value.trim());
|
|
3406
|
+
}
|
|
3407
|
+
const apiUrl = target.apiUrl;
|
|
3408
|
+
if (apiUrl) {
|
|
3409
|
+
try {
|
|
3410
|
+
const parsed = new URL(apiUrl);
|
|
3411
|
+
if (parsed.username)
|
|
3412
|
+
values.add(decodeURIComponent(parsed.username));
|
|
3413
|
+
if (parsed.password)
|
|
3414
|
+
values.add(decodeURIComponent(parsed.password));
|
|
3415
|
+
} catch {}
|
|
3416
|
+
}
|
|
3417
|
+
return [...values];
|
|
3418
|
+
}
|
|
3419
|
+
function redactSecrets(value, secrets = []) {
|
|
3420
|
+
let redacted = value;
|
|
3421
|
+
for (const secret of secrets) {
|
|
3422
|
+
if (secret.length >= 3)
|
|
3423
|
+
redacted = redacted.split(secret).join("[REDACTED]");
|
|
3424
|
+
}
|
|
3425
|
+
return redacted.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]").replace(/\besk_[A-Za-z0-9._~+/=-]+/g, "esk_[REDACTED]");
|
|
3426
|
+
}
|
|
3427
|
+
function redactOptional(value, secrets) {
|
|
3428
|
+
return value === undefined ? undefined : redactSecrets(value, secrets);
|
|
3429
|
+
}
|
|
3430
|
+
|
|
3073
3431
|
// src/service.ts
|
|
3074
3432
|
class UptimeService {
|
|
3075
3433
|
store;
|
|
3076
3434
|
checkRunner;
|
|
3435
|
+
leaseOwner = `svc_${randomUUID2().replace(/-/g, "").slice(0, 18)}`;
|
|
3077
3436
|
inFlightChecks = new Set;
|
|
3078
3437
|
constructor(options = {}) {
|
|
3079
3438
|
this.store = options.store ?? new UptimeStore(options);
|
|
@@ -3106,6 +3465,12 @@ class UptimeService {
|
|
|
3106
3465
|
summary() {
|
|
3107
3466
|
return this.store.summary();
|
|
3108
3467
|
}
|
|
3468
|
+
buildReport(options = {}) {
|
|
3469
|
+
return buildUptimeReport(this.summary(), options);
|
|
3470
|
+
}
|
|
3471
|
+
async sendReport(options = {}) {
|
|
3472
|
+
return sendUptimeReport(this.summary(), options);
|
|
3473
|
+
}
|
|
3109
3474
|
async checkMonitor(idOrName) {
|
|
3110
3475
|
const monitor = this.store.getMonitor(idOrName);
|
|
3111
3476
|
if (!monitor)
|
|
@@ -3114,6 +3479,10 @@ class UptimeService {
|
|
|
3114
3479
|
throw new Error(`Monitor is disabled: ${monitor.name}`);
|
|
3115
3480
|
if (this.inFlightChecks.has(monitor.id))
|
|
3116
3481
|
throw new Error(`Monitor check already in progress: ${monitor.name}`);
|
|
3482
|
+
const leaseTtlMs = Math.max(60000, (monitor.retryCount + 1) * monitor.timeoutMs + 1e4);
|
|
3483
|
+
if (!this.store.acquireCheckLease(monitor.id, this.leaseOwner, leaseTtlMs)) {
|
|
3484
|
+
throw new MonitorCheckBusyError(`Monitor check already in progress: ${monitor.name}`);
|
|
3485
|
+
}
|
|
3117
3486
|
this.inFlightChecks.add(monitor.id);
|
|
3118
3487
|
try {
|
|
3119
3488
|
let attemptCount = 0;
|
|
@@ -3131,10 +3500,12 @@ class UptimeService {
|
|
|
3131
3500
|
latencyMs: last.latencyMs,
|
|
3132
3501
|
statusCode: last.statusCode ?? null,
|
|
3133
3502
|
error: last.error ?? null,
|
|
3134
|
-
attemptCount
|
|
3503
|
+
attemptCount,
|
|
3504
|
+
expectedMonitorRevision: monitor.revision
|
|
3135
3505
|
});
|
|
3136
3506
|
} finally {
|
|
3137
3507
|
this.inFlightChecks.delete(monitor.id);
|
|
3508
|
+
this.store.releaseCheckLease(monitor.id, this.leaseOwner);
|
|
3138
3509
|
}
|
|
3139
3510
|
}
|
|
3140
3511
|
async checkAll() {
|
|
@@ -3163,7 +3534,13 @@ class UptimeService {
|
|
|
3163
3534
|
const current = this.store.getMonitor(monitor.id);
|
|
3164
3535
|
if (!current || !this.isDue(current, now))
|
|
3165
3536
|
continue;
|
|
3166
|
-
|
|
3537
|
+
try {
|
|
3538
|
+
results.push(await this.checkMonitor(current.id));
|
|
3539
|
+
} catch (error) {
|
|
3540
|
+
if (error instanceof MonitorCheckBusyError || error instanceof StaleCheckResultError)
|
|
3541
|
+
continue;
|
|
3542
|
+
throw error;
|
|
3543
|
+
}
|
|
3167
3544
|
}
|
|
3168
3545
|
return results;
|
|
3169
3546
|
}
|
|
@@ -3178,6 +3555,12 @@ class UptimeService {
|
|
|
3178
3555
|
return now.getTime() - last >= monitor.intervalSeconds * 1000;
|
|
3179
3556
|
}
|
|
3180
3557
|
}
|
|
3558
|
+
class MonitorCheckBusyError extends Error {
|
|
3559
|
+
constructor(message) {
|
|
3560
|
+
super(message);
|
|
3561
|
+
this.name = "MonitorCheckBusyError";
|
|
3562
|
+
}
|
|
3563
|
+
}
|
|
3181
3564
|
|
|
3182
3565
|
// src/version.ts
|
|
3183
3566
|
import { readFileSync } from "fs";
|
|
@@ -3548,11 +3931,11 @@ function dashboardHtml() {
|
|
|
3548
3931
|
}
|
|
3549
3932
|
|
|
3550
3933
|
// src/api.ts
|
|
3551
|
-
function createApiHandler(service) {
|
|
3934
|
+
function createApiHandler(service, options = {}) {
|
|
3552
3935
|
return async (request) => {
|
|
3553
3936
|
const url = new URL(request.url);
|
|
3554
3937
|
try {
|
|
3555
|
-
validateLocalMutationRequest(request, url);
|
|
3938
|
+
validateLocalMutationRequest(request, url, options);
|
|
3556
3939
|
if (request.method === "GET" && url.pathname === "/") {
|
|
3557
3940
|
return html(dashboardHtml());
|
|
3558
3941
|
}
|
|
@@ -3562,6 +3945,13 @@ function createApiHandler(service) {
|
|
|
3562
3945
|
if (request.method === "GET" && url.pathname === "/api/summary") {
|
|
3563
3946
|
return json(service.summary());
|
|
3564
3947
|
}
|
|
3948
|
+
if (request.method === "GET" && url.pathname === "/api/report") {
|
|
3949
|
+
return json(service.buildReport());
|
|
3950
|
+
}
|
|
3951
|
+
if (request.method === "POST" && url.pathname === "/api/report") {
|
|
3952
|
+
const input = await jsonBody(request);
|
|
3953
|
+
return json(await service.sendReport({ ...input, fetchImpl: options.fetchImpl }));
|
|
3954
|
+
}
|
|
3565
3955
|
if (request.method === "GET" && url.pathname === "/api/monitors") {
|
|
3566
3956
|
return json(service.listMonitors({ includeDisabled: url.searchParams.get("includeDisabled") === "true" }));
|
|
3567
3957
|
}
|
|
@@ -3614,7 +4004,11 @@ function serveUptime(options = {}) {
|
|
|
3614
4004
|
const server = Bun.serve({
|
|
3615
4005
|
hostname: options.host ?? "127.0.0.1",
|
|
3616
4006
|
port: options.port ?? 3899,
|
|
3617
|
-
fetch: createApiHandler(service
|
|
4007
|
+
fetch: createApiHandler(service, {
|
|
4008
|
+
apiToken: options.apiToken,
|
|
4009
|
+
allowUnsafeRemoteMutations: options.allowUnsafeRemoteMutations,
|
|
4010
|
+
trustedLoopback: isLoopbackHost(options.host ?? "127.0.0.1")
|
|
4011
|
+
})
|
|
3618
4012
|
});
|
|
3619
4013
|
return { server, service, scheduler };
|
|
3620
4014
|
}
|
|
@@ -3642,14 +4036,31 @@ function numericParam(url, name, fallback) {
|
|
|
3642
4036
|
const parsed = Number(raw);
|
|
3643
4037
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
3644
4038
|
}
|
|
3645
|
-
function validateLocalMutationRequest(request, url) {
|
|
4039
|
+
function validateLocalMutationRequest(request, url, options) {
|
|
3646
4040
|
if (!["POST", "PATCH", "DELETE"].includes(request.method))
|
|
3647
4041
|
return;
|
|
4042
|
+
const apiToken = options.apiToken ?? process.env.HASNA_UPTIME_API_TOKEN;
|
|
4043
|
+
const hasToken = apiToken ? hasValidApiToken(request, apiToken) : false;
|
|
4044
|
+
const allowUnsafeRemote = options.allowUnsafeRemoteMutations || process.env.HASNA_UPTIME_ALLOW_REMOTE_MUTATIONS === "1";
|
|
4045
|
+
const trustedLoopback = options.trustedLoopback ?? isLoopbackHost(url.hostname);
|
|
4046
|
+
if (!allowUnsafeRemote && !hasToken && (!trustedLoopback || !isLoopbackHost(url.hostname))) {
|
|
4047
|
+
throw new ApiError("non-loopback host rejected for local mutation", 403);
|
|
4048
|
+
}
|
|
3648
4049
|
const origin = request.headers.get("origin");
|
|
3649
4050
|
if (origin && origin !== `${url.protocol}//${url.host}`) {
|
|
3650
4051
|
throw new ApiError("cross-origin mutation rejected", 403);
|
|
3651
4052
|
}
|
|
3652
4053
|
}
|
|
4054
|
+
function isLoopbackHost(hostname) {
|
|
4055
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
4056
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
4057
|
+
}
|
|
4058
|
+
function hasValidApiToken(request, token) {
|
|
4059
|
+
const authorization = request.headers.get("authorization") ?? "";
|
|
4060
|
+
const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();
|
|
4061
|
+
const headerToken = request.headers.get("x-uptime-token")?.trim();
|
|
4062
|
+
return bearer === token || headerToken === token;
|
|
4063
|
+
}
|
|
3653
4064
|
async function jsonBody(request) {
|
|
3654
4065
|
const contentType = request.headers.get("content-type") ?? "";
|
|
3655
4066
|
const mediaType = contentType.split(";")[0]?.trim().toLowerCase();
|
|
@@ -3680,14 +4091,14 @@ function print(value, text, opts) {
|
|
|
3680
4091
|
if (wantsJson(opts))
|
|
3681
4092
|
console.log(JSON.stringify(value, null, 2));
|
|
3682
4093
|
else
|
|
3683
|
-
console.log(text);
|
|
4094
|
+
console.log(sanitizeTerminal(text));
|
|
3684
4095
|
}
|
|
3685
4096
|
function fail(error) {
|
|
3686
4097
|
const message = error instanceof Error ? error.message : String(error);
|
|
3687
4098
|
if (program2.opts().json)
|
|
3688
4099
|
console.log(JSON.stringify({ ok: false, error: message }, null, 2));
|
|
3689
4100
|
else
|
|
3690
|
-
console.error(source_default.red(message));
|
|
4101
|
+
console.error(source_default.red(sanitizeTerminal(message)));
|
|
3691
4102
|
process.exit(1);
|
|
3692
4103
|
}
|
|
3693
4104
|
program2.command("init").description("Initialize the local uptime store").option("-j, --json", "print JSON").action((opts) => {
|
|
@@ -3848,6 +4259,45 @@ program2.command("summary").description("Show uptime summary").option("-j, --jso
|
|
|
3848
4259
|
fail(error);
|
|
3849
4260
|
}
|
|
3850
4261
|
});
|
|
4262
|
+
program2.command("report").description("Build or send an uptime report through Mailery, Telephony, or Open Logs").option("--email <to>", "send an email report to one or more comma-separated recipients through Mailery").option("--from <email>", "Mailery from address").option("--mailery-url <url>", "Mailery API URL").option("--send-key <key>", "Mailery scoped send key").option("--sms <phone>", "send an SMS report to one or more comma-separated phone numbers through Telephony").option("--sms-from <phone>", "Telephony from phone number").option("--telephony-url <url>", "Telephony API URL").option("--logs", "write the report to Open Logs structured logs").option("--logs-url <url>", "Open Logs API URL").option("--logs-api-key <key>", "Open Logs API key").option("--logs-project <id>", "Open Logs project id").option("--subject <subject>", "report subject").option("--dry-run", "print the report without sending").option("-j, --json", "print JSON").action(async (opts) => {
|
|
4263
|
+
try {
|
|
4264
|
+
const svc = service();
|
|
4265
|
+
const wantsDelivery = Boolean(opts.email || opts.sms || opts.logs);
|
|
4266
|
+
if (opts.dryRun || !wantsDelivery) {
|
|
4267
|
+
const report = svc.buildReport({ subject: opts.subject });
|
|
4268
|
+
svc.close();
|
|
4269
|
+
print(report, report.text, opts);
|
|
4270
|
+
return;
|
|
4271
|
+
}
|
|
4272
|
+
const input = {
|
|
4273
|
+
subject: opts.subject,
|
|
4274
|
+
email: opts.email ? {
|
|
4275
|
+
apiUrl: opts.maileryUrl,
|
|
4276
|
+
sendKey: opts.sendKey,
|
|
4277
|
+
from: opts.from,
|
|
4278
|
+
to: splitList(opts.email)
|
|
4279
|
+
} : undefined,
|
|
4280
|
+
sms: opts.sms ? {
|
|
4281
|
+
apiUrl: opts.telephonyUrl,
|
|
4282
|
+
from: opts.smsFrom,
|
|
4283
|
+
to: splitList(opts.sms)
|
|
4284
|
+
} : undefined,
|
|
4285
|
+
logs: opts.logs ? {
|
|
4286
|
+
apiUrl: opts.logsUrl,
|
|
4287
|
+
apiKey: opts.logsApiKey,
|
|
4288
|
+
projectId: opts.logsProject
|
|
4289
|
+
} : undefined
|
|
4290
|
+
};
|
|
4291
|
+
const deliveries = await svc.sendReport(input);
|
|
4292
|
+
svc.close();
|
|
4293
|
+
const failed = deliveries.filter((delivery) => !delivery.ok);
|
|
4294
|
+
print(deliveries, renderDeliveries(deliveries), opts);
|
|
4295
|
+
if (failed.length > 0)
|
|
4296
|
+
process.exit(1);
|
|
4297
|
+
} catch (error) {
|
|
4298
|
+
fail(error);
|
|
4299
|
+
}
|
|
4300
|
+
});
|
|
3851
4301
|
program2.command("results").description("List recent check results").option("--monitor <id>", "filter by monitor id").option("--limit <n>", "max rows", parseInteger, 20).option("-j, --json", "print JSON").action((opts) => {
|
|
3852
4302
|
try {
|
|
3853
4303
|
const svc = service();
|
|
@@ -3863,15 +4313,21 @@ program2.command("incidents").description("List incidents").addOption(new Option
|
|
|
3863
4313
|
const svc = service();
|
|
3864
4314
|
const incidents = svc.listIncidents({ status: opts.status, monitorId: opts.monitor, limit: opts.limit });
|
|
3865
4315
|
svc.close();
|
|
3866
|
-
print(incidents, incidents.length ? incidents.map((i) => `${i.status.padEnd(6)} ${i.monitorId} ${i.openedAt} ${i.reason ?? ""}`).join(`
|
|
4316
|
+
print(incidents, incidents.length ? incidents.map((i) => `${i.status.padEnd(6)} ${sanitizeField(i.monitorId)} ${i.openedAt} ${sanitizeField(i.reason ?? "")}`).join(`
|
|
3867
4317
|
`) : "No incidents", opts);
|
|
3868
4318
|
} catch (error) {
|
|
3869
4319
|
fail(error);
|
|
3870
4320
|
}
|
|
3871
4321
|
});
|
|
3872
|
-
program2.command("serve").description("Serve the local API and dashboard").option("--host <host>", "host to bind", "127.0.0.1").option("--port <port>", "port", parseInteger, 3899).option("--check", "run the scheduler while serving").option("-j, --json", "print JSON").action((opts) => {
|
|
4322
|
+
program2.command("serve").description("Serve the local API and dashboard").option("--host <host>", "host to bind", "127.0.0.1").option("--port <port>", "port", parseInteger, 3899).option("--check", "run the scheduler while serving").option("--api-token <token>", "token required for non-loopback mutation hosts").option("--allow-unsafe-remote-mutations", "allow state-changing requests from non-loopback hosts without a token").option("-j, --json", "print JSON").action((opts) => {
|
|
3873
4323
|
try {
|
|
3874
|
-
const { server } = serveUptime({
|
|
4324
|
+
const { server } = serveUptime({
|
|
4325
|
+
host: opts.host,
|
|
4326
|
+
port: opts.port,
|
|
4327
|
+
check: opts.check,
|
|
4328
|
+
apiToken: opts.apiToken,
|
|
4329
|
+
allowUnsafeRemoteMutations: opts.allowUnsafeRemoteMutations
|
|
4330
|
+
});
|
|
3875
4331
|
const data = { ok: true, url: `http://${server.hostname}:${server.port}`, scheduler: Boolean(opts.check) };
|
|
3876
4332
|
if (wantsJson(opts))
|
|
3877
4333
|
console.log(JSON.stringify(data, null, 2));
|
|
@@ -3893,17 +4349,17 @@ function renderMonitors(monitors) {
|
|
|
3893
4349
|
return monitors.map((monitor) => {
|
|
3894
4350
|
const target = monitor.kind === "http" ? monitor.url : `${monitor.host}:${monitor.port}`;
|
|
3895
4351
|
const status = renderStatus(monitor.status).padEnd(14);
|
|
3896
|
-
return `${status} ${monitor.name.padEnd(24)} ${monitor.kind.padEnd(4)} ${target}`;
|
|
4352
|
+
return `${status} ${sanitizeField(monitor.name).padEnd(24)} ${monitor.kind.padEnd(4)} ${sanitizeField(target ?? "")}`;
|
|
3897
4353
|
}).join(`
|
|
3898
4354
|
`);
|
|
3899
4355
|
}
|
|
3900
4356
|
function renderMonitorDetail(monitor) {
|
|
3901
4357
|
const target = monitor.kind === "http" ? monitor.url : `${monitor.host}:${monitor.port}`;
|
|
3902
4358
|
return [
|
|
3903
|
-
`${source_default.bold(monitor.name)} ${renderStatus(monitor.status)}`,
|
|
4359
|
+
`${source_default.bold(sanitizeField(monitor.name))} ${renderStatus(monitor.status)}`,
|
|
3904
4360
|
`id: ${monitor.id}`,
|
|
3905
4361
|
`kind: ${monitor.kind}`,
|
|
3906
|
-
`target: ${target}`,
|
|
4362
|
+
`target: ${sanitizeField(target ?? "")}`,
|
|
3907
4363
|
`interval: ${monitor.intervalSeconds}s`,
|
|
3908
4364
|
`timeout: ${monitor.timeoutMs}ms`,
|
|
3909
4365
|
`retries: ${monitor.retryCount}`,
|
|
@@ -3917,7 +4373,7 @@ function renderCheckResults(results) {
|
|
|
3917
4373
|
return "No results";
|
|
3918
4374
|
return results.map((result) => {
|
|
3919
4375
|
const latency = result.latencyMs == null ? "-" : `${result.latencyMs}ms`;
|
|
3920
|
-
return `${renderStatus(result.status).padEnd(12)} ${result.monitorId} ${result.checkedAt} ${latency} ${result.error ?? ""}`;
|
|
4376
|
+
return `${renderStatus(result.status).padEnd(12)} ${sanitizeField(result.monitorId)} ${result.checkedAt} ${latency} ${sanitizeField(result.error ?? "")}`;
|
|
3921
4377
|
}).join(`
|
|
3922
4378
|
`);
|
|
3923
4379
|
}
|
|
@@ -3928,11 +4384,21 @@ function renderSummary(summary) {
|
|
|
3928
4384
|
for (const item of summary.monitors) {
|
|
3929
4385
|
const uptime = item.uptimePercent == null ? "-" : `${item.uptimePercent.toFixed(2)}%`;
|
|
3930
4386
|
const latency = item.averageLatencyMs == null ? "-" : `${item.averageLatencyMs}ms`;
|
|
3931
|
-
lines.push(`${renderStatus(item.monitor.status).padEnd(12)} ${item.monitor.name.padEnd(24)} uptime ${uptime.padStart(8)} latency ${latency}`);
|
|
4387
|
+
lines.push(`${renderStatus(item.monitor.status).padEnd(12)} ${sanitizeField(item.monitor.name).padEnd(24)} uptime ${uptime.padStart(8)} latency ${latency}`);
|
|
3932
4388
|
}
|
|
3933
4389
|
return lines.join(`
|
|
3934
4390
|
`);
|
|
3935
4391
|
}
|
|
4392
|
+
function renderDeliveries(deliveries) {
|
|
4393
|
+
if (deliveries.length === 0)
|
|
4394
|
+
return "No report deliveries requested";
|
|
4395
|
+
return deliveries.map((delivery) => {
|
|
4396
|
+
const status = delivery.ok ? source_default.green("sent") : source_default.red("failed");
|
|
4397
|
+
const detail = delivery.ok ? delivery.id ?? delivery.status ?? "" : delivery.error ?? "";
|
|
4398
|
+
return `${status.padEnd(12)} ${delivery.channel}${detail ? ` ${sanitizeField(String(detail))}` : ""}`;
|
|
4399
|
+
}).join(`
|
|
4400
|
+
`);
|
|
4401
|
+
}
|
|
3936
4402
|
function renderStatus(status) {
|
|
3937
4403
|
if (status === "up")
|
|
3938
4404
|
return source_default.green("up");
|
|
@@ -3942,4 +4408,15 @@ function renderStatus(status) {
|
|
|
3942
4408
|
return source_default.yellow("paused");
|
|
3943
4409
|
return source_default.gray(status);
|
|
3944
4410
|
}
|
|
4411
|
+
function splitList(value) {
|
|
4412
|
+
if (!value)
|
|
4413
|
+
return;
|
|
4414
|
+
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
4415
|
+
}
|
|
4416
|
+
function sanitizeTerminal(value) {
|
|
4417
|
+
return value.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, "");
|
|
4418
|
+
}
|
|
4419
|
+
function sanitizeField(value) {
|
|
4420
|
+
return value.replace(/[\x00-\x1f\x7f-\x9f]/g, " ");
|
|
4421
|
+
}
|
|
3945
4422
|
program2.parseAsync(process.argv);
|