mirror of
https://github.com/louislam/uptime-kuma.git
synced 2026-11-10 21:21:19 +08:00
chore: improve dashboard performance + add more tests (#8030)
This commit is contained in:
1 parent
365c4b289d
commit
9ced9ccd0e
89 files changed
+6856
-477
No files matched your search
@@ -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
|
||||
|
||||
@@ -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}%`);
|
||||
}
|
||||
Generated
+184
-3
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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')"
|
||||
>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"]]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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." }]);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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,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,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,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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "../fixtures";
|
||||
import { login, restoreSqliteSnapshot, screenshot } from "../util-test";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,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", () => {
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "../fixtures";
|
||||
import { login, restoreSqliteSnapshot, screenshot } from "../util-test";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user