chore: improve dashboard performance + add more tests (#8030)

This commit is contained in:
Louis Lam authored and GitHub committed 2026-10-25 02:34:12 +08:00
1 parent 365c4b289d
commit 9ced9ccd0e
89 files changed
+6856 -477

No files matched your search

+6 -1
View File
@@ -53,6 +53,7 @@ Warning: Only maintainers of Uptime Kuma can use these instructions. Other contr
```bash
npm run test-backend # Backend unit tests (node:test, run through tsx)
npm test # Backend + Playwright E2E
npm run coverage # Backend suite + HTML report at private/coverage/backend/index.html
```
### Development Workflow
@@ -127,6 +128,7 @@ npm run dev # Frontend on port 3000, backend on port 3001
- **Comments**: keep them short (one concise line explaining what/why); no multi-line essays or restating the code
- **Commits**: one short sentence describing the change; no long body
- **Naming**: JavaScript/TypeScript (camelCase), SQLite (snake_case), CSS/SCSS (kebab-case)
- **New code**: new files and modules are TypeScript (`*.ts`) with Zod validation for external/DB input; leave existing JavaScript files in JS unless that file is being converted for a reason
- JSDoc is required on every function declaration and method (`jsdoc/require-jsdoc`); `*.ts` relaxes some JSDoc and `any` rules
- Notable ESLint rules: `no-var`, `one-var: never`, `max-statements-per-line: 1`, `jsdoc/require-throws`
- **Module system**: no `"type": "module"` in package.json — `*.js` is CommonJS (`require`), `*.mjs`/`*.mts` are ESM, and `*.ts` is executed via `tsx`
@@ -168,9 +170,12 @@ npm run dev # Frontend on port 3000, backend on port 3001
## Testing
- **Backend**: Node.js test runner via tsx (`test/backend-test/**/*.{ts,js}`). Use `describe()`/`test()` from `node:test` and one test per scenario (see `test/backend-test/README.md`).
- **Backend**: Node.js test runner via tsx (`test/backend-test/**/*.{ts,js}`). Use `describe()`/`test()` from `node:test` and one test per scenario (see `test/backend-test/README.md`). `npm run test-backend` also prints a coverage summary via `--experimental-test-coverage`.
- **TDD**: write the failing test first (red), watch it fail, implement the change, then confirm green before committing.
- **Coverage**: `npm run coverage` runs the backend suite and writes an HTML report to `private/coverage/backend/index.html` (e2e coverage is not collected).
- Database monitor tests use Testcontainers; `npm run test-e2e-local` sets `SKIP_TESTCONTAINER=1` to skip them.
- **E2E**: Playwright specs in `test/e2e/specs/*.spec.js`; `setup-process.once.js` is a one-time setup project every spec depends on. Requires `npx playwright install`.
- **E2E error guard**: specs import `test`/`expect` from `test/e2e/fixtures.js`, which fails a test when the page throws an uncaught error or logs an unexpected console error (failed network loads are ignored).
- Test data lives in `data/playwright-test`; reports go to `private/playwright-report`.
## Adding New Features
+84
View File
@@ -0,0 +1,84 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { CoverageReport } from "monocart-coverage-reports";
const root = process.cwd();
const outDir = path.join(root, "private", "coverage");
const backendRaw = path.join(outDir, "raw", "backend");
/**
* Whether a source file belongs to the app (not dependencies or tests)
* @param {string} sourcePath Source path
* @returns {boolean} True when the file should be reported
*/
function isAppSource(sourcePath) {
const normalized = sourcePath.replaceAll("\\", "/");
if (normalized.includes("/node_modules/")) {
return false;
}
if (normalized.includes("/test/")) {
return false;
}
return normalized.includes("/server/") || normalized.includes("/src/");
}
/**
* Read Node's NODE_V8_COVERAGE files and attach source text to every file entry
* @param {string} dir Raw coverage directory
* @returns {Array} V8 coverage entries with source
*/
function readNodeCoverage(dir) {
const entries = [];
for (const file of fs.readdirSync(dir)) {
if (!file.endsWith(".json")) {
continue;
}
const data = JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"));
for (const entry of data.result || []) {
// Only project files: skip node internals, npm/npx and everything in node_modules
if (!entry.url.startsWith("file://")) {
continue;
}
let sourcePath;
try {
sourcePath = fileURLToPath(entry.url);
} catch {
continue;
}
if (!isAppSource(sourcePath)) {
continue;
}
if (!entry.source) {
try {
entry.source = fs.readFileSync(sourcePath, "utf8");
} catch {
continue;
}
}
entries.push(entry);
}
}
return entries;
}
if (!fs.existsSync(backendRaw)) {
console.log("No backend coverage data found. Run `npm run coverage:backend` first.");
process.exit(0);
}
const report = new CoverageReport({
name: "Backend Coverage",
outputDir: path.join(outDir, "backend"),
reports: [["html", { subdir: "." }], "json-summary"],
lcov: true,
sourceFilter: isAppSource,
});
await report.add(readNodeCoverage(backendRaw));
const result = await report.generate();
const lines = result?.summary?.lines?.pct;
console.log(`Backend coverage report written to ${path.relative(root, path.join(outDir, "backend", "index.html"))}`);
if (lines) {
console.log(`- lines ${lines}%`);
}
+184 -3
View File
@@ -45,6 +45,7 @@
"iconv-lite": "~0.6.3",
"is-url": "~1.2.4",
"isomorphic-ws": "~5.0.0",
"js-md4": "~0.3.2",
"jsesc": "~3.0.2",
"jsonata": "~2.2.2",
"kafkajs": "~2.2.4",
@@ -133,6 +134,7 @@
"eslint-plugin-vue": "~8.7.1",
"eslint-plugin-vue-scoped-css": "~2.7.2",
"favico.js": "~0.3.10",
"monocart-coverage-reports": "^2.13.2",
"node-ssh": "~13.1.0",
"postcss-html": "~1.8.1",
"postcss-rtlcss": "~5.7.1",
@@ -4676,9 +4678,9 @@
}
},
"node_modules/acorn": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
"integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==",
"dev": true,
"license": "MIT",
"bin": {
@@ -4698,6 +4700,32 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/acorn-loose": {
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/acorn-loose/-/acorn-loose-8.5.2.tgz",
"integrity": "sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.15.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/aedes": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/aedes/-/aedes-1.0.2.tgz",
@@ -6526,6 +6554,13 @@
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/console-grid": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/console-grid/-/console-grid-2.2.4.tgz",
"integrity": "sha512-OLjCRTiHhOpTRo9lQp/2FgJDyq5uQHwkEmVJulEnQ6JVf27oKKzXHZnNOv/e72V4++UdMZCrDWtvXW5sx4lyQg==",
"dev": true,
"license": "MIT"
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -7561,6 +7596,13 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/eight-colors": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/eight-colors/-/eight-colors-1.3.3.tgz",
"integrity": "sha512-4B54S2Qi4pJjeHmCbDIsveQZWQ/TSSQng4ixYJ9/SYHHpeS5nYK0pzcHvWzWUfRsvJQjwoIENhAwqg59thQceg==",
"dev": true,
"license": "MIT"
},
"node_modules/emitter-component": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/emitter-component/-/emitter-component-1.1.2.tgz",
@@ -10441,6 +10483,81 @@
"ws": "*"
}
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-lib-report/node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/istanbul-lib-report/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-reports/node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
@@ -11269,6 +11386,13 @@
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/lz-utils": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/lz-utils/-/lz-utils-2.1.1.tgz",
"integrity": "sha512-d3Thjos0PSJQAoyMj6vipSSrtrRHS7DImqUNR8x9NW3+zQIftPIbMJAWhi5nPdg5Q9zHz6lxtN8kp/VdMlhi/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -11664,6 +11788,63 @@
"node": ">=12"
}
},
"node_modules/monocart-coverage-reports": {
"version": "2.13.2",
"resolved": "https://registry.npmjs.org/monocart-coverage-reports/-/monocart-coverage-reports-2.13.2.tgz",
"integrity": "sha512-G6m6BO3wzidg9UrK2JRMW+err7OxB+iFn0kWkprQyHTUtvbv9vM8u0SrgHW1rWpOQXC4eB+nvat/xa7mLDE/9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.19.0",
"acorn-loose": "^8.5.2",
"acorn-walk": "^8.3.5",
"commander": "^15.0.0",
"console-grid": "^2.2.4",
"eight-colors": "^1.3.3",
"foreground-child": "^4.0.3",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"lz-utils": "^2.1.1",
"monocart-locator": "^1.0.6"
},
"bin": {
"mcr": "lib/cli.js"
}
},
"node_modules/monocart-coverage-reports/node_modules/commander": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
"integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/monocart-coverage-reports/node_modules/foreground-child": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-4.0.3.tgz",
"integrity": "sha512-yeXZaNbCBGaT9giTpLPBdtedzjwhlJBUoL/R4BVQU5mn0TQXOHwVIl1Q2DMuBIdNno4ktA1abZ7dQFVxD6uHxw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/monocart-locator": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/monocart-locator/-/monocart-locator-1.0.6.tgz",
"integrity": "sha512-P4DCDxeOhy59KexF3yx/kD7y64HVxHO0lskxjxAML9kTOvQnD2jTpZWFQFEFyyABx32at3t77mWLKftFfk1wHQ==",
"dev": true,
"license": "MIT"
},
"node_modules/mqemitter": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/mqemitter/-/mqemitter-7.1.0.tgz",
+7 -2
View File
@@ -27,7 +27,7 @@
"build": "vite build",
"test": "node --run test-backend && node --run test-e2e",
"test-with-build": "node --run build && npm test",
"test-backend": "cross-env TEST_BACKEND=1 node --import=tsx --test --test-reporter=spec \"test/backend-test/**/*.{ts,js}\"",
"test-backend": "cross-env TEST_BACKEND=1 node --import=tsx --test --experimental-test-coverage --test-coverage-exclude=\"test/**\" --test-reporter=spec \"test/{backend-test,frontend-test}/**/*.{ts,js}\"",
"test-e2e": "playwright test",
"test-e2e-local": "cross-env SKIP_TESTCONTAINER=1 npm run build && npm run test-e2e",
"test-e2e-ui": "playwright test --ui --ui-port=51063",
@@ -64,7 +64,10 @@
"reset-migrate-aggregate-table-state": "node extra/reset-migrate-aggregate-table-state.js",
"generate-changelog": "node ./extra/release/generate-changelog.mjs",
"create-release": "node ./extra/release/create-release.mjs",
"audit": "npm audit --omit=dev"
"audit": "npm audit --omit=dev",
"coverage:backend": "cross-env TEST_BACKEND=1 NODE_V8_COVERAGE=private/coverage/raw/backend node --import=tsx --test --test-reporter=spec \"test/{backend-test,frontend-test}/**/*.{ts,js}\"",
"coverage:report": "node extra/coverage-report.mjs",
"coverage": "npm run coverage:backend && npm run coverage:report"
},
"dependencies": {
"@better-auth/api-key": "1.6.11",
@@ -103,6 +106,7 @@
"iconv-lite": "~0.6.3",
"is-url": "~1.2.4",
"isomorphic-ws": "~5.0.0",
"js-md4": "~0.3.2",
"jsesc": "~3.0.2",
"jsonata": "~2.2.2",
"kafkajs": "~2.2.4",
@@ -191,6 +195,7 @@
"eslint-plugin-vue": "~8.7.1",
"eslint-plugin-vue-scoped-css": "~2.7.2",
"favico.js": "~0.3.10",
"monocart-coverage-reports": "^2.13.2",
"node-ssh": "~13.1.0",
"postcss-html": "~1.8.1",
"postcss-rtlcss": "~5.7.1",
+57
View File
@@ -63,6 +63,61 @@ async function sendHeartbeatList(socket, monitorID, toUser = false, overwrite =
}
}
/**
* Send the latest heartbeat of many monitors in one event.
* The full 100-beat history is fetched lazily per visible row, so the login
* payload does not grow with the number of monitors.
* @param {Socket} socket Socket.io instance
* @param {Array<number|string>} monitorIDs Monitor ids
* @returns {Promise<void>}
*/
async function sendHeartbeatLists(socket, monitorIDs) {
const result = {};
const chunkSize = 500;
for (let i = 0; i < monitorIDs.length; i += chunkSize) {
const chunk = monitorIDs.slice(i, i + chunkSize);
const placeholders = chunk.map(() => "?").join(",");
const rows = await R.getAll(
`
SELECT h.id, h.monitor_id, h.status, h.msg, h.time, h.ping, h.important, h.duration, h.down_count, h.retries
FROM heartbeat h
JOIN (
SELECT monitor_id, MAX(id) AS max_id
FROM heartbeat
WHERE monitor_id IN (${placeholders})
GROUP BY monitor_id
) latest ON h.id = latest.max_id
`,
chunk
);
for (const row of rows) {
result[row.monitor_id] = [row];
}
}
socket.emit("heartbeatLists", result);
}
/**
* Recent heartbeat history for one monitor, requested by a visible row
* @param {number} monitorID Monitor id
* @returns {Promise<Array<object>>} Oldest-to-newest heartbeats
*/
async function getHeartbeatHistory(monitorID) {
const list = await R.getAll(
`
SELECT id, monitor_id, status, msg, time, ping, important, duration, down_count, retries
FROM heartbeat
WHERE monitor_id = ?
ORDER BY time DESC
LIMIT 100
`,
[monitorID]
);
return list.reverse();
}
/**
* Important Heart beat list (aka event list)
* @param {Socket} socket Socket.io instance
@@ -218,6 +273,8 @@ module.exports = {
sendNotificationList,
sendImportantHeartbeatList,
sendHeartbeatList,
sendHeartbeatLists,
getHeartbeatHistory,
sendProxyList,
sendInfo,
+55
View File
@@ -0,0 +1,55 @@
export interface CompactPreloadData {
paths: Map<number, string[]>;
activeStatus: Map<number, boolean>;
childrenIDs: Map<number, number[]>;
tags: Map<number, unknown[]>;
}
export interface CompactMonitor {
id: number;
name: string;
type: string;
active: boolean | undefined;
parent: number | null;
childrenIDs: number[];
tags: unknown[];
path: string[];
pathName: string;
interval: number;
weight: number | null;
}
/**
* Fields the dashboard needs for one monitor. toJSON() builds ~60 fields per
* monitor, which costs over a second for thousands of monitors at login.
* @param monitor Monitor bean
* @param preloadData Data prepared for the whole list
* @returns Slim monitor payload
*/
export function toCompactMonitor(
monitor: {
id: number;
name: string;
type: string;
parent?: number | null;
interval: number;
weight?: number | null;
},
preloadData: CompactPreloadData
): CompactMonitor {
const path = preloadData.paths.get(monitor.id) || [];
return {
id: monitor.id,
name: monitor.name,
type: monitor.type,
active: preloadData.activeStatus.get(monitor.id),
parent: monitor.parent ?? null,
childrenIDs: preloadData.childrenIDs.get(monitor.id) || [],
tags: preloadData.tags.get(monitor.id) || [],
path,
pathName: path.join(" / "),
interval: monitor.interval,
weight: monitor.weight ?? null,
};
}
+22
View File
@@ -0,0 +1,22 @@
import { getTotalClientInRoom } from "./util-server";
/**
* Send a heartbeat to the dashboard, but only when someone is connected.
* Socket.io still serializes and sends the event for empty rooms, which costs
* time for thousands of monitors on every beat.
* @param io Socket server
* @param userID Room of the monitor owner
* @param payload Heartbeat payload
* @returns True when the event was emitted
*/
export function emitHeartbeat(
io: { to: (room: string | number) => { emit: (event: string, payload: unknown) => unknown } },
userID: string | number,
payload: unknown
): boolean {
if (getTotalClientInRoom(io, userID) <= 0) {
return false;
}
io.to(userID).emit("heartbeat", payload);
return true;
}
+50
View File
@@ -0,0 +1,50 @@
export interface DeferredListsOptions {
/** Send the heavy lists once */
send: () => Promise<void>;
/** Report a failed send */
onError: (error: Error) => void;
/** Fallback delay for clients that never signal */
fallbackMs?: number;
setTimer?: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
}
/**
* Sends the heartbeat/stats lists after the client painted the dashboard,
* with a timer fallback for clients that never signal. Runs at most once.
*/
export class DeferredListsSender {
private sent = false;
private fallback: ReturnType<typeof setTimeout> | null = null;
constructor(private readonly options: DeferredListsOptions) {
const setTimer = options.setTimer ?? setTimeout;
this.fallback = setTimer(() => this.run(), options.fallbackMs ?? 5000);
}
/** Client signalled that the dashboard was painted */
onRendered = (): void => {
this.run();
};
/**
* Cancel the fallback timer, e.g. when the socket disconnects
* @returns void
*/
dispose(): void {
if (this.fallback) {
const clearTimer = this.options.clearTimer ?? clearTimeout;
clearTimer(this.fallback);
this.fallback = null;
}
}
private run(): void {
if (this.sent) {
return;
}
this.sent = true;
this.dispose();
this.options.send().catch((error) => this.options.onError(error));
}
}
+159 -103
View File
@@ -29,7 +29,6 @@ const {
} = require("../../src/util");
const {
ping,
checkCertificate,
checkStatusCode,
getTotalClientInRoom,
httpNtlm,
@@ -38,14 +37,12 @@ const {
getOidcTokenClientCredentials,
rootCertificatesFingerprints,
axiosAbortSignal,
checkCertificateHostname,
encodeBase64,
checkCertExpiryNotifications,
} = require("../util-server");
const { R } = require("redbean-node");
const { BeanModel } = require("redbean-node/dist/bean-model");
const { Notification } = require("../notification");
const { Proxy } = require("../proxy");
const { demoMode } = require("../config");
const version = require("../../package.json").version;
const apicache = require("../modules/apicache");
@@ -53,8 +50,15 @@ const { UptimeKumaServer } = require("../uptime-kuma-server");
const { DockerHost } = require("../docker");
const crypto = require("crypto");
const { UptimeCalculator } = require("../uptime-calculator");
const { CookieJar } = require("tough-cookie");
const { HttpsCookieAgent } = require("http-cookie-agent/http");
const { MonitorStats } = require("../monitor-stats");
const { attachKeylogListener, getMonitorAgents } = require("../monitor-agents");
const { readTlsInfo } = require("../monitor-tls");
const { emitHeartbeat } = require("../heartbeat-emit");
const { updatePrometheusStats } = require("../prometheus-monitor");
// Last time certificate/domain info was sent to a client, per monitor
const lastInfoSent = new Map();
const INFO_REFRESH_INTERVAL_MS = 60 * 1000;
const https = require("https");
const http = require("http");
const zlib = require("node:zlib");
@@ -414,9 +418,10 @@ class Monitor extends BeanModel {
/**
* Start monitor
* @param {Server} io Socket server instance
* @param {number} firstBeatDelayMs Delay the first check (used to spread startup)
* @returns {Promise<void>}
*/
async start(io) {
async start(io, firstBeatDelayMs = 0) {
let previousBeat = null;
let retries = 0;
@@ -591,33 +596,19 @@ class Monitor extends BeanModel {
};
}
if (this.proxy_id) {
const proxy = await R.load("proxy", this.proxy_id);
// Reuse keep-alive agents per monitor: creating a new agent and socket for
// every check exhausts local ports and buffers with thousands of monitors
const agents = await getMonitorAgents(this, {
httpAgentOptions,
httpsAgentOptions,
});
if (proxy && proxy.active) {
const { httpAgent, httpsAgent } = Proxy.createAgents(proxy, {
httpsAgentOptions: httpsAgentOptions,
httpAgentOptions: httpAgentOptions,
});
options.proxy = false;
options.httpAgent = httpAgent;
options.httpsAgent = httpsAgent;
}
if (agents.proxied) {
options.proxy = false;
}
if (!options.httpAgent) {
options.httpAgent = new http.Agent(httpAgentOptions);
}
if (!options.httpsAgent) {
let jar = new CookieJar();
let httpsCookieAgentOptions = {
...httpsAgentOptions,
cookies: { jar },
};
options.httpsAgent = new HttpsCookieAgent(httpsCookieAgentOptions);
}
options.httpAgent = agents.httpAgent;
options.httpsAgent = agents.httpsAgent;
if (this.auth_method === "mtls") {
if (this.tlsCert !== null && this.tlsCert !== "") {
@@ -632,19 +623,11 @@ class Monitor extends BeanModel {
}
let tlsInfo = {};
// Store tlsInfo when secureConnect event is emitted
// The keylog event listener is a workaround to access the tlsSocket
options.httpsAgent.once("keylog", async (line, tlsSocket) => {
tlsSocket.once("secureConnect", async () => {
tlsInfo = checkCertificate(tlsSocket);
tlsInfo.valid = tlsSocket.authorized || false;
tlsInfo.hostnameMatchMonitorUrl = checkCertificateHostname(
tlsInfo.certInfo.raw,
this.getUrl()?.hostname
);
await this.handleTlsInfo(tlsInfo);
});
// Store tlsInfo when secureConnect event is emitted. The agent is reused,
// so this replaces the listener of the previous beat instead of stacking
attachKeylogListener(agents, options.httpsAgent, async (tlsSocket) => {
tlsInfo = readTlsInfo(tlsSocket, this.getUrl()?.hostname);
await this.handleTlsInfo(tlsInfo);
});
log.debug("monitor", `[${this.name}] Axios Options: ${JSON.stringify(options)}`);
@@ -667,13 +650,7 @@ class Monitor extends BeanModel {
const tlsSocket = res.request.res.socket;
if (tlsSocket) {
tlsInfo = checkCertificate(tlsSocket);
tlsInfo.valid = tlsSocket.authorized || false;
tlsInfo.hostnameMatchMonitorUrl = checkCertificateHostname(
tlsInfo.certInfo.raw,
this.getUrl()?.hostname
);
tlsInfo = readTlsInfo(tlsSocket, this.getUrl()?.hostname);
await this.handleTlsInfo(tlsInfo);
}
}
@@ -1061,7 +1038,7 @@ class Monitor extends BeanModel {
// Send to frontend
log.debug("monitor", `[${this.name}] Send to socket`);
io.to(this.user_id).emit("heartbeat", bean.toJSON());
emitHeartbeat(io, this.user_id, bean.toJSON());
Monitor.sendStats(io, this.id, this.user_id);
// Store to database
@@ -1069,10 +1046,10 @@ class Monitor extends BeanModel {
await R.store(bean);
log.debug("monitor", `[${this.name}] prometheus.update`);
const data24h = uptimeCalculator.get24Hour();
const data30d = uptimeCalculator.get30Day();
const data1y = uptimeCalculator.get1Year();
this.prometheus?.update(bean, tlsInfo, { data24h, data30d, data1y });
if (this.prometheus) {
// Serve the gauges from the grouped stats cache, never from full history
updatePrometheusStats(this.prometheus, this.id, bean, tlsInfo);
}
previousBeat = bean;
@@ -1113,6 +1090,10 @@ class Monitor extends BeanModel {
this.heartbeatInterval = setTimeout(() => {
safeBeat();
}, this.interval * 1000);
} else if (firstBeatDelayMs > 0) {
// Spread the first check over the interval instead of checking every
// monitor at the same moment when the server starts
this.heartbeatInterval = setTimeout(safeBeat, firstBeatDelayMs);
} else {
safeBeat();
}
@@ -1319,29 +1300,30 @@ class Monitor extends BeanModel {
*/
static async sendStats(io, monitorID, userID) {
const hasClients = getTotalClientInRoom(io, userID) > 0;
let uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitorID);
if (hasClients) {
// Send 24 hour average ping
let data24h = await uptimeCalculator.get24Hour();
io.to(userID).emit("avgPing", monitorID, data24h.avgPing ? Number(data24h.avgPing.toFixed(2)) : null);
// Serve the dashboard numbers from the grouped SQL cache; never initialize a full
// UptimeCalculator (thousands of stat rows) just to answer a heartbeat
const cached = MonitorStats.get(monitorID);
if (cached) {
io.to(userID).emit("stats", { [monitorID]: cached });
}
// Send 24 hour uptime
io.to(userID).emit("uptime", monitorID, 24, data24h.uptime);
const server = UptimeKumaServer.getInstance();
MonitorStats.maybeRefresh(io, userID, Object.keys(server.monitorList));
// Send 30 day uptime
let data30d = await uptimeCalculator.get30Day();
io.to(userID).emit("uptime", monitorID, 720, data30d.uptime);
// Cert and domain info are slow (DB + DNS). At one beat per monitor per minute
// and thousands of monitors they must not run on every beat while a client is open
const now = Date.now();
if (now - (lastInfoSent.get(monitorID) || 0) >= INFO_REFRESH_INTERVAL_MS) {
lastInfoSent.set(monitorID, now);
// Send 1-year uptime
let data1y = await uptimeCalculator.get1Year();
io.to(userID).emit("uptime", monitorID, "1y", data1y.uptime);
// Send Cert Info
await Monitor.sendCertInfo(io, monitorID, userID);
// Send Cert Info
await Monitor.sendCertInfo(io, monitorID, userID);
// Send domain info
await Monitor.sendDomainInfo(io, monitorID, userID);
// Send domain info
await Monitor.sendDomainInfo(io, monitorID, userID);
}
} else {
log.debug("monitor", "No clients in the room, no need to send stats");
}
@@ -1833,19 +1815,104 @@ class Monitor extends BeanModel {
if (monitorData.length > 0) {
const monitorIDs = monitorData.map((monitor) => monitor.id);
const notifications = await Monitor.getMonitorNotification(monitorIDs);
const tags = await Monitor.getMonitorTag(monitorIDs);
const maintenanceStatuses = await Promise.all(
monitorData.map((monitor) => Monitor.isUnderMaintenance(monitor.id))
);
const childrenIDs = await Promise.all(monitorData.map((monitor) => Monitor.getAllChildrenIDs(monitor.id)));
const activeStatuses = await Promise.all(
monitorData.map((monitor) => Monitor.isActive(monitor.id, monitor.active))
);
const forceInactiveStatuses = await Promise.all(
monitorData.map((monitor) => Monitor.isParentActive(monitor.id))
);
const paths = await Promise.all(monitorData.map((monitor) => Monitor.getAllPath(monitor.id, monitor.name)));
const [notifications, tags, allMonitors, maintenanceRows] = await Promise.all([
Monitor.getMonitorNotification(monitorIDs),
Monitor.getMonitorTag(monitorIDs),
R.getAll("SELECT id, name, parent, active FROM monitor"),
R.getAll("SELECT monitor_id, maintenance_id FROM monitor_maintenance"),
]);
const byId = new Map();
const childrenById = new Map();
for (const row of allMonitors) {
byId.set(row.id, row);
const key = row.parent ?? null;
if (!childrenById.has(key)) {
childrenById.set(key, []);
}
childrenById.get(key).push(row);
}
// All hierarchy work below is memoized in memory, so 5000 monitors cost
// a fixed number of queries instead of ~6 queries per monitor
const parentActiveCache = new Map();
const isParentActive = (id) => {
if (parentActiveCache.has(id)) {
return parentActiveCache.get(id);
}
const node = byId.get(id);
const parent = node?.parent ? byId.get(node.parent) : null;
const result = !parent ? true : parent.active === 1 && isParentActive(parent.id);
parentActiveCache.set(id, result);
return result;
};
const childrenCache = new Map();
const allChildrenIDs = (id) => {
if (childrenCache.has(id)) {
return childrenCache.get(id);
}
const result = [];
for (const child of childrenById.get(id) ?? []) {
result.push(child.id);
result.push(...allChildrenIDs(child.id));
}
childrenCache.set(id, result);
return result;
};
const pathCache = new Map();
const pathOf = (id) => {
if (pathCache.has(id)) {
return pathCache.get(id);
}
const node = byId.get(id);
if (!node) {
return [];
}
const parent = node.parent ? byId.get(node.parent) : null;
const result = parent ? [...pathOf(parent.id), node.name] : [node.name];
pathCache.set(id, result);
return result;
};
const maintenanceActive = new Map();
const server = UptimeKumaServer.getInstance();
for (const row of maintenanceRows) {
if (!maintenanceActive.has(row.maintenance_id)) {
const maintenance = server.getMaintenance(row.maintenance_id);
maintenanceActive.set(
row.maintenance_id,
maintenance ? await maintenance.isUnderMaintenance() : false
);
}
}
const maintenanceByMonitor = new Map();
for (const row of maintenanceRows) {
if (!maintenanceByMonitor.has(row.monitor_id)) {
maintenanceByMonitor.set(row.monitor_id, []);
}
maintenanceByMonitor.get(row.monitor_id).push(row.maintenance_id);
}
const maintenanceCache = new Map();
const isUnderMaintenance = (id) => {
if (maintenanceCache.has(id)) {
return maintenanceCache.get(id);
}
let result = (maintenanceByMonitor.get(id) ?? []).some((maintenanceID) =>
maintenanceActive.get(maintenanceID)
);
if (!result) {
const node = byId.get(id);
if (node?.parent) {
result = isUnderMaintenance(node.parent);
}
}
maintenanceCache.set(id, result);
return result;
};
notifications.forEach((row) => {
if (!notificationsMap.has(row.monitor_id)) {
@@ -1867,24 +1934,13 @@ class Monitor extends BeanModel {
});
});
monitorData.forEach((monitor, index) => {
maintenanceStatusMap.set(monitor.id, maintenanceStatuses[index]);
});
monitorData.forEach((monitor, index) => {
childrenIDsMap.set(monitor.id, childrenIDs[index]);
});
monitorData.forEach((monitor, index) => {
activeStatusMap.set(monitor.id, activeStatuses[index]);
});
monitorData.forEach((monitor, index) => {
forceInactiveMap.set(monitor.id, !forceInactiveStatuses[index]);
});
monitorData.forEach((monitor, index) => {
pathsMap.set(monitor.id, paths[index]);
monitorData.forEach((monitor) => {
const parentActive = isParentActive(monitor.id);
maintenanceStatusMap.set(monitor.id, isUnderMaintenance(monitor.id));
childrenIDsMap.set(monitor.id, allChildrenIDs(monitor.id));
activeStatusMap.set(monitor.id, monitor.active === 1 && parentActive);
forceInactiveMap.set(monitor.id, !parentActive);
pathsMap.set(monitor.id, pathOf(monitor.id));
});
}
+17 -3
View File
@@ -1,6 +1,22 @@
"use strict";
// Original source at https://github.com/elasticio/node-ntlm-client/blob/master/lib/hash.js
var crypto = require("crypto");
/**
* MD4 digest with a pure JavaScript fallback.
* OpenSSL 3 disables MD4 by default, so Node throws ERR_OSSL_EVP_UNSUPPORTED.
* @param {Buffer} data Data to hash
* @returns {Buffer} MD4 digest
*/
function md4(data) {
try {
return crypto.createHash("md4").update(data).digest();
} catch (e) {
if (e.code !== "ERR_OSSL_EVP_UNSUPPORTED") {
throw e;
}
return Buffer.from(require("js-md4").create().update(data).digest());
}
}
function createLMResponse(challenge, lmhash) {
var buf = new Buffer.alloc(24),
pwBuffer = new Buffer.alloc(21).fill(0);
@@ -52,9 +68,7 @@ function createNTLMResponse(challenge, ntlmhash) {
return buf;
}
function createNTLMHash(password) {
var md4sum = crypto.createHash("md4");
md4sum.update(new Buffer.from(password, "ucs2"));
return md4sum.digest();
return md4(new Buffer.from(password, "ucs2"));
}
function createNTLMv2Hash(ntlmhash, username, authTargetName) {
var hmac = crypto.createHmac("md5", ntlmhash);
+128
View File
@@ -0,0 +1,128 @@
import http from "node:http";
import https from "node:https";
import { R } from "redbean-node";
// @ts-ignore - tough-cookie does not ship type definitions
import { CookieJar } from "tough-cookie";
import { HttpsCookieAgent } from "http-cookie-agent/http";
import { Proxy } from "./proxy";
export interface MonitorAgentOptions {
httpAgentOptions: http.AgentOptions;
httpsAgentOptions: http.AgentOptions;
}
export interface MonitorAgents {
httpAgent: http.Agent;
httpsAgent: https.Agent;
/** True when the agents come from a proxy instead of a direct connection */
proxied: boolean;
/** Listener installed on the https agent for the current beat */
keylogHandler?: (line: Buffer, tlsSocket: KeylogSocket) => void;
}
/** Minimal TLS socket shape used by the keylog listener */
export interface KeylogSocket {
once(event: "secureConnect", listener: () => void): unknown;
}
/** Monitor instance to its keep-alive agents. A WeakMap avoids redbean's magic property setters */
const agentCache = new WeakMap<object, MonitorAgents>();
/**
* Get the keep-alive agents of a monitor, creating them on first use.
* Creating a new agent and socket for every check exhausts local ports and
* buffers with thousands of monitors, so each monitor keeps its own pair.
* @param monitor Monitor instance
* @param options Agent options built by the monitor beat
* @returns Cached agents for this monitor instance
*/
export async function getMonitorAgents(
monitor: { proxy_id?: number | null },
options: MonitorAgentOptions
): Promise<MonitorAgents> {
const cached = agentCache.get(monitor);
if (cached) {
return cached;
}
// An edit restarts the monitor with a new instance, so the cache never goes stale
const agents = await createMonitorAgents(monitor, options);
agentCache.set(monitor, agents);
return agents;
}
/**
* Drop the cached agents of a monitor
* @param monitor Monitor instance
* @returns void
*/
export function clearMonitorAgents(monitor: object): void {
agentCache.delete(monitor);
}
/**
* Create the agents for one monitor beat
* @param monitor Monitor instance
* @param options Agent options built by the monitor beat
* @returns New agents, using the proxy when one is configured and active
*/
async function createMonitorAgents(
monitor: { proxy_id?: number | null },
{ httpAgentOptions, httpsAgentOptions }: MonitorAgentOptions
): Promise<MonitorAgents> {
let httpAgent: http.Agent | null = null;
let httpsAgent: https.Agent | null = null;
let proxied = false;
if (monitor.proxy_id) {
const proxy = await R.load("proxy", monitor.proxy_id);
if (proxy && proxy.active) {
({ httpAgent, httpsAgent } = Proxy.createAgents(proxy, {
httpsAgentOptions,
httpAgentOptions,
}));
proxied = true;
}
}
if (!httpAgent) {
httpAgent = new http.Agent({ ...httpAgentOptions, keepAlive: true });
}
if (!httpsAgent) {
httpsAgent = new HttpsCookieAgent({
...httpsAgentOptions,
keepAlive: true,
cookies: { jar: new CookieJar() },
});
}
return { httpAgent, httpsAgent, proxied };
}
/**
* Install the keylog listener for one beat. Agents are reused between beats, and
* plain http monitors never emit "keylog", so the previous listener is removed
* instead of piling one up per beat.
* @param agents Cached agents of the monitor
* @param httpsAgent HTTPS agent of this beat
* @param onSecureConnect Called with the TLS socket once the handshake finishes
* @returns The installed handler
*/
export function attachKeylogListener(
agents: MonitorAgents,
httpsAgent: http.Agent,
onSecureConnect: (tlsSocket: KeylogSocket) => void
): (line: Buffer, tlsSocket: KeylogSocket) => void {
if (agents.keylogHandler) {
httpsAgent.removeListener("keylog", agents.keylogHandler);
}
agents.keylogHandler = (line, tlsSocket) => {
tlsSocket.once("secureConnect", () => onSecureConnect(tlsSocket));
};
httpsAgent.once("keylog", agents.keylogHandler);
return agents.keylogHandler;
}
+242
View File
@@ -0,0 +1,242 @@
import { R } from "redbean-node";
import dayjs from "dayjs";
import { z } from "zod";
const MINUTE = 60;
const DAY = 86400;
const REFRESH_INTERVAL_MS = 15 * 60 * 1000;
const CHUNK_SIZE = 500;
/** Dashboard uptime and ping numbers for a single monitor */
export interface MonitorStatsResult {
avgPing: number | null;
uptime24: number;
uptime720: number;
uptime1y: number;
}
/** Minimal socket room emitter, avoids importing the full socket.io type */
interface RoomEmitter {
to(room: string): { emit(event: string, payload: unknown): unknown };
}
const monitorIdsSchema = z.array(z.coerce.number().int().positive());
const statSumRowSchema = z.object({
monitor_id: z.coerce.number().int(),
up: z.coerce.number().nullable().optional(),
down: z.coerce.number().nullable().optional(),
ping_sum: z.coerce.number().nullable().optional(),
});
type StatSumRow = z.infer<typeof statSumRowSchema>;
/**
* Dashboard-level uptime/ping numbers for many monitors at once.
* Uses grouped SQL instead of loading every stat bucket into memory.
*/
export class MonitorStats {
/** monitorID -> latest stats */
static cache = new Map<number, MonitorStatsResult>();
static lastRefresh = 0;
/** In-flight aggregate scan, shared by every caller */
static refreshPromise: Promise<Record<number, MonitorStatsResult>> | null = null;
/**
* Sum stat buckets for a set of monitors in one grouped query per chunk
* @param table stat table name
* @param monitorIDs Monitor ids
* @param since Unix timestamp (seconds)
* @returns monitorID -> sum row
*/
private static async sumStats(table: string, monitorIDs: number[], since: number) {
const result = new Map<number, StatSumRow>();
for (let i = 0; i < monitorIDs.length; i += CHUNK_SIZE) {
const chunk = monitorIDs.slice(i, i + CHUNK_SIZE);
const placeholders = chunk.map(() => "?").join(",");
const rows = await R.getAll(
`SELECT monitor_id,
SUM(up) AS up,
SUM(down) AS down,
SUM(ping * up) AS ping_sum
FROM ${table}
WHERE timestamp >= ? AND monitor_id IN (${placeholders})
GROUP BY monitor_id`,
[since, ...chunk]
);
for (const row of rows) {
const parsed = statSumRowSchema.safeParse(row);
if (parsed.success) {
result.set(parsed.data.monitor_id, parsed.data);
}
}
}
return result;
}
/**
* Build the stat object from up/down/ping sums
* @param sum Row from sumStats
* @returns Uptime and average ping
*/
private static toUptime(sum?: StatSumRow): { uptime: number; avgPing: number | null } {
const up = Number(sum?.up) || 0;
const down = Number(sum?.down) || 0;
const pingSum = Number(sum?.ping_sum) || 0;
return {
uptime: up + down === 0 ? 0 : up / (up + down),
avgPing: up === 0 ? null : pingSum / up,
};
}
/**
* Aggregate the daily table once for both the 30-day and the 1-year window
* @param monitorIDs Monitor ids
* @param monthSince Unix timestamp (seconds)
* @param yearSince Unix timestamp (seconds)
* @returns monitorID -> { up30, down30, up1y, down1y }
*/
private static async sumDailyWindows(monitorIDs: number[], monthSince: number, yearSince: number) {
const result = new Map<number, { up30: number; down30: number; up1y: number; down1y: number }>();
for (let i = 0; i < monitorIDs.length; i += CHUNK_SIZE) {
const chunk = monitorIDs.slice(i, i + CHUNK_SIZE);
const placeholders = chunk.map(() => "?").join(",");
const rows = await R.getAll(
`SELECT monitor_id,
SUM(CASE WHEN timestamp >= ? THEN up ELSE 0 END) AS up30,
SUM(CASE WHEN timestamp >= ? THEN down ELSE 0 END) AS down30,
SUM(up) AS up1y,
SUM(down) AS down1y
FROM stat_daily
WHERE timestamp >= ? AND monitor_id IN (${placeholders})
GROUP BY monitor_id`,
[monthSince, monthSince, yearSince, ...chunk]
);
for (const row of rows) {
result.set(Number(row.monitor_id), {
up30: Number(row.up30) || 0,
down30: Number(row.down30) || 0,
up1y: Number(row.up1y) || 0,
down1y: Number(row.down1y) || 0,
});
}
}
return result;
}
/**
* Compute 24h / 30d / 1y stats for all monitors with two grouped queries
* @param monitorIDs Monitor ids
* @param now Current time (tests)
* @returns monitorID -> stats
*/
static async compute(
monitorIDs: Array<number | string>,
now: dayjs.Dayjs = dayjs.utc()
): Promise<Record<number, MonitorStatsResult>> {
const ids = monitorIdsSchema.parse(monitorIDs);
const unix = now.unix();
const minuteKey = Math.floor(unix / MINUTE) * MINUTE;
const dayKey = Math.floor(unix / DAY) * DAY;
const minutelySince = minuteKey - MINUTE * (1440 - 1);
const monthSince = dayKey - DAY * (30 - 1);
const yearSince = dayKey - DAY * (365 - 1);
const [minutely, daily] = await Promise.all([
MonitorStats.sumStats("stat_minutely", ids, minutelySince),
MonitorStats.sumDailyWindows(ids, monthSince, yearSince),
]);
const result: Record<number, MonitorStatsResult> = {};
for (const monitorID of ids) {
const data24 = MonitorStats.toUptime(minutely.get(monitorID));
const days = daily.get(monitorID);
const uptime30 =
(days?.up30 ?? 0) + (days?.down30 ?? 0) === 0
? 0
: (days?.up30 ?? 0) / ((days?.up30 ?? 0) + (days?.down30 ?? 0));
const uptime1y =
(days?.up1y ?? 0) + (days?.down1y ?? 0) === 0
? 0
: (days?.up1y ?? 0) / ((days?.up1y ?? 0) + (days?.down1y ?? 0));
result[monitorID] = {
avgPing: data24.avgPing === null ? null : Number(data24.avgPing.toFixed(2)),
uptime24: data24.uptime,
uptime720: uptime30,
uptime1y,
};
}
return result;
}
/**
* Reload the stats cache. Single flight: logins, beats and the periodic
* refresh all share one aggregate scan instead of starting competing scans.
* @param monitorIDs Monitor ids
* @returns monitorID -> stats
*/
static async refresh(monitorIDs: Array<number | string>): Promise<Record<number, MonitorStatsResult>> {
if (MonitorStats.refreshPromise) {
return MonitorStats.refreshPromise;
}
const ids = monitorIdsSchema.parse(monitorIDs);
MonitorStats.refreshPromise = (async () => {
const stats = await MonitorStats.compute(ids);
for (const monitorID of ids) {
MonitorStats.cache.set(monitorID, stats[monitorID]);
}
MonitorStats.lastRefresh = Date.now();
return stats;
})();
try {
return await MonitorStats.refreshPromise;
} finally {
MonitorStats.refreshPromise = null;
}
}
/**
* Cached stats for a set of monitors, without ever starting a scan
* @param monitorIDs Monitor ids
* @returns monitorID -> stats (partial)
*/
static getCached(monitorIDs: Array<number | string>): Record<number, MonitorStatsResult> {
const result: Record<number, MonitorStatsResult> = {};
for (const id of monitorIdsSchema.parse(monitorIDs)) {
const cached = MonitorStats.cache.get(id);
if (cached) {
result[id] = cached;
}
}
return result;
}
/**
* Cached stats for one monitor (may be undefined before the first refresh)
* @param monitorID Monitor id
* @returns Stats
*/
static get(monitorID: number | string): MonitorStatsResult | undefined {
return MonitorStats.cache.get(Number(monitorID));
}
/**
* Refresh all monitor stats when the cache is stale (fire and forget)
* @param io Socket server
* @param userID User room
* @param monitorIDs All monitor ids
*/
static maybeRefresh(io: RoomEmitter, userID: string, monitorIDs: Array<number | string>): void {
if (Date.now() - MonitorStats.lastRefresh < REFRESH_INTERVAL_MS) {
return;
}
MonitorStats.lastRefresh = Date.now();
MonitorStats.refresh(monitorIDs)
.then((stats) => io.to(userID).emit("stats", stats))
.catch(() => {
MonitorStats.lastRefresh = 0;
});
}
}
+42
View File
@@ -0,0 +1,42 @@
import { checkCertificate, checkCertificateHostname } from "./util-server";
/** Minimal TLS socket shape needed for the certificate info */
export interface TlsSocketLike {
authorized?: boolean;
[key: string]: unknown;
}
export interface TlsInfoResult {
certInfo: { raw: Buffer };
valid?: boolean;
hostnameMatchMonitorUrl?: boolean;
[key: string]: unknown;
}
export interface TlsInfoDeps {
checkCertificate: (socket: TlsSocketLike) => TlsInfoResult;
checkCertificateHostname: (raw: Buffer, hostname?: string) => boolean;
}
const defaultDeps: TlsInfoDeps = {
checkCertificate: (socket) => checkCertificate(socket),
checkCertificateHostname: (raw, hostname) => checkCertificateHostname(raw, hostname),
};
/**
* Build the certificate info of a TLS socket, including validity and hostname match
* @param tlsSocket Connected TLS socket
* @param hostname Hostname of the monitor URL
* @param deps Injectable certificate helpers (tests)
* @returns Certificate info
*/
export function readTlsInfo(
tlsSocket: TlsSocketLike,
hostname: string | undefined,
deps: TlsInfoDeps = defaultDeps
): TlsInfoResult {
const info = deps.checkCertificate(tlsSocket);
info.valid = tlsSocket.authorized || false;
info.hostnameMatchMonitorUrl = deps.checkCertificateHostname(info.certInfo.raw, hostname);
return info;
}
+37
View File
@@ -0,0 +1,37 @@
import { MonitorStats } from "./monitor-stats";
interface PrometheusTarget {
update: (heartbeat: unknown, tlsInfo: unknown, uptime: unknown) => void;
}
/**
* Feed the prometheus gauges from the grouped stats cache.
* Loads no stat history: without a cache entry the uptime gauges are skipped.
* @param prometheus Prometheus wrapper of the monitor
* @param monitorID Monitor id
* @param heartbeat Stored heartbeat
* @param tlsInfo Certificate info of the beat
* @returns True when cached stats were passed
*/
export function updatePrometheusStats(
prometheus: PrometheusTarget,
monitorID: number,
heartbeat: unknown,
tlsInfo: unknown
): boolean {
const stats = MonitorStats.get(monitorID);
prometheus.update(
heartbeat,
tlsInfo,
stats
? {
data24h: { uptime: stats.uptime24, avgPing: stats.avgPing ?? 0 },
data30d: { uptime: stats.uptime720, avgPing: stats.avgPing ?? 0 },
data1y: { uptime: stats.uptime1y, avgPing: stats.avgPing ?? 0 },
}
: null
);
return Boolean(stats);
}
+3
View File
@@ -265,6 +265,7 @@ router.get("/api/badge/:id/uptime/:duration?", cache("5 minutes"), async (reques
badgeValues.color = badgeConstants.naColor;
} else {
const uptimeCalculator = await UptimeCalculator.getUptimeCalculator(requestedMonitorId);
await uptimeCalculator.ensureHistoryLoaded();
const uptime = overrideValue ?? uptimeCalculator.getDataByDuration(requestedDuration).uptime;
// limit the displayed uptime percentage to four (two, when displayed as percent) decimal digits
@@ -331,6 +332,7 @@ router.get("/api/badge/:id/ping/:duration?", cache("5 minutes"), async (request,
badgeValues.color = badgeConstants.naColor;
} else {
const uptimeCalculator = await UptimeCalculator.getUptimeCalculator(requestedMonitorId);
await uptimeCalculator.ensureHistoryLoaded();
const avgPing = uptimeCalculator.getDataByDuration(requestedDuration).avgPing;
const avgPingValue = parseInt(overrideValue ?? avgPing);
@@ -386,6 +388,7 @@ router.get("/api/badge/:id/avg-response/:duration?", cache("5 minutes"), async (
if (publicMonitor) {
const uptimeCalculator = await UptimeCalculator.getUptimeCalculator(requestedMonitorId);
await uptimeCalculator.ensureHistoryLoaded();
avgPing = uptimeCalculator.getDataByDuration(`${requestedDuration}h`).avgPing;
}
+1
View File
@@ -97,6 +97,7 @@ router.get("/api/status-page/heartbeat/:slug", cache("1 minutes"), async (reques
heartbeatList[monitorID] = list.reverse().map((row) => row.toPublicJSON());
const uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitorID);
await uptimeCalculator.ensureHistoryLoaded();
uptimeList[`${monitorID}_24`] = uptimeCalculator.get24Hour().uptime;
}
+75 -14
View File
@@ -140,9 +140,13 @@ const Database = require("./database");
log.debug("server", "Importing Background Jobs");
const { initBackgroundJobs, stopBackgroundJobs } = require("./jobs");
const { DeferredListsSender } = require("./login-lists");
const { chunk, firstBeatDelayMs } = require("./start-monitors");
const { startStatsRefresh } = require("./stats-refresh");
const { apiAuth } = require("./auth");
const { Prometheus } = require("./prometheus");
const { UptimeCalculator } = require("./uptime-calculator");
const { MonitorStats } = require("./monitor-stats");
const hostname = config.hostname;
@@ -166,6 +170,8 @@ const testMode = !!args["test"] || false;
const {
sendNotificationList,
sendHeartbeatList,
sendHeartbeatLists,
getHeartbeatHistory,
sendInfo,
sendProxyList,
sendDockerHostList,
@@ -710,6 +716,21 @@ app.use(function (req, res, next) {
}
});
socket.on("getHeartbeatHistory", async (monitorID, callback) => {
try {
checkLogin(socket);
callback({
ok: true,
data: await getHeartbeatHistory(monitorID),
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("getMonitorBeats", async (monitorID, period, callback) => {
try {
checkLogin(socket);
@@ -1400,6 +1421,17 @@ app.use(function (req, res, next) {
await startMonitors();
// Warm the dashboard/prometheus stats cache once, then refresh it periodically.
// Without this, every beat would load years of stat rows per monitor.
const statMonitorIDs = Object.keys(server.monitorList);
if (statMonitorIDs.length > 0) {
startStatsRefresh({
monitorIDs: statMonitorIDs,
refresh: (ids) => MonitorStats.refresh(ids),
onError: (e) => log.error("monitor", "Failed to refresh monitor stats: " + e.message),
});
}
// Put this here. Start background jobs after the db and server is ready to prevent clear up during db migration.
await initBackgroundJobs();
@@ -1441,7 +1473,10 @@ async function afterLogin(socket, user) {
socket.userID = user.id;
socket.join(user.id);
let tPerf = Date.now();
let monitorList = await server.sendMonitorList(socket);
log.debug("perf", `[login] sendMonitorList ${Date.now() - tPerf}ms`);
tPerf = Date.now();
await Promise.allSettled([
sendInfo(socket),
server.sendMaintenanceList(socket),
@@ -1453,14 +1488,36 @@ async function afterLogin(socket, user) {
]);
await StatusPage.sendStatusPageList(io, socket);
log.debug("perf", `[login] misc lists ${Date.now() - tPerf}ms`);
tPerf = Date.now();
const monitorPromises = [];
for (let monitorID in monitorList) {
monitorPromises.push(sendHeartbeatList(socket, monitorID));
monitorPromises.push(Monitor.sendStats(io, monitorID, user.id));
}
const monitorIDs = Object.keys(monitorList);
await Promise.all(monitorPromises);
// The heartbeat and stats lists are another ~1.2 MB with thousands of monitors.
// Send them only after the client has painted the monitor list, so the dashboard
// becomes usable immediately instead of waiting for all three payloads.
const sendDeferredLists = async () => {
let t = Date.now();
// One event with every monitor's recent heartbeats, instead of one query + event per monitor
await sendHeartbeatLists(socket, monitorIDs);
log.debug("perf", `[login] heartbeatLists ${Date.now() - t}ms`);
t = Date.now();
// Serve the dashboard numbers from the cache. On a cold cache join the in-flight
// aggregate scan instead of showing empty numbers; this runs after first paint.
let stats = MonitorStats.getCached(monitorIDs);
if (Object.keys(stats).length < monitorIDs.length) {
stats = await MonitorStats.refresh(monitorIDs);
}
io.to(socket.userID).emit("stats", stats);
MonitorStats.maybeRefresh(io, socket.userID, monitorIDs);
log.debug("perf", `[login] stats ${Date.now() - t}ms`);
};
const deferredLists = new DeferredListsSender({
send: sendDeferredLists,
onError: (e) => log.error("monitor", "Failed to send dashboard lists: " + e.message),
});
socket.once("dashboardRendered", deferredLists.onRendered);
socket.once("disconnect", () => deferredLists.dispose());
// Set server timezone from client browser if not set
// It should be run once only
@@ -1544,14 +1601,18 @@ async function startMonitors() {
server.monitorList[monitor.id] = monitor;
}
for (let monitor of list) {
try {
await monitor.start(io);
} catch (e) {
log.error("monitor", e);
}
// Give some delays, so all monitors won't make request at the same moment when just start the server.
await sleep(getRandomInt(300, 1000));
// Start every monitor in small concurrent batches, but spread their first check
// over the interval, so 5000 monitors do not all check at the same second
for (const batch of chunk(list, 50)) {
await Promise.allSettled(
batch.map(async (monitor) => {
try {
await monitor.start(io, firstBeatDelayMs(monitor.interval));
} catch (e) {
log.error("monitor", e);
}
})
);
}
}
@@ -14,6 +14,7 @@ module.exports.chartSocketHandler = (socket) => {
}
let uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitorID);
await uptimeCalculator.ensureHistoryLoaded();
let data;
if (period <= 24) {
+26
View File
@@ -0,0 +1,26 @@
/**
* Split a list into batches of at most `size` items
* @param items Items to split
* @param size Maximum batch size
* @returns Batches, in order
*/
export function chunk<T>(items: T[], size: number): T[][] {
const batches: T[][] = [];
for (let i = 0; i < items.length; i += size) {
batches.push(items.slice(i, i + size));
}
return batches;
}
/**
* Delay before the first check of a monitor, spread over its interval so a
* server start does not check all monitors in the same second.
* @param intervalSeconds Monitor interval in seconds
* @param random Random source (tests)
* @returns Delay in milliseconds, between 0 and the interval
*/
export function firstBeatDelayMs(intervalSeconds: number, random: () => number = Math.random): number {
const max = Math.max(1, intervalSeconds) * 1000;
// Same bounds as getRandomInt(0, max), which is inclusive; clamped for random() === 1
return Math.min(max, Math.floor(random() * (max + 1)));
}
+35
View File
@@ -0,0 +1,35 @@
export interface StatsRefreshOptions {
monitorIDs: Array<number | string>;
refresh: (monitorIDs: Array<number | string>) => Promise<unknown>;
onError: (error: Error) => void;
initialDelayMs?: number;
intervalMs?: number;
setTimer?: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
setInterval?: (fn: () => void, ms: number) => ReturnType<typeof setInterval>;
}
/**
* Warm the stats cache once after startup, then refresh it periodically.
* Without it, every beat would load years of stat rows per monitor.
* @param options Timers and the refresh implementation
* @returns Handle to stop the timers
*/
export function startStatsRefresh(options: StatsRefreshOptions): { stop: () => void } {
const setTimer = options.setTimer ?? setTimeout;
const setIntervalFn = options.setInterval ?? setInterval;
const run = () => {
options.refresh(options.monitorIDs).catch((error) => options.onError(error));
};
const initial = setTimer(run, options.initialDelayMs ?? 5000);
const interval = setIntervalFn(run, options.intervalMs ?? 15 * 60 * 1000);
initial.unref?.();
interval.unref?.();
return {
stop() {
clearTimeout(initial);
clearInterval(interval);
},
};
}
+181 -173
View File
@@ -50,9 +50,41 @@ class UptimeCalculator {
lastHourlyUptimeData = null;
lastDailyUptimeData = null;
lastDailyStatBean = null;
lastHourlyStatBean = null;
lastMinutelyStatBean = null;
/**
* Whether the current minute/hour/day buckets were seeded from the database
* @type {boolean}
*/
currentBucketsLoaded = false;
/**
* Last hourly bucket in which old stat rows were pruned
* @type {number|null}
*/
lastPruneHour = null;
/**
* Last minute in which the hourly stat row was written
* @type {number|null}
*/
lastHourlyStore = null;
/**
* Last minute in which the daily stat row was written
* @type {number|null}
*/
lastDailyStore = null;
/**
* Whether the DB history has been loaded
* @type {boolean}
*/
historyLoaded = false;
/**
* In-flight history load
* @type {Promise<void>|null}
*/
historyLoadingPromise = null;
/**
* For migration purposes.
@@ -116,90 +148,67 @@ class UptimeCalculator {
}
/**
* Initialize the uptime calculator for a monitor
* Initialize the uptime calculator for a monitor.
* History is loaded on demand (see ensureHistoryLoaded), so a beat never
* pays for 1000+ stat rows.
* @param {number} monitorID the id of the monitor
* @returns {Promise<void>}
*/
async init(monitorID) {
this.monitorID = monitorID;
}
/**
* Load the recent stat history if it has not been loaded yet
* @returns {Promise<void>}
*/
async ensureHistoryLoaded() {
if (this.historyLoaded) {
return;
}
if (!this.historyLoadingPromise) {
this.historyLoadingPromise = this.loadHistory();
}
await this.historyLoadingPromise;
}
/**
* Load 24h minutely, 30d hourly and 365d daily stat rows from the database.
* Uses plain rows instead of redbean beans: one query per table, no per-row
* bean construction, which dominated chart latency for a single monitor.
* @returns {Promise<void>}
*/
async loadHistory() {
let monitorID = this.monitorID;
let now = this.getCurrentDate();
// Load minutely data from database (recent 24 hours only)
let minutelyStatBeans = await R.find("stat_minutely", " monitor_id = ? AND timestamp > ? ORDER BY timestamp", [
monitorID,
this.getMinutelyKey(now.subtract(24, "hour")),
]);
for (let bean of minutelyStatBeans) {
let data = {
up: bean.up,
down: bean.down,
avgPing: bean.ping,
minPing: bean.pingMin,
maxPing: bean.pingMax,
};
if (bean.extras != null) {
data = {
...data,
...JSON.parse(bean.extras),
const load = async (table, since, list) => {
const rows = await R.getAll(
`SELECT timestamp, up, down, ping, ping_min, ping_max, extras
FROM ${table} WHERE monitor_id = ? AND timestamp > ? ORDER BY timestamp`,
[monitorID, since]
);
for (const row of rows) {
let data = {
up: row.up,
down: row.down,
avgPing: row.ping,
minPing: row.ping_min,
maxPing: row.ping_max,
};
if (row.extras != null) {
data = { ...data, ...JSON.parse(row.extras) };
}
this.addHistoryEntry(list, row.timestamp, data);
}
};
let key = bean.timestamp;
this.minutelyUptimeDataList.push(key, data);
}
await load("stat_minutely", this.getMinutelyKey(now.subtract(24, "hour")), this.minutelyUptimeDataList);
await load("stat_hourly", this.getHourlyKey(now.subtract(30, "day")), this.hourlyUptimeDataList);
await load("stat_daily", this.getDailyKey(now.subtract(365, "day")), this.dailyUptimeDataList);
// Load hourly data from database (recent 30 days only)
let hourlyStatBeans = await R.find("stat_hourly", " monitor_id = ? AND timestamp > ? ORDER BY timestamp", [
monitorID,
this.getHourlyKey(now.subtract(30, "day")),
]);
for (let bean of hourlyStatBeans) {
let data = {
up: bean.up,
down: bean.down,
avgPing: bean.ping,
minPing: bean.pingMin,
maxPing: bean.pingMax,
};
if (bean.extras != null) {
data = {
...data,
...JSON.parse(bean.extras),
};
}
this.hourlyUptimeDataList.push(bean.timestamp, data);
}
// Load daily data from database (recent 365 days only)
let dailyStatBeans = await R.find("stat_daily", " monitor_id = ? AND timestamp > ? ORDER BY timestamp", [
monitorID,
this.getDailyKey(now.subtract(365, "day")),
]);
for (let bean of dailyStatBeans) {
let data = {
up: bean.up,
down: bean.down,
avgPing: bean.ping,
minPing: bean.pingMin,
maxPing: bean.pingMax,
};
if (bean.extras != null) {
data = {
...data,
...JSON.parse(bean.extras),
};
}
this.dailyUptimeDataList.push(bean.timestamp, data);
}
this.historyLoaded = true;
}
/**
@@ -210,6 +219,10 @@ class UptimeCalculator {
* @throws {Error} Invalid status
*/
async update(status, ping = 0, date) {
// A beat without an explicit date happens now, so its current buckets must be
// continued from the database. Check this before defaulting `date`.
const isLiveBeat = !date;
if (!date) {
date = this.getCurrentDate();
}
@@ -224,6 +237,12 @@ class UptimeCalculator {
let hourlyKey = this.getHourlyKey(date);
let dailyKey = this.getDailyKey(date);
// Continue the current buckets from their stored values instead of starting at zero,
// otherwise a restart would overwrite the minute/hour/day counts with only new beats
if (!this.migrationMode && isLiveBeat && !this.currentBucketsLoaded && !this.historyLoaded) {
await this.ensureCurrentBuckets(divisionKey, hourlyKey, dailyKey);
}
let minutelyData = this.minutelyUptimeDataList[divisionKey];
let hourlyData = this.hourlyUptimeDataList[hourlyKey];
let dailyData = this.dailyUptimeDataList[dailyKey];
@@ -299,65 +318,32 @@ class UptimeCalculator {
return date;
}
let dailyStatBean = await this.getDailyStatBean(dailyKey);
dailyStatBean.up = dailyData.up;
dailyStatBean.down = dailyData.down;
dailyStatBean.ping = dailyData.avgPing;
dailyStatBean.pingMin = dailyData.minPing;
dailyStatBean.pingMax = dailyData.maxPing;
{
// eslint-disable-next-line no-unused-vars
const { up, down, avgPing, minPing, maxPing, timestamp, ...extras } = dailyData;
if (Object.keys(extras).length > 0) {
dailyStatBean.extras = JSON.stringify(extras);
}
}
await R.store(dailyStatBean);
// Store the aggregates. Minutely is written every beat, hourly/daily at most
// once per minute: their values only need to be durable, not per-check.
let currentDate = this.getCurrentDate();
const currentMinute = this.getMinutelyKey(currentDate, false);
// For migration mode, we don't need to store old hourly and minutely data, but we need 30-day's hourly data
// Run anyway for non-migration mode
if (!this.migrationMode || date.isAfter(currentDate.subtract(this.statHourlyKeepDay, "day"))) {
let hourlyStatBean = await this.getHourlyStatBean(hourlyKey);
hourlyStatBean.up = hourlyData.up;
hourlyStatBean.down = hourlyData.down;
hourlyStatBean.ping = hourlyData.avgPing;
hourlyStatBean.pingMin = hourlyData.minPing;
hourlyStatBean.pingMax = hourlyData.maxPing;
{
// eslint-disable-next-line no-unused-vars
const { up, down, avgPing, minPing, maxPing, timestamp, ...extras } = hourlyData;
if (Object.keys(extras).length > 0) {
hourlyStatBean.extras = JSON.stringify(extras);
}
}
await R.store(hourlyStatBean);
if (this.lastDailyStore !== currentMinute) {
this.lastDailyStore = currentMinute;
await this.upsertStat("stat_daily", dailyKey, dailyData);
}
// For migration mode, we don't need to store old hourly and minutely data, but we need 24-hour's minutely data
// Run anyway for non-migration mode
if (!this.migrationMode || date.isAfter(currentDate.subtract(this.statMinutelyKeepHour, "hour"))) {
let minutelyStatBean = await this.getMinutelyStatBean(divisionKey);
minutelyStatBean.up = minutelyData.up;
minutelyStatBean.down = minutelyData.down;
minutelyStatBean.ping = minutelyData.avgPing;
minutelyStatBean.pingMin = minutelyData.minPing;
minutelyStatBean.pingMax = minutelyData.maxPing;
{
// eslint-disable-next-line no-unused-vars
const { up, down, avgPing, minPing, maxPing, timestamp, ...extras } = minutelyData;
if (Object.keys(extras).length > 0) {
minutelyStatBean.extras = JSON.stringify(extras);
}
if (this.lastHourlyStore !== currentMinute) {
this.lastHourlyStore = currentMinute;
if (!this.migrationMode || date.isAfter(currentDate.subtract(this.statHourlyKeepDay, "day"))) {
await this.upsertStat("stat_hourly", hourlyKey, hourlyData);
}
await R.store(minutelyStatBean);
}
if (!this.migrationMode || date.isAfter(currentDate.subtract(this.statMinutelyKeepHour, "hour"))) {
await this.upsertStat("stat_minutely", divisionKey, minutelyData);
}
// No need to remove old data in migration mode
if (!this.migrationMode) {
// Remove the old data
// TODO: Improvement: Convert it to a job?
// Prune at most once per hour per monitor; retention is measured in hours/days,
// so doing it on every beat only adds write transactions at scale
if (!this.migrationMode && this.lastPruneHour !== hourlyKey) {
this.lastPruneHour = hourlyKey;
log.debug("uptime_calc", "Remove old data");
await R.exec("DELETE FROM stat_minutely WHERE monitor_id = ? AND timestamp < ?", [
this.monitorID,
@@ -374,69 +360,91 @@ class UptimeCalculator {
}
/**
* Get the daily stat bean
* @param {number} timestamp milliseconds
* @returns {Promise<import("redbean-node").Bean>} stat_daily bean
* Add a history entry without duplicating a bucket that already exists
* @param {object} list Bucket list
* @param {number} key Bucket timestamp
* @param {object} data Bucket data
* @returns {void}
*/
async getDailyStatBean(timestamp) {
if (this.lastDailyStatBean && this.lastDailyStatBean.timestamp === timestamp) {
return this.lastDailyStatBean;
addHistoryEntry(list, key, data) {
if (key in list) {
Object.assign(list[key], data);
} else {
list.push(key, data);
}
let bean = await R.findOne("stat_daily", " monitor_id = ? AND timestamp = ?", [this.monitorID, timestamp]);
if (!bean) {
bean = R.dispense("stat_daily");
bean.monitor_id = this.monitorID;
bean.timestamp = timestamp;
}
this.lastDailyStatBean = bean;
return this.lastDailyStatBean;
}
/**
* Get the hourly stat bean
* @param {number} timestamp milliseconds
* @returns {Promise<import("redbean-node").Bean>} stat_hourly bean
* Load only the three buckets that are currently being written, so a restart
* continues their counts. Full history is loaded on demand for charts only.
* @param {number} minutelyKey Current minute bucket
* @param {number} hourlyKey Current hour bucket
* @param {number} dailyKey Current day bucket
* @returns {Promise<void>}
*/
async getHourlyStatBean(timestamp) {
if (this.lastHourlyStatBean && this.lastHourlyStatBean.timestamp === timestamp) {
return this.lastHourlyStatBean;
async ensureCurrentBuckets(minutelyKey, hourlyKey, dailyKey) {
if (this.currentBucketsLoaded || this.historyLoaded) {
return;
}
this.currentBucketsLoaded = true;
let bean = await R.findOne("stat_hourly", " monitor_id = ? AND timestamp = ?", [this.monitorID, timestamp]);
const [minutely, hourly, daily] = await Promise.all([
R.findOne("stat_minutely", " monitor_id = ? AND timestamp = ? ", [this.monitorID, minutelyKey]),
R.findOne("stat_hourly", " monitor_id = ? AND timestamp = ? ", [this.monitorID, hourlyKey]),
R.findOne("stat_daily", " monitor_id = ? AND timestamp = ? ", [this.monitorID, dailyKey]),
]);
if (!bean) {
bean = R.dispense("stat_hourly");
bean.monitor_id = this.monitorID;
bean.timestamp = timestamp;
}
this.lastHourlyStatBean = bean;
return this.lastHourlyStatBean;
this.applyStatBean(this.minutelyUptimeDataList, minutelyKey, minutely);
this.applyStatBean(this.hourlyUptimeDataList, hourlyKey, hourly);
this.applyStatBean(this.dailyUptimeDataList, dailyKey, daily);
}
/**
* Get the minutely stat bean
* @param {number} timestamp milliseconds
* @returns {Promise<import("redbean-node").Bean>} stat_minutely bean
* Copy a stored stat row into an in-memory bucket
* @param {object} list Bucket list
* @param {number} key Bucket timestamp
* @param {import("redbean-node").Bean | null} bean Stored stat row
* @returns {void}
*/
async getMinutelyStatBean(timestamp) {
if (this.lastMinutelyStatBean && this.lastMinutelyStatBean.timestamp === timestamp) {
return this.lastMinutelyStatBean;
applyStatBean(list, key, bean) {
const data = list[key];
if (!bean || !data) {
return;
}
Object.assign(data, {
up: bean.up,
down: bean.down,
avgPing: bean.ping,
minPing: bean.pingMin,
maxPing: bean.pingMax,
...(bean.extras != null ? JSON.parse(bean.extras) : {}),
});
}
let bean = await R.findOne("stat_minutely", " monitor_id = ? AND timestamp = ?", [this.monitorID, timestamp]);
if (!bean) {
bean = R.dispense("stat_minutely");
bean.monitor_id = this.monitorID;
bean.timestamp = timestamp;
/**
* Insert or update one stat row without going through redbean beans
* @param {string} table stat table name
* @param {number} timestamp Bucket timestamp
* @param {object} data Aggregated uptime data
* @returns {Promise<void>}
*/
async upsertStat(table, timestamp, data) {
const { up, down, avgPing, minPing, maxPing, ...extras } = data;
const row = {
monitor_id: this.monitorID,
timestamp,
up,
down,
ping: avgPing,
ping_min: minPing,
ping_max: maxPing,
};
const mergeColumns = ["up", "down", "ping", "ping_min", "ping_max"];
if (Object.keys(extras).length > 0) {
row.extras = JSON.stringify(extras);
mergeColumns.push("extras");
}
this.lastMinutelyStatBean = bean;
return this.lastMinutelyStatBean;
await R.knex(table).insert(row).onConflict(["monitor_id", "timestamp"]).merge(mergeColumns);
}
/**
+15 -3
View File
@@ -14,6 +14,7 @@ const path = require("path");
const axios = require("axios");
const { isSSL, sslKey, sslCert, sslKeyPassphrase } = require("./config");
const { getSession, getDisableAuthSession } = require("./better-auth");
const { toCompactMonitor } = require("./compact-monitor");
// DO NOT IMPORT HERE IF THE MODULES USED `UptimeKumaServer.getInstance()`, put at the bottom of this file instead.
/**
@@ -145,6 +146,14 @@ class UptimeKumaServer {
this.io = new Server(this.httpServer, {
cors,
cookie: true,
perMessageDeflate: {
threshold: 1024,
},
// Compress polling responses too. The login burst is several MB of JSON;
// without this it is sent uncompressed while the client is still on polling.
httpCompression: {
threshold: 1024,
},
allowRequest: async (req, callback) => {
let transport;
// It should be always true, but just in case, because this property is not documented
@@ -235,7 +244,7 @@ class UptimeKumaServer {
* @returns {Promise<object>} List of monitors
*/
async sendMonitorList(socket) {
let list = await this.getMonitorJSONList();
let list = await this.getMonitorJSONList(null, true);
this.io.to(socket.userID).emit("monitorList", list);
return list;
}
@@ -266,11 +275,12 @@ class UptimeKumaServer {
/**
* Get a list of monitors, optionally filtered to a single monitor.
* @param {number} monitorID - The ID of monitor for.
* @param {boolean} compact - Send only the fields the dashboard needs
* @returns {Promise<object>} A promise that resolves to an object with monitor IDs as keys and monitor objects as values.
*
* Generated by Trelent
*/
async getMonitorJSONList(monitorID = null) {
async getMonitorJSONList(monitorID = null, compact = false) {
let queryKeys = [];
let queryParams = [];
@@ -298,7 +308,9 @@ class UptimeKumaServer {
const preloadData = await Monitor.preparePreloadData(monitorData);
const result = {};
monitorList.forEach((monitor) => (result[monitor.id] = monitor.toJSON(preloadData)));
monitorList.forEach((monitor) => {
result[monitor.id] = compact ? toCompactMonitor(monitor, preloadData) : monitor.toJSON(preloadData);
});
return result;
}
+101
View File
@@ -1,14 +1,115 @@
<template>
<transition name="fade">
<div v-if="showLoading" class="app-loading" aria-hidden="true">
<div class="app-loading-track">
<div class="app-loading-fill" :style="{ width: shownProgress + '%' }"></div>
</div>
</div>
</transition>
<router-view />
</template>
<script>
import { markRaw } from "vue";
import { setPageLocale } from "./util-frontend";
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
import { DashboardProgress } from "./dashboard-progress";
export default {
data() {
return {
showLoading: false,
shownProgress: 0,
progressController: markRaw(
new DashboardProgress(() => {
this.showLoading = this.progressController.visible;
this.shownProgress = this.progressController.value;
})
),
};
},
computed: {
/**
* Dashboard data loading progress (0-100)
* @returns {number} Progress
*/
progress() {
return this.$root.dashboardLoadProgress || 0;
},
},
watch: {
progress(value) {
this.progressController.update(value);
},
"$route.path"() {
this.markDashboardLoading();
},
},
created() {
setPageLocale();
},
mounted() {
this.markDashboardLoading();
if (this.progress > 0) {
this.progressController.update(this.progress);
}
},
beforeUnmount() {
this.progressController.clearTimers();
},
methods: {
/**
* Show a small progress step as soon as the dashboard route is reached
* @returns {void}
*/
markDashboardLoading() {
const current = this.$root.dashboardLoadProgress || 0;
const next = this.progressController.routeProgress(this.$route.path, current);
if (next !== this.$root.dashboardLoadProgress) {
this.$root.dashboardLoadProgress = next;
}
},
},
};
polyfillCountryFlagEmojis();
</script>
<style lang="scss" scoped>
@import "./assets/vars.scss";
.app-loading {
position: fixed;
inset: 0;
z-index: 1500;
display: flex;
align-items: center;
justify-content: center;
padding-top: 56px;
pointer-events: none;
}
.app-loading-track {
width: 240px;
max-width: 60vw;
height: 6px;
border-radius: 3px;
background-color: rgba(128, 128, 128, 0.25);
overflow: hidden;
}
.app-loading-fill {
height: 100%;
background-color: $primary;
border-radius: 3px;
transition: width 0.2s linear;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+55
View File
@@ -0,0 +1,55 @@
import { DOWN, MAINTENANCE, PENDING, UP } from "./util";
export interface BeatStatusEntry {
text: string;
color: string;
}
/** Translator signature of the app ($t) */
export type Translate = (key: string) => string;
/**
* Display text and color for a heartbeat status
* @param translate Translation function
* @param beat Latest heartbeat, if any
* @returns Status entry for the dashboard
*/
export function beatStatusEntry(translate: Translate, beat: { status?: number } | undefined): BeatStatusEntry {
if (!beat) {
return { text: translate("Unknown"), color: "secondary" };
}
switch (beat.status) {
case UP:
return { text: translate("Up"), color: "primary" };
case DOWN:
return { text: translate("Down"), color: "danger" };
case PENDING:
return { text: translate("Pending"), color: "warning" };
case MAINTENANCE:
return { text: translate("statusMaintenance"), color: "maintenance" };
default:
return { text: translate("Unknown"), color: "secondary" };
}
}
/** Reactive containers the socket mixin updates per beat */
export interface BeatState {
lastHeartbeatList: Record<number, unknown>;
statusList: Record<number, BeatStatusEntry>;
lastBeatRaw: Record<number, unknown>;
}
/**
* Store the newest beat of a monitor, in place, so a beat stays O(1)
* @param state Mixin state
* @param monitorID Monitor id
* @param beat Latest heartbeat
* @param entry Display status for the heartbeat
* @returns void
*/
export function applyBeat(state: BeatState, monitorID: number, beat: unknown, entry: BeatStatusEntry): void {
state.lastHeartbeatList[monitorID] = beat;
state.statusList[monitorID] = entry;
state.lastBeatRaw[monitorID] = beat;
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Coalesce heartbeat version bumps: thousands of beats arrive every minute and
* each one may want to refresh the counters, but once per interval is enough.
*/
export class BeatVersionThrottle {
private timer: ReturnType<typeof setTimeout> | null = null;
/**
* @param bump Called at most once per interval
* @param delayMs Minimum time between bumps
*/
constructor(
private readonly bump: () => void,
private readonly delayMs = 1000
) {}
/**
* Request a bump, ignored while one is already pending
* @returns void
*/
schedule(): void {
if (this.timer) {
return;
}
this.timer = setTimeout(() => {
this.timer = null;
this.bump();
}, this.delayMs);
}
/**
* Drop a pending bump
* @returns void
*/
cancel(): void {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
}
+40
View File
@@ -39,6 +39,7 @@
</template>
<script>
import { claimHeartbeatHistory, releaseHeartbeatHistory } from "../heartbeat-history";
import dayjs from "dayjs";
import { DOWN, UP, PENDING, MAINTENANCE } from "../util.ts";
import Tooltip from "./Tooltip.vue";
@@ -366,8 +367,47 @@ export default {
this.$nextTick(() => {
this.drawCanvas();
});
this.requestHistory();
},
methods: {
/**
* Fetch the 100-beat history for this row on demand.
* The login only sends the latest beat, so off-screen monitors cost nothing.
* @returns {void}
*/
requestHistory() {
if (this.heartbeatList !== null) {
return;
}
const beats = this.$root.heartbeatList[this.monitorId];
const requested = this.$root.heartbeatHistoryRequested;
if (beats && beats.length > 1) {
return;
}
if (!claimHeartbeatHistory(requested, this.monitorId)) {
return;
}
const socket = this.$root.getSocket();
if (!socket) {
// The socket is not initialized yet when a deep link mounts before the root
releaseHeartbeatHistory(requested, this.monitorId);
setTimeout(() => this.requestHistory(), 250);
return;
}
socket.emit("getHeartbeatHistory", this.monitorId, (res) => {
if (res?.ok) {
const current = this.$root.heartbeatList[this.monitorId] ?? [];
if (current.length <= 1) {
this.$root.heartbeatList[this.monitorId] = res.data;
}
} else {
releaseHeartbeatHistory(requested, this.monitorId);
}
});
},
/**
* Resize the heartbeat bar
* @returns {void}
+161 -18
View File
@@ -11,6 +11,7 @@
<input
v-model="searchText"
class="form-control search-input"
data-testid="monitor-search"
:placeholder="$t('Search...')"
:aria-label="$t('Search monitored sites')"
autocomplete="off"
@@ -23,6 +24,7 @@
v-if="!selectMode"
v-model="selectMode"
class="form-check-input"
data-testid="select-mode-toggle"
type="checkbox"
:aria-label="$t('selectAllMonitorsAria')"
@change="selectAll = selectMode"
@@ -31,6 +33,7 @@
v-else
v-model="selectAll"
class="form-check-input"
data-testid="select-all-toggle"
type="checkbox"
:aria-label="selectAll ? $t('deselectAllMonitorsAria') : $t('selectAllMonitorsAria')"
/>
@@ -55,6 +58,7 @@
<button
class="btn btn-outline-normal dropdown-toggle"
type="button"
data-testid="bulk-actions"
data-bs-toggle="dropdown"
:aria-label="$t('Actions')"
:disabled="bulkActionInProgress"
@@ -64,13 +68,18 @@
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="#" @click.prevent="pauseDialog">
<a class="dropdown-item" href="#" data-testid="bulk-pause" @click.prevent="pauseDialog">
<font-awesome-icon icon="pause" class="me-2" />
{{ $t("Pause") }}
</a>
</li>
<li>
<a class="dropdown-item" href="#" @click.prevent="resumeSelected">
<a
class="dropdown-item"
href="#"
data-testid="bulk-resume"
@click.prevent="resumeSelected"
>
<font-awesome-icon icon="play" class="me-2" />
{{ $t("Resume") }}
</a>
@@ -78,6 +87,7 @@
<li>
<a
class="dropdown-item text-danger"
data-testid="bulk-delete"
href="#"
@click.prevent="$refs.confirmDelete.show()"
>
@@ -99,16 +109,23 @@
:class="{ scrollbar: scrollbar }"
:style="monitorListStyle"
data-testid="monitor-list"
@scroll.passive="onListScroll"
>
<div v-if="Object.keys($root.monitorList).length === 0" class="text-center mt-3">
<div v-if="!$root.monitorListLoaded" class="list-loading" aria-busy="true">
<div v-for="i in 8" :key="i" class="skeleton-row"></div>
</div>
<div v-else-if="Object.keys($root.monitorList).length === 0" class="text-center mt-3">
{{ $t("No Monitors, please") }}
<router-link to="/add">{{ $t("add one") }}</router-link>
</div>
<div :style="{ height: renderedRows.top + 'px' }"></div>
<MonitorListItem
v-for="item in sortedMonitorList"
v-for="item in renderedRows.items"
:key="`${item.id}-${collapseKey}`"
:ref="(el) => setRowRef(el, item.id)"
:monitor="item"
:children-by-parent="childrenByParent"
:isSelectMode="selectMode"
:isSelected="isSelected"
:select="select"
@@ -116,6 +133,7 @@
:filter-func="filterFunc"
:sort-func="sortFunc"
/>
<div :style="{ height: renderedRows.bottom + 'px' }"></div>
</div>
</div>
@@ -133,6 +151,7 @@ import Confirm from "../components/Confirm.vue";
import MonitorListItem from "../components/MonitorListItem.vue";
import MonitorListFilter from "./MonitorListFilter.vue";
import { getMonitorRelativeURL } from "../util.ts";
import { computeListWindow, computeRowMetrics } from "../monitor-list-window";
export default {
components: {
@@ -161,9 +180,56 @@ export default {
tags: null,
},
collapseKey: 0,
scrollTop: 0,
viewportHeight: 0,
rowHeights: {},
};
},
computed: {
/**
* Groups all monitors by parent id in a single pass so rows never scan the full list.
* @returns {Map<number|null, Array>} Parent id -> direct children
*/
childrenByParent() {
const map = new Map();
for (const monitor of Object.values(this.$root.monitorList)) {
const key = monitor.parent ?? null;
let children = map.get(key);
if (!children) {
children = [];
map.set(key, children);
}
children.push(monitor);
}
return map;
},
/**
* Row offsets and heights for the windowed list.
* @returns {{heights: number[], offsets: number[], total: number}} Layout data
*/
rowMetrics() {
const ids = this.sortedMonitorList.map((monitor) => monitor.id);
return computeRowMetrics(ids, this.rowHeights);
},
/**
* Only the rows inside (and slightly around) the viewport, plus spacer sizes.
* This keeps the DOM at ~50 rows no matter how many monitors exist.
* @returns {{items: Array, top: number, bottom: number}} Visible slice and spacers
*/
renderedRows() {
const list = this.sortedMonitorList;
const { heights, offsets, total } = this.rowMetrics;
const window = computeListWindow(offsets, heights, total, this.scrollTop, this.viewportHeight);
return {
items: list.slice(window.start, window.end),
top: window.top,
bottom: window.bottom,
};
},
/**
* Improve the sticky appearance of the list by increasing its
* height as user scrolls down.
@@ -187,15 +253,7 @@ export default {
* @returns {Array} The sorted list of monitors.
*/
sortedMonitorList() {
let result = Object.values(this.$root.monitorList);
result = result.filter((monitor) => {
// The root list does not show children
if (monitor.parent !== null) {
return false;
}
return true;
});
let result = this.childrenByParent.get(null) ?? [];
result = result.filter(this.filterFunc);
@@ -245,8 +303,9 @@ export default {
* @returns {Array} Array of group monitors with children
*/
groupMonitors() {
const monitors = Object.values(this.$root.monitorList);
return monitors.filter((m) => m.type === "group" && monitors.some((child) => child.parent === m.id));
return (this.childrenByParent.get(null) ?? []).filter(
(m) => m.type === "group" && (this.childrenByParent.get(m.id)?.length ?? 0) > 0
);
},
/**
@@ -305,13 +364,68 @@ export default {
}
},
},
created() {
this.rowRefs = new Map();
},
mounted() {
window.addEventListener("scroll", this.onScroll);
window.addEventListener("resize", this.onListScroll);
this.onListScroll();
},
updated() {
this.measureRows();
},
beforeUnmount() {
window.removeEventListener("scroll", this.onScroll);
window.removeEventListener("resize", this.onListScroll);
},
methods: {
/**
* Keep the reference to each mounted row so its real height can be measured
* @param {object} el Row component instance
* @param {number} id Monitor id
* @returns {void}
*/
setRowRef(el, id) {
if (el) {
this.rowRefs.set(id, el);
} else {
this.rowRefs.delete(id);
}
},
/**
* Read the real height of mounted rows (tags and the bottom bar change it)
* @returns {void}
*/
measureRows() {
let changed = false;
for (const [id, row] of this.rowRefs) {
const el = row?.$el;
if (!el) {
continue;
}
const height = el.offsetHeight;
if (height > 0 && Math.abs((this.rowHeights[id] ?? 0) - height) > 1) {
this.rowHeights[id] = height;
changed = true;
}
}
if (changed) {
this.$nextTick(this.onListScroll);
}
},
/**
* Track the list container's scroll position for windowing
* @returns {void}
*/
onListScroll() {
const el = this.$refs.monitorList;
if (!el) {
return;
}
this.scrollTop = el.scrollTop;
this.viewportHeight = el.clientHeight;
},
/**
* Handle user scroll
* @returns {void}
@@ -528,8 +642,8 @@ export default {
filterFunc(monitor) {
// Group monitors bypass filter if at least 1 of children matched
if (monitor.type === "group") {
const children = Object.values(this.$root.monitorList).filter((m) => m.parent === monitor.id);
if (children.some((child, index, children) => this.filterFunc(child))) {
const children = this.childrenByParent.get(monitor.id) ?? [];
if (children.some((child) => this.filterFunc(child))) {
return true;
}
}
@@ -581,6 +695,12 @@ export default {
* @returns {number} -1, 0 or 1
*/
sortFunc(m1, m2) {
if (!m1 || !m2) {
return 0;
}
// A monitor may be added before its name arrives (e.g. optimistic update)
const name1 = typeof m1.name === "string" ? m1.name : "";
const name2 = typeof m2.name === "string" ? m2.name : "";
if (m1.active !== m2.active) {
if (m1.active === false) {
return 1;
@@ -601,7 +721,7 @@ export default {
}
}
return m1.name.localeCompare(m2.name);
return name1.localeCompare(name2);
},
},
};
@@ -817,6 +937,29 @@ export default {
transition: none !important;
}
.list-loading {
padding: 10px 4px;
}
.skeleton-row {
height: 48px;
margin-bottom: 8px;
border-radius: 8px;
background: linear-gradient(90deg, rgba(128, 128, 128, 0.08), rgba(128, 128, 128, 0.18), rgba(128, 128, 128, 0.08));
background-size: 200% 100%;
animation: skeleton-shimmer 1.2s ease-in-out infinite;
}
@keyframes skeleton-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.tags {
margin-top: 4px;
padding-left: 67px;
+52 -9
View File
@@ -1,5 +1,8 @@
<template>
<MonitorListFilterDropdown :filterActive="filterState.status?.length > 0 || filterState.active?.length > 0">
<MonitorListFilterDropdown
test-id="filter-status"
:filterActive="filterState.status?.length > 0 || filterState.active?.length > 0"
>
<template #status>
<Status
v-if="filterState.status?.length === 1 && !filterState.active?.length"
@@ -19,7 +22,12 @@
</template>
<template #dropdown>
<li>
<div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(1)">
<div
class="dropdown-item"
tabindex="0"
data-testid="filter-status-up"
@click.stop="toggleStatusFilter(1)"
>
<div class="d-flex align-items-center justify-content-between">
<Status :status="1" />
<span class="ps-3">
@@ -32,7 +40,12 @@
</div>
</li>
<li>
<div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(0)">
<div
class="dropdown-item"
tabindex="0"
data-testid="filter-status-down"
@click.stop="toggleStatusFilter(0)"
>
<div class="d-flex align-items-center justify-content-between">
<Status :status="0" />
<span class="ps-3">
@@ -45,7 +58,12 @@
</div>
</li>
<li>
<div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(2)">
<div
class="dropdown-item"
tabindex="0"
data-testid="filter-status-pending"
@click.stop="toggleStatusFilter(2)"
>
<div class="d-flex align-items-center justify-content-between">
<Status :status="2" />
<span class="ps-3">
@@ -58,7 +76,12 @@
</div>
</li>
<li>
<div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(3)">
<div
class="dropdown-item"
tabindex="0"
data-testid="filter-status-maintenance"
@click.stop="toggleStatusFilter(3)"
>
<div class="d-flex align-items-center justify-content-between">
<Status :status="3" />
<span class="ps-3">
@@ -74,7 +97,12 @@
<hr class="dropdown-divider" />
</li>
<li>
<div class="dropdown-item" tabindex="0" @click.stop="toggleActiveFilter(true)">
<div
class="dropdown-item"
tabindex="0"
data-testid="filter-active-running"
@click.stop="toggleActiveFilter(true)"
>
<div class="d-flex align-items-center justify-content-between">
<span class="badge status-pill running">
<font-awesome-icon icon="play" class="icon-small" />
@@ -90,7 +118,12 @@
</div>
</li>
<li>
<div class="dropdown-item" tabindex="0" @click.stop="toggleActiveFilter(false)">
<div
class="dropdown-item"
tabindex="0"
data-testid="filter-active-paused"
@click.stop="toggleActiveFilter(false)"
>
<div class="d-flex align-items-center justify-content-between">
<span class="badge status-pill paused">
<font-awesome-icon icon="pause" class="icon-small" />
@@ -107,7 +140,11 @@
</li>
</template>
</MonitorListFilterDropdown>
<MonitorListFilterDropdown :filterActive="filterState.tags?.length > 0" @open-menu="getExistingTags">
<MonitorListFilterDropdown
test-id="filter-tags"
:filterActive="filterState.tags?.length > 0"
@open-menu="getExistingTags"
>
<template #status>
<span
v-if="filterState.tags?.length === 1"
@@ -125,7 +162,12 @@
<div class="tags-dropdown-scroll">
<ul class="list-unstyled m-0 p-0">
<li v-for="tag in tagsList" :key="tag.id">
<div class="dropdown-item" tabindex="0" @click.stop="toggleTagFilter(tag)">
<div
class="dropdown-item"
tabindex="0"
:data-testid="'filter-tag-' + tag.id"
@click.stop="toggleTagFilter(tag)"
>
<div class="d-flex align-items-center justify-content-between">
<span class="tag-name-wrapper" :title="tag.name">
<Tag :item="tag" :size="'sm'" :scrollable="true" :constrained="true" />
@@ -153,6 +195,7 @@
v-if="hasGroups"
type="button"
class="btn btn-outline-normal btn-collapse-all"
data-testid="collapse-all"
:title="allCollapsed ? $t('Expand All Groups') : $t('Collapse All Groups')"
@click="$emit('toggle-collapse-all')"
>
+11 -1
View File
@@ -1,6 +1,12 @@
<template>
<div tabindex="-1" class="dropdown" @focusin="openMenu" @focusout="handleFocusOut">
<button type="button" class="filter-dropdown-status" :class="{ active: filterActive }" tabindex="0">
<button
type="button"
class="filter-dropdown-status"
:class="{ active: filterActive }"
tabindex="0"
:data-testid="testId"
>
<div class="px-1 d-flex align-items-center">
<slot name="status"></slot>
</div>
@@ -22,6 +28,10 @@ export default {
type: Boolean,
required: true,
},
testId: {
type: String,
default: undefined,
},
},
emits: ["openMenu"],
data() {
+62 -36
View File
@@ -15,6 +15,7 @@
<input
class="form-check-input select-input"
type="checkbox"
:data-testid="'monitor-select-' + monitor.id"
:aria-label="$t('Check/Uncheck')"
:checked="isSelected(monitor.id)"
@click.stop="toggleSelection"
@@ -28,7 +29,12 @@
<Uptime :monitor="monitor" type="24" :pill="true" />
</div>
<div class="d-flex align-items-center gap-2 flex-fill" style="min-width: 0">
<span v-if="hasChildren" class="collapse-padding" @click.prevent="changeCollapsed">
<span
v-if="hasChildren"
class="collapse-padding"
data-testid="monitor-collapse"
@click.prevent="changeCollapsed"
>
<font-awesome-icon
icon="chevron-down"
class="animated"
@@ -49,11 +55,7 @@
</div>
</div>
</div>
<div
v-show="$root.userHeartbeatBar == 'normal'"
:key="$root.userHeartbeatBar"
class="col-3 col-xl-6"
>
<div v-if="$root.userHeartbeatBar == 'normal'" :key="$root.userHeartbeatBar" class="col-3 col-xl-6">
<HeartbeatBar ref="heartbeatBar" size="small" :monitor-id="monitor.id" />
</div>
</div>
@@ -72,6 +74,7 @@
v-for="(item, index) in sortedChildMonitorList"
:key="index"
:monitor="item"
:children-by-parent="childrenByParent"
:isSelectMode="isSelectMode"
:isSelected="isSelected"
:select="select"
@@ -99,6 +102,11 @@ export default {
Tag,
},
props: {
/** Parent id -> children index, shared by the whole list */
childrenByParent: {
type: Map,
default: () => new Map(),
},
/** Monitor this represents */
monitor: {
type: Object,
@@ -148,10 +156,7 @@ export default {
},
computed: {
sortedChildMonitorList() {
let result = Object.values(this.$root.monitorList);
// Get children
result = result.filter((childMonitor) => childMonitor.parent === this.monitor.id);
let result = this.childrenByParent.get(this.monitor.id) ?? [];
// Run filter on children
result = result.filter(this.filterFunc);
@@ -276,37 +281,58 @@ export default {
return;
}
const parentId = this.monitor.id;
if (draggedMonitor.parent === parentId) {
return;
}
// Never move a group into itself or one of its descendants
let ancestorId = parentId;
while (ancestorId != null) {
if (ancestorId === draggedMonitorId) {
this.$root.toastError("Cannot move a group into its own subgroup");
return;
}
ancestorId = this.$root.monitorList[ancestorId]?.parent ?? null;
}
// Save original parent so we can revert locally if server returns error
const originalParent = draggedMonitor.parent;
// Prepare a full monitor object (clone) and set new parent
const monitorToSave = JSON.parse(JSON.stringify(draggedMonitor));
monitorToSave.parent = this.monitor.id;
// Optimistically update local state so UI updates immediately
this.$root.monitorList[draggedMonitorId].parent = this.monitor.id;
// Send updated monitor state via socket
try {
this.$root.getSocket().emit("editMonitor", monitorToSave, (res) => {
if (!res || !res.ok) {
// Revert local change on error
if (this.$root.monitorList[draggedMonitorId]) {
this.$root.monitorList[draggedMonitorId].parent = originalParent;
}
if (res && res.msg) {
this.$root.toastError(res.msg);
}
} else {
this.$root.toastRes(res);
}
});
} catch (e) {
// revert on exception
const revert = () => {
if (this.$root.monitorList[draggedMonitorId]) {
this.$root.monitorList[draggedMonitorId].parent = originalParent;
}
}
};
// Optimistically update local state so UI updates immediately
this.$root.monitorList[draggedMonitorId].parent = parentId;
// The dashboard list only holds summary fields, so load the full monitor first:
// saving the summary would clear the monitor's other settings
this.$root.getSocket().emit("getMonitor", draggedMonitorId, (res) => {
if (!res || !res.ok) {
revert();
return;
}
const monitorToSave = res.monitor;
monitorToSave.parent = parentId;
try {
this.$root.getSocket().emit("editMonitor", monitorToSave, (saveRes) => {
if (!saveRes || !saveRes.ok) {
revert();
if (saveRes && saveRes.msg) {
this.$root.toastError(saveRes.msg);
}
} else {
this.$root.toastRes(saveRes);
}
});
} catch (e) {
revert();
}
});
},
/**
* Get URL of monitor
+150
View File
@@ -0,0 +1,150 @@
export interface ProgressClock {
setTimeout(fn: () => void, ms: number): unknown;
clearTimeout(handle: unknown): void;
setInterval(fn: () => void, ms: number): unknown;
clearInterval(handle: unknown): void;
}
/** Progress shown as soon as the dashboard route is reached */
export const ROUTE_PROGRESS = 8;
/** Delay before the overlay appears, to avoid flashing on fast loads */
export const SHOW_DELAY_MS = 150;
/** Never let the overlay outlive a stalled connection */
export const SAFETY_MS = 15000;
export const CREEP_INTERVAL_MS = 100;
/** Delay between filling the bar and hiding it */
export const FINISH_DELAY_MS = 250;
/** The bar crawls up to this value while waiting for the server */
export const MAX_CREEP = 90;
const defaultClock: ProgressClock = {
setTimeout: (fn, ms) => setTimeout(fn, ms),
clearTimeout: (handle) => clearTimeout(handle),
setInterval: (fn, ms) => setInterval(fn, ms),
clearInterval: (handle) => clearInterval(handle),
};
/**
* State machine of the dashboard loading bar.
* Owns the timers so the behaviour can be tested without mounting the app.
*/
export class DashboardProgress {
visible = false;
value = 0;
private showTimer: unknown = null;
private finishTimer: unknown = null;
private safetyTimer: unknown = null;
private creepTimer: unknown = null;
constructor(
private readonly onChange: () => void = () => {},
private readonly clock: ProgressClock = defaultClock
) {}
/**
* Progress to use for a route change
* @param path New route path
* @param current Current global progress
* @returns Progress after the route change
*/
routeProgress(path: string, current: number): number {
if (path.startsWith("/dashboard") && !current) {
return ROUTE_PROGRESS;
}
return current;
}
/**
* Apply a real progress update from the socket events
* @param progress Progress 0-100
* @returns void
*/
update(progress: number): void {
if (progress >= 100) {
this.finish();
return;
}
if (progress <= 0) {
this.reset();
return;
}
this.value = Math.max(this.value, progress);
if (!this.visible) {
this.clock.clearTimeout(this.showTimer);
this.showTimer = this.clock.setTimeout(() => {
this.visible = true;
this.startCreep();
this.clock.clearTimeout(this.safetyTimer);
this.safetyTimer = this.clock.setTimeout(() => {
this.visible = false;
this.emit();
}, SAFETY_MS);
this.emit();
}, SHOW_DELAY_MS);
}
this.emit();
}
/**
* Crawl toward the maximum while waiting for the server
* @returns void
*/
startCreep(): void {
this.clock.clearInterval(this.creepTimer);
this.creepTimer = this.clock.setInterval(() => {
if (this.value < MAX_CREEP) {
const step = Math.max(0.4, (MAX_CREEP - this.value) * 0.03);
this.value = Math.min(MAX_CREEP, this.value + step);
this.emit();
}
}, CREEP_INTERVAL_MS);
}
/**
* Fill the bar, then hide it shortly after
* @returns void
*/
finish(): void {
this.clearTimers();
this.value = 100;
this.emit();
this.finishTimer = this.clock.setTimeout(() => {
this.visible = false;
this.emit();
}, FINISH_DELAY_MS);
}
/**
* Hide the overlay and reset the bar
* @returns void
*/
reset(): void {
this.clearTimers();
this.visible = false;
this.value = 0;
this.emit();
}
/**
* Clear all pending timers
* @returns void
*/
clearTimers(): void {
this.clock.clearTimeout(this.showTimer);
this.clock.clearTimeout(this.finishTimer);
this.clock.clearTimeout(this.safetyTimer);
this.clock.clearInterval(this.creepTimer);
this.showTimer = null;
this.finishTimer = null;
this.safetyTimer = null;
this.creepTimer = null;
}
private emit(): void {
this.onChange();
}
}
+26
View File
@@ -0,0 +1,26 @@
/** Monitors whose 100-beat history was already requested */
export type RequestedHistory = Set<number>;
/**
* Claim the history request of a monitor, so concurrent rows emit it only once.
* @param requested Monitors whose history was already requested
* @param monitorId Monitor id
* @returns True when the caller should emit the request
*/
export function claimHeartbeatHistory(requested: RequestedHistory, monitorId: number): boolean {
if (requested.has(monitorId)) {
return false;
}
requested.add(monitorId);
return true;
}
/**
* Release the claim, so a later render can retry (socket not ready, request failed).
* @param requested Monitors whose history was already requested
* @param monitorId Monitor id
* @returns void
*/
export function releaseHeartbeatHistory(requested: RequestedHistory, monitorId: number): void {
requested.delete(monitorId);
}
+104 -56
View File
@@ -1,3 +1,4 @@
import { markRaw } from "vue";
import { io } from "socket.io-client";
import { useToast } from "vue-toastification";
import Favico from "favico.js";
@@ -6,6 +7,8 @@ import mitt from "mitt";
import { DOWN, MAINTENANCE, PENDING, UP } from "../util.ts";
import { getToastSuccessTimeout, getToastErrorTimeout } from "../util-frontend.js";
import { BeatVersionThrottle } from "../beat-version-throttle";
import { applyBeat, beatStatusEntry } from "../beat-state";
import { logout as betterAuthLogout } from "../auth-client";
const toast = useToast();
@@ -40,6 +43,22 @@ export default {
monitorTypeList: {},
maintenanceList: {},
heartbeatList: {},
// Kept in sync incrementally by the heartbeat handlers. As computed values they
// were rebuilt over all monitors on every beat (thousands of monitors = hot loop)
lastHeartbeatList: {},
statusList: {},
// Non-reactive copy used by the dashboard counters, so a beat does not force
// an O(monitors) recount; the counters refresh at most once per second
lastBeatRaw: markRaw({}),
heartbeatVersion: 0,
heartbeatVersionThrottle: markRaw(
new BeatVersionThrottle(() => {
this.heartbeatVersion++;
}, 1000)
),
heartbeatHistoryRequested: new Set(),
monitorListLoaded: false,
dashboardLoadProgress: 0,
avgPingList: {},
uptimeList: {},
tlsInfoList: {},
@@ -70,6 +89,27 @@ export default {
},
methods: {
/**
* Store the newest beat of a monitor and its display status.
* Called per beat: must stay O(1), thousands of monitors beat every minute.
* @param {number} monitorID Monitor id
* @param {object} beat Heartbeat
* @returns {void}
*/
updateLastBeat(monitorID, beat) {
applyBeat(this, monitorID, beat, this.statusEntryFor(beat));
this.heartbeatVersionThrottle.schedule();
},
/**
* Map a heartbeat status to its display text and color
* @param {object} beat Heartbeat
* @returns {{text: string, color: string}} Status entry
*/
statusEntryFor(beat) {
return beatStatusEntry(this.$t.bind(this), beat);
},
/**
* Initialize connection to socket server
* @param {boolean} bypass Should the check for if we
@@ -101,8 +141,9 @@ export default {
let protocol = location.protocol + "//";
let url;
const env = process.env.NODE_ENV || "production";
if (env === "development" || localStorage.dev === "dev") {
// The Vite dev frontend runs on :3000 and the backend on :3001. Anywhere else,
// connect to the origin that served this page (do not rely on build-time NODE_ENV)
if (location.port === "3000" || localStorage.dev === "dev") {
url = protocol + location.hostname + ":3001";
} else {
// Connect to the current url
@@ -111,10 +152,14 @@ export default {
socket = io(url, {
withCredentials: true,
// Prefer WebSocket so the large login payload is compressed by perMessageDeflate
// and is not sent over a single slow polling response
transports: ["websocket", "polling"],
});
socket.on("info", (info) => {
this.info = info;
// Sent without `runtime` before login (hideVersion), so keep a safe default
this.info = { runtime: { platform: "" }, ...info };
});
socket.on("setup", () => {
@@ -124,15 +169,33 @@ export default {
socket.on("session", (username) => {
this.loggedIn = true;
this.username = username;
if (!this.$root.dashboardLoadProgress) {
this.$root.dashboardLoadProgress = 8;
}
});
socket.on("loginRequired", () => {
this.allowLoginDialog = true;
this.$root.dashboardLoadProgress = 0;
});
socket.on("monitorList", (data) => {
this.assignMonitorUrlParser(data);
this.monitorList = data;
this.monitorListLoaded = true;
// Tell the server the list is painted, so it sends the heavier heartbeat and
// stats payloads only now (not before the first render). The loading bar is
// done at the same point: the list is what the user is waiting for.
this.$nextTick(() => {
requestAnimationFrame(() => {
this.$root.dashboardLoadProgress = 100;
if (socket && !socket.dashboardRendered) {
socket.dashboardRendered = true;
socket.emit("dashboardRendered");
}
});
});
});
socket.on("updateMonitorIntoList", (data) => {
@@ -189,6 +252,7 @@ export default {
}
this.heartbeatList[data.monitorID].push(data);
this.updateLastBeat(data.monitorID, data);
if (this.heartbeatList[data.monitorID].length >= 150) {
this.heartbeatList[data.monitorID].shift();
@@ -221,12 +285,34 @@ export default {
} else {
this.heartbeatList[monitorID] = data.concat(this.heartbeatList[monitorID]);
}
if (data.length > 0) {
this.updateLastBeat(monitorID, data[data.length - 1]);
}
});
socket.on("heartbeatLists", (data) => {
for (const monitorID in data) {
this.heartbeatList[monitorID] = data[monitorID];
const beats = data[monitorID];
if (beats.length > 0) {
this.updateLastBeat(monitorID, beats[beats.length - 1]);
}
}
});
socket.on("avgPing", (monitorID, data) => {
this.avgPingList[monitorID] = data;
});
socket.on("stats", (data) => {
for (const [monitorID, stats] of Object.entries(data)) {
this.avgPingList[monitorID] = stats.avgPing;
this.uptimeList[`${monitorID}_24`] = stats.uptime24;
this.uptimeList[`${monitorID}_720`] = stats.uptime720;
this.uptimeList[`${monitorID}_1y`] = stats.uptime1y;
}
});
socket.on("uptime", (monitorID, type, data) => {
this.uptimeList[`${monitorID}_${type}`] = data;
});
@@ -254,6 +340,7 @@ export default {
disconnectTimeout = setTimeout(() => {
this.connectionErrorMsg = `${this.$t("Lost connection to the socket server.")} ${this.$t("Reconnecting...")}`;
this.socket.connected = false;
this.$root.dashboardLoadProgress = 0;
}, 2000);
});
@@ -265,6 +352,8 @@ export default {
this.socket.connectCount++;
this.socket.connected = true;
this.showReverseProxyGuide = false;
// A new connection gets its own monitor list; allow the next one to signal paint
socket.dashboardRendered = false;
// Reset Heartbeat list if it is re-connect
if (this.socket.connectCount >= 2) {
@@ -532,6 +621,14 @@ export default {
clearData() {
console.log("reset heartbeat list");
this.heartbeatList = {};
this.lastHeartbeatList = {};
this.statusList = {};
this.lastBeatRaw = markRaw({});
this.heartbeatVersion = 0;
this.heartbeatVersionThrottle.cancel();
this.heartbeatHistoryRequested = new Set();
this.monitorListLoaded = false;
this.$root.dashboardLoadProgress = 0;
},
/**
@@ -607,58 +704,6 @@ export default {
}
},
lastHeartbeatList() {
let result = {};
for (let monitorID in this.heartbeatList) {
let index = this.heartbeatList[monitorID].length - 1;
result[monitorID] = this.heartbeatList[monitorID][index];
}
return result;
},
statusList() {
let result = {};
let unknown = {
text: this.$t("Unknown"),
color: "secondary",
};
for (let monitorID in this.lastHeartbeatList) {
let lastHeartBeat = this.lastHeartbeatList[monitorID];
if (!lastHeartBeat) {
result[monitorID] = unknown;
} else if (lastHeartBeat.status === UP) {
result[monitorID] = {
text: this.$t("Up"),
color: "primary",
};
} else if (lastHeartBeat.status === DOWN) {
result[monitorID] = {
text: this.$t("Down"),
color: "danger",
};
} else if (lastHeartBeat.status === PENDING) {
result[monitorID] = {
text: this.$t("Pending"),
color: "warning",
};
} else if (lastHeartBeat.status === MAINTENANCE) {
result[monitorID] = {
text: this.$t("statusMaintenance"),
color: "maintenance",
};
} else {
result[monitorID] = unknown;
}
}
return result;
},
stats() {
let result = {
active: 0,
@@ -670,8 +715,11 @@ export default {
pause: 0,
};
// Recompute at most once per second, not on every heartbeat
this.heartbeatVersion;
const raw = this.lastBeatRaw;
for (let monitorID in this.$root.monitorList) {
let beat = this.$root.lastHeartbeatList[monitorID];
let beat = raw[monitorID];
let monitor = this.$root.monitorList[monitorID];
if (monitor && !monitor.active) {
+91
View File
@@ -0,0 +1,91 @@
/** Height used for a row until the component has measured it */
export const DEFAULT_ROW_HEIGHT = 58;
/** Rows mounted above and below the viewport */
const OVERSCAN = 300;
export interface RowMetrics {
heights: number[];
offsets: number[];
total: number;
}
export interface ListWindow {
start: number;
end: number;
top: number;
bottom: number;
}
/**
* Cumulative offsets of the monitor rows, used to window the list.
* @param ids Monitor ids, in display order
* @param rowHeights Measured row heights by monitor id
* @param defaultHeight Height for rows that were not measured yet
* @returns Row heights, offsets and total height
*/
export function computeRowMetrics(
ids: Array<number | string>,
rowHeights: Record<string, number>,
defaultHeight = DEFAULT_ROW_HEIGHT
): RowMetrics {
const heights: number[] = [];
const offsets: number[] = [];
let total = 0;
for (const id of ids) {
offsets.push(total);
const height = rowHeights[id] ?? defaultHeight;
heights.push(height);
total += height;
}
return { heights, offsets, total };
}
/**
* Rows to mount for the current scroll offset, plus the spacer heights that keep
* the scrollbar proportional to the full list.
* @param offsets Cumulative row offsets
* @param heights Row heights
* @param total Total height of all rows
* @param scrollTop Current scroll offset
* @param viewportHeight Visible height (0 falls back to 800)
* @param overscan Extra pixels mounted above and below the viewport
* @returns Window bounds and spacer heights
*/
export function computeListWindow(
offsets: number[],
heights: number[],
total: number,
scrollTop: number,
viewportHeight: number,
overscan = OVERSCAN
): ListWindow {
if (offsets.length === 0) {
return { start: 0, end: 0, top: 0, bottom: 0 };
}
const viewport = viewportHeight || 800;
const startY = Math.max(0, scrollTop - overscan);
const endY = scrollTop + viewport + overscan;
let start = 0;
while (start < offsets.length - 1 && offsets[start] + heights[start] < startY) {
start++;
}
let end = start;
while (end < offsets.length && offsets[end] < endY) {
end++;
}
const last = end - 1;
return {
start,
end,
top: offsets[start],
bottom: total - (offsets[last] + heights[last]),
};
}
+48 -3
View File
@@ -498,12 +498,15 @@ export default {
code: "",
},
deleteChildrenMonitors: false,
// Full monitor loaded on demand: the dashboard list only carries its summary fields
fetchedMonitor: null,
};
},
computed: {
monitor() {
let id = this.$route.params.id;
return this.$root.monitorList[id];
// The dashboard list only carries the fields the list needs, so this page
// renders nothing until the full monitor has been fetched
return this.fetchedMonitor;
},
/**
@@ -584,7 +587,7 @@ export default {
},
group() {
return this.monitor.path.slice(0, -1).join(" / ");
return (this.monitor.path || []).slice(0, -1).join(" / ");
},
pushURL() {
@@ -609,6 +612,10 @@ export default {
this.getImportantHeartbeatListPaged();
},
"$route.params.id"(id) {
this.fetchMonitor(id);
},
monitor(to) {
this.getImportantHeartbeatListLength();
},
@@ -624,6 +631,7 @@ export default {
mounted() {
this.getImportantHeartbeatListLength();
this.fetchMonitor(this.$route.params.id);
this.$root.emitter.on("newImportantHeartbeat", this.onNewImportantHeartbeat);
@@ -641,6 +649,25 @@ export default {
methods: {
getResBaseURL,
/**
* Load the full monitor for this page. The dashboard list is a summary only.
* @param {string} id Monitor id
* @returns {void}
*/
fetchMonitor(id) {
this.fetchedMonitor = null;
const socket = this.$root.getSocket();
if (!socket) {
// Deep links mount this page before the root initializes the socket
setTimeout(() => this.fetchMonitor(id), 250);
return;
}
socket.emit("getMonitor", id, (res) => {
if (res.ok && this.$route.params.id === id) {
this.fetchedMonitor = res.monitor;
}
});
},
/**
* Request a test notification be sent for this monitor
* @returns {void}
@@ -664,6 +691,9 @@ export default {
*/
resumeMonitor() {
this.$root.getSocket().emit("resumeMonitor", this.monitor.id, (res) => {
if (res.ok) {
this.setFetchedMonitorActive(true);
}
this.$root.toastRes(res);
});
},
@@ -674,10 +704,25 @@ export default {
*/
pauseMonitor() {
this.$root.getSocket().emit("pauseMonitor", this.monitor.id, (res) => {
if (res.ok) {
this.setFetchedMonitorActive(false);
}
this.$root.toastRes(res);
});
},
/**
* Keep the fetched (full) monitor in sync with an active-state change.
* The dashboard list is a summary, so this page keeps its own copy.
* @param {boolean} active New active state
* @returns {void}
*/
setFetchedMonitorActive(active) {
if (this.fetchedMonitor) {
this.fetchedMonitor.active = active;
}
},
/**
* Show dialog to confirm deletion
* @returns {void}
+3 -1
View File
@@ -317,7 +317,9 @@ describe("SFTP Monitor", () => {
describe(
"SFTP Monitor (real OpenSSH server)",
{
skip: !!process.env.CI && (process.platform !== "linux" || process.arch !== "x64"),
skip:
(!!process.env.CI && (process.platform !== "linux" || process.arch !== "x64")) ||
process.env.SKIP_TESTCONTAINER,
},
() => {
let monitor;
+113
View File
@@ -0,0 +1,113 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import analytics from "../../server/analytics/analytics";
describe("Status page analytics", () => {
describe("getAnalyticsScript()", () => {
test("returns the Google tag script for google analytics", () => {
const html = analytics.getAnalyticsScript({
analyticsType: "google",
analyticsId: "G-ABC123",
});
assert.match(html, /googletagmanager\.com\/gtag\/js\?id=G-ABC123/);
assert.match(html, /gtag\('config', 'G-ABC123'\)/);
});
test("escapes a script-closing payload in the google tag id", () => {
const html = analytics.getAnalyticsScript({
analyticsType: "google",
analyticsId: "</script><script>alert(1)</script>",
});
assert.ok(!html.includes("</script><script>alert(1)</script>"));
});
test("returns the Matomo script with tracker url and site id", () => {
const html = analytics.getAnalyticsScript({
analyticsType: "matomo",
analyticsScriptUrl: "matomo.example.com",
analyticsId: "7",
});
assert.match(html, /var u="\/\/matomo\.example\.com\/"/);
assert.match(html, /setSiteId', 7/);
});
test("returns the Plausible script with script url and domain", () => {
const html = analytics.getAnalyticsScript({
analyticsType: "plausible",
analyticsScriptUrl: "https://plausible.io/js/script.js",
analyticsId: "example.com",
});
assert.match(html, /src="https:\/\/plausible\.io\/js\/script\.js"/);
assert.match(html, /data-domain="example\.com"/);
});
test("returns the Umami script with script url and website id", () => {
const html = analytics.getAnalyticsScript({
analyticsType: "umami",
analyticsScriptUrl: "https://umami.example.com/script.js",
analyticsId: "website-1",
});
assert.match(html, /src="https:\/\/umami\.example\.com\/script\.js"/);
assert.match(html, /data-website-id="website-1"/);
});
test("returns the Rybbit script with script url and site id", () => {
const html = analytics.getAnalyticsScript({
analyticsType: "rybbit",
analyticsScriptUrl: "https://rybbit.example.com/script.js",
analyticsId: "site-1",
});
assert.match(html, /src="https:\/\/rybbit\.example\.com\/script\.js"/);
assert.match(html, /data-site-id="site-1"/);
});
test("returns null for an unknown analytics type", () => {
assert.strictEqual(analytics.getAnalyticsScript({ analyticsType: "unknown" }), null);
});
});
describe("isValidAnalyticsConfig()", () => {
test("accepts google analytics with only an id", () => {
assert.strictEqual(analytics.isValidAnalyticsConfig({ analyticsType: "google", analyticsId: "G-1" }), true);
});
test("rejects google analytics without an id", () => {
assert.strictEqual(analytics.isValidAnalyticsConfig({ analyticsType: "google" }), false);
});
test("accepts umami with both url and id", () => {
assert.strictEqual(
analytics.isValidAnalyticsConfig({
analyticsType: "umami",
analyticsScriptUrl: "https://umami.example.com/script.js",
analyticsId: "id",
}),
true
);
});
test("rejects umami without a script url", () => {
assert.strictEqual(analytics.isValidAnalyticsConfig({ analyticsType: "umami", analyticsId: "id" }), false);
});
test("rejects matomo without a site id", () => {
assert.strictEqual(
analytics.isValidAnalyticsConfig({
analyticsType: "matomo",
analyticsScriptUrl: "matomo.example.com",
}),
false
);
});
test("rejects an unknown analytics type", () => {
assert.strictEqual(analytics.isValidAnalyticsConfig({ analyticsType: "unknown" }), false);
});
});
});
+76
View File
@@ -0,0 +1,76 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { toCompactMonitor } = require("../../server/compact-monitor");
/**
* Build preload data with one monitor
* @param {object} overrides Map overrides
* @returns {object} Preload data
*/
function preload(overrides = {}) {
return {
paths: new Map([[1, ["Group", "Child"]]]),
activeStatus: new Map([[1, true]]),
childrenIDs: new Map([[1, [2, 3]]]),
tags: new Map([[1, [{ tag_id: 5, name: "env" }]]]),
...overrides,
};
}
describe("toCompactMonitor()", () => {
test("keeps the fields the dashboard uses", () => {
const monitor = { id: 1, name: "API", type: "http", parent: null, interval: 60, weight: 2000 };
const compact = toCompactMonitor(monitor, preload());
assert.deepStrictEqual(compact, {
id: 1,
name: "API",
type: "http",
active: true,
parent: null,
childrenIDs: [2, 3],
tags: [{ tag_id: 5, name: "env" }],
path: ["Group", "Child"],
pathName: "Group / Child",
interval: 60,
weight: 2000,
});
});
test("defaults missing parent, children, tags and path", () => {
const compact = toCompactMonitor(
{ id: 9, name: "Orphan", type: "group", interval: 60 },
{ paths: new Map(), activeStatus: new Map(), childrenIDs: new Map(), tags: new Map() }
);
assert.strictEqual(compact.active, undefined);
assert.deepStrictEqual(compact.childrenIDs, []);
assert.deepStrictEqual(compact.tags, []);
assert.deepStrictEqual(compact.path, []);
assert.strictEqual(compact.pathName, "");
assert.strictEqual(compact.parent, null);
assert.strictEqual(compact.weight, null);
});
test("does not include sensitive monitor fields", () => {
const compact = toCompactMonitor(
{
id: 1,
name: "API",
type: "http",
parent: null,
interval: 60,
url: "https://secret.example",
basic_auth_pass: "secret",
},
preload()
);
assert.strictEqual("url" in compact, false);
assert.strictEqual("basic_auth_pass" in compact, false);
});
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { emitHeartbeat } from "../../server/heartbeat-emit";
/**
* Socket.io-like object with an optional client in the room
* @param {boolean} hasClient Whether the room has a socket
* @returns {object} IO with recorded events
*/
function fakeIo(hasClient) {
const events = [];
return {
events,
sockets: {
adapter: {
rooms: new Map(hasClient ? [["user1", new Set(["socket"])]] : []),
},
},
to(room) {
return {
emit(event, payload) {
events.push({ room, event, payload });
},
};
},
};
}
describe("emitHeartbeat()", () => {
test("emits the heartbeat when a client is connected", () => {
const io = fakeIo(true);
const payload = { id: 1, status: 1 };
const emitted = emitHeartbeat(io, "user1", payload);
assert.strictEqual(emitted, true);
assert.deepStrictEqual(io.events, [{ room: "user1", event: "heartbeat", payload }]);
});
test("skips the emit when nobody is in the room", () => {
const io = fakeIo(false);
const emitted = emitHeartbeat(io, "user1", { id: 1 });
assert.strictEqual(emitted, false);
assert.deepStrictEqual(io.events, []);
});
test("skips the emit when the socket server has no adapter", () => {
const io = { to: () => ({ emit: () => {} }) };
assert.strictEqual(emitHeartbeat(io, "user1", {}), false);
});
});
@@ -0,0 +1,99 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { getHeartbeatHistory, sendHeartbeatLists } = require("../../server/client");
const { R } = require("redbean-node");
/**
* Replace R.getAll for the duration of one test
* @param {Function} replacement Stub
* @returns {Function} Restore function
*/
function stubGetAll(replacement) {
const original = R.getAll;
R.getAll = replacement;
return () => {
R.getAll = original;
};
}
describe("getHeartbeatHistory()", () => {
test("returns the latest 100 beats oldest first", async () => {
const calls = [];
const restore = stubGetAll(async (sql, params) => {
calls.push({ sql, params });
// The query returns newest first; the client expects oldest first
return [{ id: 3 }, { id: 2 }, { id: 1 }];
});
try {
const result = await getHeartbeatHistory(42);
assert.deepStrictEqual(
result.map((beat) => beat.id),
[1, 2, 3]
);
assert.match(calls[0].sql, /ORDER BY time DESC/);
assert.match(calls[0].sql, /LIMIT 100/);
assert.deepStrictEqual(calls[0].params, [42]);
} finally {
restore();
}
});
test("returns an empty list for a monitor without beats", async () => {
const restore = stubGetAll(async () => []);
try {
assert.deepStrictEqual(await getHeartbeatHistory(7), []);
} finally {
restore();
}
});
});
describe("sendHeartbeatLists()", () => {
test("emits the latest beat of every monitor in one event", async () => {
const emitted = [];
const socket = {
emit(event, payload) {
emitted.push({ event, payload });
},
};
const restore = stubGetAll(async () => [
{ id: 1, monitor_id: 10, status: 1 },
{ id: 2, monitor_id: 11, status: 0 },
]);
try {
await sendHeartbeatLists(socket, [10, 11]);
assert.strictEqual(emitted.length, 1);
assert.strictEqual(emitted[0].event, "heartbeatLists");
assert.deepStrictEqual(Object.keys(emitted[0].payload), ["10", "11"]);
assert.strictEqual(emitted[0].payload[10][0].id, 1);
} finally {
restore();
}
});
test("queries in chunks of 500 monitors", async () => {
const calls = [];
const socket = { emit() {} };
const ids = Array.from({ length: 501 }, (_, i) => i + 1);
const restore = stubGetAll(async (sql, params) => {
calls.push(params.length);
return [];
});
try {
await sendHeartbeatLists(socket, ids);
assert.deepStrictEqual(calls, [500, 1]);
} finally {
restore();
}
});
});
+63
View File
@@ -0,0 +1,63 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const Incident = require("../../server/model/incident");
/**
* Build an Incident instance without touching the database
* @param {object} fields Bean fields
* @returns {object} Incident instance
*/
function incidentWith(fields) {
return Object.assign(Object.create(Incident.prototype), fields);
}
describe("Incident", () => {
describe("toPublicJSON()", () => {
test("maps bean fields to the public payload", () => {
const incident = incidentWith({
id: 7,
style: "warning",
title: "Degraded",
content: "Investigating",
pin: 1,
active: 1,
created_date: "2026-01-01 00:00:00",
last_updated_date: "2026-01-01 01:00:00",
status_page_id: 3,
});
assert.deepStrictEqual(incident.toPublicJSON(), {
id: 7,
style: "warning",
title: "Degraded",
content: "Investigating",
pin: true,
active: true,
createdDate: "2026-01-01 00:00:00",
lastUpdatedDate: "2026-01-01 01:00:00",
status_page_id: 3,
});
});
test("coerces numeric zero to false for pin and active", () => {
const incident = incidentWith({ id: 1, pin: 0, active: 0 });
const json = incident.toPublicJSON();
assert.strictEqual(json.pin, false);
assert.strictEqual(json.active, false);
});
test("coerces null pin and active to false", () => {
const incident = incidentWith({ id: 1, pin: null, active: null });
const json = incident.toPublicJSON();
assert.strictEqual(json.pin, false);
assert.strictEqual(json.active, false);
});
});
});
+80
View File
@@ -0,0 +1,80 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { DeferredListsSender } from "../../server/login-lists";
/**
* Build a sender with counters
* @param {object} options Overrides
* @returns {object} Sender and its counters
*/
function createSender(options = {}) {
const calls = { send: 0, errors: [] };
const sender = new DeferredListsSender({
send: async () => {
calls.send++;
},
onError: (error) => calls.errors.push(error),
...options,
});
return { sender, calls };
}
describe("DeferredListsSender", () => {
test("sends on the dashboard signal and cancels the fallback", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const { sender, calls } = createSender();
sender.onRendered();
assert.strictEqual(calls.send, 1);
t.mock.timers.tick(5000);
assert.strictEqual(calls.send, 1);
});
test("uses the fallback when the client never signals", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const { calls } = createSender();
t.mock.timers.tick(4999);
assert.strictEqual(calls.send, 0);
t.mock.timers.tick(1);
assert.strictEqual(calls.send, 1);
});
test("sends only once when signalled twice", async () => {
const { sender, calls } = createSender();
sender.onRendered();
sender.onRendered();
await new Promise((resolve) => setImmediate(resolve));
assert.strictEqual(calls.send, 1);
sender.dispose();
});
test("reports a failed send", async () => {
const failure = new Error("boom");
const { sender, calls } = createSender({
send: async () => {
throw failure;
},
});
sender.onRendered();
await new Promise((resolve) => setImmediate(resolve));
assert.deepStrictEqual(calls.errors, [failure]);
sender.dispose();
});
test("dispose() drops the pending fallback", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const { sender, calls } = createSender();
sender.dispose();
t.mock.timers.tick(5000);
assert.strictEqual(calls.send, 0);
});
});
+164
View File
@@ -0,0 +1,164 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { createRequire } from "node:module";
import dayjs from "dayjs";
const require = createRequire(import.meta.url);
dayjs.extend(require("dayjs/plugin/utc"));
dayjs.extend(require("dayjs/plugin/customParseFormat"));
dayjs.extend(require("../../server/modules/dayjs/plugin/timezone"));
const Maintenance = require("../../server/model/maintenance");
/**
* Build a Maintenance instance without touching the database
* @param {object} fields Maintenance fields
* @returns {object} Maintenance instance
*/
function maintenanceWith(fields) {
return Object.assign(Object.create(Maintenance.prototype), fields);
}
describe("Maintenance", () => {
describe("getDayOfWeekList()", () => {
test("parses the stored JSON and sorts the days", () => {
assert.deepStrictEqual(maintenanceWith({ weekdays: "[3,1,2]" }).getDayOfWeekList(), [1, 2, 3]);
});
test("returns an empty list when no weekday is selected", () => {
assert.deepStrictEqual(maintenanceWith({ weekdays: "[]" }).getDayOfWeekList(), []);
});
});
describe("getDayOfMonthList()", () => {
test("parses the stored JSON and sorts numerically", () => {
assert.deepStrictEqual(maintenanceWith({ days_of_month: "[10,2]" }).getDayOfMonthList(), [2, 10]);
});
});
describe("calcDuration()", () => {
test("returns the duration between start and end on the same day", () => {
assert.strictEqual(maintenanceWith({ start_time: "10:00", end_time: "12:30" }).calcDuration(), 2.5 * 3600);
});
test("adds 24 hours when the window crosses midnight", () => {
assert.strictEqual(maintenanceWith({ start_time: "23:00", end_time: "01:00" }).calcDuration(), 2 * 3600);
});
});
describe("inferDuration()", () => {
test("returns a positive custom duration as is", () => {
assert.strictEqual(maintenanceWith({ duration: 60 }).inferDuration(5000), 5000);
});
test("falls back to the configured duration", () => {
assert.strictEqual(maintenanceWith({ duration: 60, end_date: null }).inferDuration(0), 60000);
});
});
describe("inferDuration() with an end date", () => {
test("uses the remaining time when it is shorter than the configured duration", () => {
const endDate = dayjs().add(30, "second").toISOString();
const duration = maintenanceWith({ duration: 60, end_date: endDate }).inferDuration(0);
assert.ok(duration > 20000 && duration <= 31000, String(duration));
});
});
describe("getStatus()", () => {
test("returns inactive when the maintenance is disabled", async () => {
assert.strictEqual(await maintenanceWith({ active: false }).getStatus(), "inactive");
});
test("returns under-maintenance for a manual strategy", async () => {
assert.strictEqual(
await maintenanceWith({ active: true, strategy: "manual" }).getStatus(),
"under-maintenance"
);
});
test("returns scheduled when the start date is in the future", async () => {
const maintenance = maintenanceWith({
active: true,
strategy: "single",
timezone: "Asia/Hong_Kong",
start_date: dayjs().add(1, "day").toISOString(),
});
assert.strictEqual(await maintenance.getStatus(), "scheduled");
});
test("returns ended when the end date is in the past", async () => {
const maintenance = maintenanceWith({
active: true,
strategy: "single",
timezone: "Asia/Hong_Kong",
end_date: dayjs().subtract(1, "day").toISOString(),
});
assert.strictEqual(await maintenance.getStatus(), "ended");
});
test("returns under-maintenance for a running single strategy", async () => {
const maintenance = maintenanceWith({
active: true,
strategy: "single",
timezone: "Asia/Hong_Kong",
});
assert.strictEqual(await maintenance.getStatus(), "under-maintenance");
});
test("returns unknown when the bean has no status", async () => {
const maintenance = maintenanceWith({
active: true,
strategy: "recurring",
timezone: "Asia/Hong_Kong",
beanMeta: {},
});
assert.strictEqual(await maintenance.getStatus(), "unknown");
});
});
describe("getTimezone()", () => {
test("returns the configured timezone", async () => {
assert.strictEqual(await maintenanceWith({ timezone: "Asia/Hong_Kong" }).getTimezone(), "Asia/Hong_Kong");
});
});
describe("getTimezoneOffset()", () => {
test("formats the offset of the configured timezone", async () => {
assert.strictEqual(await maintenanceWith({ timezone: "Asia/Hong_Kong" }).getTimezoneOffset(), "+08:00");
});
});
describe("getRunningTimeslot()", () => {
test("returns the slot when now is inside the running window", () => {
const job = { nextRun: () => dayjs().subtract(30, "second").toDate() };
const maintenance = maintenanceWith({ duration: 60, beanMeta: { job } });
const slot = maintenance.getRunningTimeslot();
assert.ok(slot);
assert.ok(dayjs(slot.startDate).isBefore(dayjs(slot.endDate)));
});
test("returns null when now is outside the running window", () => {
const job = { nextRun: () => dayjs().subtract(120, "second").toDate() };
const maintenance = maintenanceWith({ duration: 60, beanMeta: { job } });
assert.strictEqual(maintenance.getRunningTimeslot(), null);
});
});
describe("validateCron()", () => {
test("accepts a valid cron expression", () => {
assert.doesNotThrow(() => Maintenance.validateCron("*/5 * * * *"));
});
test("rejects an invalid cron expression", () => {
assert.throws(() => Maintenance.validateCron("not a cron"));
});
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { ManualMonitorType } = require("../../server/monitor-types/manual");
const { UP, DOWN, PENDING } = require("../../src/util");
/**
* Run the manual monitor type check
* @param {number|null} manualStatus Manual status
* @returns {Promise<object>} Heartbeat result
*/
async function check(manualStatus) {
const monitorType = new ManualMonitorType();
const heartbeat = {};
await monitorType.check({ manual_status: manualStatus }, heartbeat);
return heartbeat;
}
describe("ManualMonitorType", () => {
test("check() reports up when the manual status is up", async () => {
const heartbeat = await check(UP);
assert.strictEqual(heartbeat.status, UP);
assert.strictEqual(heartbeat.msg, "Up");
});
test("check() reports down when the manual status is down", async () => {
const heartbeat = await check(DOWN);
assert.strictEqual(heartbeat.status, DOWN);
assert.strictEqual(heartbeat.msg, "Down");
});
test("check() reports the manual status with a pending message for other statuses", async () => {
const heartbeat = await check(3);
assert.strictEqual(heartbeat.status, 3);
assert.strictEqual(heartbeat.msg, "Pending");
});
test("check() reports pending when no manual status is set", async () => {
const heartbeat = await check(null);
assert.strictEqual(heartbeat.status, PENDING);
assert.strictEqual(heartbeat.msg, "Manual monitoring - No status set");
});
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { createRequire } from "node:module";
import { setTimeout as sleep } from "node:timers/promises";
const require = createRequire(import.meta.url);
const MemoryCache = require("../../server/modules/apicache/memory-cache");
describe("MemoryCache", () => {
test("add() stores a value and updates the size", () => {
const cache = new MemoryCache();
cache.add("key", "value", 1000);
assert.strictEqual(cache.size, 1);
assert.strictEqual(cache.getValue("key"), "value");
});
test("get() returns the stored entry with its expiry", () => {
const cache = new MemoryCache();
const before = Date.now();
cache.add("key", "value", 1000);
const entry = cache.get("key");
assert.strictEqual(entry.value, "value");
assert.ok(entry.expire >= before + 1000);
});
test("delete() removes the entry and decreases the size", () => {
const cache = new MemoryCache();
cache.add("key", "value", 1000);
cache.delete("key");
assert.strictEqual(cache.size, 0);
assert.strictEqual(cache.getValue("key"), undefined);
});
test("clear() empties the cache", () => {
const cache = new MemoryCache();
cache.add("a", 1, 1000);
cache.add("b", 2, 1000);
assert.strictEqual(cache.clear(), true);
assert.strictEqual(cache.size, 0);
});
test("expires an entry after its ttl and calls the timeout callback", async () => {
const cache = new MemoryCache();
const removed = [];
cache.add("key", "value", 20, (value, key) => removed.push([key, value]));
assert.strictEqual(cache.getValue("key"), "value");
await sleep(50);
assert.strictEqual(cache.getValue("key"), undefined);
assert.deepStrictEqual(removed, [["key", "value"]]);
});
});
+81
View File
@@ -0,0 +1,81 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { EventEmitter } from "node:events";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { attachKeylogListener, clearMonitorAgents, getMonitorAgents } = require("../../server/monitor-agents");
/**
* Build the agent options passed by Monitor.beat()
* @returns {object} Agent options
*/
function agentOptions() {
return { httpAgentOptions: {}, httpsAgentOptions: {} };
}
describe("getMonitorAgents()", () => {
test("reuses the same agents for one monitor", async () => {
const monitor = { proxy_id: null };
const first = await getMonitorAgents(monitor, agentOptions());
const second = await getMonitorAgents(monitor, agentOptions());
assert.strictEqual(second.httpAgent, first.httpAgent);
assert.strictEqual(second.httpsAgent, first.httpsAgent);
});
test("creates keep-alive agents for a direct connection", async () => {
const agents = await getMonitorAgents({ proxy_id: null }, agentOptions());
assert.strictEqual(agents.httpAgent.keepAlive, true);
assert.strictEqual(agents.httpsAgent.keepAlive, true);
assert.strictEqual(agents.proxied, false);
});
test("does not share agents between monitors", async () => {
const first = await getMonitorAgents({ proxy_id: null }, agentOptions());
const second = await getMonitorAgents({ proxy_id: null }, agentOptions());
assert.notStrictEqual(second.httpAgent, first.httpAgent);
assert.notStrictEqual(second.httpsAgent, first.httpsAgent);
});
test("clearMonitorAgents() drops the cached agents", async () => {
const monitor = { proxy_id: null };
const first = await getMonitorAgents(monitor, agentOptions());
clearMonitorAgents(monitor);
const second = await getMonitorAgents(monitor, agentOptions());
assert.notStrictEqual(second.httpAgent, first.httpAgent);
assert.notStrictEqual(second.httpsAgent, first.httpsAgent);
});
});
describe("attachKeylogListener()", () => {
test("replaces the listener of the previous beat", () => {
const agents = {};
const httpsAgent = new EventEmitter();
const first = attachKeylogListener(agents, httpsAgent, () => {});
const second = attachKeylogListener(agents, httpsAgent, () => {});
assert.notStrictEqual(first, second);
assert.strictEqual(httpsAgent.listenerCount("keylog"), 1);
});
test("calls back after the TLS handshake", () => {
const agents = {};
const httpsAgent = new EventEmitter();
let certificates = 0;
attachKeylogListener(agents, httpsAgent, () => certificates++);
const tlsSocket = new EventEmitter();
httpsAgent.emit("keylog", Buffer.from(""), tlsSocket);
assert.strictEqual(certificates, 0);
tlsSocket.emit("secureConnect");
assert.strictEqual(certificates, 1);
});
});
+337
View File
@@ -0,0 +1,337 @@
import { afterEach, describe, test } from "node:test";
import assert from "node:assert";
import { createRequire } from "node:module";
import http from "node:http";
import https from "node:https";
const require = createRequire(import.meta.url);
const Monitor = require("../../server/model/monitor");
const { R } = require("redbean-node");
const { UptimeCalculator } = require("../../server/uptime-calculator");
const { Prometheus } = require("../../server/prometheus");
// Self-signed certificate for 127.0.0.1, tests only
const TEST_CERT = `-----BEGIN CERTIFICATE-----
MIIDGjCCAgKgAwIBAgIUBDEuoYxCxNILP6rHwaw2Opgn1nYwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTI2MTAyNDE4MTA0N1oXDTM2MTAy
MTE4MTA0N1owFDESMBAGA1UEAwwJMTI3LjAuMC4xMIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEAsg0yB6hTl9+/G6quAdQOLT7710xuPoFnnTxiSaEKApuY
Rs+yLg9KcMoiY3eFjdWn4KTfjMy6/Kibm9dbLXYI/5mXIwEDcW85SptFny6SKPVA
mRcX436TxDYkDYWNAxtQROam2C1JLOJOHofPZvo5rkQsYLu+9Px2kWVdU85CmFxL
MbLUyOxaMOjqZtBeTTFY7I0NBW8tOsdhJWhdsEH0Yw9zYwpsvpcO5YQHMFFEHdC+
2E2vKxkuwH7lz+p9K1CG5JTZ8sMdo9y7CicTYSHYTwhnwqJsfltPV/CeqBMpL4IP
qMxNO9VeR79KN/J5/oZvsdFSjT7GaReEAesPjaR0vwIDAQABo2QwYjAdBgNVHQ4E
FgQUghkRk5FifZZdAa1DhBo41vuXmCIwHwYDVR0jBBgwFoAUghkRk5FifZZdAa1D
hBo41vuXmCIwDwYDVR0TAQH/BAUwAwEB/zAPBgNVHREECDAGhwR/AAABMA0GCSqG
SIb3DQEBCwUAA4IBAQBXUy0QPY88+Q2GyI59EfWq0mYRTUVa+Dv0lz+W4V3tBR4b
Cvh0VEZEpuo5uyiRreVOyjTXinRJVDA+xk6XYzTbwqUj9MRkwbVa/qqydBr/Zrwa
rZSDTmzIWSO2RQMOeI8zkK6SND5Lg8K4aWNjmoAJs/t/ZYaINxk2AUotmXM5bKPP
7QnNORbZgqoyse5Ct+7c2L6ngQOiDKiNG8AisAVI91C5v6M/5AVIIAlENtH4V9sy
HEAOA+GcFrb1hlIjbK61ff1nj2ni7HZtSlrK9HRhNTLOtkpkXcQUL3OqSyhVsCnV
vD02OP0l7N8x0j5O4c2YiqbpUsQ6p4LcYYmBCO/T
-----END CERTIFICATE-----`;
const TEST_KEY = `-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCyDTIHqFOX378b
qq4B1A4tPvvXTG4+gWedPGJJoQoCm5hGz7IuD0pwyiJjd4WN1afgpN+MzLr8qJub
11stdgj/mZcjAQNxbzlKm0WfLpIo9UCZFxfjfpPENiQNhY0DG1BE5qbYLUks4k4e
h89m+jmuRCxgu770/HaRZV1TzkKYXEsxstTI7Fow6Opm0F5NMVjsjQ0Fby06x2El
aF2wQfRjD3NjCmy+lw7lhAcwUUQd0L7YTa8rGS7AfuXP6n0rUIbklNnywx2j3LsK
JxNhIdhPCGfComx+W09X8J6oEykvgg+ozE071V5Hv0o38nn+hm+x0VKNPsZpF4QB
6w+NpHS/AgMBAAECggEAMnvXUaCCgy9nqlqMl+weUOno0jTTlVlvnX9/hszoX7Dq
TjmZlcG0I6LMeVNsRLjtrp5Q3EU4R2cvJ9K6AXvUNVTBK6Wf6kvU/Lhl1yqHOqG/
PSj1FwXOs/V952+UeesGqWKUr/q3IsjPyYxMXDBapnSh+JODI0dp/vO0sIxtCXG4
tP4R9QkhEhQ+1KmhgQuYVN0NVje/wxE3dCLBgRpNSMBI583/K69IT05pEtkdaLk3
KklZqowdSUmehjdZXG0kDFSkjWLa2bchy/BdiVHGQyxWI3R048WIQ2t2pDQinM1x
MLHoxHb2hNmUCGcKhkJS7GXx1VBL24sdd+P7fgVLEQKBgQDXmTR5E40v3uXC74RN
7umqgUDp5yt/ufiMU5sJ60MXruYh7PpdRJBA5rMtvXw4nT8QWfPElOBb2fH4HFIX
X4lmf5jHGBJ1B+Xo26Of2yDd0cZuX/lva+zOmJkfH0zGRV0ruSubOsDWXgLZk1bC
dK/GfuMSRIbDw9Xzb0bgUUeq3QKBgQDTasWEFjUd/L5XJhAa6XcjGiIWdCJBbUla
0kSAU1cAJpHVYuaNJxyZf20EhmSDQI23JvjKbuf02tPot3RdCX2piScnvUxxcG4G
Pj6MDOpc3rTTqt6oer5aRQH/qbDUCVyMc58h1sL8hbAVd/TsLOODHCjTppB7f2JW
6rzTbWWeSwKBgEY+05rzZNgJDuFhusDNq/Jy5JyRRln6VOIbx3MG5ThNMwwwsy46
TannwM+RYmzrxwVj2YneW+9xZmZJi/Gzngf4YOXVkQXyidVB4uENu3qqg7WorZPB
HUn+7Dz4rMIZDY9WaL/N99CgltsfJ3B6Jl9NLlzgWOxl3UrQoc4qjWg9AoGBAMz1
cvzFxsMGlsTIGs7sTVqTaD6aYedJ+tZ4iH5bzTlRKwpS8i4fn8NT3K2JWIJCH+FS
f9lIwckIKG4xBDd6DwA//dyx0R0coR2eVGbT0pUsNuQJgAiXzliXFAP2T36QgM+M
+9YDbU8AkB+QBrq8HeiqcQ4XKXUAnqaitWLHaL+XAoGAe2xBuTtY9j06N3xOI+BB
Ou1aoTvfAnTcR4EtnZwfrcC2yJScPPc7I6LhS8s2bqv5PqETvyDMZKR4ptpK6+y4
ZxS1xQ23DAuKVk7/lHpiwQAIeWp9WT0ZXCIQEoNUSOqyERB7DiGeIu/dEXaQGMv8
bKYQDvxfHpW3ZVybuKBDgGU=
-----END PRIVATE KEY-----`;
const originals = {
findOne: R.findOne,
dispense: R.dispense,
store: R.store,
isoDateTimeMillis: R.isoDateTimeMillis,
getUptimeCalculator: UptimeCalculator.getUptimeCalculator,
sendNotification: Monitor.sendNotification,
sendStats: Monitor.sendStats,
isUnderMaintenance: Monitor.isUnderMaintenance,
load: R.load,
prometheusUpdate: Prometheus.prototype.update,
prometheusRemove: Prometheus.prototype.remove,
handleTlsInfo: Monitor.prototype.handleTlsInfo,
};
afterEach(() => {
R.findOne = originals.findOne;
R.dispense = originals.dispense;
R.store = originals.store;
R.isoDateTimeMillis = originals.isoDateTimeMillis;
UptimeCalculator.getUptimeCalculator = originals.getUptimeCalculator;
Monitor.sendNotification = originals.sendNotification;
Monitor.sendStats = originals.sendStats;
Monitor.isUnderMaintenance = originals.isUnderMaintenance;
R.load = originals.load;
Prometheus.prototype.update = originals.prometheusUpdate;
Prometheus.prototype.remove = originals.prometheusRemove;
Monitor.prototype.handleTlsInfo = originals.handleTlsInfo;
});
/**
* Start an HTTP server that answers every request with 200 OK
* @returns {Promise<{url: string, close: Function}>} Server URL and closer
*/
async function localServer() {
const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("OK");
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address();
return {
url: `http://127.0.0.1:${port}/`,
close: () => new Promise((resolve) => server.close(resolve)),
};
}
/**
* Monitor instance with every field a beat reads
* @param {string} url Monitor URL
* @returns {object} Monitor
*/
function fakeMonitor(url) {
return Object.assign(Object.create(Monitor.prototype), {
id: 9002,
name: "Beat Monitor",
type: "http",
url,
interval: 60,
retryInterval: 60,
resendInterval: 0,
maxretries: 0,
maxredirects: 10,
timeout: 5,
user_id: "user1",
active: 1,
ignore_tls: 0,
upside_down: 0,
save_response: 0,
save_error_response: 0,
expiry_notification: 0,
domain_expiry_notification: 0,
accepted_statuscodes_json: '["200-299"]',
headers: null,
body: null,
method: "get",
auth_method: null,
proxy_id: null,
ipFamily: null,
cacheBust: false,
getTags: async () => [],
});
}
/**
* Start an HTTPS server with the self-signed test certificate
* @returns {Promise<{url: string, close: Function}>} Server URL and closer
*/
async function localTlsServer() {
const server = https.createServer({ key: TEST_KEY, cert: TEST_CERT }, (req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("OK");
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address();
return {
url: `https://127.0.0.1:${port}/`,
close: () => new Promise((resolve) => server.close(resolve)),
};
}
/**
* IO with one client in the user room
* @returns {object} IO with captured events
*/
function fakeIo() {
const events = [];
return {
events,
sockets: { adapter: { rooms: new Map([["user1", new Set(["socket"])]]) } },
to(room) {
return {
emit(event, ...args) {
events.push({ room, event, args });
},
};
},
};
}
describe("Monitor beat over HTTP", () => {
test("collects the certificate info from the TLS keylog event", async () => {
const server = await localTlsServer();
const monitor = fakeMonitor(server.url);
monitor.ignoreTls = 1;
const io = fakeIo();
R.findOne = async () => null;
R.dispense = () => ({
monitor_id: monitor.id,
toJSON() {
return { monitor_id: this.monitor_id, status: this.status };
},
});
R.store = async () => {};
R.isoDateTimeMillis = () => "2024-01-01 00:00:00";
UptimeCalculator.getUptimeCalculator = async () => ({
update: async () => ({ unix: () => 1704067200 }),
});
Monitor.sendNotification = async () => {};
Monitor.isUnderMaintenance = async () => false;
Monitor.sendStats = async () => {};
Prometheus.prototype.remove = () => {};
let beatDone;
const beatFinished = new Promise((resolve) => {
beatDone = resolve;
});
Prometheus.prototype.update = () => {
beatDone();
};
const tlsInfos = [];
Monitor.prototype.handleTlsInfo = async function (tlsInfo) {
tlsInfos.push(tlsInfo);
};
try {
await monitor.start(io, 0);
await Promise.race([
beatFinished,
new Promise((_, reject) => setTimeout(() => reject(new Error("beat timeout")), 5000)),
]);
await new Promise((resolve) => setImmediate(resolve));
assert.strictEqual(tlsInfos.length, 1, "certificate info handled once");
assert.ok(tlsInfos[0].certInfo, "certificate parsed");
assert.strictEqual(typeof tlsInfos[0].hostnameMatchMonitorUrl, "boolean");
} finally {
monitor.stop();
await server.close();
}
});
test("checks, emits the heartbeat and updates prometheus", async () => {
const server = await localServer();
const monitor = fakeMonitor(server.url);
const io = fakeIo();
R.findOne = async () => null;
R.dispense = () => ({
monitor_id: monitor.id,
toJSON() {
return { monitor_id: this.monitor_id, status: this.status, msg: this.msg };
},
});
R.store = async () => {};
R.isoDateTimeMillis = () => "2024-01-01 00:00:00";
UptimeCalculator.getUptimeCalculator = async () => ({
update: async () => ({ unix: () => 1704067200 }),
});
Monitor.sendNotification = async () => {};
Monitor.isUnderMaintenance = async () => false;
const prometheusUpdates = [];
let beatDone;
const beatFinished = new Promise((resolve) => {
beatDone = resolve;
});
Prometheus.prototype.update = (...args) => {
prometheusUpdates.push(args);
beatDone();
};
Prometheus.prototype.remove = () => {};
Monitor.sendStats = async () => {};
try {
await monitor.start(io, 0);
// Prometheus is updated after the heartbeat was emitted and stored
await Promise.race([
beatFinished,
new Promise((_, reject) => setTimeout(() => reject(new Error("beat timeout")), 5000)),
]);
await new Promise((resolve) => setImmediate(resolve));
const heartbeat = io.events.find((event) => event.event === "heartbeat");
assert.ok(heartbeat, "heartbeat emitted");
assert.strictEqual(heartbeat.args[0].status, 1);
assert.strictEqual(prometheusUpdates.length, 1, "prometheus updated once");
assert.strictEqual(prometheusUpdates[0][0].status, heartbeat.args[0].status);
} finally {
monitor.stop();
await server.close();
}
});
test("uses the proxy agents of an active proxy", async () => {
const server = await localServer();
const monitor = fakeMonitor(server.url);
monitor.proxy_id = 42;
const io = fakeIo();
R.findOne = async () => null;
R.dispense = () => ({
monitor_id: monitor.id,
toJSON() {
return { monitor_id: this.monitor_id, status: this.status };
},
});
R.store = async () => {};
R.isoDateTimeMillis = () => "2024-01-01 00:00:00";
R.load = async () => ({ id: 42, active: 1, protocol: "http", host: "127.0.0.1", port: 1, auth: false });
UptimeCalculator.getUptimeCalculator = async () => ({
update: async () => ({ unix: () => 1704067200 }),
});
Monitor.sendNotification = async () => {};
Monitor.isUnderMaintenance = async () => false;
Monitor.sendStats = async () => {};
Prometheus.prototype.remove = () => {};
let beatDone;
const beatFinished = new Promise((resolve) => {
beatDone = resolve;
});
Prometheus.prototype.update = () => {
beatDone();
};
try {
await monitor.start(io, 0);
await Promise.race([
beatFinished,
new Promise((_, reject) => setTimeout(() => reject(new Error("beat timeout")), 5000)),
]);
await new Promise((resolve) => setImmediate(resolve));
const heartbeat = io.events.find((event) => event.event === "heartbeat");
assert.ok(heartbeat, "heartbeat emitted");
// The proxy port is closed, so the check reports DOWN through the proxy
assert.strictEqual(heartbeat.args[0].status, 0);
} finally {
monitor.stop();
await server.close();
}
});
});
+139
View File
@@ -0,0 +1,139 @@
import { afterEach, describe, test } from "node:test";
import assert from "node:assert";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const Monitor = require("../../server/model/monitor");
const { R } = require("redbean-node");
const { UptimeKumaServer } = require("../../server/uptime-kuma-server");
const originals = {
getAll: R.getAll,
getMonitorNotification: Monitor.getMonitorNotification,
getMonitorTag: Monitor.getMonitorTag,
getInstance: UptimeKumaServer.getInstance,
};
afterEach(() => {
R.getAll = originals.getAll;
Monitor.getMonitorNotification = originals.getMonitorNotification;
Monitor.getMonitorTag = originals.getMonitorTag;
UptimeKumaServer.getInstance = originals.getInstance;
});
/**
* Stub the database and the maintenance registry
* @param {Array} monitors All monitor rows
* @param {Array} maintenanceRows monitor_id/maintenance_id rows
* @param {Map} maintenanceById Maintenance id to maintenance object
* @param {Array} tags monitor tag rows
* @param {Array} notifications monitor notification rows
* @returns {void}
*/
function stubPreload(monitors, maintenanceRows, maintenanceById, { tags = [], notifications = [] } = {}) {
Monitor.getMonitorNotification = async () => notifications;
Monitor.getMonitorTag = async () => tags;
R.getAll = async (sql) => (sql.includes("monitor_maintenance") ? maintenanceRows : monitors);
UptimeKumaServer.getInstance = () => ({
getMaintenance: (id) => maintenanceById.get(id),
});
}
/**
* Plain monitor rows as returned by the preload query
* @param {number} id Monitor id
* @param {object} fields Overrides
* @returns {object} Monitor row
*/
function monitorRow(id, fields = {}) {
return { id, name: `Monitor ${id}`, parent: null, active: 1, ...fields };
}
describe("Monitor.preparePreloadData()", () => {
test("returns empty maps without monitors", async () => {
const result = await Monitor.preparePreloadData([]);
assert.strictEqual(result.paths.size, 0);
assert.strictEqual(result.childrenIDs.size, 0);
assert.strictEqual(result.activeStatus.size, 0);
});
test("builds paths, children and effective active state from the hierarchy", async () => {
const monitors = [
monitorRow(1, { name: "Group" }),
monitorRow(2, { name: "Child", parent: 1 }),
monitorRow(3, { name: "Grandchild", parent: 2, active: 0 }),
monitorRow(4, { name: "Sibling", parent: 1 }),
];
stubPreload(monitors, [], new Map());
const result = await Monitor.preparePreloadData(monitors);
assert.deepStrictEqual(result.paths.get(3), ["Group", "Child", "Grandchild"]);
assert.deepStrictEqual(result.paths.get(4), ["Group", "Sibling"]);
assert.deepStrictEqual(result.childrenIDs.get(1), [2, 3, 4]);
assert.strictEqual(result.activeStatus.get(1), true);
assert.strictEqual(result.activeStatus.get(3), false);
assert.strictEqual(result.forceInactive.get(3), false);
});
test("marks children of an inactive group as force inactive", async () => {
const monitors = [monitorRow(1, { active: 0 }), monitorRow(2, { parent: 1 })];
stubPreload(monitors, [], new Map());
const result = await Monitor.preparePreloadData(monitors);
assert.strictEqual(result.activeStatus.get(2), false);
assert.strictEqual(result.forceInactive.get(2), true);
assert.strictEqual(result.forceInactive.get(1), false);
});
test("inherits maintenance from the parent group", async () => {
const monitors = [monitorRow(1), monitorRow(2, { parent: 1 }), monitorRow(3, { parent: 2 })];
const maintenanceRows = [{ monitor_id: 2, maintenance_id: 10 }];
const maintenance = new Map([[10, { isUnderMaintenance: async () => true }]]);
stubPreload(monitors, maintenanceRows, maintenance);
const result = await Monitor.preparePreloadData(monitors);
assert.strictEqual(result.maintenanceStatus.get(1), false);
assert.strictEqual(result.maintenanceStatus.get(2), true);
assert.strictEqual(result.maintenanceStatus.get(3), true);
});
test("ignores a maintenance window that is not active", async () => {
const monitors = [monitorRow(1)];
const maintenance = new Map([[10, { isUnderMaintenance: async () => false }]]);
stubPreload(monitors, [{ monitor_id: 1, maintenance_id: 10 }], maintenance);
const result = await Monitor.preparePreloadData(monitors);
assert.strictEqual(result.maintenanceStatus.get(1), false);
});
test("returns an empty path for a monitor missing from the hierarchy", async () => {
const monitor = monitorRow(9, { name: "Ghost" });
// The monitor list does not contain this id
stubPreload([monitorRow(1)], [], new Map());
const result = await Monitor.preparePreloadData([monitor]);
assert.deepStrictEqual(result.paths.get(9), []);
assert.deepStrictEqual(result.childrenIDs.get(9), []);
});
test("maps tags and notifications by monitor", async () => {
const monitors = [monitorRow(1)];
stubPreload(monitors, [], new Map(), {
tags: [{ monitor_id: 1, tag_id: 5, value: "prod", name: "env", color: "#fff" }],
notifications: [{ monitor_id: 1, notification_id: 9 }],
});
const result = await Monitor.preparePreloadData(monitors);
assert.deepStrictEqual(result.tags.get(1), [
{ tag_id: 5, monitor_id: 1, value: "prod", name: "env", color: "#fff" },
]);
assert.deepStrictEqual(result.notifications.get(1), { 9: true });
});
});
@@ -0,0 +1,132 @@
import { afterEach, describe, test } from "node:test";
import assert from "node:assert";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const Monitor = require("../../server/model/monitor");
const { MonitorStats } = require("../../server/monitor-stats");
const originals = {
get: MonitorStats.get,
maybeRefresh: MonitorStats.maybeRefresh,
sendCertInfo: Monitor.sendCertInfo,
sendDomainInfo: Monitor.sendDomainInfo,
now: Date.now,
};
afterEach(() => {
MonitorStats.get = originals.get;
MonitorStats.maybeRefresh = originals.maybeRefresh;
Monitor.sendCertInfo = originals.sendCertInfo;
Monitor.sendDomainInfo = originals.sendDomainInfo;
Date.now = originals.now;
});
/**
* Socket.io-like object with an optional client in the user room
* @param {boolean} hasClient Whether the room has a socket
* @returns {object} IO with recorded events
*/
function fakeIo(hasClient) {
const events = [];
return {
events,
sockets: {
adapter: {
rooms: new Map(hasClient ? [["user1", new Set(["socket"])]] : []),
},
},
to(room) {
return {
emit(event, ...args) {
events.push({ room, event, args });
},
};
},
};
}
/**
* Stub the cert/domain senders
* @returns {object} Call counters
*/
function stubInfoSenders() {
const calls = { cert: 0, domain: 0 };
Monitor.sendCertInfo = async () => {
calls.cert++;
};
Monitor.sendDomainInfo = async () => {
calls.domain++;
};
return calls;
}
describe("Monitor.sendStats()", () => {
test("does nothing when nobody is connected", async () => {
const io = fakeIo(false);
const calls = stubInfoSenders();
let refreshed = 0;
MonitorStats.get = () => undefined;
MonitorStats.maybeRefresh = () => {
refreshed++;
};
await Monitor.sendStats(io, 1, "user1");
assert.strictEqual(io.events.length, 0);
assert.deepStrictEqual(calls, { cert: 0, domain: 0 });
assert.strictEqual(refreshed, 0);
});
test("emits the cached stats and sends cert/domain info once per minute", async () => {
const io = fakeIo(true);
const calls = stubInfoSenders();
const cached = { avgPing: 12.5, uptime24: 0.99, uptime720: 0.98, uptime1y: 0.97 };
let refreshed = 0;
MonitorStats.get = () => cached;
MonitorStats.maybeRefresh = () => {
refreshed++;
};
Date.now = () => 1000000;
await Monitor.sendStats(io, 101, "user1");
const statsEvents = io.events.filter((event) => event.event === "stats");
assert.strictEqual(statsEvents.length, 1);
assert.deepStrictEqual(statsEvents[0].args[0], { 101: cached });
assert.strictEqual(refreshed, 1);
assert.deepStrictEqual(calls, { cert: 1, domain: 1 });
// A second beat within the same minute only emits the stats
await Monitor.sendStats(io, 101, "user1");
assert.strictEqual(io.events.filter((event) => event.event === "stats").length, 2);
assert.deepStrictEqual(calls, { cert: 1, domain: 1 });
});
test("sends the info again after the refresh interval", async () => {
const io = fakeIo(true);
const calls = stubInfoSenders();
MonitorStats.get = () => undefined;
MonitorStats.maybeRefresh = () => {};
Date.now = () => 2000000;
await Monitor.sendStats(io, 202, "user1");
Date.now = () => 2000000 + 60 * 1000;
await Monitor.sendStats(io, 202, "user1");
assert.deepStrictEqual(calls, { cert: 2, domain: 2 });
});
test("skips the stats event when no cache entry exists", async () => {
const io = fakeIo(true);
stubInfoSenders();
MonitorStats.get = () => undefined;
MonitorStats.maybeRefresh = () => {};
Date.now = () => 3000000;
await Monitor.sendStats(io, 303, "user1");
assert.strictEqual(io.events.filter((event) => event.event === "stats").length, 0);
});
});
+67
View File
@@ -0,0 +1,67 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const Monitor = require("../../server/model/monitor");
/**
* Build a monitor instance without touching the database
* @param {object} fields Field overrides
* @returns {object} Monitor instance
*/
function fakeMonitor(fields = {}) {
return Object.assign(Object.create(Monitor.prototype), {
id: 9001,
name: "Start Monitor",
type: "http",
interval: 60,
getTags: async () => [],
...fields,
});
}
/**
* Start a monitor while capturing the scheduled timer instead of running the check
* @param {object} monitor Monitor instance
* @param {number} firstBeatDelayMs Delay passed to start()
* @returns {Promise<number | null>} Scheduled delay in ms
*/
async function captureStartDelay(monitor, firstBeatDelayMs) {
const originalSetTimeout = global.setTimeout;
let delayMs = null;
global.setTimeout = (fn, ms) => {
delayMs = ms;
return {};
};
try {
await monitor.start({}, firstBeatDelayMs);
} finally {
global.setTimeout = originalSetTimeout;
}
return delayMs;
}
describe("Monitor.start() first beat", () => {
test("spreads the first check by the given delay", async () => {
const delay = await captureStartDelay(fakeMonitor(), 30000);
assert.strictEqual(delay, 30000);
});
test("push monitors wait for their interval instead", async () => {
const delay = await captureStartDelay(fakeMonitor({ type: "push", interval: 60 }), 0);
assert.strictEqual(delay, 60 * 1000);
});
test("keeps the per-monitor interval for the next check", async () => {
const monitor = fakeMonitor({ interval: 120 });
await captureStartDelay(monitor, 15000);
assert.strictEqual(monitor.interval, 120);
});
});
+168
View File
@@ -0,0 +1,168 @@
import { beforeEach, describe, test } from "node:test";
import assert from "node:assert";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { MonitorStats } = require("../../server/monitor-stats");
/** The class keeps private statics, which are plain properties at runtime */
const stats = MonitorStats;
/** Fixed clock for compute() */
const NOW = { unix: () => 1704067200 };
const originalSumStats = stats.sumStats;
const originalDaily = stats.sumDailyWindows;
const originalCompute = stats.compute;
/**
* Reset the cache and the stubs between tests
* @returns {void}
*/
function reset() {
stats.cache.clear();
stats.lastRefresh = 0;
stats.refreshPromise = null;
stats.sumStats = originalSumStats;
stats.sumDailyWindows = originalDaily;
stats.compute = originalCompute;
}
beforeEach(reset);
/**
* Build a socket.io-like emitter that records the events
* @returns {object} Room emitter and the emitted events
*/
function fakeIo() {
const events = [];
return {
events,
to(room) {
return {
emit(event, payload) {
events.push({ room, event, payload });
},
};
},
};
}
describe("MonitorStats.compute()", () => {
test("derives uptime and average ping from the grouped sums", async () => {
stats.sumStats = async () => new Map([[1, { monitor_id: 1, up: 3, down: 1, ping_sum: 300 }]]);
stats.sumDailyWindows = async () => new Map([[1, { up30: 29, down30: 1, up1y: 364, down1y: 1 }]]);
const result = await stats.compute([1], NOW);
assert.deepStrictEqual(result[1], {
avgPing: 100,
uptime24: 0.75,
uptime720: 29 / 30,
uptime1y: 364 / 365,
});
});
test("returns zeros when the monitor has no stat rows", async () => {
stats.sumStats = async () => new Map();
stats.sumDailyWindows = async () => new Map();
const result = await stats.compute([5], NOW);
assert.deepStrictEqual(result[5], {
avgPing: null,
uptime24: 0,
uptime720: 0,
uptime1y: 0,
});
});
test("rejects invalid monitor ids", async () => {
stats.sumStats = async () => new Map();
stats.sumDailyWindows = async () => new Map();
await assert.rejects(() => stats.compute([0]));
});
});
describe("MonitorStats.refresh()", () => {
test("shares one scan between concurrent callers", async () => {
let calls = 0;
stats.compute = async () => {
calls++;
return { 1: { avgPing: 10, uptime24: 1, uptime720: 1, uptime1y: 1 } };
};
const [first, second] = await Promise.all([stats.refresh([1]), stats.refresh([1])]);
assert.strictEqual(calls, 1);
assert.deepStrictEqual(first, second);
assert.deepStrictEqual(stats.cache.get(1), first[1]);
assert.ok(stats.lastRefresh > 0);
});
test("does not keep a failed scan cached", async () => {
stats.compute = async () => {
throw new Error("scan failed");
};
await assert.rejects(() => stats.refresh([1]), /scan failed/);
assert.strictEqual(stats.refreshPromise, null);
assert.strictEqual(stats.cache.size, 0);
});
});
describe("MonitorStats cache", () => {
test("getCached() returns only monitors that are cached", () => {
stats.cache.set(1, { avgPing: 1, uptime24: 1, uptime720: 1, uptime1y: 1 });
assert.deepStrictEqual(Object.keys(stats.getCached([1, 2])), ["1"]);
});
test("get() accepts a string id", () => {
stats.cache.set(3, { avgPing: 5, uptime24: 1, uptime720: 1, uptime1y: 1 });
assert.strictEqual(stats.get("3").avgPing, 5);
assert.strictEqual(stats.get(4), undefined);
});
});
describe("MonitorStats.maybeRefresh()", () => {
test("skips the scan while the cache is fresh", () => {
let calls = 0;
stats.compute = async () => {
calls++;
return {};
};
stats.lastRefresh = Date.now();
MonitorStats.maybeRefresh(fakeIo(), "user", [1]);
assert.strictEqual(calls, 0);
});
test("refreshes and emits the stats when the cache is stale", async () => {
stats.compute = async () => ({ 1: { avgPing: 2, uptime24: 1, uptime720: 1, uptime1y: 1 } });
const io = fakeIo();
MonitorStats.maybeRefresh(io, "user", [1]);
await new Promise((resolve) => setImmediate(resolve));
MonitorStats.maybeRefresh(io, "user", [1]);
await new Promise((resolve) => setImmediate(resolve));
assert.strictEqual(io.events.length, 1);
assert.strictEqual(io.events[0].event, "stats");
assert.strictEqual(io.events[0].room, "user");
});
test("allows another attempt after a failed scan", async () => {
stats.compute = async () => {
throw new Error("scan failed");
};
MonitorStats.maybeRefresh(fakeIo(), "user", [1]);
await new Promise((resolve) => setImmediate(resolve));
assert.strictEqual(stats.lastRefresh, 0);
});
});
+60
View File
@@ -0,0 +1,60 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { readTlsInfo } = require("../../server/monitor-tls");
const raw = Buffer.from("certificate");
/**
* Certificate helper stubs
* @param {boolean} hostnameMatches Value returned by the hostname check
* @returns {object} Dependencies and calls
*/
function deps(hostnameMatches) {
const calls = { certificate: 0, hostname: [] };
return {
calls,
deps: {
checkCertificate: () => {
calls.certificate++;
return { certInfo: { raw } };
},
checkCertificateHostname: (certRaw, hostname) => {
calls.hostname.push({ certRaw, hostname });
return hostnameMatches;
},
},
};
}
describe("readTlsInfo()", () => {
test("marks a certificate as valid when the socket is authorized", () => {
const { deps: helpers, calls } = deps(true);
const info = readTlsInfo({ authorized: true }, "example.com", helpers);
assert.strictEqual(info.valid, true);
assert.strictEqual(info.hostnameMatchMonitorUrl, true);
assert.strictEqual(calls.certificate, 1);
assert.deepStrictEqual(calls.hostname, [{ certRaw: raw, hostname: "example.com" }]);
});
test("treats a missing authorization as invalid", () => {
const { deps: helpers } = deps(true);
const info = readTlsInfo({}, "example.com", helpers);
assert.strictEqual(info.valid, false);
});
test("keeps a hostname mismatch", () => {
const { deps: helpers } = deps(false);
const info = readTlsInfo({ authorized: true }, "wrong.example", helpers);
assert.strictEqual(info.valid, true);
assert.strictEqual(info.hostnameMatchMonitorUrl, false);
});
});
+265
View File
@@ -0,0 +1,265 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const Monitor = require("../../server/model/monitor");
const { MIN_INTERVAL_SECOND, RESPONSE_BODY_LENGTH_MAX } = require("../../src/util");
/**
* Build a Monitor instance without touching the database
* @param {object} fields Monitor fields
* @returns {object} Monitor instance
*/
function monitorWith(fields) {
// toJSON() parses these JSON columns unconditionally
return Object.assign(
Object.create(Monitor.prototype),
{
kafkaProducerBrokers: "[]",
kafkaProducerSaslOptions: "{}",
rabbitmqNodes: "[]",
conditions: "[]",
accepted_statuscodes_json: '["200-299"]',
},
fields
);
}
/**
* Preload maps as produced by Monitor.preparePreloadData()
* @param {number} id Monitor id
* @returns {object} Preload data
*/
function preloadFor(id) {
return {
paths: new Map([[id, ["Group"]]]),
childrenIDs: new Map([[id, [2, 3]]]),
activeStatus: new Map([[id, true]]),
forceInactive: new Map([[id, false]]),
tags: new Map([[id, [{ id: 1, name: "env", value: "prod" }]]]),
notifications: new Map([[id, { 1: true }]]),
maintenanceStatus: new Map([[id, false]]),
};
}
describe("Monitor", () => {
describe("getUrl()", () => {
test("returns a URL object for a valid url", () => {
const monitor = monitorWith({ url: "https://example.com/path?q=1" });
assert.strictEqual(monitor.getUrl().href, "https://example.com/path?q=1");
});
test("returns null for an invalid url", () => {
assert.strictEqual(monitorWith({ url: "not a url" }).getUrl(), null);
});
});
describe("getURLProtocol()", () => {
test("returns the protocol of a valid url", () => {
assert.strictEqual(monitorWith({ url: "https://example.com" }).getURLProtocol(), "https:");
});
test("returns null for an invalid url", () => {
assert.strictEqual(monitorWith({ url: "example" }).getURLProtocol(), null);
});
});
describe("getAcceptedStatuscodes()", () => {
test("parses the stored JSON array", () => {
const monitor = monitorWith({ accepted_statuscodes_json: '["200-299","404"]' });
assert.deepStrictEqual(monitor.getAcceptedStatuscodes(), ["200-299", "404"]);
});
});
describe("boolean getters", () => {
test("coerce stored numbers to booleans", () => {
const monitor = monitorWith({
upsideDown: 1,
ignoreTls: 0,
ping_numeric: 1,
invertKeyword: 0,
cacheBust: 1,
grpcEnableTls: 0,
save_response: 1,
save_error_response: 0,
expiryNotification: 1,
kafkaProducerSsl: 1,
});
assert.strictEqual(monitor.isUpsideDown(), true);
assert.strictEqual(monitor.getIgnoreTls(), false);
assert.strictEqual(monitor.isPingNumeric(), true);
assert.strictEqual(monitor.isInvertKeyword(), false);
assert.strictEqual(monitor.getCacheBust(), true);
assert.strictEqual(monitor.getGrpcEnableTls(), false);
assert.strictEqual(monitor.getSaveResponse(), true);
assert.strictEqual(monitor.getSaveErrorResponse(), false);
assert.strictEqual(monitor.isEnabledExpiryNotification(), true);
assert.strictEqual(monitor.getKafkaProducerSsl(), true);
});
});
describe("validate()", () => {
test("accepts a valid configuration", () => {
const monitor = monitorWith({ interval: MIN_INTERVAL_SECOND, retryInterval: MIN_INTERVAL_SECOND });
assert.doesNotThrow(() => monitor.validate());
});
test("rejects an interval below the minimum", () => {
const monitor = monitorWith({ interval: MIN_INTERVAL_SECOND - 1, retryInterval: MIN_INTERVAL_SECOND });
assert.throws(() => monitor.validate(), /Interval cannot be less than/);
});
test("rejects a retry interval below the minimum", () => {
const monitor = monitorWith({ interval: MIN_INTERVAL_SECOND, retryInterval: MIN_INTERVAL_SECOND - 1 });
assert.throws(() => monitor.validate(), /Retry interval cannot be less than/);
});
test("rejects a negative response max length", () => {
const monitor = monitorWith({
interval: MIN_INTERVAL_SECOND,
retryInterval: MIN_INTERVAL_SECOND,
response_max_length: -1,
});
assert.throws(() => monitor.validate(), /Response max length cannot be less than 0/);
});
test("rejects a response max length above the limit", () => {
const monitor = monitorWith({
interval: MIN_INTERVAL_SECOND,
retryInterval: MIN_INTERVAL_SECOND,
response_max_length: RESPONSE_BODY_LENGTH_MAX + 1,
});
assert.throws(() => monitor.validate(), /Response max length cannot be more than/);
});
test("rejects invalid kafka producer brokers JSON", () => {
const monitor = monitorWith({
interval: MIN_INTERVAL_SECOND,
retryInterval: MIN_INTERVAL_SECOND,
kafkaProducerBrokers: "{not json",
});
assert.throws(() => monitor.validate(), /Kafka Producer Brokers must be valid JSON/);
});
});
describe("isImportantBeat()", () => {
test("treats the first beat as important", () => {
assert.strictEqual(Monitor.isImportantBeat(true, null, 0), true);
});
test("treats up to down as important", () => {
assert.strictEqual(Monitor.isImportantBeat(false, 1, 0), true);
});
test("treats down to up as important", () => {
assert.strictEqual(Monitor.isImportantBeat(false, 0, 1), true);
});
test("treats pending to down as important", () => {
assert.strictEqual(Monitor.isImportantBeat(false, 2, 0), true);
});
test("treats maintenance to up as important", () => {
assert.strictEqual(Monitor.isImportantBeat(false, 3, 1), true);
});
test("treats up to maintenance as important", () => {
assert.strictEqual(Monitor.isImportantBeat(false, 1, 3), true);
});
test("treats up to up as not important", () => {
assert.strictEqual(Monitor.isImportantBeat(false, 1, 1), false);
});
test("treats down to down as not important", () => {
assert.strictEqual(Monitor.isImportantBeat(false, 0, 0), false);
});
});
describe("isImportantForNotification()", () => {
test("treats maintenance to up as not important", () => {
assert.strictEqual(Monitor.isImportantForNotification(false, 3, 1), false);
});
test("treats down to maintenance as not important", () => {
assert.strictEqual(Monitor.isImportantForNotification(false, 0, 3), false);
});
test("treats maintenance to down as important", () => {
assert.strictEqual(Monitor.isImportantForNotification(false, 3, 0), true);
});
test("treats the first beat as important", () => {
assert.strictEqual(Monitor.isImportantForNotification(true, null, 0), true);
});
});
describe("toJSON()", () => {
test("adds preloaded paths, children, tags and status", () => {
const monitor = monitorWith({
id: 1,
name: "api",
type: "http",
url: "https://example.com",
interval: 60,
weight: 2000,
parent: null,
accepted_statuscodes_json: '["200-299"]',
});
const json = monitor.toJSON(preloadFor(1));
assert.strictEqual(json.id, 1);
assert.strictEqual(json.name, "api");
assert.strictEqual(json.pathName, "Group");
assert.deepStrictEqual(json.childrenIDs, [2, 3]);
assert.deepStrictEqual(json.tags, [{ id: 1, name: "env", value: "prod" }]);
assert.strictEqual(json.active, true);
});
test("omits sensitive request data unless explicitly included", () => {
const monitor = monitorWith({
id: 1,
name: "api",
type: "http",
url: "https://example.com",
interval: 60,
accepted_statuscodes_json: '["200-299"]',
headers: '{"x-key":"secret"}',
body: "payload",
});
const publicJson = monitor.toJSON(preloadFor(1), false);
const fullJson = monitor.toJSON(preloadFor(1), true);
assert.strictEqual(publicJson.headers, undefined);
assert.strictEqual(publicJson.body, undefined);
assert.strictEqual(fullJson.headers, '{"x-key":"secret"}');
assert.strictEqual(fullJson.body, "payload");
});
test("adds a screenshot path for real-browser monitors", () => {
const monitor = monitorWith({
id: 1,
name: "browser",
type: "real-browser",
weight: 2000,
accepted_statuscodes_json: '["200-299"]',
});
const json = monitor.toJSON(preloadFor(1));
assert.strictEqual(json.screenshot, "/screenshots/1.png");
});
});
});
+67
View File
@@ -0,0 +1,67 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const crypto = require("crypto");
const hash = require("../../server/modules/axios-ntlm/lib/hash.js");
/**
* Replace crypto.createHash for one test
* @param {Function} replacement Stub
* @returns {Function} Restore function
*/
function stubCreateHash(replacement) {
const original = crypto.createHash;
crypto.createHash = replacement;
return () => {
crypto.createHash = original;
};
}
// Known NTLM hash vectors: MD4 of the password encoded as UTF-16LE
describe("NTLM hash", () => {
describe("createNTLMHash()", () => {
test("returns the documented hash for the password 'Password'", () => {
assert.strictEqual(hash.createNTLMHash("Password").toString("hex"), "a4f49c406510bdcab6824ee7c30fd852");
});
test("returns the documented hash for an empty password", () => {
assert.strictEqual(hash.createNTLMHash("").toString("hex"), "31d6cfe0d16ae931b73c59d7e0c089c0");
});
test("encodes the password as UTF-16LE before hashing", () => {
assert.strictEqual(hash.createNTLMHash("päss").toString("hex"), "411b68984d6b19bbb302455798320a06");
});
});
});
describe("MD4 fallback", () => {
test("uses js-md4 when OpenSSL rejects MD4", () => {
const restore = stubCreateHash(() => {
const error = new Error("Digest method not supported");
error.code = "ERR_OSSL_EVP_UNSUPPORTED";
throw error;
});
try {
assert.strictEqual(hash.createNTLMHash("Password").toString("hex"), "a4f49c406510bdcab6824ee7c30fd852");
} finally {
restore();
}
});
test("rethrows other crypto errors", () => {
const restore = stubCreateHash(() => {
const error = new Error("boom");
error.code = "EOTHER";
throw error;
});
try {
assert.throws(() => hash.createNTLMHash("Password"), /boom/);
} finally {
restore();
}
});
});
+47
View File
@@ -0,0 +1,47 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const passwordHash = require("../../server/password-hash");
const legacyPasswordHash = require("password-hash");
describe("password-hash", () => {
test("generate() returns a bcrypt hash", async () => {
const hash = await passwordHash.generate("secret");
assert.match(hash, /^\$2[aby]\$/);
assert.notStrictEqual(hash, "secret");
});
test("verify() accepts the matching password", async () => {
const hash = await passwordHash.generate("secret");
assert.strictEqual(passwordHash.verify("secret", hash), true);
});
test("verify() rejects a wrong password", async () => {
const hash = await passwordHash.generate("secret");
assert.strictEqual(passwordHash.verify("wrong", hash), false);
});
test("needRehash() is false for a current bcrypt hash", async () => {
const hash = await passwordHash.generate("secret");
assert.strictEqual(passwordHash.needRehash(hash), false);
});
test("verify() still accepts a legacy sha1 hash", () => {
const legacyHash = legacyPasswordHash.generate("secret", { algorithm: "sha1" });
assert.strictEqual(passwordHash.verify("secret", legacyHash), true);
assert.strictEqual(passwordHash.verify("wrong", legacyHash), false);
});
test("needRehash() asks for a rehash of a legacy sha1 hash", () => {
const legacyHash = legacyPasswordHash.generate("secret", { algorithm: "sha1" });
assert.strictEqual(passwordHash.needRehash(legacyHash), true);
});
});
@@ -0,0 +1,61 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { Prometheus } from "../../server/prometheus";
describe("Prometheus helpers", () => {
describe("sanitizeForPrometheus()", () => {
test("removes characters that are invalid in a label name", () => {
assert.strictEqual(Prometheus.sanitizeForPrometheus("hello world!"), "helloworld");
});
test("removes a leading digit", () => {
assert.strictEqual(Prometheus.sanitizeForPrometheus("123abc"), "abc");
});
test("keeps underscores and existing letters", () => {
assert.strictEqual(Prometheus.sanitizeForPrometheus("_abc_123"), "_abc_123");
});
test("returns an empty string when nothing valid is left", () => {
assert.strictEqual(Prometheus.sanitizeForPrometheus("!!!"), "");
});
test("removes separators inside a name", () => {
assert.strictEqual(Prometheus.sanitizeForPrometheus("a-b.c"), "abc");
});
});
describe("mapTagsToLabels()", () => {
const mapTags = (tags) => Prometheus.prototype.mapTagsToLabels.call({ sortTags: Prometheus.sortTags }, tags);
test("groups values by sanitized tag name and sorts them", () => {
const labels = mapTags([
{ name: "env", value: "prod" },
{ name: "env", value: "dev" },
]);
assert.deepStrictEqual(labels, { env: ["dev", "prod"] });
});
test("skips a tag name that sanitizes to nothing", () => {
const labels = mapTags([{ name: "!!!", value: "x" }]);
assert.deepStrictEqual(labels, {});
});
test("skips an empty tag value", () => {
const labels = mapTags([{ name: "env", value: "" }]);
assert.deepStrictEqual(labels, { env: [] });
});
test("keeps tag names ordered alphabetically", () => {
const labels = mapTags([
{ name: "zone", value: "a" },
{ name: "env", value: "b" },
]);
assert.deepStrictEqual(Object.keys(labels), ["env", "zone"]);
});
});
});
@@ -0,0 +1,45 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { Prometheus } = require("../../server/prometheus");
describe("Prometheus", () => {
describe("constructor", () => {
test("builds the monitor label values", () => {
const monitor = {
id: 12,
name: "API",
type: "http",
url: "https://example.com",
hostname: "example.com",
port: 443,
};
const prometheus = new Prometheus(monitor, []);
assert.deepStrictEqual(prometheus.monitorLabelValues, {
monitor_id: 12,
monitor_name: "API",
monitor_type: "http",
monitor_url: "https://example.com",
monitor_hostname: "example.com",
monitor_port: 443,
});
});
test("adds sanitized tag labels", () => {
const monitor = { id: 1, name: "db", type: "port", hostname: "db.local", port: 5432 };
const tags = [
{ name: "env", value: "prod" },
{ name: "team name", value: "data ops" },
];
const prometheus = new Prometheus(monitor, tags);
assert.deepStrictEqual(prometheus.monitorLabelValues.env, ["prod"]);
assert.deepStrictEqual(prometheus.monitorLabelValues.teamname, ["dataops"]);
});
});
});
@@ -0,0 +1,71 @@
import { afterEach, describe, test } from "node:test";
import assert from "node:assert";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { updatePrometheusStats } = require("../../server/prometheus-monitor");
const { MonitorStats } = require("../../server/monitor-stats");
const originalGet = MonitorStats.get;
afterEach(() => {
MonitorStats.get = originalGet;
});
/**
* Prometheus wrapper that records the update call
* @returns {object} Wrapper and its calls
*/
function fakePrometheus() {
const calls = [];
return {
calls,
update(...args) {
calls.push(args);
},
};
}
describe("updatePrometheusStats()", () => {
test("passes the cached stats in the prometheus shape", () => {
MonitorStats.get = () => ({ avgPing: 12.345, uptime24: 0.9, uptime720: 0.8, uptime1y: 0.7 });
const prometheus = fakePrometheus();
const heartbeat = { id: 1 };
const tlsInfo = { valid: true };
const usedCache = updatePrometheusStats(prometheus, 5, heartbeat, tlsInfo);
assert.strictEqual(usedCache, true);
assert.strictEqual(prometheus.calls.length, 1);
assert.strictEqual(prometheus.calls[0][0], heartbeat);
assert.strictEqual(prometheus.calls[0][1], tlsInfo);
assert.deepStrictEqual(prometheus.calls[0][2], {
data24h: { uptime: 0.9, avgPing: 12.345 },
data30d: { uptime: 0.8, avgPing: 12.345 },
data1y: { uptime: 0.7, avgPing: 12.345 },
});
});
test("passes null when the cache is cold", () => {
MonitorStats.get = () => undefined;
const prometheus = fakePrometheus();
const usedCache = updatePrometheusStats(prometheus, 5, {}, undefined);
assert.strictEqual(usedCache, false);
assert.deepStrictEqual(prometheus.calls[0][2], null);
});
test("uses zero for a monitor without a ping", () => {
MonitorStats.get = () => ({ avgPing: null, uptime24: 1, uptime720: 1, uptime1y: 1 });
const prometheus = fakePrometheus();
updatePrometheusStats(prometheus, 5, {}, undefined);
assert.deepStrictEqual(prometheus.calls[0][2], {
data24h: { uptime: 1, avgPing: 0 },
data30d: { uptime: 1, avgPing: 0 },
data1y: { uptime: 1, avgPing: 0 },
});
});
});
+55
View File
@@ -0,0 +1,55 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { Proxy } = require("../../server/proxy");
/**
* Build an HTTP proxy config
* @param {object} overrides Fields to override
* @returns {object} Proxy config
*/
function httpProxy(overrides = {}) {
return { protocol: "http", host: "127.0.0.1", port: 8080, auth: false, ...overrides };
}
describe("Proxy.createAgents()", () => {
test("creates separate http and https agents for an http proxy", () => {
const { httpAgent, httpsAgent } = Proxy.createAgents(httpProxy(), {
httpAgentOptions: {},
httpsAgentOptions: {},
});
assert.ok(httpAgent);
assert.ok(httpsAgent);
assert.notStrictEqual(httpAgent, httpsAgent);
});
test("passes the proxy credentials to the agent url", () => {
const { httpAgent } = Proxy.createAgents(httpProxy({ auth: true, username: "user", password: "pass" }), {
httpAgentOptions: {},
httpsAgentOptions: {},
});
assert.strictEqual(httpAgent.proxy.username, "user");
assert.strictEqual(httpAgent.proxy.password, "pass");
});
test("uses a single agent for socks proxies", () => {
const { httpAgent, httpsAgent } = Proxy.createAgents(httpProxy({ protocol: "socks5", port: 1080 }), {
httpAgentOptions: {},
httpsAgentOptions: { rejectUnauthorized: false },
});
assert.ok(httpAgent);
assert.strictEqual(httpAgent, httpsAgent);
});
test("rejects an unsupported proxy protocol", () => {
assert.throws(
() => Proxy.createAgents(httpProxy({ protocol: "ftp" }), { httpAgentOptions: {}, httpsAgentOptions: {} }),
/Unsupported proxy protocol provided/
);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { loginRateLimiter, apiRateLimiter } = require("../../server/rate-limiter");
const perfHooks = require("perf_hooks");
describe("KumaRateLimiter", () => {
test("pass() allows a request while tokens remain", async () => {
assert.strictEqual(await apiRateLimiter.pass(), true);
});
test("removeTokens() reports the remaining tokens", async () => {
// The limiter refills from performance.now(); freeze it so the two calls see no refill
const original = Object.getOwnPropertyDescriptor(perfHooks, "performance");
Object.defineProperty(perfHooks, "performance", {
configurable: true,
value: { now: () => 1000 },
});
try {
const before = await apiRateLimiter.removeTokens(1);
const after = await apiRateLimiter.removeTokens(1);
assert.strictEqual(after, before - 1);
} finally {
Object.defineProperty(perfHooks, "performance", original);
}
});
test("pass() rejects requests after the tokens are used up", async () => {
const responses = [];
const results = [];
// loginRateLimiter is configured with 20 tokens per minute
for (let i = 0; i < 20; i++) {
results.push(await loginRateLimiter.pass((res) => responses.push(res)));
}
const rejected = await loginRateLimiter.pass((res) => responses.push(res));
assert.deepStrictEqual(
results.every((allowed) => allowed === true),
true
);
assert.strictEqual(rejected, false);
assert.deepStrictEqual(responses, [{ ok: false, msg: "Too frequently, try again later." }]);
});
});
+86
View File
@@ -0,0 +1,86 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { afterEach } from "node:test";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { UptimeKumaServer } = require("../../server/uptime-kuma-server");
const { R } = require("redbean-node");
const Monitor = require("../../server/model/monitor");
const originals = {
findAll: R.findAll,
preparePreloadData: Monitor.preparePreloadData,
};
afterEach(() => {
R.findAll = originals.findAll;
Monitor.preparePreloadData = originals.preparePreloadData;
});
/**
* Stub the database rows and the preload data
* @returns {object} One monitor row
*/
function stubMonitorList() {
const monitor = {
id: 1,
name: "API",
type: "http",
parent: null,
interval: 60,
weight: 2000,
active: 1,
toJSON: () => ({ id: 1, full: true }),
};
R.findAll = async () => [monitor];
Monitor.preparePreloadData = async () => ({
paths: new Map([[1, ["Group"]]]),
activeStatus: new Map([[1, true]]),
childrenIDs: new Map([[1, []]]),
tags: new Map([[1, []]]),
});
return monitor;
}
describe("socket.io payload compression", () => {
test("compresses websocket and polling payloads above 1 KB", () => {
const server = UptimeKumaServer.getInstance();
const opts = server.io.engine.opts;
assert.strictEqual(opts.perMessageDeflate.threshold, 1024);
assert.strictEqual(opts.httpCompression.threshold, 1024);
});
});
describe("getMonitorJSONList()", () => {
test("returns the compact payload for the dashboard", async () => {
stubMonitorList();
const server = Object.create(UptimeKumaServer.prototype);
const list = await server.getMonitorJSONList(null, true);
assert.deepStrictEqual(list[1], {
id: 1,
name: "API",
type: "http",
active: true,
parent: null,
childrenIDs: [],
tags: [],
path: ["Group"],
pathName: "Group",
interval: 60,
weight: 2000,
});
});
test("returns the full payload when compact is not requested", async () => {
stubMonitorList();
const server = Object.create(UptimeKumaServer.prototype);
const list = await server.getMonitorJSONList(null, false);
assert.deepStrictEqual(list[1], { id: 1, full: true });
});
});
+50
View File
@@ -0,0 +1,50 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { chunk, firstBeatDelayMs } from "../../server/start-monitors";
describe("chunk()", () => {
test("returns no batches for an empty list", () => {
assert.deepStrictEqual(chunk([], 50), []);
});
test("splits monitors into batches of at most 50", () => {
const monitors = Array.from({ length: 120 }, (_, i) => i);
const batches = chunk(monitors, 50);
assert.deepStrictEqual(
batches.map((batch) => batch.length),
[50, 50, 20]
);
assert.strictEqual(batches[0][0], 0);
assert.strictEqual(batches[2][19], 119);
});
});
describe("firstBeatDelayMs()", () => {
test("stays inside the monitor interval", () => {
assert.strictEqual(
firstBeatDelayMs(60, () => 0),
0
);
assert.strictEqual(
firstBeatDelayMs(60, () => 0.5),
30000
);
assert.strictEqual(
firstBeatDelayMs(60, () => 1),
60000
);
});
test("never returns more than one second for a zero interval", () => {
assert.strictEqual(
firstBeatDelayMs(0, () => 1),
1000
);
});
test("treats a negative interval as one second", () => {
assert.ok(firstBeatDelayMs(-5, () => 0.999) <= 1000);
});
});
+90
View File
@@ -0,0 +1,90 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { startStatsRefresh } from "../../server/stats-refresh";
/**
* Create a refresh scheduler with counters
* @param {object} options Overrides
* @returns {object} Scheduler and counters
*/
function createScheduler(options = {}) {
const calls = { refresh: 0, errors: [] };
const scheduler = startStatsRefresh({
monitorIDs: [1, 2],
refresh: async () => {
calls.refresh++;
},
onError: (error) => calls.errors.push(error),
...options,
});
return { scheduler, calls };
}
describe("startStatsRefresh()", () => {
test("warms the cache after the initial delay", (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
const { scheduler, calls } = createScheduler();
t.mock.timers.tick(4999);
assert.strictEqual(calls.refresh, 0);
t.mock.timers.tick(1);
assert.strictEqual(calls.refresh, 1);
scheduler.stop();
});
test("refreshes periodically", (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
const { scheduler, calls } = createScheduler();
t.mock.timers.tick(5000);
t.mock.timers.tick(15 * 60 * 1000);
assert.strictEqual(calls.refresh, 2);
t.mock.timers.tick(15 * 60 * 1000);
assert.strictEqual(calls.refresh, 3);
scheduler.stop();
});
test("reports refresh failures", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
const failure = new Error("scan failed");
const { scheduler, calls } = createScheduler({
refresh: async () => {
throw failure;
},
});
t.mock.timers.tick(5000);
await new Promise((resolve) => setImmediate(resolve));
assert.deepStrictEqual(calls.errors, [failure]);
scheduler.stop();
});
test("stop() cancels both timers", (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
const { scheduler, calls } = createScheduler();
scheduler.stop();
t.mock.timers.tick(15 * 60 * 1000);
assert.strictEqual(calls.refresh, 0);
});
test("does not keep the process alive by default", () => {
// The real timers are unref'ed; assert the option is applied by inspecting the return
const scheduler = startStatsRefresh({
monitorIDs: [],
refresh: async () => {},
onError: () => {},
initialDelayMs: 10,
intervalMs: 10000,
});
assert.strictEqual(typeof scheduler.stop, "function");
scheduler.stop();
});
});
+76
View File
@@ -0,0 +1,76 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const StatusPage = require("../../server/model/status_page");
const {
UP,
DOWN,
PENDING,
MAINTENANCE,
STATUS_PAGE_ALL_UP,
STATUS_PAGE_PARTIAL_DOWN,
STATUS_PAGE_ALL_DOWN,
STATUS_PAGE_MAINTENANCE,
} = require("../../src/util");
/**
* Build a StatusPage instance without touching the database
* @param {object} fields Status page fields
* @returns {object} StatusPage instance
*/
function statusPageWith(fields) {
return Object.assign(Object.create(StatusPage.prototype), fields);
}
describe("StatusPage", () => {
describe("overallStatus()", () => {
test("returns -1 when there are no services", () => {
assert.strictEqual(StatusPage.overallStatus([]), -1);
});
test("returns all up when every service is up", () => {
assert.strictEqual(StatusPage.overallStatus([{ status: UP }, { status: UP }]), STATUS_PAGE_ALL_UP);
});
test("returns partial down when at least one service is down", () => {
assert.strictEqual(StatusPage.overallStatus([{ status: UP }, { status: DOWN }]), STATUS_PAGE_PARTIAL_DOWN);
});
test("returns all down when no service is up", () => {
assert.strictEqual(StatusPage.overallStatus([{ status: DOWN }, { status: PENDING }]), STATUS_PAGE_ALL_DOWN);
});
test("returns maintenance when any service is under maintenance", () => {
assert.strictEqual(
StatusPage.overallStatus([{ status: UP }, { status: MAINTENANCE }]),
STATUS_PAGE_MAINTENANCE
);
});
});
describe("getStatusDescription()", () => {
test("describes every overall status", () => {
assert.strictEqual(StatusPage.getStatusDescription(-1), "No Services");
assert.strictEqual(StatusPage.getStatusDescription(STATUS_PAGE_ALL_UP), "All Systems Operational");
assert.strictEqual(StatusPage.getStatusDescription(STATUS_PAGE_PARTIAL_DOWN), "Partially Degraded Service");
assert.strictEqual(StatusPage.getStatusDescription(STATUS_PAGE_ALL_DOWN), "Degraded Service");
assert.strictEqual(StatusPage.getStatusDescription(MAINTENANCE), "Under maintenance");
});
test("returns a placeholder for an unknown status", () => {
assert.strictEqual(StatusPage.getStatusDescription(99), "?");
});
});
describe("getIcon()", () => {
test("falls back to the default icon", () => {
assert.strictEqual(statusPageWith({ icon: null }).getIcon(), "/icon.svg");
});
test("returns the configured icon", () => {
assert.strictEqual(statusPageWith({ icon: "/icon-custom.svg" }).getIcon(), "/icon-custom.svg");
});
});
});
@@ -0,0 +1,275 @@
import { afterEach, describe, test } from "node:test";
import assert from "node:assert";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { UptimeCalculator } = require("../../server/uptime-calculator");
const { R } = require("redbean-node");
const dayjs = require("dayjs");
const { UP } = require("../../src/util");
dayjs.extend(require("dayjs/plugin/utc"));
const originals = {
getAll: R.getAll,
findOne: R.findOne,
exec: R.exec,
knex: R.knex,
testBackend: process.env.TEST_BACKEND,
};
afterEach(() => {
R.getAll = originals.getAll;
R.findOne = originals.findOne;
R.exec = originals.exec;
// knex is a getter on the prototype; drop the own override to restore it
delete R.knex;
if (originals.testBackend === undefined) {
delete process.env.TEST_BACKEND;
} else {
process.env.TEST_BACKEND = originals.testBackend;
}
UptimeCalculator.currentDate = undefined;
});
/**
* Create a calculator for a monitor without a database
* @param {number} monitorID Monitor id
* @returns {UptimeCalculator} Calculator
*/
function calculator(monitorID = 7) {
const calc = new UptimeCalculator();
calc.monitorID = monitorID;
return calc;
}
describe("UptimeCalculator.loadHistory()", () => {
test("loads minutely buckets with their extras", async () => {
R.getAll = async (sql) => {
if (sql.includes("stat_minutely")) {
return [
{
timestamp: 1700000000,
up: 2,
down: 1,
ping: 10,
ping_min: 5,
ping_max: 20,
extras: '{"custom":1}',
},
];
}
return [];
};
const calc = calculator();
await calc.loadHistory();
assert.strictEqual(calc.historyLoaded, true);
assert.deepStrictEqual(calc.minutelyUptimeDataList[1700000000], {
up: 2,
down: 1,
avgPing: 10,
minPing: 5,
maxPing: 20,
custom: 1,
});
});
test("queries each stat table once", async () => {
const tables = [];
R.getAll = async (sql) => {
tables.push(sql.includes("stat_minutely") ? "minutely" : sql.includes("stat_hourly") ? "hourly" : "daily");
return [];
};
await calculator().loadHistory();
assert.deepStrictEqual(tables, ["minutely", "hourly", "daily"]);
});
});
describe("UptimeCalculator.addHistoryEntry()", () => {
test("merges into an existing bucket instead of duplicating it", () => {
const calc = calculator();
const list = calc.minutelyUptimeDataList;
list.push(100, { up: 1, down: 0, avgPing: 5 });
calc.addHistoryEntry(list, 100, { up: 2, down: 1, avgPing: 7 });
assert.strictEqual(list.length(), 1);
assert.deepStrictEqual(list[100], { up: 2, down: 1, avgPing: 7 });
});
test("pushes a new bucket", () => {
const calc = calculator();
const list = calc.minutelyUptimeDataList;
calc.addHistoryEntry(list, 200, { up: 1, down: 0, avgPing: 3 });
assert.strictEqual(list.length(), 1);
assert.deepStrictEqual(list[200], { up: 1, down: 0, avgPing: 3 });
});
});
describe("UptimeCalculator.ensureCurrentBuckets()", () => {
test("continues the current buckets from their stored rows", async () => {
let queries = 0;
R.findOne = async () => {
queries++;
return { up: 3, down: 1, ping: 8, pingMin: 4, pingMax: 12, extras: null };
};
const calc = calculator();
// The current buckets are created by get*Key during a normal beat
calc.minutelyUptimeDataList.push(100, { up: 0, down: 0, avgPing: 0, minPing: 0, maxPing: 0 });
calc.hourlyUptimeDataList.push(200, { up: 0, down: 0, avgPing: 0, minPing: 0, maxPing: 0 });
calc.dailyUptimeDataList.push(300, { up: 0, down: 0, avgPing: 0, minPing: 0, maxPing: 0 });
await calc.ensureCurrentBuckets(100, 200, 300);
assert.strictEqual(queries, 3);
assert.deepStrictEqual(calc.minutelyUptimeDataList[100], {
up: 3,
down: 1,
avgPing: 8,
minPing: 4,
maxPing: 12,
});
assert.strictEqual(calc.currentBucketsLoaded, true);
});
test("does nothing once the current buckets were loaded", async () => {
let queries = 0;
R.findOne = async () => {
queries++;
return null;
};
const calc = calculator();
await calc.ensureCurrentBuckets(100, 200, 300);
await calc.ensureCurrentBuckets(400, 500, 600);
assert.strictEqual(queries, 3);
});
});
describe("UptimeCalculator.upsertStat()", () => {
test("upserts the aggregated row and its extras", async () => {
let captured = null;
Object.defineProperty(R, "knex", {
configurable: true,
writable: true,
value: (table) => ({
insert(row) {
captured = { table, row };
return {
onConflict(columns) {
captured.conflict = columns;
return {
merge(mergeColumns) {
captured.merge = mergeColumns;
return Promise.resolve();
},
};
},
};
},
}),
});
await calculator().upsertStat("stat_minutely", 123, {
up: 1,
down: 2,
avgPing: 3,
minPing: 1,
maxPing: 5,
custom: "x",
});
assert.strictEqual(captured.table, "stat_minutely");
assert.deepStrictEqual(captured.row, {
monitor_id: 7,
timestamp: 123,
up: 1,
down: 2,
ping: 3,
ping_min: 1,
ping_max: 5,
extras: '{"custom":"x"}',
});
assert.deepStrictEqual(captured.conflict, ["monitor_id", "timestamp"]);
assert.deepStrictEqual(captured.merge, ["up", "down", "ping", "ping_min", "ping_max", "extras"]);
});
test("omits the extras column when there is nothing extra", async () => {
let captured = null;
Object.defineProperty(R, "knex", {
configurable: true,
writable: true,
value: () => ({
insert(row) {
captured = { row };
return {
onConflict() {
return {
merge(columns) {
captured.merge = columns;
return Promise.resolve();
},
};
},
};
},
}),
});
await calculator().upsertStat("stat_daily", 1, { up: 1, down: 0, avgPing: 2, minPing: 1, maxPing: 3 });
assert.strictEqual("extras" in captured.row, false);
assert.deepStrictEqual(captured.merge, ["up", "down", "ping", "ping_min", "ping_max"]);
});
});
describe("UptimeCalculator.update() storage", () => {
test("stores the buckets and prunes once per hour", async () => {
delete process.env.TEST_BACKEND;
UptimeCalculator.currentDate = dayjs.utc("2024-01-01T10:00:00Z");
const tables = [];
Object.defineProperty(R, "knex", {
configurable: true,
writable: true,
value: (table) => ({
insert() {
tables.push(table);
return {
onConflict() {
return {
merge() {
return Promise.resolve();
},
};
},
};
},
}),
});
const execs = [];
R.exec = async (sql, params) => {
execs.push({ sql, params });
};
const calc = calculator();
calc.currentBucketsLoaded = true;
calc.historyLoaded = true;
await calc.update(UP);
assert.deepStrictEqual(tables.sort(), ["stat_daily", "stat_hourly", "stat_minutely"]);
assert.strictEqual(execs.length, 2);
assert.match(execs[0].sql, /DELETE FROM stat_minutely/);
assert.match(execs[1].sql, /DELETE FROM stat_hourly/);
// A second beat in the same hour must not prune again
await calc.update(UP);
assert.strictEqual(execs.length, 2);
});
});
+114 -43
View File
@@ -7,9 +7,19 @@ dayjs.extend(require("dayjs/plugin/utc"));
dayjs.extend(require("../../server/modules/dayjs/plugin/timezone"));
dayjs.extend(require("dayjs/plugin/customParseFormat"));
/**
* Create a calculator whose bucket loading is stubbed, so update() does not query the database
* @returns {UptimeCalculator} Calculator instance
*/
function createCalculator() {
const calculator = new UptimeCalculator();
calculator.currentBucketsLoaded = true;
return calculator;
}
describe("Uptime Calculator", () => {
test("getCurrentDate() returns custom date when set", () => {
let c1 = new UptimeCalculator();
let c1 = createCalculator();
// Test custom date
UptimeCalculator.currentDate = dayjs.utc("2021-01-01T00:00:00.000Z");
@@ -18,34 +28,95 @@ describe("Uptime Calculator", () => {
test("update() with UP status returns correct timestamp", async () => {
UptimeCalculator.currentDate = dayjs.utc("2023-08-12 20:46:59");
let c2 = new UptimeCalculator();
let c2 = createCalculator();
let date = await c2.update(UP);
assert.strictEqual(date.unix(), dayjs.utc("2023-08-12 20:46:59").unix());
});
test("update() with MAINTENANCE status returns correct timestamp", async () => {
UptimeCalculator.currentDate = dayjs.utc("2023-08-12 20:47:20");
let c2 = new UptimeCalculator();
let c2 = createCalculator();
let date = await c2.update(MAINTENANCE);
assert.strictEqual(date.unix(), dayjs.utc("2023-08-12 20:47:20").unix());
});
test("update() with DOWN status returns correct timestamp", async () => {
UptimeCalculator.currentDate = dayjs.utc("2023-08-12 20:47:20");
let c2 = new UptimeCalculator();
let c2 = createCalculator();
let date = await c2.update(DOWN);
assert.strictEqual(date.unix(), dayjs.utc("2023-08-12 20:47:20").unix());
});
test("update() with PENDING status returns correct timestamp", async () => {
UptimeCalculator.currentDate = dayjs.utc("2023-08-12 20:47:20");
let c2 = new UptimeCalculator();
let c2 = createCalculator();
let date = await c2.update(PENDING);
assert.strictEqual(date.unix(), dayjs.utc("2023-08-12 20:47:20").unix());
});
test("update() loads the current buckets for a live beat", async () => {
UptimeCalculator.currentDate = dayjs.utc("2023-08-12 20:46:59");
const calculator = new UptimeCalculator();
calculator.currentBucketsLoaded = false;
let loaded = false;
calculator.ensureCurrentBuckets = async () => {
loaded = true;
};
await calculator.update(UP);
assert.strictEqual(loaded, true);
});
test("update() skips loading buckets when a date is passed", async () => {
const calculator = new UptimeCalculator();
calculator.currentBucketsLoaded = false;
let loaded = false;
calculator.ensureCurrentBuckets = async () => {
loaded = true;
};
await calculator.update(UP, 0, dayjs.utc("2023-08-12 20:46:59"));
assert.strictEqual(loaded, false);
});
test("ensureHistoryLoaded() loads the history once for concurrent callers", async () => {
const calculator = createCalculator();
let loads = 0;
let finishLoad;
calculator.loadHistory = async () => {
loads++;
await new Promise((resolve) => {
finishLoad = resolve;
});
calculator.historyLoaded = true;
};
const first = calculator.ensureHistoryLoaded();
const second = calculator.ensureHistoryLoaded();
finishLoad();
await Promise.all([first, second]);
assert.strictEqual(loads, 1);
assert.strictEqual(calculator.historyLoaded, true);
});
test("ensureHistoryLoaded() does nothing once the history is loaded", async () => {
const calculator = createCalculator();
let loads = 0;
calculator.historyLoaded = true;
calculator.loadHistory = async () => {
loads++;
};
await calculator.ensureHistoryLoaded();
assert.strictEqual(loads, 0);
});
test("flatStatus() converts statuses correctly", () => {
let c2 = new UptimeCalculator();
let c2 = createCalculator();
assert.strictEqual(c2.flatStatus(UP), UP);
//assert.strictEqual(c2.flatStatus(MAINTENANCE), UP);
assert.strictEqual(c2.flatStatus(DOWN), DOWN);
@@ -53,23 +124,23 @@ describe("Uptime Calculator", () => {
});
test("getMinutelyKey() returns correct timestamp for start of minute", () => {
let c2 = new UptimeCalculator();
let c2 = createCalculator();
let divisionKey = c2.getMinutelyKey(dayjs.utc("2023-08-12 20:46:00"));
assert.strictEqual(divisionKey, dayjs.utc("2023-08-12 20:46:00").unix());
// Edge case 1
c2 = new UptimeCalculator();
c2 = createCalculator();
divisionKey = c2.getMinutelyKey(dayjs.utc("2023-08-12 20:46:01"));
assert.strictEqual(divisionKey, dayjs.utc("2023-08-12 20:46:00").unix());
// Edge case 2
c2 = new UptimeCalculator();
c2 = createCalculator();
divisionKey = c2.getMinutelyKey(dayjs.utc("2023-08-12 20:46:59"));
assert.strictEqual(divisionKey, dayjs.utc("2023-08-12 20:46:00").unix());
});
test("missing cleanup buckets are not created when createIfMissing is false", () => {
let c2 = new UptimeCalculator();
let c2 = createCalculator();
c2.getMinutelyKey(dayjs.utc("2023-08-12 20:46:59"));
c2.getHourlyKey(dayjs.utc("2023-08-12 20:46:59"));
@@ -91,7 +162,7 @@ describe("Uptime Calculator", () => {
let startDate = dayjs.utc("2023-08-12 00:00:00");
// First test the broken version that creates missing buckets during cleanup lookup.
let broken = new UptimeCalculator();
let broken = createCalculator();
let minutelyQueueLimit = broken.minutelyUptimeDataList.__limit;
let hourlyQueueLimit = broken.hourlyUptimeDataList.__limit;
let totalTicks = Math.max(minutelyQueueLimit, hourlyQueueLimit);
@@ -118,7 +189,7 @@ describe("Uptime Calculator", () => {
assert.strictEqual(broken.getDataArray(hourlyQueueLimit, "hour").length, hourlyQueueLimit / 2);
// Now test the fixed version that should not create missing buckets.
let fixed = new UptimeCalculator();
let fixed = createCalculator();
let fixedMinutelyTickDate = startDate;
let fixedHourlyTickDate = startDate;
for (let tick = 0; tick < totalTicks; tick++) {
@@ -142,32 +213,32 @@ describe("Uptime Calculator", () => {
});
test("getDailyKey() returns correct timestamp for start of day", () => {
let c2 = new UptimeCalculator();
let c2 = createCalculator();
let dailyKey = c2.getDailyKey(dayjs.utc("2023-08-12 20:46:00"));
assert.strictEqual(dailyKey, dayjs.utc("2023-08-12").unix());
c2 = new UptimeCalculator();
c2 = createCalculator();
dailyKey = c2.getDailyKey(dayjs.utc("2023-08-12 23:45:30"));
assert.strictEqual(dailyKey, dayjs.utc("2023-08-12").unix());
// Edge case 1
c2 = new UptimeCalculator();
c2 = createCalculator();
dailyKey = c2.getDailyKey(dayjs.utc("2023-08-12 23:59:59"));
assert.strictEqual(dailyKey, dayjs.utc("2023-08-12").unix());
// Edge case 2
c2 = new UptimeCalculator();
c2 = createCalculator();
dailyKey = c2.getDailyKey(dayjs.utc("2023-08-12 00:00:00"));
assert.strictEqual(dailyKey, dayjs.utc("2023-08-12").unix());
// Test timezone
c2 = new UptimeCalculator();
c2 = createCalculator();
dailyKey = c2.getDailyKey(dayjs("Sat Dec 23 2023 05:38:39 GMT+0800 (Hong Kong Standard Time)"));
assert.strictEqual(dailyKey, dayjs.utc("2023-12-22").unix());
});
test("lastDailyUptimeData tracks UP status correctly", async () => {
let c2 = new UptimeCalculator();
let c2 = createCalculator();
await c2.update(UP);
assert.strictEqual(c2.lastDailyUptimeData.up, 1);
});
@@ -176,20 +247,20 @@ describe("Uptime Calculator", () => {
UptimeCalculator.currentDate = dayjs.utc("2023-08-12 20:46:59");
// No data
let c2 = new UptimeCalculator();
let c2 = createCalculator();
let data = c2.get24Hour();
assert.strictEqual(data.uptime, 0);
assert.strictEqual(data.avgPing, null);
// 1 Up
c2 = new UptimeCalculator();
c2 = createCalculator();
await c2.update(UP, 100);
let uptime = c2.get24Hour().uptime;
assert.strictEqual(uptime, 1);
assert.strictEqual(c2.get24Hour().avgPing, 100);
// 2 Up
c2 = new UptimeCalculator();
c2 = createCalculator();
await c2.update(UP, 100);
await c2.update(UP, 200);
uptime = c2.get24Hour().uptime;
@@ -197,7 +268,7 @@ describe("Uptime Calculator", () => {
assert.strictEqual(c2.get24Hour().avgPing, 150);
// 3 Up
c2 = new UptimeCalculator();
c2 = createCalculator();
await c2.update(UP, 0);
await c2.update(UP, 100);
await c2.update(UP, 400);
@@ -206,28 +277,28 @@ describe("Uptime Calculator", () => {
assert.strictEqual(c2.get24Hour().avgPing, 166.66666666666666);
// 1 MAINTENANCE
c2 = new UptimeCalculator();
c2 = createCalculator();
await c2.update(MAINTENANCE);
uptime = c2.get24Hour().uptime;
assert.strictEqual(uptime, 0);
assert.strictEqual(c2.get24Hour().avgPing, null);
// 1 PENDING
c2 = new UptimeCalculator();
c2 = createCalculator();
await c2.update(PENDING);
uptime = c2.get24Hour().uptime;
assert.strictEqual(uptime, 0);
assert.strictEqual(c2.get24Hour().avgPing, null);
// 1 DOWN
c2 = new UptimeCalculator();
c2 = createCalculator();
await c2.update(DOWN);
uptime = c2.get24Hour().uptime;
assert.strictEqual(uptime, 0);
assert.strictEqual(c2.get24Hour().avgPing, null);
// 2 DOWN
c2 = new UptimeCalculator();
c2 = createCalculator();
await c2.update(DOWN);
await c2.update(DOWN);
uptime = c2.get24Hour().uptime;
@@ -235,7 +306,7 @@ describe("Uptime Calculator", () => {
assert.strictEqual(c2.get24Hour().avgPing, null);
// 1 DOWN, 1 UP
c2 = new UptimeCalculator();
c2 = createCalculator();
await c2.update(DOWN);
await c2.update(UP, 0.5);
uptime = c2.get24Hour().uptime;
@@ -243,7 +314,7 @@ describe("Uptime Calculator", () => {
assert.strictEqual(c2.get24Hour().avgPing, 0.5);
// 1 UP, 1 DOWN
c2 = new UptimeCalculator();
c2 = createCalculator();
await c2.update(UP, 123);
await c2.update(DOWN);
uptime = c2.get24Hour().uptime;
@@ -251,7 +322,7 @@ describe("Uptime Calculator", () => {
assert.strictEqual(c2.get24Hour().avgPing, 123);
// Add 24 hours
c2 = new UptimeCalculator();
c2 = createCalculator();
await c2.update(UP, 0);
await c2.update(UP, 0);
await c2.update(UP, 0);
@@ -281,25 +352,25 @@ describe("Uptime Calculator", () => {
UptimeCalculator.currentDate = dayjs.utc("2023-08-12 20:46:59");
// No data
let c2 = new UptimeCalculator();
let c2 = createCalculator();
let uptime = c2.get7Day().uptime;
assert.strictEqual(uptime, 0);
// 1 Up
c2 = new UptimeCalculator();
c2 = createCalculator();
await c2.update(UP);
uptime = c2.get7Day().uptime;
assert.strictEqual(uptime, 1);
// 2 Up
c2 = new UptimeCalculator();
c2 = createCalculator();
await c2.update(UP);
await c2.update(UP);
uptime = c2.get7Day().uptime;
assert.strictEqual(uptime, 1);
// 3 Up
c2 = new UptimeCalculator();
c2 = createCalculator();
await c2.update(UP);
await c2.update(UP);
await c2.update(UP);
@@ -307,46 +378,46 @@ describe("Uptime Calculator", () => {
assert.strictEqual(uptime, 1);
// 1 MAINTENANCE
c2 = new UptimeCalculator();
c2 = createCalculator();
await c2.update(MAINTENANCE);
uptime = c2.get7Day().uptime;
assert.strictEqual(uptime, 0);
// 1 PENDING
c2 = new UptimeCalculator();
c2 = createCalculator();
await c2.update(PENDING);
uptime = c2.get7Day().uptime;
assert.strictEqual(uptime, 0);
// 1 DOWN
c2 = new UptimeCalculator();
c2 = createCalculator();
await c2.update(DOWN);
uptime = c2.get7Day().uptime;
assert.strictEqual(uptime, 0);
// 2 DOWN
c2 = new UptimeCalculator();
c2 = createCalculator();
await c2.update(DOWN);
await c2.update(DOWN);
uptime = c2.get7Day().uptime;
assert.strictEqual(uptime, 0);
// 1 DOWN, 1 UP
c2 = new UptimeCalculator();
c2 = createCalculator();
await c2.update(DOWN);
await c2.update(UP);
uptime = c2.get7Day().uptime;
assert.strictEqual(uptime, 0.5);
// 1 UP, 1 DOWN
c2 = new UptimeCalculator();
c2 = createCalculator();
await c2.update(UP);
await c2.update(DOWN);
uptime = c2.get7Day().uptime;
assert.strictEqual(uptime, 0.5);
// Add 7 days
c2 = new UptimeCalculator();
c2 = createCalculator();
await c2.update(UP);
await c2.update(UP);
await c2.update(UP);
@@ -364,7 +435,7 @@ describe("Uptime Calculator", () => {
test("get30Day() calculates 30-day uptime correctly with 1 check per day", async () => {
UptimeCalculator.currentDate = dayjs.utc("2023-08-12 20:46:59");
let c2 = new UptimeCalculator();
let c2 = createCalculator();
let uptime = c2.get30Day().uptime;
assert.strictEqual(uptime, 0);
@@ -397,7 +468,7 @@ describe("Uptime Calculator", () => {
test("get1Year() calculates 1-year uptime correctly with 1 check per day", async () => {
UptimeCalculator.currentDate = dayjs.utc("2023-08-12 20:46:59");
let c2 = new UptimeCalculator();
let c2 = createCalculator();
let uptime = c2.get1Year().uptime;
assert.strictEqual(uptime, 0);
@@ -429,7 +500,7 @@ describe("Uptime Calculator", () => {
async (t) => {
console.log("Memory usage before preparation", memoryUsage());
let c = new UptimeCalculator();
let c = createCalculator();
let up = 0;
let down = 0;
let interval = 20;
+57
View File
@@ -0,0 +1,57 @@
import { test as base, expect } from "@playwright/test";
// Console errors that are expected in the e2e suite and never indicate an app bug.
const IGNORED_CONSOLE_ERRORS = [
// Failed network requests: several specs point monitors at unreachable or 404 URLs on purpose
/^Failed to load resource:/,
];
/**
* Whether a console error is part of the expected noise
* @param {string} text Console message text
* @returns {boolean} True when the error should be ignored
*/
function isIgnoredConsoleError(text) {
return IGNORED_CONSOLE_ERRORS.some((pattern) => pattern.test(text));
}
// Every spec imports this test: a throw in the page (including Vue render errors) or an
// unexpected console error fails that test instead of only showing up in the log.
export const test = base.extend({
/**
* Page with an attached browser error guard
* @param {{ page: import("@playwright/test").Page }} fixtures Fixtures
* @param {Function} use Fixture use callback
* @param {import("@playwright/test").TestInfo} testInfo Test info
* @returns {Promise<void>}
*/
page: async ({ page }, use, testInfo) => {
const errors = [];
page.on("pageerror", (error) => {
errors.push(`pageerror: ${error.message}`);
});
page.on("console", (message) => {
if (message.type() !== "error") {
return;
}
const text = message.text();
if (!isIgnoredConsoleError(text)) {
errors.push(`console: ${text}`);
}
});
await use(page);
if (errors.length > 0) {
await testInfo.attach("browser-console-errors", {
body: errors.join("\n"),
contentType: "text/plain",
});
throw new Error(`Browser reported ${errors.length} error(s):\n${errors.slice(0, 10).join("\n")}`);
}
},
});
export { expect };
+1 -1
View File
@@ -1,4 +1,4 @@
import { test, expect } from "@playwright/test";
import { test, expect } from "../fixtures";
import { restoreSqliteSnapshot } from "../util-test";
const serverUrl = require("../../../playwright.config.js").url;
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "../fixtures";
import { login, restoreSqliteSnapshot, screenshot } from "../util-test";
test.describe("Domain Expiry Notification", () => {
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "../fixtures";
import { login, restoreSqliteSnapshot, screenshot } from "../util-test";
test.describe("Example Spec", () => {
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "../fixtures";
import { login, restoreSqliteSnapshot, screenshot } from "../util-test";
test.describe("Friendly Name Tests", () => {
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "../fixtures";
import { login, restoreSqliteSnapshot, screenshot } from "../util-test";
test.describe("Incident History", () => {
+62
View File
@@ -0,0 +1,62 @@
import { expect, test } from "../fixtures";
import { login, restoreSqliteSnapshot } from "../util-test";
test.beforeEach(async ({ page }) => {
await restoreSqliteSnapshot(page);
});
/**
* Create a push monitor and return its push URL
* @param {import("@playwright/test").Page} page Page
* @param {string} name Monitor name
* @returns {Promise<string>} Push URL
*/
async function addPushMonitor(page, name) {
await page.goto("./add");
await login(page);
await page.getByTestId("monitor-type-select").selectOption("push");
await page.getByTestId("friendly-name-input").fill(name);
await page.getByTestId("save-button").click();
await page.waitForURL(/\/dashboard\/\d+/);
return await page.locator('a[href*="/api/push/"]').first().getAttribute("href");
}
/**
* Create a single maintenance window covering all monitors for the next hour
* @param {import("@playwright/test").Page} page Page
* @param {string} title Maintenance title
* @param {string} monitorName Monitor to include
* @returns {Promise<void>}
*/
async function addMaintenance(page, title, monitorName) {
await page.goto("./add-maintenance");
await page.locator("#name").fill(title);
await page.locator("#strategy").selectOption("single");
await page.getByText("Pick Affected Monitors…").click();
await page.getByRole("option", { name: monitorName }).click();
await page.keyboard.press("Escape");
await page.locator("#monitor-submit-btn").click();
await page.waitForURL(/\/maintenance$/);
}
test.describe("Maintenance", () => {
test("counts monitors under maintenance in the list filter", async ({ page }) => {
const pushUrl = await addPushMonitor(page, "Maintenance Monitor");
await addMaintenance(page, "Planned Window", "Maintenance Monitor");
await expect(page.locator(".item", { hasText: "Planned Window" })).toContainText("Under Maintenance");
// The beat is recorded as maintenance instead of up
await page.request.get(pushUrl);
await page.goto("./dashboard");
await page.waitForSelector('[data-testid="monitor-list"] .draggable-item');
await page.getByTestId("filter-status").click();
const maintenanceFilter = page.getByTestId("filter-status-maintenance");
await expect(maintenanceFilter).toContainText("1");
await maintenanceFilter.click();
const list = page.getByTestId("monitor-list");
await expect(list).toContainText("Maintenance Monitor");
});
});
+29
View File
@@ -0,0 +1,29 @@
import { expect, test } from "../fixtures";
import { login, restoreSqliteSnapshot } from "../util-test";
test.beforeEach(async ({ page }) => {
await restoreSqliteSnapshot(page);
});
test.describe("Badge endpoints", () => {
test("serve uptime and ping badges for a monitor with heartbeats", async ({ page }) => {
await page.goto("./add");
await login(page);
await page.getByTestId("monitor-type-select").selectOption("push");
await page.getByTestId("friendly-name-input").fill("Badge Monitor");
await page.getByTestId("save-button").click();
await page.waitForURL(/\/dashboard\/\d+/);
const monitorID = page.url().split("/").pop();
const pushUrl = await page.locator('a[href*="/api/push/"]').first().getAttribute("href");
await page.request.get(`${pushUrl}?ping=50`);
const uptimeBadge = await page.request.get(`/api/badge/${monitorID}/uptime/24`);
expect(uptimeBadge.status()).toBe(200);
expect(await uptimeBadge.text()).toContain("<svg");
const pingBadge = await page.request.get(`/api/badge/${monitorID}/ping/24`);
expect(pingBadge.status()).toBe(200);
expect(await pingBadge.text()).toContain("<svg");
});
});
+116
View File
@@ -0,0 +1,116 @@
import { expect, test } from "../fixtures";
import { login, restoreSqliteSnapshot } from "../util-test";
test.beforeEach(async ({ page }) => {
await restoreSqliteSnapshot(page);
});
/**
* Create a monitor and land on its details page
* @param {import("@playwright/test").Page} page Page
* @param {string} name Monitor name
* @param {string} type Monitor type
* @returns {Promise<void>}
*/
async function createMonitor(page, name, type = "http") {
await page.goto("./add");
await login(page);
await page.getByTestId("monitor-type-select").selectOption(type);
await page.getByTestId("friendly-name-input").fill(name);
if (type === "http") {
await page.getByTestId("url-input").fill("https://example.com");
}
await page.getByTestId("save-button").click();
await page.waitForURL("/dashboard/*");
}
/**
* Confirm the open dialog
* @param {import("@playwright/test").Page} page Page
* @returns {Promise<void>}
*/
async function confirmDialog(page) {
await page.locator(".modal.show").getByRole("button", { name: "Yes", exact: true }).click();
}
test.describe("Monitor details", () => {
test("shows the monitor details and opens the edit form", async ({ page }) => {
const name = "Detail Monitor";
await createMonitor(page, name);
await expect(page.locator("h1")).toContainText(name);
await expect(page.locator(".url")).toContainText("https://example.com");
await expect(page.getByText("Check every 60 seconds")).toBeVisible();
await expect(page.getByTestId("monitor-status")).toBeVisible();
await page.getByRole("link", { name: "Edit" }).click();
await page.waitForURL("/edit/*");
await expect(page.getByTestId("friendly-name-input")).toHaveValue(name);
});
test("shows the ping chart", async ({ page }) => {
await createMonitor(page, "Chart Monitor");
// The chart box is open by default and requests the beats on mount
await expect(page.locator(".ping-chart-wrapper canvas")).toBeVisible();
});
test("pauses and resumes the monitor", async ({ page }) => {
await createMonitor(page, "Pausable Monitor");
await page.getByRole("button", { name: "Pause" }).click();
await confirmDialog(page);
await expect(page.getByRole("button", { name: "Resume" })).toBeVisible();
await page.getByRole("button", { name: "Resume" }).click();
await expect(page.getByRole("button", { name: "Pause" })).toBeVisible();
});
test("deletes the monitor from its details page", async ({ page }) => {
const name = "Deletable Monitor";
await createMonitor(page, name);
await page.getByRole("button", { name: "Delete" }).click();
await confirmDialog(page);
await page.waitForURL(/\/dashboard$/);
const list = page.getByTestId("monitor-list");
await expect(list).not.toContainText(name);
});
test("clears the important events", async ({ page }) => {
await createMonitor(page, "Clear Events Monitor", "push");
// Send one heartbeat so there is an important event to clear
const pushUrl = await page.locator('a[href*="/api/push/"]').first().getAttribute("href");
await page.request.get(pushUrl);
await page.reload();
await expect(page.getByText("No important events")).toBeHidden();
await page.getByRole("button", { name: "Clear Data" }).click();
await page.getByRole("button", { name: "Events", exact: true }).click();
await confirmDialog(page);
await expect(page.getByText("No important events")).toBeVisible();
});
test("clears the heartbeats shown in the monitor list", async ({ page }) => {
await createMonitor(page, "Clear Heartbeats Monitor", "push");
const detailsURL = page.url();
const monitorID = detailsURL.split("/").pop();
// Send one heartbeat and confirm the list shows it
const pushUrl = await page.locator('a[href*="/api/push/"]').first().getAttribute("href");
await page.request.get(pushUrl);
await page.goto("./dashboard");
await expect(page.locator('[aria-label^="Heartbeat history: 1 checks"]')).toBeVisible();
// Clear the heartbeats from the details page
await page.goto("./dashboard/" + monitorID);
await page.getByRole("button", { name: "Clear Data" }).click();
await page.getByRole("button", { name: "Heartbeats", exact: true }).click();
await confirmDialog(page);
// The list row no longer has heartbeat data
await page.goto("./dashboard");
await expect(page.locator('[aria-label^="Heartbeat history: 0 checks"]')).toBeVisible();
});
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "../fixtures";
import { login, restoreSqliteSnapshot, screenshot } from "../util-test";
/**
+99
View File
@@ -0,0 +1,99 @@
import { expect, test } from "../fixtures";
import { login, restoreSqliteSnapshot, screenshot } from "../util-test";
test.describe("Monitor Group", () => {
test.beforeEach(async ({ page }) => {
await restoreSqliteSnapshot(page);
});
test("lists a group with its child monitor and collapses the child", async ({ page }, testInfo) => {
const groupName = "Example Group";
const childName = "Grouped Monitor";
await page.goto("./add");
await login(page);
// Create the group
await page.getByTestId("monitor-type-select").selectOption("group");
await page.getByTestId("friendly-name-input").fill(groupName);
await page.getByTestId("save-button").click();
await page.waitForURL("/dashboard/*");
// Create a monitor inside the group
await page.goto("./add");
await page.getByTestId("monitor-type-select").selectOption("http");
await page.getByTestId("friendly-name-input").fill(childName);
await page.getByTestId("url-input").fill("https://example.com");
await page.locator("#monitorGroupSelector").selectOption({ label: groupName });
await page.getByTestId("save-button").click();
await page.waitForURL("/dashboard/*");
// Saving navigates to the new monitor's details page; go back to the list
await page.goto("./dashboard");
await page.waitForSelector('[data-testid="monitor-list"] .draggable-item');
const monitorList = page.getByTestId("monitor-list");
await expect(monitorList).toContainText(groupName);
// Groups are collapsed by default: the child row is hidden
await expect(monitorList).not.toContainText(childName);
// Expanding the group shows the child row
await page.getByTestId("monitor-collapse").first().click();
await expect(monitorList).toContainText(childName);
await screenshot(testInfo, page);
// Collapsing again hides it
await page.getByTestId("monitor-collapse").first().click();
await expect(monitorList).not.toContainText(childName);
});
test("moves a monitor to another group by drag and drop", async ({ page }) => {
const groupA = "Group A";
const groupB = "Group B";
const childName = "Draggable Monitor";
await page.goto("./add");
await login(page);
// Group A with one monitor
await page.getByTestId("monitor-type-select").selectOption("group");
await page.getByTestId("friendly-name-input").fill(groupA);
await page.getByTestId("save-button").click();
await page.waitForURL("/dashboard/*");
await page.goto("./add");
await page.getByTestId("monitor-type-select").selectOption("http");
await page.getByTestId("friendly-name-input").fill(childName);
await page.getByTestId("url-input").fill("https://example.com");
await page.locator("#monitorGroupSelector").selectOption({ label: groupA });
await page.getByTestId("save-button").click();
await page.waitForURL("/dashboard/*");
// Group B
await page.goto("./add");
await page.getByTestId("monitor-type-select").selectOption("group");
await page.getByTestId("friendly-name-input").fill(groupB);
await page.getByTestId("save-button").click();
await page.waitForURL("/dashboard/*");
await page.goto("./dashboard");
await page.waitForSelector('[data-testid="monitor-list"] .draggable-item');
// Expand group A so the child row is visible, then drag it onto group B
await page
.locator(".draggable-item", { hasText: groupA })
.locator('[data-testid="monitor-collapse"]')
.first()
.click();
const childHref = await page.locator("a.item", { hasText: childName }).first().getAttribute("href");
await page
.locator("a.item", { hasText: childName })
.first()
.dragTo(page.locator("a.item", { hasText: groupB }).first());
// The monitor now belongs to group B
const childId = childHref.split("/").pop();
await page.goto("./edit/" + childId);
await expect(page.locator("#monitorGroupSelector option:checked")).toHaveText(groupB);
});
});
@@ -0,0 +1,77 @@
import { expect, test } from "../fixtures";
import { login, restoreSqliteSnapshot } from "../util-test";
test.beforeEach(async ({ page }) => {
await restoreSqliteSnapshot(page);
});
/**
* Open the monitor history settings page and log in
* @param {import("@playwright/test").Page} page Page
* @returns {Promise<void>}
*/
async function openSettings(page) {
await page.goto("./settings/monitor-history");
// Log in only when the session is not restored yet
await Promise.race([page.locator("#keepDataPeriodDays").waitFor(), page.getByPlaceholder("Username").waitFor()]);
if (await page.getByPlaceholder("Username").isVisible()) {
await login(page);
}
await expect(page.locator("#keepDataPeriodDays")).toBeVisible();
}
/**
* Confirm the open dialog
* @param {import("@playwright/test").Page} page Page
* @returns {Promise<void>}
*/
async function confirmDialog(page) {
await page.locator(".modal.show").getByRole("button", { name: "Yes", exact: true }).click();
}
test.describe("Monitor history settings", () => {
test("saves the data retention period", async ({ page }) => {
await openSettings(page);
await page.locator("#keepDataPeriodDays").fill("30");
await page.getByRole("button", { name: "Save", exact: true }).click();
await page.reload();
await expect(page.locator("#keepDataPeriodDays")).toHaveValue("30");
});
test("clears all statistics and heartbeats", async ({ page }) => {
// Create a push monitor with one heartbeat
await page.goto("./add");
await login(page);
await page.getByTestId("monitor-type-select").selectOption("push");
await page.getByTestId("friendly-name-input").fill("Stats Monitor");
await page.getByTestId("save-button").click();
await page.waitForURL("/dashboard/*");
const detailsURL = page.url();
const monitorID = detailsURL.split("/").pop();
const pushUrl = await page.locator('a[href*="/api/push/"]').first().getAttribute("href");
await page.request.get(pushUrl);
await page.goto("./dashboard");
await expect(page.locator('[aria-label^="Heartbeat history: 1 checks"]')).toBeVisible();
// Clear everything from the settings page
await openSettings(page);
await page.getByRole("button", { name: "Clear all statistics" }).click();
await confirmDialog(page);
// The monitor no longer has heartbeats
await page.goto("./dashboard/" + monitorID);
await page.goto("./dashboard");
await expect(page.locator('[aria-label^="Heartbeat history: 0 checks"]')).toBeVisible();
});
test("shrinks the sqlite database", async ({ page }) => {
await openSettings(page);
await page.getByRole("button", { name: /Shrink Database/ }).click();
await expect(page.locator(".Vue-Toastification__toast").first()).toContainText("Done");
});
});
+201
View File
@@ -0,0 +1,201 @@
import { expect, test } from "../fixtures";
import { login, restoreSqliteSnapshot } from "../util-test";
test.beforeEach(async ({ page }) => {
await restoreSqliteSnapshot(page);
});
/**
* Create a monitor through the add form
* @param {import("@playwright/test").Page} page Page
* @param {object} options Name, type and optional parent group
* @param {boolean} loginFirst Log in on this navigation
* @returns {Promise<void>}
*/
async function addMonitor(page, options, loginFirst = false) {
const { name, type = "http", parent } = options;
await page.goto("./add");
if (loginFirst) {
await login(page);
}
await page.getByTestId("monitor-type-select").selectOption(type);
await page.getByTestId("friendly-name-input").fill(name);
if (type === "http") {
await page.getByTestId("url-input").fill("https://example.com");
}
if (parent) {
await page.locator("#monitorGroupSelector").selectOption({ label: parent });
}
await page.getByTestId("save-button").click();
await page.waitForURL("/dashboard/*");
}
/**
* Open the dashboard and wait for the monitor list
* @param {import("@playwright/test").Page} page Page
* @returns {Promise<import("@playwright/test").Locator>} Monitor list locator
*/
async function openDashboard(page) {
await page.goto("./dashboard");
await page.waitForSelector('[data-testid="monitor-list"] .draggable-item');
return page.getByTestId("monitor-list");
}
/**
* Confirm the open dialog
* @param {import("@playwright/test").Page} page Page
* @returns {Promise<void>}
*/
async function confirmDialog(page) {
await page.locator(".modal.show").getByRole("button", { name: "Yes", exact: true }).click();
}
/**
* Open a monitor's details page from the list
* @param {import("@playwright/test").Page} page Page
* @param {string} name Monitor name
* @returns {Promise<void>}
*/
async function openDetails(page, name) {
await page.goto("./dashboard");
await page.waitForSelector('[data-testid="monitor-list"] .draggable-item');
await page.locator("a.item", { hasText: name }).first().click();
await page.waitForURL(/\/dashboard\/\d+/);
}
test.describe("Monitor list actions", () => {
test("shows a renamed monitor in the list", async ({ page }) => {
await addMonitor(page, { name: "Old Name" }, true);
await openDetails(page, "Old Name");
await page.getByRole("link", { name: "Edit" }).click();
await page.waitForURL("/edit/*");
await page.getByTestId("friendly-name-input").fill("New Name");
await page.getByTestId("save-button").click();
await expect(page.locator(".Vue-Toastification__toast").first()).toContainText("Saved");
const list = await openDashboard(page);
await expect(list).toContainText("New Name");
await expect(list).not.toContainText("Old Name");
});
test("clones a monitor into the list", async ({ page }) => {
await addMonitor(page, { name: "Original Monitor" }, true);
await openDetails(page, "Original Monitor");
await page.getByRole("link", { name: "Clone" }).click();
await page.waitForURL("/clone/*");
await page.getByTestId("friendly-name-input").fill("Cloned Monitor");
await page.getByTestId("save-button").click();
await page.waitForURL(/\/dashboard\/\d+/);
const list = await openDashboard(page);
await expect(list).toContainText("Original Monitor");
await expect(list).toContainText("Cloned Monitor");
});
test("renames a group and keeps its child", async ({ page }) => {
await addMonitor(page, { name: "Group A", type: "group" }, true);
await addMonitor(page, { name: "Child A", parent: "Group A" });
await openDetails(page, "Group A");
await page.getByRole("link", { name: "Edit" }).click();
await page.waitForURL("/edit/*");
await page.getByTestId("friendly-name-input").fill("Renamed Group");
await page.getByTestId("save-button").click();
await expect(page.locator(".Vue-Toastification__toast").first()).toContainText("Saved");
const list = await openDashboard(page);
await expect(list).toContainText("Renamed Group");
await page.getByTestId("monitor-collapse").first().click();
await expect(list).toContainText("Child A");
});
test("deletes a group but keeps its monitors", async ({ page }) => {
await addMonitor(page, { name: "Group B", type: "group" }, true);
await addMonitor(page, { name: "Child B", parent: "Group B" });
await openDetails(page, "Group B");
await page.getByRole("button", { name: "Delete" }).click();
await confirmDialog(page);
await page.waitForURL(/\/dashboard$/);
const list = await openDashboard(page);
await expect(list).not.toContainText("Group B");
await expect(list).toContainText("Child B");
});
test("deletes a group together with its monitors", async ({ page }) => {
await addMonitor(page, { name: "Group C", type: "group" }, true);
await addMonitor(page, { name: "Child C", parent: "Group C" });
await openDetails(page, "Group C");
await page.getByRole("button", { name: "Delete" }).click();
await page.locator("#delete-children-checkbox").check();
await confirmDialog(page);
await page.waitForURL(/\/dashboard$/);
// Nothing is left, so the list shows the empty state instead of rows
await expect(page.locator(".draggable-item")).toHaveCount(0);
await expect(page.getByTestId("monitor-list")).toContainText("No Monitors");
});
test("creates a group from the monitor form", async ({ page }) => {
await page.goto("./add");
await login(page);
// The action button lives next to the group select
await page.locator("#monitorGroupSelector + button").click();
await page.locator("#draftGroupName").fill("Draft Group");
await page.getByRole("button", { name: "Confirm", exact: true }).click();
// The dialog and its backdrop must be gone before the form can be saved
await expect(page.locator("#draftGroupName")).toBeHidden();
await expect(page.locator("#monitorGroupSelector")).toContainText("Draft Group");
await page.getByTestId("monitor-type-select").selectOption("http");
await page.getByTestId("friendly-name-input").fill("Draft Child");
await page.getByTestId("url-input").fill("https://example.com");
await page.locator("#monitorGroupSelector").selectOption({ label: "Draft Group" });
await page.getByTestId("save-button").click();
await page.waitForURL("/dashboard/*");
const list = await openDashboard(page);
await expect(list).toContainText("Draft Group");
});
test("expands and collapses all groups", async ({ page }) => {
await addMonitor(page, { name: "Group One", type: "group" }, true);
await addMonitor(page, { name: "Child One", parent: "Group One" });
await addMonitor(page, { name: "Group Two", type: "group" });
await addMonitor(page, { name: "Child Two", parent: "Group Two" });
const list = await openDashboard(page);
await expect(list).not.toContainText("Child One");
await expect(list).not.toContainText("Child Two");
await page.getByTestId("collapse-all").click();
await expect(list).toContainText("Child One");
await expect(list).toContainText("Child Two");
await page.getByTestId("collapse-all").click();
await expect(list).not.toContainText("Child One");
await expect(list).not.toContainText("Child Two");
});
test("switches the heartbeat bar theme", async ({ page }) => {
await addMonitor(page, { name: "Beat Monitor" }, true);
let list = await openDashboard(page);
await expect(list.locator(".heartbeat-canvas").first()).toBeVisible();
await page.goto("./settings/appearance");
await page.locator('label[for="btncheck6"]').click();
list = await openDashboard(page);
await expect(list.locator(".heartbeat-canvas")).toHaveCount(0);
await page.goto("./settings/appearance");
await page.locator('label[for="btncheck4"]').click();
list = await openDashboard(page);
await expect(list.locator(".heartbeat-canvas").first()).toBeVisible();
});
});
+185
View File
@@ -0,0 +1,185 @@
import { expect, test } from "../fixtures";
import { login, restoreSqliteSnapshot } from "../util-test";
test.beforeEach(async ({ page }) => {
await restoreSqliteSnapshot(page);
});
/**
* Create a monitor through the add form
* @param {import("@playwright/test").Page} page Page
* @param {object} options Monitor name, type and url
* @param {boolean} loginFirst Log in on this navigation
* @returns {Promise<void>}
*/
async function addMonitor(page, options, loginFirst = false) {
const { name, type = "http", url = "https://example.com" } = options;
await page.goto("./add");
if (loginFirst) {
await login(page);
}
await page.getByTestId("monitor-type-select").selectOption(type);
await page.getByTestId("friendly-name-input").fill(name);
if (type === "http") {
await page.getByTestId("url-input").fill(url);
}
await page.getByTestId("save-button").click();
await page.waitForURL("/dashboard/*");
}
/**
* Open the dashboard and wait for the monitor list
* @param {import("@playwright/test").Page} page Page
* @returns {Promise<import("@playwright/test").Locator>} Monitor list locator
*/
async function openDashboard(page) {
await page.goto("./dashboard");
await page.waitForSelector('[data-testid="monitor-list"] .draggable-item');
return page.getByTestId("monitor-list");
}
/**
* Confirm the open dialog
* @param {import("@playwright/test").Page} page Page
* @returns {Promise<void>}
*/
async function confirmDialog(page) {
await page.locator(".modal.show").getByRole("button", { name: "Yes", exact: true }).click();
}
test.describe("Monitor list", () => {
test("connects to the socket over websocket", async ({ page }) => {
await addMonitor(page, { name: "Socket Monitor" }, true);
await openDashboard(page);
const transport = await page.evaluate(() => {
const app = document.querySelector("#app").__vue_app__;
const root = app._container._vnode.component.proxy;
return root.getSocket().io.engine.transport.name;
});
expect(transport).toBe("websocket");
});
test("clears the loading overlay once the list is ready", async ({ page }) => {
await addMonitor(page, { name: "Overlay Monitor" }, true);
const list = await openDashboard(page);
await expect(list).toContainText("Overlay Monitor");
await expect(page.locator(".app-loading")).toHaveCount(0);
});
test("searches monitors by name", async ({ page }) => {
await addMonitor(page, { name: "Alpha Monitor" }, true);
await addMonitor(page, { name: "Beta Monitor" });
const list = await openDashboard(page);
await page.getByTestId("monitor-search").fill("Alpha");
await expect(list).toContainText("Alpha Monitor");
await expect(list).not.toContainText("Beta Monitor");
await page.getByTestId("monitor-search").fill("");
await expect(list).toContainText("Beta Monitor");
});
test("filters monitors by status", async ({ page }) => {
await addMonitor(page, { name: "Push Up", type: "push" }, true);
const pushUp = await page.locator('a[href*="/api/push/"]').first().getAttribute("href");
await page.request.get(pushUp);
await addMonitor(page, { name: "Push Down", type: "push" });
const pushDown = await page.locator('a[href*="/api/push/"]').first().getAttribute("href");
await page.request.get(pushDown.replace("status=up", "status=down"));
const list = await openDashboard(page);
await page.getByTestId("filter-status").click();
await page.getByTestId("filter-status-down").click();
await expect(list).toContainText("Push Down");
await expect(list).not.toContainText("Push Up");
});
test("filters monitors by paused state and resumes them in bulk", async ({ page }) => {
await addMonitor(page, { name: "Alpha Monitor" }, true);
await addMonitor(page, { name: "Beta Monitor" });
const list = await openDashboard(page);
// Entering select mode selects everything; keep only Alpha selected
await page.getByTestId("select-mode-toggle").click();
await expect(page.getByTestId("select-all-toggle")).toBeVisible();
await page.locator('[data-testid^="monitor-select-"]').nth(1).uncheck();
await page.getByTestId("bulk-actions").click();
await page.getByTestId("bulk-pause").click();
await page.locator(".modal.show").waitFor();
await confirmDialog(page);
await expect(page.locator(".Vue-Toastification__toast").first()).toContainText("Paused");
// Only the paused monitor is listed
await page.getByTestId("filter-status").click();
await page.getByTestId("filter-active-paused").click();
await expect(list).toContainText("Alpha Monitor");
await expect(list).not.toContainText("Beta Monitor");
// Resume it again (bulk pause leaves select mode)
if (!(await page.getByTestId("select-all-toggle").isVisible())) {
await page.getByTestId("select-mode-toggle").click();
await expect(page.getByTestId("select-all-toggle")).toBeVisible();
}
await page.locator('[data-testid^="monitor-select-"]').first().check();
await page.getByTestId("bulk-actions").click();
await page.getByTestId("bulk-resume").click();
// Clearing the filter shows both again
await page.getByTestId("filter-status").click();
await page.getByTestId("filter-active-paused").click();
await expect(list).toContainText("Beta Monitor");
});
test("deletes selected monitors in bulk", async ({ page }) => {
await addMonitor(page, { name: "Alpha Monitor" }, true);
await addMonitor(page, { name: "Beta Monitor" });
const list = await openDashboard(page);
await page.getByTestId("select-mode-toggle").click();
await expect(page.getByTestId("select-all-toggle")).toBeVisible();
await page.getByTestId("select-all-toggle").check();
await page.getByTestId("bulk-actions").click();
await page.getByTestId("bulk-delete").click();
await confirmDialog(page);
await expect(list).not.toContainText("Alpha Monitor");
await expect(list).not.toContainText("Beta Monitor");
});
test("filters monitors by tag", async ({ page }) => {
await addMonitor(page, { name: "Tagged Monitor" }, true);
await addMonitor(page, { name: "Untagged Monitor" });
// Add a tag to the first monitor through its edit form
await page.goto("./dashboard");
await page.waitForSelector('[data-testid="monitor-list"] .draggable-item');
const href = await page.locator("a.item", { hasText: "Tagged Monitor" }).first().getAttribute("href");
const monitorID = href.split("/").pop();
await page.goto("./edit/" + monitorID);
await page.getByTestId("add-tag-button").click();
await page.getByTestId("tag-name-input").fill("team");
await page.getByTestId("tag-value-input").fill("infra");
await page.getByTestId("tag-color-select").click();
await page.getByTestId("tag-color-select").getByRole("option", { name: "Orange" }).click();
await page.getByTestId("add-tags-final-button").click();
await page.getByTestId("save-button").click();
await expect(page.locator(".Vue-Toastification__toast").first()).toContainText("Saved");
const list = await openDashboard(page);
await page.getByTestId("filter-tags").click();
await page.locator('[data-testid^="filter-tag-"]').first().waitFor();
await page.locator('[data-testid^="filter-tag-"]').first().click();
await expect(list).toContainText("Tagged Monitor");
await expect(list).not.toContainText("Untagged Monitor");
});
});
+92
View File
@@ -0,0 +1,92 @@
import { expect, test } from "../fixtures";
import { login, restoreSqliteSnapshot } from "../util-test";
test.beforeEach(async ({ page }) => {
await restoreSqliteSnapshot(page);
});
/**
* Create a monitor with one tag through the add form
* @param {import("@playwright/test").Page} page Page
* @param {string} name Monitor name
* @param {string} tagName Tag name
* @returns {Promise<void>}
*/
async function addTaggedMonitor(page, name, tagName) {
await page.goto("./add");
await login(page);
await page.getByTestId("monitor-type-select").selectOption("http");
await page.getByTestId("friendly-name-input").fill(name);
await page.getByTestId("url-input").fill("https://example.com");
await page.getByTestId("add-tag-button").click();
await page.getByTestId("tag-name-input").fill(tagName);
await page.getByTestId("tag-value-input").fill("infra");
await page.getByTestId("tag-color-select").click();
await page.getByTestId("tag-color-select").getByRole("option", { name: "Orange" }).click();
await page.getByTestId("add-tags-final-button").click();
await page.getByTestId("save-button").click();
await page.waitForURL(/\/dashboard/);
}
/**
* Open the dashboard and wait for the monitor rows
* @param {import("@playwright/test").Page} page Page
* @returns {Promise<import("@playwright/test").Locator>} Monitor list locator
*/
async function openDashboard(page) {
await page.goto("./dashboard");
await page.waitForSelector('[data-testid="monitor-list"] .draggable-item');
return page.getByTestId("monitor-list");
}
test.describe("Monitor tags", () => {
test("renames a tag on the monitor list", async ({ page }) => {
await addTaggedMonitor(page, "Tagged Item", "team");
await page.goto("./settings/tags");
await page.locator(".tags-list-row", { hasText: "team" }).click();
await expect(page.locator("#tag-name")).toHaveValue("team");
await page.locator("#tag-name").fill("crew");
await page.locator(".modal.show").getByRole("button", { name: "Save", exact: true }).click();
await expect(page.locator(".modal.show")).toBeHidden();
const list = await openDashboard(page);
const row = list.locator(".draggable-item", { hasText: "Tagged Item" });
await expect(row.locator(".tag-wrapper")).toContainText("crew: infra");
await expect(row.locator(".tag-wrapper")).not.toContainText("team");
});
test("removes a tag from a monitor", async ({ page }) => {
await addTaggedMonitor(page, "Tagged Item", "team");
const list = await openDashboard(page);
await expect(list.locator(".draggable-item", { hasText: "Tagged Item" }).locator(".tag-wrapper")).toHaveCount(
1
);
const href = await list.locator("a.item", { hasText: "Tagged Item" }).first().getAttribute("href");
await page.goto("./edit/" + href.split("/").pop());
await page.locator(".tag-wrapper .btn-remove").first().click();
await page.getByTestId("save-button").click();
await expect(page.locator(".Vue-Toastification__toast").filter({ hasText: "Saved" })).toBeVisible();
const updatedList = await openDashboard(page);
await expect(
updatedList.locator(".draggable-item", { hasText: "Tagged Item" }).locator(".tag-wrapper")
).toHaveCount(0);
});
test("deletes a tag from the tag settings", async ({ page }) => {
await addTaggedMonitor(page, "Tagged Item", "team");
await page.goto("./settings/tags");
await page.locator(".tags-list-row", { hasText: "team" }).locator(".btn-rm-tag").click();
await page.locator(".modal.show").getByRole("button", { name: "Yes", exact: true }).click();
await expect(page.locator(".tags-list-row")).toHaveCount(0);
const list = await openDashboard(page);
await expect(list.locator(".draggable-item", { hasText: "Tagged Item" }).locator(".tag-wrapper")).toHaveCount(
0
);
});
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { test } from "@playwright/test";
import { test } from "../fixtures";
import { getSqliteDatabaseExists, login, screenshot, takeSqliteSnapshot } from "../util-test";
test.describe("Uptime Kuma Setup", () => {
+113
View File
@@ -0,0 +1,113 @@
import { expect, test } from "../fixtures";
import { login, restoreSqliteSnapshot } from "../util-test";
test.beforeEach(async ({ page }) => {
await restoreSqliteSnapshot(page);
});
/**
* Create an HTTP monitor
* @param {import("@playwright/test").Page} page Page
* @param {string} name Monitor name
* @returns {Promise<void>}
*/
async function addHttpMonitor(page, name) {
await page.goto("./add");
await login(page);
await page.getByTestId("monitor-type-select").selectOption("http");
await page.getByTestId("friendly-name-input").fill(name);
await page.getByTestId("url-input").fill("https://example.com");
await page.getByTestId("save-button").click();
await page.waitForURL(/\/dashboard\/\d+/);
return page.url().split("/").pop();
}
/**
* Create a push monitor and return its push URL
* @param {import("@playwright/test").Page} page Page
* @param {string} name Monitor name
* @returns {Promise<string>} Push URL
*/
async function addPushMonitor(page, name) {
await page.goto("./add");
await login(page);
await page.getByTestId("monitor-type-select").selectOption("push");
await page.getByTestId("friendly-name-input").fill(name);
await page.getByTestId("save-button").click();
await page.waitForURL(/\/dashboard\/\d+/);
return await page.locator('a[href*="/api/push/"]').first().getAttribute("href");
}
/**
* Create a status page with one group holding the given monitor
* @param {import("@playwright/test").Page} page Page
* @param {string} name Status page name
* @param {string} slug Status page slug
* @param {string} monitorName Monitor to publish
* @returns {Promise<void>}
*/
async function addStatusPageWithMonitor(page, name, slug, monitorName) {
await page.goto("./add-status-page");
await page.getByTestId("name-input").fill(name);
await page.getByTestId("slug-input").fill(slug);
await page.getByTestId("submit-button").click();
await page.waitForURL(`/status/${slug}?edit`);
await expect(page.getByTestId("edit-sidebar")).toHaveAttribute("data-status-page-id", /^\d+$/);
await page.getByTestId("add-group-button").click();
await page.getByTestId("group-name").fill("Core");
await page.getByTestId("monitor-select").click();
await page.getByTestId("monitor-select").getByRole("option", { name: monitorName }).click();
await expect(page.getByTestId("monitor")).toHaveCount(1);
// Saving reloads the page, the edit sidebar disappears afterwards
await Promise.all([page.waitForEvent("load"), page.getByTestId("save-button").click()]);
await expect(page.getByTestId("edit-sidebar")).toHaveCount(0);
}
test.describe("Public status page", () => {
test("shows a published monitor to logged-out visitors", async ({ page }) => {
const monitorID = await addHttpMonitor(page, "Public Monitor");
await addStatusPageWithMonitor(page, "Public Demo", "public-demo", "Public Monitor");
// Visit the page like a visitor without a session
await page.context().clearCookies();
await page.goto("./status/public-demo");
await expect(page.getByTestId("group-name")).toContainText("Core");
await expect(page.getByTestId("monitor-name")).toContainText("Public Monitor");
await expect(page.getByTestId("edit-button")).toHaveCount(0);
// The public monitor also answers the avg-response badge from its history
const badge = await page.request.get(`/api/badge/${monitorID}/avg-response/24`);
expect(badge.status()).toBe(200);
expect(await badge.text()).toContain("<svg");
});
test("shows an active maintenance window to logged-out visitors", async ({ page }) => {
const pushUrl = await addPushMonitor(page, "Maintenance Monitor");
await addStatusPageWithMonitor(page, "Maintenance Demo", "maintenance-demo", "Maintenance Monitor");
// A single window covering now, linked to the status page
await page.goto("./add-maintenance");
await page.locator("#name").fill("Planned Window");
await page.locator("#strategy").selectOption("single");
await page.getByText("Pick Affected Monitors…").click();
await page.getByRole("option", { name: "Maintenance Monitor" }).click();
await page.keyboard.press("Escape");
await page.getByText("Select status pages…").click();
await page.getByRole("option", { name: "Maintenance Demo" }).click();
await page.keyboard.press("Escape");
await page.locator("#monitor-submit-btn").click();
await page.waitForURL(/\/maintenance$/);
// The beat inside the window is recorded as maintenance
await page.request.get(pushUrl);
await page.context().clearCookies();
await page.goto("./status/maintenance-demo");
await expect(page.locator(".bg-maintenance")).toContainText("Planned Window");
await expect(page.getByTestId("monitor-name")).toContainText("Maintenance Monitor");
});
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "../fixtures";
import { login, restoreSqliteSnapshot, screenshot } from "../util-test";
/**
+67
View File
@@ -0,0 +1,67 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { applyBeat, beatStatusEntry } from "../../src/beat-state";
import { DOWN, MAINTENANCE, PENDING, UP } from "../../src/util";
/**
* Translator stub that returns the key
* @param {string} key Translation key
* @returns {string} Key
*/
function translate(key) {
return key;
}
describe("beatStatusEntry()", () => {
test("maps every heartbeat status", () => {
assert.deepStrictEqual(beatStatusEntry(translate, { status: UP }), { text: "Up", color: "primary" });
assert.deepStrictEqual(beatStatusEntry(translate, { status: DOWN }), { text: "Down", color: "danger" });
assert.deepStrictEqual(beatStatusEntry(translate, { status: PENDING }), {
text: "Pending",
color: "warning",
});
assert.deepStrictEqual(beatStatusEntry(translate, { status: MAINTENANCE }), {
text: "statusMaintenance",
color: "maintenance",
});
});
test("falls back to unknown without a beat or status", () => {
assert.deepStrictEqual(beatStatusEntry(translate, undefined), {
text: "Unknown",
color: "secondary",
});
assert.deepStrictEqual(beatStatusEntry(translate, { status: 99 }), {
text: "Unknown",
color: "secondary",
});
});
});
describe("applyBeat()", () => {
test("updates the three maps in place", () => {
const state = { lastHeartbeatList: {}, statusList: {}, lastBeatRaw: {} };
const beat = { status: UP, msg: "OK" };
const entry = { text: "Up", color: "primary" };
applyBeat(state, 12, beat, entry);
assert.strictEqual(state.lastHeartbeatList[12], beat);
assert.strictEqual(state.statusList[12], entry);
assert.strictEqual(state.lastBeatRaw[12], beat);
});
test("keeps the other monitors untouched", () => {
const state = {
lastHeartbeatList: { 1: { status: UP } },
statusList: { 1: { text: "Up", color: "primary" } },
lastBeatRaw: { 1: { status: UP } },
};
const beat = { status: DOWN };
applyBeat(state, 2, beat, { text: "Down", color: "danger" });
assert.deepStrictEqual(state.lastHeartbeatList[1], { status: UP });
assert.strictEqual(Object.keys(state.lastHeartbeatList).length, 2);
});
});
@@ -0,0 +1,51 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { BeatVersionThrottle } from "../../src/beat-version-throttle";
describe("BeatVersionThrottle", () => {
test("bumps at most once per interval", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
let bumps = 0;
const throttle = new BeatVersionThrottle(() => bumps++, 1000);
throttle.schedule();
throttle.schedule();
throttle.schedule();
assert.strictEqual(bumps, 0);
t.mock.timers.tick(999);
assert.strictEqual(bumps, 0);
t.mock.timers.tick(1);
assert.strictEqual(bumps, 1);
});
test("can bump again after the interval elapsed", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
let bumps = 0;
const throttle = new BeatVersionThrottle(() => bumps++, 1000);
throttle.schedule();
t.mock.timers.tick(1000);
assert.strictEqual(bumps, 1);
throttle.schedule();
t.mock.timers.tick(1000);
assert.strictEqual(bumps, 2);
});
test("cancel() drops the pending bump", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
let bumps = 0;
const throttle = new BeatVersionThrottle(() => bumps++, 1000);
throttle.schedule();
throttle.cancel();
t.mock.timers.tick(1000);
assert.strictEqual(bumps, 0);
});
});
@@ -0,0 +1,116 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import {
CREEP_INTERVAL_MS,
DashboardProgress,
FINISH_DELAY_MS,
MAX_CREEP,
ROUTE_PROGRESS,
SAFETY_MS,
SHOW_DELAY_MS,
} from "../../src/dashboard-progress";
describe("DashboardProgress", () => {
test("shows the overlay only after the short delay", (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
const progress = new DashboardProgress();
progress.update(8);
assert.strictEqual(progress.visible, false);
t.mock.timers.tick(SHOW_DELAY_MS - 1);
assert.strictEqual(progress.visible, false);
t.mock.timers.tick(1);
assert.strictEqual(progress.visible, true);
assert.strictEqual(progress.value, 8);
});
test("crawls toward the maximum while waiting", (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
const progress = new DashboardProgress();
progress.update(10);
t.mock.timers.tick(SHOW_DELAY_MS);
t.mock.timers.tick(CREEP_INTERVAL_MS);
const afterOneTick = progress.value;
assert.ok(afterOneTick > 10);
t.mock.timers.tick(CREEP_INTERVAL_MS * 1000);
assert.strictEqual(progress.value, MAX_CREEP);
});
test("fills the bar and hides it after finishing", (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
const progress = new DashboardProgress();
progress.update(50);
t.mock.timers.tick(SHOW_DELAY_MS);
progress.update(100);
assert.strictEqual(progress.value, 100);
assert.strictEqual(progress.visible, true);
t.mock.timers.tick(FINISH_DELAY_MS);
assert.strictEqual(progress.visible, false);
});
test("hides the overlay after the safety timeout", (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
const progress = new DashboardProgress();
progress.update(20);
t.mock.timers.tick(SHOW_DELAY_MS);
assert.strictEqual(progress.visible, true);
t.mock.timers.tick(SAFETY_MS);
assert.strictEqual(progress.visible, false);
});
test("resets on zero progress and stops the creep", (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
const progress = new DashboardProgress();
progress.update(30);
t.mock.timers.tick(SHOW_DELAY_MS);
progress.update(0);
assert.strictEqual(progress.visible, false);
assert.strictEqual(progress.value, 0);
t.mock.timers.tick(CREEP_INTERVAL_MS * 100);
assert.strictEqual(progress.value, 0);
});
test("does not move backwards on a lower update", () => {
const progress = new DashboardProgress();
progress.update(60);
progress.update(20);
assert.strictEqual(progress.value, 60);
progress.clearTimers();
});
test("marks the dashboard route with a small step only once", () => {
const progress = new DashboardProgress();
assert.strictEqual(progress.routeProgress("/dashboard", 0), ROUTE_PROGRESS);
assert.strictEqual(progress.routeProgress("/dashboard/1", 0), ROUTE_PROGRESS);
assert.strictEqual(progress.routeProgress("/dashboard", ROUTE_PROGRESS), ROUTE_PROGRESS);
assert.strictEqual(progress.routeProgress("/settings", 0), 0);
});
test("notifies the app on every change", (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
let changes = 0;
const progress = new DashboardProgress(() => changes++);
progress.update(10);
t.mock.timers.tick(SHOW_DELAY_MS);
const afterShow = changes;
progress.update(100);
t.mock.timers.tick(FINISH_DELAY_MS);
assert.ok(afterShow >= 2);
assert.ok(changes > afterShow);
});
});
@@ -0,0 +1,37 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { claimHeartbeatHistory, releaseHeartbeatHistory } from "../../src/heartbeat-history";
describe("heartbeat history requests", () => {
test("claims a monitor once", () => {
const requested = new Set<number>();
assert.strictEqual(claimHeartbeatHistory(requested, 42), true);
assert.strictEqual(claimHeartbeatHistory(requested, 42), false);
});
test("tracks each monitor separately", () => {
const requested = new Set<number>();
assert.strictEqual(claimHeartbeatHistory(requested, 1), true);
assert.strictEqual(claimHeartbeatHistory(requested, 2), true);
assert.deepStrictEqual([...requested], [1, 2]);
});
test("allows a retry after the request is released", () => {
const requested = new Set<number>();
claimHeartbeatHistory(requested, 7);
releaseHeartbeatHistory(requested, 7);
assert.strictEqual(claimHeartbeatHistory(requested, 7), true);
});
test("releasing an unknown monitor is a no-op", () => {
const requested = new Set<number>();
releaseHeartbeatHistory(requested, 9);
assert.strictEqual(requested.size, 0);
});
});
@@ -0,0 +1,80 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { computeListWindow, computeRowMetrics } from "../../src/monitor-list-window";
/**
* Build the metrics of `count` rows of the same height
* @param {number} count Row count
* @param {number} height Row height
* @returns {object} Row metrics
*/
function metrics(count, height = 58) {
const ids = Array.from({ length: count }, (_, i) => i + 1);
const heights = Object.fromEntries(ids.map((id) => [id, height]));
return computeRowMetrics(ids, heights);
}
describe("computeRowMetrics()", () => {
test("uses the default row height for rows without a measured height", () => {
const result = computeRowMetrics([1, 2, 3], { 2: 100 });
assert.deepStrictEqual(result.heights, [58, 100, 58]);
assert.deepStrictEqual(result.offsets, [0, 58, 158]);
assert.strictEqual(result.total, 216);
});
test("returns empty metrics for an empty list", () => {
assert.deepStrictEqual(computeRowMetrics([], {}), { heights: [], offsets: [], total: 0 });
});
});
describe("computeListWindow()", () => {
test("returns an empty window for an empty list", () => {
assert.deepStrictEqual(computeListWindow([], [], 0, 0, 800), { start: 0, end: 0, top: 0, bottom: 0 });
});
test("starts at the first row and adds the overscan below", () => {
const { heights, offsets, total } = metrics(500);
const window = computeListWindow(offsets, heights, total, 0, 800);
assert.strictEqual(window.start, 0);
assert.strictEqual(window.end, 19);
assert.strictEqual(window.top, 0);
assert.strictEqual(window.bottom, total - 19 * 58);
});
test("follows the scroll offset with a 300px overscan on both sides", () => {
const { heights, offsets, total } = metrics(500);
const window = computeListWindow(offsets, heights, total, 5800, 800);
assert.strictEqual(window.start, 94); // 93 ends at 5452 < 5500
assert.strictEqual(window.end, 119); // 118 starts at 6844 < 6900, 119 starts at 6902
assert.strictEqual(window.top, 94 * 58);
assert.strictEqual(window.bottom, total - 119 * 58);
});
test("mounts a bounded number of rows for thousands of monitors", () => {
const { heights, offsets, total } = metrics(5000);
const window = computeListWindow(offsets, heights, total, 145000, 800);
assert.ok(window.end - window.start <= 25, `mounted ${window.end - window.start} rows`);
});
test("keeps the bottom spacer valid on the last row", () => {
const { heights, offsets, total } = metrics(500);
const window = computeListWindow(offsets, heights, total, total - 58, 800);
assert.strictEqual(window.end, 500);
assert.strictEqual(window.bottom, 0);
});
test("uses the measured height of each row", () => {
const ids = [1, 2, 3, 4, 5];
const { heights, offsets, total } = computeRowMetrics(ids, { 1: 100, 2: 100, 3: 100, 4: 100, 5: 100 });
const window = computeListWindow(offsets, heights, total, 0, 250);
// The 300px overscan reaches past the last row, so all five are mounted
assert.strictEqual(window.end, 5);
assert.strictEqual(window.bottom, 0);
});
});