feat: add Vue monitor web sentinel dashboard

This commit is contained in:
Codex
2026-06-27 06:12:46 +00:00
parent 6e06972acf
commit 0fb4f9fc30
13 changed files with 1797 additions and 266 deletions
+135
View File
@@ -7,18 +7,95 @@
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"@types/node": "latest", "@types/node": "latest",
"@vitejs/plugin-vue": "^6.0.7",
"playwright": "^1.59.1", "playwright": "^1.59.1",
"postgres": "^3.4.9", "postgres": "^3.4.9",
"typescript": "latest", "typescript": "latest",
"vite": "^8.1.0",
"vue": "3",
"xlsx": "^0.18.5", "xlsx": "^0.18.5",
}, },
}, },
}, },
"packages": { "packages": {
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
"@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
"@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
"@oxc-project/types": ["@oxc-project/types@0.137.0", "", {}, "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.3", "", { "os": "android", "cpu": "arm64" }, "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.3", "", { "os": "linux", "cpu": "arm" }, "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.3", "", { "os": "none", "cpu": "arm64" }, "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.3", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.3", "", { "os": "win32", "cpu": "x64" }, "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.7", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25" } }, "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg=="],
"@vue/compiler-core": ["@vue/compiler-core@3.5.39", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/shared": "3.5.39", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw=="],
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.39", "", { "dependencies": { "@vue/compiler-core": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg=="],
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.39", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/compiler-core": "3.5.39", "@vue/compiler-dom": "3.5.39", "@vue/compiler-ssr": "3.5.39", "@vue/shared": "3.5.39", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.15", "source-map-js": "^1.2.1" } }, "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg=="],
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.39", "", { "dependencies": { "@vue/compiler-dom": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw=="],
"@vue/reactivity": ["@vue/reactivity@3.5.39", "", { "dependencies": { "@vue/shared": "3.5.39" } }, "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog=="],
"@vue/runtime-core": ["@vue/runtime-core@3.5.39", "", { "dependencies": { "@vue/reactivity": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw=="],
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.39", "", { "dependencies": { "@vue/reactivity": "3.5.39", "@vue/runtime-core": "3.5.39", "@vue/shared": "3.5.39", "csstype": "^3.2.3" } }, "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww=="],
"@vue/server-renderer": ["@vue/server-renderer@3.5.39", "", { "dependencies": { "@vue/compiler-ssr": "3.5.39", "@vue/shared": "3.5.39" }, "peerDependencies": { "vue": "3.5.39" } }, "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw=="],
"@vue/shared": ["@vue/shared@3.5.39", "", {}, "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA=="],
"adler-32": ["adler-32@1.3.1", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="], "adler-32": ["adler-32@1.3.1", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="],
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
@@ -29,26 +106,84 @@
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"frac": ["frac@1.1.2", "", {}, "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="], "frac": ["frac@1.1.2", "", {}, "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
"playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
"postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="],
"rolldown": ["rolldown@1.1.3", "", { "dependencies": { "@oxc-project/types": "=0.137.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.3", "@rolldown/binding-darwin-arm64": "1.1.3", "@rolldown/binding-darwin-x64": "1.1.3", "@rolldown/binding-freebsd-x64": "1.1.3", "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", "@rolldown/binding-linux-arm64-gnu": "1.1.3", "@rolldown/binding-linux-arm64-musl": "1.1.3", "@rolldown/binding-linux-ppc64-gnu": "1.1.3", "@rolldown/binding-linux-s390x-gnu": "1.1.3", "@rolldown/binding-linux-x64-gnu": "1.1.3", "@rolldown/binding-linux-x64-musl": "1.1.3", "@rolldown/binding-openharmony-arm64": "1.1.3", "@rolldown/binding-wasm32-wasi": "1.1.3", "@rolldown/binding-win32-arm64-msvc": "1.1.3", "@rolldown/binding-win32-x64-msvc": "1.1.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="], "ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
"vite": ["vite@8.1.0", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "~1.1.2", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q=="],
"vue": ["vue@3.5.39", "", { "dependencies": { "@vue/compiler-dom": "3.5.39", "@vue/compiler-sfc": "3.5.39", "@vue/runtime-dom": "3.5.39", "@vue/server-renderer": "3.5.39", "@vue/shared": "3.5.39" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA=="],
"wmf": ["wmf@1.0.2", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="], "wmf": ["wmf@1.0.2", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="],
"word": ["word@0.3.0", "", {}, "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="], "word": ["word@0.3.0", "", {}, "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="],
"xlsx": ["xlsx@0.18.5", "", { "dependencies": { "adler-32": "~1.3.0", "cfb": "~1.2.1", "codepage": "~1.15.0", "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" }, "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ=="], "xlsx": ["xlsx@0.18.5", "", { "dependencies": { "adler-32": "~1.3.0", "cfb": "~1.2.1", "codepage": "~1.15.0", "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" }, "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ=="],
"vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
} }
} }
@@ -43,6 +43,19 @@ sentinel:
maintenance: maintenance:
startCommand: sentinel maintenance start startCommand: sentinel maintenance start
stopCommand: sentinel maintenance stop stopCommand: sentinel maintenance stop
monitorWeb:
frontendStack: vue3-vendored-runtime
runtimeMode: runner-served-bridge
assetRoot: scripts/assets/web-probe-sentinel-monitor-web
envReuse:
mode: docker-layer-and-ci-node-deps
nodeDepsPath: /opt/hwlab-ci-node-deps/node_modules
gitMirror:
source: source.gitMirrorReadUrl
preSync: required
postFlush: required
ciBudget:
maxSeconds: 120
confirmWait: confirmWait:
maxSeconds: 120 maxSeconds: 120
targetValidation: targetValidation:
@@ -43,6 +43,19 @@ sentinel:
maintenance: maintenance:
startCommand: sentinel maintenance start startCommand: sentinel maintenance start
stopCommand: sentinel maintenance stop stopCommand: sentinel maintenance stop
monitorWeb:
frontendStack: vue3-vendored-runtime
runtimeMode: runner-served-bridge
assetRoot: scripts/assets/web-probe-sentinel-monitor-web
envReuse:
mode: docker-layer-and-ci-node-deps
nodeDepsPath: /opt/hwlab-ci-node-deps/node_modules
gitMirror:
source: source.gitMirrorReadUrl
preSync: required
postFlush: required
ciBudget:
maxSeconds: 120
confirmWait: confirmWait:
maxSeconds: 120 maxSeconds: 120
targetValidation: targetValidation:
@@ -43,6 +43,19 @@ sentinel:
maintenance: maintenance:
startCommand: sentinel maintenance start startCommand: sentinel maintenance start
stopCommand: sentinel maintenance stop stopCommand: sentinel maintenance stop
monitorWeb:
frontendStack: vue3-vendored-runtime
runtimeMode: runner-served-bridge
assetRoot: scripts/assets/web-probe-sentinel-monitor-web
envReuse:
mode: docker-layer-and-ci-node-deps
nodeDepsPath: /opt/hwlab-ci-node-deps/node_modules
gitMirror:
source: source.gitMirrorReadUrl
preSync: required
postFlush: required
ciBudget:
maxSeconds: 120
confirmWait: confirmWait:
maxSeconds: 120 maxSeconds: 120
targetValidation: targetValidation:
+5 -1
View File
@@ -6,14 +6,18 @@
"scripts": { "scripts": {
"cli": "bun scripts/cli.ts", "cli": "bun scripts/cli.ts",
"check": "bun scripts/cli.ts check", "check": "bun scripts/cli.ts check",
"e2e": "bun scripts/cli.ts e2e run" "e2e": "bun scripts/cli.ts e2e run",
"monitor-web:verify": "bun scripts/verify-web-probe-sentinel-monitor-web.ts"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"@types/node": "latest", "@types/node": "latest",
"@vitejs/plugin-vue": "^6.0.7",
"playwright": "^1.59.1", "playwright": "^1.59.1",
"postgres": "^3.4.9", "postgres": "^3.4.9",
"typescript": "latest", "typescript": "latest",
"vite": "^8.1.0",
"vue": "3",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
} }
} }
@@ -0,0 +1,782 @@
:root {
color-scheme: light;
--bg: #f5f7f8;
--panel: #ffffff;
--panel-soft: #f9fbfb;
--text: #17201f;
--muted: #5e6b68;
--line: #dce3e1;
--line-strong: #b8c4c0;
--red: #d43c35;
--red-soft: #ffe8e5;
--amber: #b7791f;
--amber-soft: #fff2d6;
--green: #1f8a5b;
--green-soft: #e4f6ed;
--blue: #2a6fbb;
--ink: #203330;
--shadow: 0 10px 28px rgba(32, 51, 48, 0.08);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body,
#monitor-web-root {
height: 100%;
margin: 0;
}
body {
background: var(--bg);
color: var(--text);
overflow: hidden;
}
button,
select,
input {
font: inherit;
}
.monitor-shell {
display: flex;
height: 100dvh;
min-height: 0;
flex-direction: column;
gap: 10px;
overflow: hidden;
padding: 12px;
}
.topbar,
.status-strip,
.entry-strip,
.trend-stage,
.workspace-grid {
width: min(100%, 1760px);
margin: 0 auto;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex: 0 0 auto;
min-height: 58px;
}
.title-block {
display: flex;
min-width: 0;
align-items: center;
gap: 12px;
}
.mark {
width: 34px;
height: 34px;
border: 2px solid var(--ink);
border-radius: 8px;
background: linear-gradient(135deg, #ffffff 0%, #e7f1ef 100%);
box-shadow: inset 0 -8px 0 rgba(31, 138, 91, 0.16);
}
h1,
h2,
h3,
p {
margin: 0;
}
h1 {
font-size: 20px;
line-height: 1.15;
letter-spacing: 0;
}
.subtitle {
display: flex;
min-width: 0;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-top: 4px;
color: var(--muted);
font-size: 12px;
}
code,
.mono {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
.pill,
.toolbar-button,
.entry-link,
.legend-item,
.metric,
.timeline-item,
.run-row,
.finding-card,
.check-chip {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
}
.toolbar {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.toolbar-button,
select {
min-height: 34px;
border: 1px solid var(--line-strong);
border-radius: 8px;
background: #ffffff;
color: var(--text);
cursor: pointer;
}
.toolbar-button {
padding: 0 12px;
}
select {
padding: 0 28px 0 10px;
}
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 30px;
padding: 0 10px;
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
.pill::before {
content: "";
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--muted);
}
.pill.blocked,
.pill.degraded {
border-color: #efb3ad;
background: var(--red-soft);
color: #8b1f1a;
}
.pill.blocked::before,
.pill.degraded::before {
background: var(--red);
}
.pill.warning {
border-color: #e5c06b;
background: var(--amber-soft);
color: #73500f;
}
.pill.warning::before {
background: var(--amber);
}
.pill.healthy,
.pill.idle {
border-color: #b9ddc9;
background: var(--green-soft);
color: #17633f;
}
.pill.healthy::before,
.pill.idle::before {
background: var(--green);
}
.entry-strip {
display: grid;
grid-template-columns: minmax(180px, 240px) minmax(0, 1fr);
gap: 10px;
flex: 0 0 auto;
align-items: stretch;
}
.entry-copy {
display: flex;
min-height: 52px;
flex-direction: column;
justify-content: center;
color: var(--muted);
font-size: 12px;
}
.entry-copy strong {
color: var(--text);
font-size: 15px;
}
.entry-links {
display: flex;
min-width: 0;
gap: 8px;
overflow-x: auto;
padding-bottom: 2px;
}
.entry-link {
display: grid;
grid-template-columns: minmax(130px, 1fr) auto;
min-width: 220px;
max-width: 360px;
gap: 10px;
align-items: center;
padding: 9px 10px;
color: var(--text);
text-decoration: none;
}
.entry-link.current {
border-color: var(--green);
box-shadow: inset 3px 0 0 var(--green);
}
.entry-link.disabled {
opacity: 0.55;
}
.entry-link span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.entry-link small {
color: var(--muted);
font-size: 11px;
}
.trend-stage {
display: grid;
grid-template-columns: minmax(420px, 1.55fr) minmax(300px, 0.9fr);
gap: 10px;
flex: 0 0 auto;
min-height: 212px;
}
.trend-panel,
.timeline-panel,
.pane,
.status-strip {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
box-shadow: var(--shadow);
}
.trend-panel,
.timeline-panel {
min-width: 0;
padding: 12px;
}
.panel-header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.panel-header h2 {
font-size: 15px;
line-height: 1.25;
}
.panel-header p,
.muted {
color: var(--muted);
font-size: 12px;
}
.trend-chart-wrap {
position: relative;
min-height: 142px;
border: 1px solid var(--line);
border-radius: 8px;
background: linear-gradient(180deg, #ffffff 0%, #f7faf9 100%);
overflow: hidden;
}
.trend-chart {
display: block;
width: 100%;
height: 142px;
}
.trend-empty {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--muted);
font-size: 13px;
}
.trend-red {
fill: none;
stroke: var(--red);
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}
.trend-warning {
fill: none;
stroke: var(--amber);
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}
.trend-total {
fill: none;
stroke: var(--blue);
stroke-width: 2;
stroke-dasharray: 5 6;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0.75;
}
.trend-grid-line {
stroke: #e5ecea;
stroke-width: 1;
}
.trend-dot-red {
fill: var(--red);
}
.trend-dot-warning {
fill: var(--amber);
}
.trend-legend {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 28px;
padding: 0 9px;
color: var(--muted);
font-size: 12px;
}
.legend-swatch {
width: 18px;
height: 3px;
border-radius: 999px;
}
.legend-swatch.red {
background: var(--red);
}
.legend-swatch.warning {
background: var(--amber);
}
.legend-swatch.total {
background: var(--blue);
}
.timeline-panel {
display: flex;
min-height: 0;
flex-direction: column;
}
.timeline-list {
display: grid;
max-height: 150px;
gap: 7px;
overflow: auto;
padding-right: 2px;
}
.timeline-item {
display: grid;
grid-template-columns: 78px minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
padding: 7px 8px;
font-size: 12px;
}
.timeline-item strong,
.run-row strong,
.finding-card strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeline-marker {
width: 9px;
height: 9px;
border-radius: 999px;
background: var(--green);
}
.timeline-item.red .timeline-marker,
.run-row.red .severity-dot,
.finding-card.red .severity-dot {
background: var(--red);
}
.timeline-item.warning .timeline-marker,
.run-row.warning .severity-dot,
.finding-card.warning .severity-dot {
background: var(--amber);
}
.timeline-item.info .timeline-marker,
.run-row.info .severity-dot,
.finding-card.info .severity-dot {
background: var(--blue);
}
.status-strip {
display: grid;
grid-template-columns: repeat(5, minmax(120px, 1fr));
gap: 8px;
flex: 0 0 auto;
padding: 8px;
}
.metric {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
background: var(--panel-soft);
}
.metric span {
color: var(--muted);
font-size: 11px;
}
.metric strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
}
.metric.warning {
border-color: #e5c06b;
background: var(--amber-soft);
}
.workspace-grid {
display: grid;
grid-template-columns: minmax(260px, 0.82fr) minmax(420px, 1.34fr) minmax(300px, 1fr);
gap: 10px;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.pane {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
overflow: auto;
padding: 12px;
}
.pane-header {
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: start;
justify-content: space-between;
gap: 10px;
margin: -12px -12px 10px;
padding: 12px;
border-bottom: 1px solid var(--line);
background: rgba(255, 255, 255, 0.96);
}
.pane-header h2 {
font-size: 15px;
}
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.filter-row input,
.filter-row select {
min-width: 0;
min-height: 32px;
border: 1px solid var(--line-strong);
border-radius: 8px;
padding: 0 10px;
background: #ffffff;
}
.run-list,
.finding-list,
.detail-stack {
display: grid;
gap: 8px;
}
.run-row,
.finding-card {
display: grid;
gap: 6px;
padding: 10px;
text-align: left;
}
.run-row {
cursor: pointer;
}
.run-row.selected {
border-color: var(--blue);
box-shadow: inset 3px 0 0 var(--blue);
}
.row-line {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.severity-line {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.severity-dot {
width: 8px;
height: 8px;
flex: 0 0 auto;
border-radius: 999px;
background: var(--green);
}
.tag {
display: inline-flex;
align-items: center;
min-height: 22px;
max-width: 100%;
border-radius: 999px;
background: #eef3f1;
color: var(--muted);
padding: 0 8px;
font-size: 11px;
}
.tag.red {
background: var(--red-soft);
color: #8b1f1a;
}
.tag.warning {
background: var(--amber-soft);
color: #73500f;
}
.detail-card {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel-soft);
padding: 12px;
}
.detail-card h3 {
margin-bottom: 8px;
font-size: 14px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.detail-grid .metric {
background: #ffffff;
}
pre {
max-height: 240px;
margin: 0;
overflow: auto;
border: 1px solid var(--line);
border-radius: 8px;
background: #10211e;
color: #eef8f4;
padding: 10px;
font-size: 12px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
.check-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.check-chip {
min-height: 26px;
padding: 5px 8px;
color: var(--muted);
font-size: 11px;
}
.check-chip.ok {
border-color: #b9ddc9;
background: var(--green-soft);
color: #17633f;
}
.check-chip.bad {
border-color: #efb3ad;
background: var(--red-soft);
color: #8b1f1a;
}
.banner {
width: min(100%, 1760px);
margin: 0 auto;
flex: 0 0 auto;
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px 12px;
font-size: 13px;
}
.banner.error {
border-color: #efb3ad;
background: var(--red-soft);
color: #8b1f1a;
}
.empty {
display: grid;
min-height: 120px;
place-items: center;
border: 1px dashed var(--line-strong);
border-radius: 8px;
color: var(--muted);
font-size: 13px;
}
@media (max-width: 1120px) {
body {
overflow: auto;
}
.monitor-shell {
height: auto;
min-height: 100dvh;
overflow: visible;
}
.trend-stage,
.workspace-grid,
.entry-strip {
grid-template-columns: 1fr;
}
.workspace-grid {
overflow: visible;
}
.pane {
max-height: 72dvh;
}
.status-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.monitor-shell {
padding: 8px;
}
.topbar {
align-items: stretch;
flex-direction: column;
}
.toolbar {
justify-content: stretch;
}
.toolbar-button,
.toolbar select {
flex: 1 1 96px;
}
.entry-link {
min-width: 190px;
}
.status-strip,
.detail-grid {
grid-template-columns: 1fr;
}
.timeline-item {
grid-template-columns: 62px minmax(0, 1fr);
}
.timeline-item .tag {
grid-column: 2;
width: max-content;
}
}
@@ -0,0 +1,564 @@
import { createApp, computed, onMounted, ref } from "./vendor/vue.runtime.esm-browser.prod.js";
const bootstrap = readBootstrap();
createApp({
setup() {
const loading = ref(true);
const error = ref("");
const overview = ref(null);
const runs = ref([]);
const findings = ref([]);
const selectedRunId = ref("");
const selectedDetail = ref(null);
const runFilter = ref("");
const severityFilter = ref("");
const findingFilter = ref("");
const autoRefresh = ref(true);
const refreshSeconds = ref(30);
const lastLoadedAt = ref("");
let lastAutoRefreshAt = 0;
const sentinels = computed(() => {
const rows = Array.isArray(bootstrap.sentinels) ? bootstrap.sentinels : [];
return rows.length > 0 ? rows : [{ id: bootstrap.sentinelId, enabled: true }];
});
const currentStatus = computed(() => String(overview.value?.status || "idle"));
const latestRun = computed(() => overview.value?.latestRun || null);
const severityTotals = computed(() => overview.value?.severityCounts || {});
const filteredRuns = computed(() => {
const needle = runFilter.value.trim().toLowerCase();
return runs.value.filter((run) => {
if (severityFilter.value && severityClass(run) !== severityFilter.value) return false;
if (!needle) return true;
return [run.id, run.scenarioId, run.status, run.observerId].some((item) => String(item || "").toLowerCase().includes(needle));
});
});
const selectedRun = computed(() => runs.value.find((run) => run.id === selectedRunId.value) || latestRun.value);
const trendRows = computed(() => runs.value.slice().sort((a, b) => Date.parse(a.updatedAt || a.createdAt || "") - Date.parse(b.updatedAt || b.createdAt || "")).slice(-48));
const trendMax = computed(() => Math.max(1, ...trendRows.value.flatMap((run) => [redCount(run), warningCount(run), findingCount(run)])));
const trendPolylines = computed(() => ({
red: trendPolyline((run) => redCount(run)),
warning: trendPolyline((run) => warningCount(run)),
total: trendPolyline((run) => findingCount(run)),
}));
const trendDots = computed(() => trendRows.value.map((run, index) => ({
id: run.id || String(index),
x: trendX(index, trendRows.value.length),
redY: trendY(redCount(run)),
warningY: trendY(warningCount(run)),
severity: severityClass(run),
title: `${shortId(run.id)} ${formatDate(run.updatedAt || run.createdAt)}`,
})));
const timelineRuns = computed(() => runs.value.slice(0, 16));
const rootCauseFindings = computed(() => {
const rows = findings.value.filter((item) => item.rootCause || item.nextAction || ["red", "warning"].includes(severityClass(item)));
return rows.slice(0, 14);
});
const cadence = computed(() => {
const intervalMs = Number(overview.value?.scheduler?.intervalMs || 0);
const latestAge = Number(overview.value?.freshness?.latestRunAgeSeconds ?? -1);
const heartbeatAge = Number(overview.value?.freshness?.schedulerHeartbeatAgeSeconds ?? -1);
const intervalSeconds = intervalMs > 0 ? Math.round(intervalMs / 1000) : 0;
const stale = intervalSeconds > 0 && latestAge > intervalSeconds * 2;
return {
intervalSeconds,
latestAge,
heartbeatAge,
stale,
label: intervalSeconds > 0 ? `${formatDuration(intervalSeconds)} 间隔` : "未配置",
alert: stale ? `最近运行 ${formatDuration(latestAge)} 前,超过预设间隔 2 倍;按 SPEC 作为非阻塞报警展示。` : "运行新鲜度在预设窗口内",
};
});
const healthChecks = computed(() => {
const checks = overview.value?.health?.checks || {};
return Object.entries(checks).map(([key, value]) => ({
key,
ok: value?.ok !== false,
text: `${key} ${value?.ok === false ? "异常" : "ok"}`,
}));
});
async function loadAll(options = {}) {
if (!options.silent) loading.value = true;
error.value = "";
setReady(false);
try {
const [overviewPayload, runsPayload, findingsPayload] = await Promise.all([
fetchJson("/api/overview"),
fetchJson("/api/runs?limit=80&sort=updated"),
fetchJson("/api/findings?limit=80&window=24h"),
]);
overview.value = overviewPayload;
runs.value = Array.isArray(runsPayload.runs) ? runsPayload.runs : Array.isArray(runsPayload.items) ? runsPayload.items : [];
findings.value = Array.isArray(findingsPayload.findings) ? findingsPayload.findings : Array.isArray(findingsPayload.groups) ? findingsPayload.groups : [];
lastLoadedAt.value = new Date().toISOString();
lastAutoRefreshAt = Date.now();
const keepSelected = runs.value.find((run) => run.id === selectedRunId.value);
const nextRun = keepSelected || runs.value[0] || latestRun.value;
if (nextRun?.id) await selectRun(nextRun, true);
} catch (cause) {
error.value = String(cause?.message || cause);
} finally {
loading.value = false;
setReady(true);
}
}
async function selectRun(run, silent = false) {
const runId = typeof run === "string" ? run : run?.id;
if (!runId) return;
selectedRunId.value = runId;
if (!silent) selectedDetail.value = null;
try {
selectedDetail.value = await fetchJson(`/api/runs/${encodeURIComponent(runId)}`);
} catch (cause) {
selectedDetail.value = { ok: false, error: String(cause?.message || cause), runId };
}
}
function refreshNow() {
void loadAll();
}
function currentHref(item) {
if (!item || item.id === bootstrap.sentinelId) return bootstrap.basePath || "/";
if (item.id === "workbench-dsflash-go-tool-call-10x") return "/";
return `/sentinels/${encodeURIComponent(item.id)}/`;
}
function trendPolyline(accessor) {
if (trendRows.value.length < 2) return "";
return trendRows.value.map((run, index) => `${trendX(index, trendRows.value.length)},${trendY(accessor(run))}`).join(" ");
}
function trendX(index, total) {
if (total <= 1) return 24;
return Math.round(24 + index * (672 / (total - 1)));
}
function trendY(value) {
return Math.round(126 - (Number(value || 0) / trendMax.value) * 102);
}
onMounted(() => {
void loadAll();
window.setInterval(() => {
if (!autoRefresh.value) return;
if (Date.now() - lastAutoRefreshAt >= Math.max(5, Number(refreshSeconds.value || 30)) * 1000) void loadAll({ silent: true });
}, 1000);
});
return {
bootstrap,
loading,
error,
overview,
runs,
findings,
selectedRunId,
selectedDetail,
runFilter,
severityFilter,
findingFilter,
autoRefresh,
refreshSeconds,
lastLoadedAt,
sentinels,
currentStatus,
latestRun,
severityTotals,
filteredRuns,
selectedRun,
trendRows,
trendPolylines,
trendDots,
timelineRuns,
rootCauseFindings,
cadence,
healthChecks,
loadAll,
selectRun,
refreshNow,
currentHref,
redCount,
warningCount,
findingCount,
severityClass,
formatDate,
formatDuration,
shortId,
rootCauseText,
findingTitle,
detailSummaryText,
commandSummary,
statusLabel,
};
},
template: `
<div class="monitor-shell" data-monitor-shell="true" :data-monitor-status="currentStatus">
<header class="topbar">
<div class="title-block">
<div class="mark" aria-hidden="true"></div>
<div>
<h1>HWLAB Web哨兵</h1>
<p class="subtitle">
<span>{{ bootstrap.node }} / {{ bootstrap.lane }}</span>
<span class="mono">{{ bootstrap.sentinelId }}</span>
<span>Vue monitor-web</span>
</p>
</div>
</div>
<div class="toolbar" aria-label="观察面板控制">
<span class="pill" :class="currentStatus">{{ statusLabel(currentStatus) }}</span>
<select v-model.number="refreshSeconds" aria-label="刷新间隔">
<option :value="5">5s</option>
<option :value="10">10s</option>
<option :value="30">30s</option>
</select>
<button class="toolbar-button" type="button" @click="autoRefresh = !autoRefresh">{{ autoRefresh ? "自动" : "手动" }}</button>
<button class="toolbar-button" type="button" @click="refreshNow">刷新</button>
</div>
</header>
<section class="entry-strip" aria-label="哨兵入口">
<div class="entry-copy">
<strong>哨兵入口</strong>
<span>{{ sentinels.length }} 个实例,当前入口固定在第一屏</span>
</div>
<div class="entry-links">
<a
v-for="item in sentinels"
:key="item.id"
class="entry-link"
:class="{ current: item.id === bootstrap.sentinelId, disabled: item.enabled === false }"
:href="currentHref(item)"
>
<span>{{ item.id }}</span>
<small>{{ item.id === bootstrap.sentinelId ? "当前" : item.enabled === false ? "停用" : "查看" }}</small>
</a>
</div>
</section>
<section class="trend-stage" aria-label="运行趋势与时间线">
<section class="trend-panel" aria-labelledby="trend-heading">
<div class="panel-header">
<div>
<h2 id="trend-heading">红色 / 警告数量曲线</h2>
<p>按运行更新时间展示最近 {{ trendRows.length }} 次变化</p>
</div>
<span class="pill" :class="cadence.stale ? 'warning' : 'healthy'">{{ cadence.stale ? "非阻塞报警" : "新鲜" }}</span>
</div>
<div class="trend-chart-wrap">
<svg class="trend-chart" viewBox="0 0 720 142" role="img" aria-label="红色和警告发现数量趋势" data-monitor-trend-curve="true">
<line class="trend-grid-line" x1="24" x2="696" y1="24" y2="24"></line>
<line class="trend-grid-line" x1="24" x2="696" y1="75" y2="75"></line>
<line class="trend-grid-line" x1="24" x2="696" y1="126" y2="126"></line>
<polyline v-if="trendPolylines.total" class="trend-total" :points="trendPolylines.total"></polyline>
<polyline v-if="trendPolylines.warning" class="trend-warning" :points="trendPolylines.warning"></polyline>
<polyline v-if="trendPolylines.red" class="trend-red" :points="trendPolylines.red"></polyline>
<g v-for="dot in trendDots" :key="dot.id">
<circle class="trend-dot-warning" :cx="dot.x" :cy="dot.warningY" r="3">
<title>{{ dot.title }}</title>
</circle>
<circle class="trend-dot-red" :cx="dot.x" :cy="dot.redY" r="3">
<title>{{ dot.title }}</title>
</circle>
</g>
</svg>
<div v-if="trendRows.length === 0" class="trend-empty">暂无运行数据</div>
</div>
<div class="trend-legend">
<span class="legend-item"><span class="legend-swatch red"></span>红色 {{ redCount({ severityCounts: severityTotals }) }}</span>
<span class="legend-item"><span class="legend-swatch warning"></span>警告 {{ warningCount({ severityCounts: severityTotals }) }}</span>
<span class="legend-item"><span class="legend-swatch total"></span>发现总量 {{ findingCount({ findingCount: overview?.latestRun?.findingCount, severityCounts: severityTotals }) }}</span>
<span class="legend-item">{{ cadence.alert }}</span>
</div>
</section>
<section class="timeline-panel" aria-labelledby="timeline-heading" data-monitor-timeline="true">
<div class="panel-header">
<div>
<h2 id="timeline-heading">运行时间线</h2>
<p>位于哨兵入口下方,方便首屏确认最近运行</p>
</div>
<span class="tag">{{ cadence.label }}</span>
</div>
<div class="timeline-list">
<button
v-for="run in timelineRuns"
:key="run.id"
class="timeline-item"
:class="severityClass(run)"
type="button"
@click="selectRun(run)"
>
<span class="muted">{{ formatDate(run.updatedAt || run.createdAt) }}</span>
<span class="severity-line"><span class="timeline-marker"></span><strong>{{ run.scenarioId || shortId(run.id) }}</strong></span>
<span class="tag" :class="severityClass(run)">{{ findingCount(run) }} 项</span>
</button>
<div v-if="timelineRuns.length === 0" class="empty">暂无时间线记录</div>
</div>
</section>
</section>
<section class="status-strip" aria-label="状态指标">
<div class="metric">
<span>最近运行</span>
<strong>{{ latestRun ? formatDate(latestRun.updatedAt || latestRun.createdAt) : "-" }}</strong>
</div>
<div class="metric" :class="{ warning: cadence.stale }">
<span>调度新鲜度</span>
<strong>{{ cadence.latestAge >= 0 ? formatDuration(cadence.latestAge) : "-" }}</strong>
</div>
<div class="metric">
<span>红色</span>
<strong>{{ redCount({ severityCounts: severityTotals }) }}</strong>
</div>
<div class="metric">
<span>警告</span>
<strong>{{ warningCount({ severityCounts: severityTotals }) }}</strong>
</div>
<div class="metric">
<span>最后刷新</span>
<strong>{{ lastLoadedAt ? formatDate(lastLoadedAt) : "-" }}</strong>
</div>
</section>
<section v-if="error" class="banner error" id="monitor-web-error">{{ error }}</section>
<section class="workspace-grid" aria-label="哨兵工作区" data-monitor-independent-scroll="true">
<aside class="pane pane-runs" aria-labelledby="runs-heading">
<div class="pane-header">
<div>
<h2 id="runs-heading">运行记录</h2>
<p class="muted">{{ filteredRuns.length }} / {{ runs.length }}</p>
</div>
<span v-if="loading" class="tag">加载中</span>
</div>
<div class="filter-row">
<input v-model="runFilter" type="search" placeholder="搜索 run / scenario" aria-label="搜索运行">
<select v-model="severityFilter" aria-label="严重级别筛选">
<option value="">全部</option>
<option value="red">红色</option>
<option value="warning">警告</option>
<option value="info">信息</option>
<option value="healthy">正常</option>
</select>
</div>
<div class="run-list">
<button
v-for="run in filteredRuns"
:key="run.id"
class="run-row"
:class="[severityClass(run), { selected: run.id === selectedRunId }]"
type="button"
@click="selectRun(run)"
>
<span class="row-line">
<span class="severity-line"><span class="severity-dot"></span><strong>{{ run.scenarioId || shortId(run.id) }}</strong></span>
<span class="tag" :class="severityClass(run)">{{ findingCount(run) }}</span>
</span>
<span class="row-line muted">
<span>{{ run.status || "-" }}</span>
<span>{{ formatDate(run.updatedAt || run.createdAt) }}</span>
</span>
</button>
<div v-if="filteredRuns.length === 0" class="empty">没有匹配的运行记录</div>
</div>
</aside>
<main class="pane pane-detail" aria-labelledby="detail-heading">
<div class="pane-header">
<div>
<h2 id="detail-heading">运行详情</h2>
<p class="muted">{{ selectedRun ? selectedRun.id : "未选择" }}</p>
</div>
<span v-if="selectedRun" class="tag" :class="severityClass(selectedRun)">{{ severityClass(selectedRun) }}</span>
</div>
<div v-if="selectedRun" class="detail-stack">
<section class="detail-card">
<h3>摘要</h3>
<div class="detail-grid">
<div class="metric"><span>状态</span><strong>{{ selectedRun.status || "-" }}</strong></div>
<div class="metric"><span>发现数</span><strong>{{ findingCount(selectedRun) }}</strong></div>
<div class="metric"><span>Observer</span><strong>{{ selectedRun.observerId || "-" }}</strong></div>
<div class="metric"><span>更新时间</span><strong>{{ formatDate(selectedRun.updatedAt || selectedRun.createdAt) }}</strong></div>
</div>
</section>
<section class="detail-card">
<h3>健康检查</h3>
<div class="check-grid">
<span v-for="check in healthChecks" :key="check.key" class="check-chip" :class="check.ok ? 'ok' : 'bad'">{{ check.text }}</span>
<span v-if="healthChecks.length === 0" class="check-chip">无检查数据</span>
</div>
</section>
<section class="detail-card">
<h3>报告摘要</h3>
<pre>{{ detailSummaryText(selectedDetail) }}</pre>
</section>
<section class="detail-card">
<h3>复现命令</h3>
<pre>{{ commandSummary(selectedDetail) }}</pre>
</section>
</div>
<div v-else class="empty">选择一条运行记录查看详情</div>
</main>
<aside class="pane pane-findings" aria-labelledby="findings-heading">
<div class="pane-header">
<div>
<h2 id="findings-heading">根因与发现</h2>
<p class="muted">优先展示 OTel/报告中已归因线索</p>
</div>
<span class="tag">{{ findings.length }} 项</span>
</div>
<div class="filter-row">
<input v-model="findingFilter" type="search" placeholder="搜索 finding / root cause" aria-label="搜索发现">
</div>
<div class="finding-list">
<article
v-for="item in rootCauseFindings.filter((finding) => !findingFilter || JSON.stringify(finding).toLowerCase().includes(findingFilter.toLowerCase())).slice(0, 24)"
:key="item.code || item.findingId || item.latestRunId"
class="finding-card"
:class="severityClass(item)"
>
<span class="row-line">
<span class="severity-line"><span class="severity-dot"></span><strong>{{ findingTitle(item) }}</strong></span>
<span class="tag" :class="severityClass(item)">{{ item.runCount || item.count || 1 }}</span>
</span>
<p class="muted">{{ rootCauseText(item) }}</p>
<p v-if="item.nextAction" class="muted">方案: {{ item.nextAction }}</p>
</article>
<div v-if="rootCauseFindings.length === 0" class="empty">暂无已归因发现</div>
</div>
</aside>
</section>
</div>
`,
}).mount("#monitor-web-root");
function readBootstrap() {
const root = document.querySelector("#monitor-web-root");
const script = document.querySelector("#monitor-web-bootstrap");
let parsed = {};
try {
parsed = script?.textContent ? JSON.parse(script.textContent) : {};
} catch {
parsed = {};
}
return {
node: root?.getAttribute("data-node") || parsed.node || "",
lane: root?.getAttribute("data-lane") || parsed.lane || "",
sentinelId: root?.getAttribute("data-sentinel-id") || parsed.sentinelId || "",
basePath: root?.getAttribute("data-base-path") || parsed.basePath || "",
publicOrigin: root?.getAttribute("data-public-origin") || parsed.publicOrigin || "",
contractVersion: root?.getAttribute("data-contract-version") || parsed.contractVersion || "",
sentinels: Array.isArray(parsed.sentinels) ? parsed.sentinels : [],
};
}
async function fetchJson(path) {
const response = await fetch(apiUrl(path), { cache: "no-store" });
if (!response.ok) throw new Error(`${path} HTTP ${response.status}`);
return await response.json();
}
function apiUrl(path) {
const prefix = bootstrap.basePath || "";
const suffix = path.startsWith("/") ? path : `/${path}`;
return `${prefix}${suffix}`;
}
function setReady(value) {
document.querySelector("#monitor-web-root")?.setAttribute("data-monitor-ready", value ? "true" : "false");
}
function redCount(item) {
const counts = item?.severityCounts || item || {};
return number(counts.red) + number(counts.critical) + number(counts.error);
}
function warningCount(item) {
const counts = item?.severityCounts || item || {};
return number(counts.warning) + number(counts.warn) + number(counts.amber);
}
function findingCount(item) {
if (Number.isFinite(Number(item?.findingCount))) return Number(item.findingCount);
if (Number.isFinite(Number(item?.finding_count))) return Number(item.finding_count);
if (Number.isFinite(Number(item?.count))) return Number(item.count);
const counts = item?.severityCounts || item || {};
return Object.values(counts).reduce((sum, value) => sum + number(value), 0);
}
function severityClass(item) {
const explicit = String(item?.maxSeverity || item?.severity || "").toLowerCase();
if (["red", "critical", "error", "blocked"].includes(explicit)) return "red";
if (["warning", "warn", "amber"].includes(explicit)) return "warning";
if (["info", "notice"].includes(explicit)) return "info";
if (redCount(item) > 0) return "red";
if (warningCount(item) > 0) return "warning";
return "healthy";
}
function formatDate(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
const now = Date.now();
const seconds = Math.max(0, Math.round((now - date.getTime()) / 1000));
if (seconds < 90) return `${seconds}s前`;
if (seconds < 7200) return `${Math.round(seconds / 60)}m前`;
if (seconds < 172800) return `${Math.round(seconds / 3600)}h前`;
return date.toISOString().slice(5, 16).replace("T", " ");
}
function formatDuration(seconds) {
const value = Math.max(0, Number(seconds || 0));
if (value < 90) return `${Math.round(value)}s`;
if (value < 7200) return `${Math.round(value / 60)}m`;
if (value < 172800) return `${Math.round(value / 3600)}h`;
return `${Math.round(value / 86400)}d`;
}
function shortId(value) {
const text = String(value || "");
return text.length > 18 ? `${text.slice(0, 10)}...${text.slice(-6)}` : text || "-";
}
function rootCauseText(item) {
return item?.rootCause || item?.evidenceSummary || item?.summary || "尚未记录根因,等待下一次 OTel/报告归因。";
}
function findingTitle(item) {
return item?.code || item?.findingId || item?.scenarioId || item?.latestRunId || "finding";
}
function detailSummaryText(detail) {
if (!detail) return "加载详情中";
if (detail.ok === false) return detail.error || "详情不可用";
const summary = detail.summary && Object.keys(detail.summary).length > 0 ? detail.summary : detail.run;
return JSON.stringify(summary || {}, null, 2);
}
function commandSummary(detail) {
const commands = detail?.commands || {};
const lines = Object.entries(commands).map(([key, value]) => `${key}: ${value}`);
return lines.length > 0 ? lines.join("\n") : "暂无命令";
}
function statusLabel(status) {
const value = String(status || "");
if (value === "blocked") return "阻塞";
if (value === "degraded") return "降级";
if (value === "warning") return "警告";
if (value === "healthy") return "健康";
return "空闲";
}
function number(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2018-present, Yuxi (Evan) You
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because one or more lines are too long
+90 -30
View File
@@ -2,6 +2,7 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p8-web-probe-sentinel-recovery. // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p8-web-probe-sentinel-recovery.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel. // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p10-monitor-web-aggregation. // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p10-monitor-web-aggregation.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-27-p11-monitor-web-observability-dashboard.
// Responsibility: YAML-first CI/CD, image, GitOps and Argo command plan for the web-probe sentinel. // Responsibility: YAML-first CI/CD, image, GitOps and Argo command plan for the web-probe sentinel.
import { createHash, randomUUID } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
@@ -147,6 +148,7 @@ interface SentinelImagePlan {
readonly entrypoint: string; readonly entrypoint: string;
readonly dockerfileSha256: string; readonly dockerfileSha256: string;
readonly dockerfilePreview: string; readonly dockerfilePreview: string;
readonly monitorWeb: Record<string, unknown>;
} }
interface SentinelObservedStatus { interface SentinelObservedStatus {
@@ -190,7 +192,7 @@ interface ChildCliResult {
readonly result: CompactCommandResult & { stdoutTail: string; stderrTail: string }; readonly result: CompactCommandResult & { stdoutTail: string; stderrTail: string };
} }
const SPEC_REF = "PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel"; const SPEC_REF = "PJ2026-01060508 Web哨兵 draft-2026-06-27-p11-monitor-web-observability-dashboard";
export function runWebProbeSentinelCommand(spec: HwlabRuntimeLaneSpec, options: WebProbeSentinelOptions): RenderedCliResult { export function runWebProbeSentinelCommand(spec: HwlabRuntimeLaneSpec, options: WebProbeSentinelOptions): RenderedCliResult {
if (options.kind === "config") return withWebProbeSentinelConfigRendered(webProbeSentinelConfigPlan(spec, options.action, options.sentinelId)); if (options.kind === "config") return withWebProbeSentinelConfigRendered(webProbeSentinelConfigPlan(spec, options.action, options.sentinelId));
@@ -367,6 +369,7 @@ function sentinelImagePlan(cicd: Record<string, unknown>, sourceHead: SourceHead
entrypoint, entrypoint,
dockerfileSha256: sha256(dockerfile), dockerfileSha256: sha256(dockerfile),
dockerfilePreview: dockerfile, dockerfilePreview: dockerfile,
monitorWeb: monitorWebCicdPlan(cicd),
}; };
} }
@@ -376,12 +379,28 @@ function sentinelDockerfile(baseImage: string, entrypoint: string): string {
"WORKDIR /app", "WORKDIR /app",
"COPY . /app", "COPY . /app",
"RUN if [ -d /opt/hwlab-ci-node-deps/node_modules ]; then mkdir -p /app/node_modules; for dep in /opt/hwlab-ci-node-deps/node_modules/*; do ln -sf \"$dep\" \"/app/node_modules/$(basename \"$dep\")\"; done; fi", "RUN if [ -d /opt/hwlab-ci-node-deps/node_modules ]; then mkdir -p /app/node_modules; for dep in /opt/hwlab-ci-node-deps/node_modules/*; do ln -sf \"$dep\" \"/app/node_modules/$(basename \"$dep\")\"; done; fi",
"RUN bun scripts/verify-web-probe-sentinel-monitor-web.ts",
"ENV NODE_ENV=production", "ENV NODE_ENV=production",
`ENTRYPOINT ["bun", "${entrypoint}"]`, `ENTRYPOINT ["bun", "${entrypoint}"]`,
"", "",
].join("\n"); ].join("\n");
} }
function monitorWebCicdPlan(cicd: Record<string, unknown>): Record<string, unknown> {
return {
stack: stringAtNullable(cicd, "monitorWeb.frontendStack") ?? "vue3-vendored-runtime",
runtimeMode: stringAtNullable(cicd, "monitorWeb.runtimeMode") ?? "runner-served-bridge",
assetRoot: stringAtNullable(cicd, "monitorWeb.assetRoot") ?? "scripts/assets/web-probe-sentinel-monitor-web",
verifyCommand: "bun scripts/verify-web-probe-sentinel-monitor-web.ts",
gitMirrorReadUrl: stringAt(cicd, "source.gitMirrorReadUrl"),
sourceMode: stringAt(cicd, "builder.sourceMode"),
envReuseMode: stringAtNullable(cicd, "monitorWeb.envReuse.mode") ?? "docker-layer-and-ci-node-deps",
envReuseNodeDepsPath: stringAtNullable(cicd, "monitorWeb.envReuse.nodeDepsPath") ?? "/opt/hwlab-ci-node-deps/node_modules",
ciBudgetSeconds: numberAtNullable(cicd, "monitorWeb.ciBudget.maxSeconds") ?? numberAt(cicd, "confirmWait.maxSeconds"),
valuesRedacted: true,
};
}
function renderSentinelManifests( function renderSentinelManifests(
spec: HwlabRuntimeLaneSpec, spec: HwlabRuntimeLaneSpec,
sentinelId: string, sentinelId: string,
@@ -1964,15 +1983,16 @@ for (let attempt = 1; attempt <= maxNavigationAttempts; attempt += 1) {
httpStatus = response?.status() ?? null; httpStatus = response?.status() ?? null;
await page.waitForLoadState("networkidle", { timeout: Math.min(5000, perAttemptTimeout) }).catch(() => {}); await page.waitForLoadState("networkidle", { timeout: Math.min(5000, perAttemptTimeout) }).catch(() => {});
await page.waitForFunction(() => { await page.waitForFunction(() => {
const root = document.querySelector("#sentinel-dashboard"); const root = document.querySelector("#monitor-web-root");
if (!root) return false; if (!root) return false;
const error = document.querySelector("#error-banner"); const ready = root.getAttribute("data-monitor-ready") === "true";
const runs = document.querySelectorAll("#runs-body tr").length; const error = document.querySelector("#monitor-web-error");
const statusText = document.querySelector("#status-pill")?.textContent || ""; const runs = document.querySelectorAll(".run-list .run-row").length;
return (error && !error.hidden) || runs > 0 || (statusText.trim() && statusText.trim() !== "空闲"); const trend = document.querySelector("[data-monitor-trend-curve]");
return ready && (error || runs > 0 || trend);
}, null, { timeout: Math.min(5000, perAttemptTimeout) }).catch(() => {}); }, null, { timeout: Math.min(5000, perAttemptTimeout) }).catch(() => {});
await page.waitForTimeout(500); await page.waitForTimeout(500);
const shellReady = await page.evaluate(() => Boolean(document.querySelector("#sentinel-dashboard"))).catch(() => false); const shellReady = await page.evaluate(() => Boolean(document.querySelector("#monitor-web-root"))).catch(() => false);
if (shellReady || attempt === maxNavigationAttempts) break; if (shellReady || attempt === maxNavigationAttempts) break;
} catch (error) { } catch (error) {
navigationError = String(error?.message || error).slice(0, 500); navigationError = String(error?.message || error).slice(0, 500);
@@ -1990,9 +2010,13 @@ if (captureScreenshot && screenshotPath) {
const dom = await page.evaluate(() => { const dom = await page.evaluate(() => {
const visible = (element) => Boolean(element && !element.hidden); const visible = (element) => Boolean(element && !element.hidden);
const text = (selector) => String(document.querySelector(selector)?.textContent || "").replace(/\s+/g, " ").trim(); const text = (selector) => String(document.querySelector(selector)?.textContent || "").replace(/\s+/g, " ").trim();
const root = document.querySelector("#sentinel-dashboard"); const root = document.querySelector("#monitor-web-root");
const error = document.querySelector("#error-banner"); const shell = document.querySelector("[data-monitor-shell='true']");
const loading = document.querySelector("#loading-banner"); const error = document.querySelector("#monitor-web-error");
const trend = document.querySelector("[data-monitor-trend-curve]");
const timeline = document.querySelector("[data-monitor-timeline='true']");
const workspace = document.querySelector("[data-monitor-independent-scroll='true']");
const panes = Array.from(document.querySelectorAll(".workspace-grid .pane"));
const doc = document.documentElement; const doc = document.documentElement;
const body = document.body; const body = document.body;
const viewport = { width: window.innerWidth, height: window.innerHeight }; const viewport = { width: window.innerWidth, height: window.innerHeight };
@@ -2024,7 +2048,8 @@ const dom = await page.evaluate(() => {
} }
} }
return { return {
shell: Boolean(root), shell: Boolean(root && shell),
ready: root?.getAttribute("data-monitor-ready") === "true",
dataset: root ? { dataset: root ? {
node: root.getAttribute("data-node"), node: root.getAttribute("data-node"),
lane: root.getAttribute("data-lane"), lane: root.getAttribute("data-lane"),
@@ -2034,16 +2059,39 @@ const dom = await page.evaluate(() => {
} : {}, } : {},
title: document.title, title: document.title,
finalUrl: window.location.href, finalUrl: window.location.href,
statusText: text("#status-pill"), statusText: text(".topbar .pill"),
subtitle: text("#sentinel-subtitle"), subtitle: text(".subtitle"),
summaryText: text("#status-summary"), summaryText: text(".status-strip"),
runRows: document.querySelectorAll("#runs-body tr").length, runRows: document.querySelectorAll(".run-list .run-row").length,
findingItems: document.querySelectorAll("#findings-list > *").length, findingItems: document.querySelectorAll(".finding-list .finding-card").length,
detailTabs: document.querySelectorAll("#detail-tabs [data-detail-tab]").length, trendCurve: Boolean(trend),
timelineItems: document.querySelectorAll("#run-timeline > *").length, trendPanelText: text("#trend-heading"),
loadingVisible: visible(loading), timelineItems: document.querySelectorAll(".timeline-list .timeline-item").length,
timelineVisible: Boolean(timeline),
errorVisible: visible(error), errorVisible: visible(error),
errorText: visible(error) ? text("#error-banner").slice(0, 500) : "", errorText: visible(error) ? text("#monitor-web-error").slice(0, 500) : "",
scrollModel: {
workspace: Boolean(workspace),
paneCount: panes.length,
panes: panes.map((pane) => {
const style = window.getComputedStyle(pane);
const rect = pane.getBoundingClientRect();
return {
className: String(pane.className || ""),
overflowY: style.overflowY,
scrollHeight: pane.scrollHeight,
clientHeight: pane.clientHeight,
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height),
};
}),
independentScroll: panes.length >= 3 && panes.every((pane) => {
const style = window.getComputedStyle(pane);
return style.overflowY === "auto" || style.overflowY === "scroll";
}),
},
layout: { layout: {
viewport, viewport,
documentSize, documentSize,
@@ -2060,8 +2108,12 @@ const ok = !navigationError
&& httpStatus >= 200 && httpStatus >= 200
&& httpStatus < 300 && httpStatus < 300
&& dom.shell === true && dom.shell === true
&& dom.ready === true
&& dom.errorVisible !== true && dom.errorVisible !== true
&& dom.runRows > 0 && dom.trendCurve === true
&& dom.timelineVisible === true
&& dom.scrollModel?.independentScroll === true
&& dom.layout?.horizontalOverflow !== true
&& pageErrors.length === 0; && pageErrors.length === 0;
console.log("__WEB_PROBE_SENTINEL_DASHBOARD_JSON__" + JSON.stringify({ console.log("__WEB_PROBE_SENTINEL_DASHBOARD_JSON__" + JSON.stringify({
@@ -2905,28 +2957,33 @@ function probeSentinelPublicExposure(state: SentinelCicdState, timeoutSeconds: n
function probeSentinelPublicDashboard(state: SentinelCicdState, timeoutSeconds: number): Record<string, unknown> { function probeSentinelPublicDashboard(state: SentinelCicdState, timeoutSeconds: number): Record<string, unknown> {
const publicBaseUrl = stringAt(state.publicExposure, "publicBaseUrl").replace(/\/$/u, ""); const publicBaseUrl = stringAt(state.publicExposure, "publicBaseUrl").replace(/\/$/u, "");
const rootUrl = `${publicBaseUrl}/`; const rootUrl = `${publicBaseUrl}/`;
const cssUrl = `${publicBaseUrl}/dashboard/assets/dashboard.css`; const cssUrl = `${publicBaseUrl}/monitor-web/assets/monitor-web.css`;
const jsUrl = `${publicBaseUrl}/dashboard/assets/dashboard.js`; const jsUrl = `${publicBaseUrl}/monitor-web/assets/monitor-web.js`;
const vueUrl = `${publicBaseUrl}/monitor-web/assets/vendor/vue.runtime.esm-browser.prod.js`;
const script = [ const script = [
"set +e", "set +e",
`root_url=${shellQuote(rootUrl)}`, `root_url=${shellQuote(rootUrl)}`,
`css_url=${shellQuote(cssUrl)}`, `css_url=${shellQuote(cssUrl)}`,
`js_url=${shellQuote(jsUrl)}`, `js_url=${shellQuote(jsUrl)}`,
`vue_url=${shellQuote(vueUrl)}`,
"root_body=$(mktemp)", "root_body=$(mktemp)",
"css_body=$(mktemp)", "css_body=$(mktemp)",
"js_body=$(mktemp)", "js_body=$(mktemp)",
"vue_body=$(mktemp)",
"root_code=$(curl -sS -L --connect-timeout 8 --max-time 20 -o \"$root_body\" --write-out '%{http_code}' \"$root_url\" 2>/tmp/web-probe-sentinel-dashboard-root.err); root_rc=$?", "root_code=$(curl -sS -L --connect-timeout 8 --max-time 20 -o \"$root_body\" --write-out '%{http_code}' \"$root_url\" 2>/tmp/web-probe-sentinel-dashboard-root.err); root_rc=$?",
"css_code=$(curl -sS -L --connect-timeout 8 --max-time 20 -o \"$css_body\" --write-out '%{http_code}' \"$css_url\" 2>/tmp/web-probe-sentinel-dashboard-css.err); css_rc=$?", "css_code=$(curl -sS -L --connect-timeout 8 --max-time 20 -o \"$css_body\" --write-out '%{http_code}' \"$css_url\" 2>/tmp/web-probe-sentinel-dashboard-css.err); css_rc=$?",
"js_code=$(curl -sS -L --connect-timeout 8 --max-time 20 -o \"$js_body\" --write-out '%{http_code}' \"$js_url\" 2>/tmp/web-probe-sentinel-dashboard-js.err); js_rc=$?", "js_code=$(curl -sS -L --connect-timeout 8 --max-time 20 -o \"$js_body\" --write-out '%{http_code}' \"$js_url\" 2>/tmp/web-probe-sentinel-dashboard-js.err); js_rc=$?",
"node - \"$root_url\" \"$css_url\" \"$js_url\" \"$root_code\" \"$root_rc\" \"$css_code\" \"$css_rc\" \"$js_code\" \"$js_rc\" \"$root_body\" \"$css_body\" \"$js_body\" <<'NODE'", "vue_code=$(curl -sS -L --connect-timeout 8 --max-time 20 -o \"$vue_body\" --write-out '%{http_code}' \"$vue_url\" 2>/tmp/web-probe-sentinel-dashboard-vue.err); vue_rc=$?",
"node - \"$root_url\" \"$css_url\" \"$js_url\" \"$vue_url\" \"$root_code\" \"$root_rc\" \"$css_code\" \"$css_rc\" \"$js_code\" \"$js_rc\" \"$vue_code\" \"$vue_rc\" \"$root_body\" \"$css_body\" \"$js_body\" \"$vue_body\" <<'NODE'",
"const fs=require('node:fs');", "const fs=require('node:fs');",
"const [rootUrl,cssUrl,jsUrl,rootCode,rootRc,cssCode,cssRc,jsCode,jsRc,rootPath,cssPath,jsPath]=process.argv.slice(2);", "const [rootUrl,cssUrl,jsUrl,vueUrl,rootCode,rootRc,cssCode,cssRc,jsCode,jsRc,vueCode,vueRc,rootPath,cssPath,jsPath,vuePath]=process.argv.slice(2);",
"function read(path){try{return fs.readFileSync(path,'utf8')}catch{return ''}}", "function read(path){try{return fs.readFileSync(path,'utf8')}catch{return ''}}",
"const root=read(rootPath); const css=read(cssPath); const js=read(jsPath);", "const root=read(rootPath); const css=read(cssPath); const js=read(jsPath); const vue=read(vuePath);",
"const rootOk=Number(rootRc)===0&&Number(rootCode)>=200&&Number(rootCode)<300&&root.includes('id=\"sentinel-dashboard\"')&&root.includes('/dashboard/assets/dashboard.js');", "const rootOk=Number(rootRc)===0&&Number(rootCode)>=200&&Number(rootCode)<300&&root.includes('id=\"monitor-web-root\"')&&root.includes('/monitor-web/assets/monitor-web.js')&&root.includes('monitor-web-bootstrap');",
"const cssOk=Number(cssRc)===0&&Number(cssCode)>=200&&Number(cssCode)<300&&css.includes('sentinel-shell')&&css.length>1000;", "const cssOk=Number(cssRc)===0&&Number(cssCode)>=200&&Number(cssCode)<300&&css.includes('monitor-shell')&&css.includes('workspace-grid')&&css.includes('trend-stage')&&css.length>1000;",
"const jsOk=Number(jsRc)===0&&Number(jsCode)>=200&&Number(jsCode)<300&&js.includes('createAutoRefresh')&&js.includes('/api/overview')&&js.length>1000;", "const jsOk=Number(jsRc)===0&&Number(jsCode)>=200&&Number(jsCode)<300&&js.includes('createApp')&&js.includes('/api/overview')&&js.includes('data-monitor-trend-curve')&&js.length>1000;",
"console.log(JSON.stringify({ok:rootOk&&cssOk&&jsOk,root:{url:rootUrl,httpStatus:Number(rootCode),bytes:Buffer.byteLength(root),shell:root.includes('id=\"sentinel-dashboard\"')},css:{url:cssUrl,httpStatus:Number(cssCode),bytes:Buffer.byteLength(css),shell:css.includes('sentinel-shell')},js:{url:jsUrl,httpStatus:Number(jsCode),bytes:Buffer.byteLength(js),apiClient:js.includes('/api/overview'),autoRefresh:js.includes('createAutoRefresh')},valuesRedacted:true}));", "const vueOk=Number(vueRc)===0&&Number(vueCode)>=200&&Number(vueCode)<300&&vue.includes('createApp')&&vue.length>80000;",
"console.log(JSON.stringify({ok:rootOk&&cssOk&&jsOk&&vueOk,root:{url:rootUrl,httpStatus:Number(rootCode),bytes:Buffer.byteLength(root),shell:root.includes('id=\"monitor-web-root\"'),contract:root.includes('draft-2026-06-27-p11-monitor-web-observability-dashboard')},css:{url:cssUrl,httpStatus:Number(cssCode),bytes:Buffer.byteLength(css),workspaceGrid:css.includes('workspace-grid'),trendStage:css.includes('trend-stage')},js:{url:jsUrl,httpStatus:Number(jsCode),bytes:Buffer.byteLength(js),vueApp:js.includes('createApp'),apiClient:js.includes('/api/overview'),trend:js.includes('data-monitor-trend-curve')},vue:{url:vueUrl,httpStatus:Number(vueCode),bytes:Buffer.byteLength(vue),runtime:vue.includes('createApp')},valuesRedacted:true}));",
"NODE", "NODE",
].join("\n"); ].join("\n");
const result = runCommand(["bash", "-lc", script], repoRoot, { timeoutMs: Math.min(timeoutSeconds, 30) * 1000 }); const result = runCommand(["bash", "-lc", script], repoRoot, { timeoutMs: Math.min(timeoutSeconds, 30) * 1000 });
@@ -4151,6 +4208,7 @@ function renderImageResult(result: Record<string, unknown>): string {
const sourceMirror = record(result.sourceMirror); const sourceMirror = record(result.sourceMirror);
const sourceMirrorSync = record(result.sourceMirrorSync); const sourceMirrorSync = record(result.sourceMirrorSync);
const image = record(result.image); const image = record(result.image);
const monitorWeb = record(image.monitorWeb);
const registry = record(result.registry); const registry = record(result.registry);
const publish = record(result.publish); const publish = record(result.publish);
const blocker = record(result.blocker); const blocker = record(result.blocker);
@@ -4167,6 +4225,8 @@ function renderImageResult(result: Record<string, unknown>): string {
"", "",
table(["IMAGE", "BASE", "ENTRYPOINT", "DOCKERFILE"], [[image.ref, image.baseImage, image.entrypoint, short(image.dockerfileSha256)]]), table(["IMAGE", "BASE", "ENTRYPOINT", "DOCKERFILE"], [[image.ref, image.baseImage, image.entrypoint, short(image.dockerfileSha256)]]),
"", "",
Object.keys(monitorWeb).length === 0 ? "MONITOR_WEB\n-" : table(["STACK", "MODE", "ASSETS", "VERIFY", "ENV_REUSE"], [[monitorWeb.stack, monitorWeb.runtimeMode, monitorWeb.assetRoot, monitorWeb.verifyCommand, `${monitorWeb.envReuseMode}:${monitorWeb.envReuseNodeDepsPath}`]]),
"",
Object.keys(registry).length === 0 ? "REGISTRY\n-" : table(["PROBED", "PRESENT", "DIGEST"], [[record(registry.probe).url ?? "-", record(registry.probe).present ?? "-", short(record(registry.probe).digest)]]), Object.keys(registry).length === 0 ? "REGISTRY\n-" : table(["PROBED", "PRESENT", "DIGEST"], [[record(registry.probe).url ?? "-", record(registry.probe).present ?? "-", short(record(registry.probe).digest)]]),
"", "",
Object.keys(sourceMirrorSync).length === 0 ? "SOURCE_MIRROR_SYNC\n-" : table(["OK", "PHASE", "JOB", "COMMIT", "ELAPSED"], [[sourceMirrorSync.ok, sourceMirrorSync.phase, sourceMirrorSync.jobName, short(record(sourceMirrorSync.payload).mirrorCommit), sourceMirrorSync.elapsedMs ?? "-"]]), Object.keys(sourceMirrorSync).length === 0 ? "SOURCE_MIRROR_SYNC\n-" : table(["OK", "PHASE", "JOB", "COMMIT", "ELAPSED"], [[sourceMirrorSync.ok, sourceMirrorSync.phase, sourceMirrorSync.jobName, short(record(sourceMirrorSync.payload).mirrorCommit), sourceMirrorSync.elapsedMs ?? "-"]]),
@@ -1,6 +1,7 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-desktop-view-density. // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-desktop-view-density.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel. // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p10-monitor-web-aggregation. // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p10-monitor-web-aggregation.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-27-p11-monitor-web-observability-dashboard.
// Responsibility: Static dashboard shell and asset serving for the web-probe sentinel frontend. // Responsibility: Static dashboard shell and asset serving for the web-probe sentinel frontend.
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { rootPath } from "./config"; import { rootPath } from "./config";
@@ -14,24 +15,35 @@ interface DashboardShellConfig {
} }
const DASHBOARD_ASSET_ROOT = "scripts/assets/web-probe-sentinel-dashboard"; const DASHBOARD_ASSET_ROOT = "scripts/assets/web-probe-sentinel-dashboard";
const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-26-p10-monitor-web-aggregation"; const MONITOR_WEB_ASSET_ROOT = "scripts/assets/web-probe-sentinel-monitor-web";
const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-27-p11-monitor-web-observability-dashboard";
export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig): string { export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig): string {
const publicOrigin = stringOrNull(config.publicExposure.publicBaseUrl) ?? ""; const publicOrigin = stringOrNull(config.publicExposure.publicBaseUrl) ?? "";
const basePath = publicBasePath(publicOrigin); const basePath = publicBasePath(publicOrigin);
const registryHtml = renderSentinelRegistryStrip(config, basePath); const sentinels = sentinelRegistryRows(config);
const bootstrap = {
node: config.node,
lane: config.lane,
sentinelId: config.sentinelId,
publicOrigin,
basePath,
configReady: config.plan.ok,
contractVersion: DASHBOARD_CONTRACT_VERSION,
sentinels,
valuesRedacted: true,
};
return `<!doctype html> return `<!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>HWLAB Web哨兵</title> <title>HWLAB Web哨兵 monitor-web</title>
<link rel="stylesheet" href="${escapeAttr(basePath)}/dashboard/assets/dashboard.css"> <link rel="stylesheet" href="${escapeAttr(basePath)}/monitor-web/assets/monitor-web.css">
</head> </head>
<body> <body>
<main <main
id="sentinel-dashboard" id="monitor-web-root"
class="sentinel-shell"
data-node="${escapeAttr(config.node)}" data-node="${escapeAttr(config.node)}"
data-lane="${escapeAttr(config.lane)}" data-lane="${escapeAttr(config.lane)}"
data-sentinel-id="${escapeAttr(config.sentinelId)}" data-sentinel-id="${escapeAttr(config.sentinelId)}"
@@ -39,226 +51,21 @@ export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig
data-public-origin="${escapeAttr(publicOrigin)}" data-public-origin="${escapeAttr(publicOrigin)}"
data-config-ready="${config.plan.ok ? "true" : "false"}" data-config-ready="${config.plan.ok ? "true" : "false"}"
data-contract-version="${DASHBOARD_CONTRACT_VERSION}" data-contract-version="${DASHBOARD_CONTRACT_VERSION}"
data-viewport="" data-monitor-ready="false"
> >
<section class="sentinel-topbar" aria-label="Web哨兵概览"> monitor-web...
<div class="sentinel-title">
<div class="sentinel-mark" aria-hidden="true"></div>
<div>
<h1>HWLAB Web哨兵</h1>
<p id="sentinel-subtitle">
<span>${escapeHtml(config.node)} / ${escapeHtml(config.lane)}</span>
<span class="sentinel-subtitle-separator">·</span>
<code class="sentinel-id-chip">${escapeHtml(config.sentinelId)}</code>
<span id="sentinel-origin-note"></span>
</p>
</div>
</div>
<div class="sentinel-toolbar" aria-label="监控面板控制">
<span id="status-pill" class="status-pill status-idle"></span>
<label class="refresh-toggle">
<input id="auto-refresh-enabled" type="checkbox">
<span></span>
</label>
<select id="auto-refresh-interval" aria-label="自动刷新间隔">
<option value="5">5s</option>
<option value="10">10s</option>
<option value="30">30s</option>
</select>
<button id="manual-refresh" class="icon-button" type="button" title="刷新 (r)" aria-label="刷新"></button>
<button id="latest-run" class="icon-button" type="button" title="跳转最新运行" aria-label="跳转最新"></button>
<button id="filter-red" class="icon-button" type="button" title="只看红色发现" aria-label="只看红色"></button>
</div>
</section>
<section id="status-summary" class="status-summary" aria-label="状态摘要条" hidden>
<span class="summary-item"><span class="summary-label"></span><strong id="summary-status">-</strong></span>
<span class="summary-item"><span class="summary-label"></span><strong id="summary-latest">-</strong><small id="summary-latest-age"></small></span>
<span class="summary-item"><span class="summary-label"></span><strong id="summary-findings">0</strong><small id="summary-findings-note"></small></span>
<span class="summary-item"><span class="summary-label"></span><strong id="summary-scheduler">-</strong><small id="summary-budget"></small></span>
<span class="summary-item summary-checks" id="summary-checks" hidden></span>
</section>
${registryHtml}
<section id="loading-banner" class="banner banner-muted" hidden></section>
<section id="error-banner" class="banner banner-danger" hidden></section>
<section class="overview-checks overview-checks-collapsed" id="overview-checks" aria-label="哨兵健康检查" hidden>
<button type="button" class="check-summary-pill" id="check-summary-pill"> -</button>
<div class="overview-checks-detail" id="overview-checks-detail">
<span id="check-config" class="check-chip">config -</span>
<span id="check-pvc" class="check-chip">pvc -</span>
<span id="check-analyzer" class="check-chip">analyzer -</span>
<span id="check-public" class="check-chip">public -</span>
<span id="check-maintenance" class="check-chip">maintenance -</span>
</div>
</section>
<section class="dashboard-grid">
<section class="panel panel-runs" aria-labelledby="runs-heading">
<div class="panel-header">
<h2 id="runs-heading"></h2>
<span id="runs-count" class="panel-subtitle">-</span>
</div>
<div class="filter-collapse">
<button type="button" class="filter-summary" id="runs-filter-summary" aria-expanded="false">筛选: 全部</button>
<form id="runs-filter" class="runs-filter" hidden>
<label><span></span>
<select id="filter-status" name="status">
<option value=""></option>
<option value="planned"></option>
<option value="running"></option>
<option value="analyzed"></option>
<option value="blocked"></option>
<option value="interrupted"></option>
</select>
</label>
<label><span></span>
<select id="filter-severity" name="severity">
<option value=""></option>
<option value="red"></option>
<option value="warning"></option>
<option value="info"></option>
</select>
</label>
<label><span></span>
<select id="filter-window" name="window">
<option value=""></option>
<option value="1h">1h</option>
<option value="6h">6h</option>
<option value="24h">24h</option>
<option value="7d">7d</option>
</select>
</label>
<label><span></span>
<select id="filter-sort" name="sort">
<option value="updated"></option>
<option value="created"></option>
<option value="findings"></option>
<option value="severity"></option>
</select>
</label>
<label class="filter-search"><span></span>
<input id="filter-search" name="search" type="search" placeholder="run、observer、report">
</label>
<button id="clear-filters" class="icon-button" type="button"></button>
</form>
</div>
<div class="table-frame">
<table class="runs-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody id="runs-body"></tbody>
</table>
</div>
</section>
<section class="panel panel-detail" aria-labelledby="detail-heading">
<div class="panel-header">
<h2 id="detail-heading"></h2>
<span id="detail-subtitle" class="panel-subtitle"></span>
</div>
<nav class="detail-tabs" id="detail-tabs" aria-label="详情分页" hidden>
<button type="button" class="detail-tab active" data-detail-tab="overview"></button>
<button type="button" class="detail-tab" data-detail-tab="findings"></button>
<button type="button" class="detail-tab" data-detail-tab="turn"></button>
<button type="button" class="detail-tab" data-detail-tab="trace">Trace</button>
<button type="button" class="detail-tab" data-detail-tab="evidence"></button>
</nav>
<div id="detail-content" class="detail-content"></div>
</section>
<section class="panel panel-findings" aria-labelledby="findings-heading">
<div class="panel-header">
<h2 id="findings-heading"></h2>
<span id="findings-count" class="panel-subtitle">-</span>
</div>
<div class="filter-collapse">
<button type="button" class="filter-summary" id="findings-filter-summary" aria-expanded="false">筛选: 全部</button>
<form id="findings-filter" class="findings-filter" hidden>
<label><span></span>
<select id="finding-filter-severity" name="fseverity">
<option value=""></option>
<option value="red"></option>
<option value="warning"></option>
<option value="info"></option>
</select>
</label>
<label><span></span>
<select id="finding-filter-window" name="fwindow">
<option value="24h">24h</option>
<option value="1h">1h</option>
<option value="6h">6h</option>
<option value="7d">7d</option>
<option value=""></option>
</select>
</label>
<label><span></span>
<input id="finding-filter-code" name="fcode" type="search" placeholder="finding code">
</label>
<label><span></span>
<input id="finding-filter-scenario" name="fscenario" type="search" placeholder="scenario">
</label>
<button id="finding-clear-filters" class="icon-button" type="button"></button>
</form>
</div>
<div id="finding-aggregation" class="finding-aggregation"></div>
<div id="findings-list" class="finding-list"></div>
<div id="findings-drilldown" class="findings-drilldown" hidden></div>
</section>
</section>
<section class="panel timeline-panel" aria-labelledby="timeline-heading">
<div class="panel-header">
<h2 id="timeline-heading">线</h2>
<span id="timeline-count" class="panel-subtitle">-</span>
<button type="button" class="timeline-toggle" id="timeline-toggle" aria-expanded="true"></button>
</div>
<div id="run-timeline" class="run-timeline"></div>
</section>
<div id="copy-toast" class="copy-toast" hidden></div>
</main> </main>
<script type="module" src="${escapeAttr(basePath)}/dashboard/assets/dashboard.js"></script> <script id="monitor-web-bootstrap" type="application/json">${jsonForScript(bootstrap)}</script>
<script type="module" src="${escapeAttr(basePath)}/monitor-web/assets/monitor-web.js"></script>
</body> </body>
</html>`; </html>`;
} }
function renderSentinelRegistryStrip(config: DashboardShellConfig, basePath: string): string { function sentinelRegistryRows(config: DashboardShellConfig): Array<{ readonly id: string; readonly enabled: boolean }> {
const rows = Array.isArray(config.plan.sentinels) ? config.plan.sentinels.map((item) => ({ return Array.isArray(config.plan.sentinels) ? config.plan.sentinels.map((item) => ({
id: stringOrNull(item.id) ?? "", id: stringOrNull(item.id) ?? "",
enabled: item.enabled !== false, enabled: item.enabled !== false,
})).filter((item) => item.id.length > 0) : []; })).filter((item) => item.id.length > 0) : [];
if (rows.length <= 1) return "";
return `<section class="sentinel-registry-strip" aria-label="多哨兵入口">
<div class="sentinel-registry-copy">
<strong></strong>
<span> workspace ${rows.length} </span>
</div>
<div class="sentinel-registry-links">
${rows.map((item) => renderSentinelRegistryLink(item.id, item.enabled, item.id === config.sentinelId, basePath)).join("")}
</div>
</section>`;
}
function renderSentinelRegistryLink(id: string, enabled: boolean, current: boolean, basePath: string): string {
const href = current
? `${basePath || "/"}`
: id === "workbench-dsflash-go-tool-call-10x"
? "/"
: `/sentinels/${encodeURIComponent(id)}/`;
return `<a class="sentinel-registry-link${current ? " current" : ""}${enabled ? "" : " disabled"}" href="${escapeAttr(href)}">
<span>${escapeHtml(id)}</span>
<small>${current ? "当前" : enabled ? "查看" : "停用"}</small>
</a>`;
} }
function publicBasePath(publicBaseUrl: string): string { function publicBasePath(publicBaseUrl: string): string {
@@ -273,6 +80,10 @@ function publicBasePath(publicBaseUrl: string): string {
export function webProbeSentinelDashboardAssetResponse(pathname: string): Response | null { export function webProbeSentinelDashboardAssetResponse(pathname: string): Response | null {
if (pathname === "/dashboard/assets/dashboard.css") return textAsset(`${DASHBOARD_ASSET_ROOT}/dashboard.css`, "text/css; charset=utf-8"); if (pathname === "/dashboard/assets/dashboard.css") return textAsset(`${DASHBOARD_ASSET_ROOT}/dashboard.css`, "text/css; charset=utf-8");
if (pathname === "/dashboard/assets/dashboard.js") return textAsset(`${DASHBOARD_ASSET_ROOT}/dashboard.js`, "application/javascript; charset=utf-8"); if (pathname === "/dashboard/assets/dashboard.js") return textAsset(`${DASHBOARD_ASSET_ROOT}/dashboard.js`, "application/javascript; charset=utf-8");
if (pathname === "/monitor-web/assets/monitor-web.css") return textAsset(`${MONITOR_WEB_ASSET_ROOT}/monitor-web.css`, "text/css; charset=utf-8");
if (pathname === "/monitor-web/assets/monitor-web.js") return textAsset(`${MONITOR_WEB_ASSET_ROOT}/monitor-web.js`, "application/javascript; charset=utf-8");
if (pathname === "/monitor-web/assets/vendor/vue.runtime.esm-browser.prod.js") return textAsset(`${MONITOR_WEB_ASSET_ROOT}/vendor/vue.runtime.esm-browser.prod.js`, "application/javascript; charset=utf-8");
if (pathname === "/monitor-web/assets/vendor/VUE-LICENSE") return textAsset(`${MONITOR_WEB_ASSET_ROOT}/vendor/VUE-LICENSE`, "text/plain; charset=utf-8");
return null; return null;
} }
@@ -296,3 +107,7 @@ function escapeHtml(value: string): string {
function escapeAttr(value: string): string { function escapeAttr(value: string): string {
return escapeHtml(value).replace(/'/gu, "&#39;"); return escapeHtml(value).replace(/'/gu, "&#39;");
} }
function jsonForScript(value: unknown): string {
return JSON.stringify(value).replace(/</gu, "\\u003c");
}
+54 -18
View File
@@ -2,6 +2,7 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p7-web-probe-sentinel-dashboard. // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p7-web-probe-sentinel-dashboard.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-desktop-view-density. // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-desktop-view-density.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel. // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-27-p11-monitor-web-observability-dashboard.
// Responsibility: Persistent HTTP wrapper service for web-probe observe scheduling, index, health, metrics, maintenance, and dashboard. // Responsibility: Persistent HTTP wrapper service for web-probe observe scheduling, index, health, metrics, maintenance, and dashboard.
import { Buffer } from "node:buffer"; import { Buffer } from "node:buffer";
import { createHash, randomUUID } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
@@ -14,7 +15,7 @@ import { webProbeSentinelConfigPlan, type WebProbeSentinelConfigPlan } from "./h
import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes"; import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
import { resolveWebProbeSentinel, readConfigRefTarget as readSentinelConfigRefTarget } from "./hwlab-node-web-sentinel-resolver"; import { resolveWebProbeSentinel, readConfigRefTarget as readSentinelConfigRefTarget } from "./hwlab-node-web-sentinel-resolver";
const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-26-p9-desktop-view-density"; const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-27-p11-monitor-web-observability-dashboard";
const DASHBOARD_MAX_TEXT_BYTES = 16_000; const DASHBOARD_MAX_TEXT_BYTES = 16_000;
export interface WebProbeSentinelServiceConfig { export interface WebProbeSentinelServiceConfig {
@@ -280,55 +281,90 @@ export function startWebProbeSentinelHttpService(service: WebProbeSentinelServic
async function sentinelFetch(service: WebProbeSentinelService, request: Request): Promise<Response> { async function sentinelFetch(service: WebProbeSentinelService, request: Request): Promise<Response> {
const url = new URL(request.url); const url = new URL(request.url);
if (request.method === "GET" && url.pathname === "/api/health") return jsonResponse(service.health(), service.health().ok === true ? 200 : 503); const pathname = normalizedSentinelRequestPath(service, url.pathname);
if (request.method === "GET" && url.pathname === "/api/status") return jsonResponse(service.status()); if (request.method === "GET" && pathname === "/api/health") return jsonResponse(service.health(), service.health().ok === true ? 200 : 503);
if (request.method === "GET" && url.pathname === "/api/overview") return jsonResponse(service.overview()); if (request.method === "GET" && pathname === "/api/status") return jsonResponse(service.status());
if (request.method === "GET" && url.pathname === "/api/runs") return jsonResponse(service.dashboardRuns(url)); if (request.method === "GET" && pathname === "/api/overview") return jsonResponse(service.overview());
if (request.method === "GET" && url.pathname === "/api/findings") return jsonResponse(service.findings(url)); if (request.method === "GET" && pathname === "/api/runs") return jsonResponse(service.dashboardRuns(url));
const runViewsMatch = /^\/api\/runs\/([^/]+)\/views$/u.exec(url.pathname); if (request.method === "GET" && pathname === "/api/findings") return jsonResponse(service.findings(url));
const runViewsMatch = /^\/api\/runs\/([^/]+)\/views$/u.exec(pathname);
if (request.method === "GET" && runViewsMatch !== null) { if (request.method === "GET" && runViewsMatch !== null) {
const view = url.searchParams.get("view"); const view = url.searchParams.get("view");
const result = service.runViews(decodeURIComponent(runViewsMatch[1]), view, url); const result = service.runViews(decodeURIComponent(runViewsMatch[1]), view, url);
return jsonResponse(result, result.ok === false ? 404 : 200); return jsonResponse(result, result.ok === false ? 404 : 200);
} }
const runDetailMatch = /^\/api\/runs\/([^/]+)$/u.exec(url.pathname); const runDetailMatch = /^\/api\/runs\/([^/]+)$/u.exec(pathname);
if (request.method === "GET" && runDetailMatch !== null) { if (request.method === "GET" && runDetailMatch !== null) {
const result = service.runDetail(decodeURIComponent(runDetailMatch[1])); const result = service.runDetail(decodeURIComponent(runDetailMatch[1]));
return jsonResponse(result, result.ok === false ? 404 : 200); return jsonResponse(result, result.ok === false ? 404 : 200);
} }
if (request.method === "GET" && url.pathname === "/api/maintenance") return jsonResponse({ ok: true, maintenance: service.maintenance(), valuesRedacted: true }); if (request.method === "GET" && pathname === "/api/maintenance") return jsonResponse({ ok: true, maintenance: service.maintenance(), valuesRedacted: true });
if (request.method === "POST" && (url.pathname === "/api/maintenance/start" || url.pathname === "/api/maintenance/stop")) { if (request.method === "POST" && (pathname === "/api/maintenance/start" || pathname === "/api/maintenance/stop")) {
const body = await readJsonBody(request); const body = await readJsonBody(request);
const active = url.pathname.endsWith("/start"); const active = pathname.endsWith("/start");
return jsonResponse({ ok: true, maintenance: service.setMaintenance(active, body), valuesRedacted: true }); return jsonResponse({ ok: true, maintenance: service.setMaintenance(active, body), valuesRedacted: true });
} }
if (request.method === "POST" && url.pathname === "/api/runs/plan") { if (request.method === "POST" && pathname === "/api/runs/plan") {
const body = await readJsonBody(request); const body = await readJsonBody(request);
return jsonResponse(service.planScenarioRun(stringField(body, "scenarioId"), stringOrNull(body.reason) ?? "manual")); return jsonResponse(service.planScenarioRun(stringField(body, "scenarioId"), stringOrNull(body.reason) ?? "manual"));
} }
if (request.method === "POST" && url.pathname === "/api/runs/record") { if (request.method === "POST" && pathname === "/api/runs/record") {
const body = await readJsonBody(request); const body = await readJsonBody(request);
return jsonResponse(service.recordRun(body)); return jsonResponse(service.recordRun(body));
} }
if (request.method === "GET" && url.pathname === "/api/report") { if (request.method === "GET" && pathname === "/api/report") {
const view = url.searchParams.get("view") ?? stringOrNull(service.config.reportViews.defaultView) ?? "summary"; const view = url.searchParams.get("view") ?? stringOrNull(service.config.reportViews.defaultView) ?? "summary";
const runId = url.searchParams.get("run") ?? url.searchParams.get("runId"); const runId = url.searchParams.get("run") ?? url.searchParams.get("runId");
const report = service.report(view, runId); const report = service.report(view, runId);
return jsonResponse(report, report.ok === false ? 404 : 200); return jsonResponse(report, report.ok === false ? 404 : 200);
} }
if (request.method === "GET" && url.pathname === "/metrics") { if (request.method === "GET" && pathname === "/metrics") {
return new Response(service.metrics(), { headers: { "content-type": "text/plain; version=0.0.4; charset=utf-8" } }); return new Response(service.metrics(), { headers: { "content-type": "text/plain; version=0.0.4; charset=utf-8" } });
} }
if (request.method === "GET" && url.pathname.startsWith("/dashboard/assets/")) { if (request.method === "GET" && (pathname.includes("/dashboard/assets/") || pathname.includes("/monitor-web/assets/"))) {
const asset = webProbeSentinelDashboardAssetResponse(url.pathname); const asset = webProbeSentinelDashboardAssetResponse(normalizedDashboardAssetPath(pathname));
if (asset !== null) return asset; if (asset !== null) return asset;
} }
if (request.method === "GET" && (url.pathname === "/" || url.pathname === "/dashboard")) { if (request.method === "GET" && (pathname === "/" || pathname === "/dashboard" || pathname === "/monitor-web")) {
return new Response(service.dashboardHtml(), { headers: { "content-type": "text/html; charset=utf-8" } }); return new Response(service.dashboardHtml(), { headers: { "content-type": "text/html; charset=utf-8" } });
} }
return jsonResponse({ ok: false, error: "not-found", path: url.pathname, valuesRedacted: true }, 404); return jsonResponse({ ok: false, error: "not-found", path: url.pathname, valuesRedacted: true }, 404);
} }
function normalizedSentinelRequestPath(service: WebProbeSentinelService, pathname: string): string {
const publicPath = publicExposurePath(service.config.publicExposure);
if (publicPath.length > 0 && (pathname === publicPath || pathname === `${publicPath}/`)) return "/";
if (publicPath.length > 0 && pathname.startsWith(`${publicPath}/`)) return pathname.slice(publicPath.length) || "/";
if (/^\/sentinels\/[^/]+\/?$/u.test(pathname)) return "/";
for (const marker of ["/api/", "/monitor-web/assets/", "/dashboard/assets/"]) {
const index = pathname.indexOf(marker);
if (index > 0) return pathname.slice(index);
}
for (const marker of ["/monitor-web", "/dashboard", "/metrics"]) {
if (pathname.endsWith(marker)) return marker;
}
return pathname;
}
function publicExposurePath(publicExposure: Record<string, unknown>): string {
const publicBaseUrl = stringOrNull(publicExposure.publicBaseUrl);
if (publicBaseUrl === null) return "";
try {
const pathname = new URL(publicBaseUrl).pathname.replace(/\/+$/u, "");
return pathname === "/" ? "" : pathname;
} catch {
return "";
}
}
function normalizedDashboardAssetPath(pathname: string): string {
for (const marker of ["/monitor-web/assets/", "/dashboard/assets/"]) {
const index = pathname.indexOf(marker);
if (index >= 0) return pathname.slice(index);
}
return pathname;
}
function initializeIndex(db: Database): void { function initializeIndex(db: Database): void {
db.exec(` db.exec(`
PRAGMA journal_mode = WAL; PRAGMA journal_mode = WAL;
@@ -0,0 +1,68 @@
import { readFileSync, statSync } from "node:fs";
import { rootPath } from "./src/config";
const checks: Array<{ readonly path: string; readonly contains: readonly string[]; readonly minBytes: number }> = [
{
path: "scripts/assets/web-probe-sentinel-monitor-web/monitor-web.js",
minBytes: 8_000,
contains: [
"createApp",
"/api/overview",
"data-monitor-trend-curve",
"data-monitor-independent-scroll",
"rootCause",
],
},
{
path: "scripts/assets/web-probe-sentinel-monitor-web/monitor-web.css",
minBytes: 8_000,
contains: [
".trend-stage",
".workspace-grid",
"overflow: hidden",
"overflow: auto",
".trend-red",
".trend-warning",
],
},
{
path: "scripts/assets/web-probe-sentinel-monitor-web/vendor/vue.runtime.esm-browser.prod.js",
minBytes: 80_000,
contains: ["createApp"],
},
{
path: "scripts/assets/web-probe-sentinel-monitor-web/vendor/VUE-LICENSE",
minBytes: 500,
contains: ["MIT License"],
},
];
const failures: string[] = [];
for (const check of checks) {
const absolutePath = rootPath(check.path);
let text = "";
try {
const stat = statSync(absolutePath);
if (stat.size < check.minBytes) failures.push(`${check.path} is too small: ${stat.size} < ${check.minBytes}`);
text = readFileSync(absolutePath, "utf8");
} catch (error) {
failures.push(`${check.path} is missing: ${String(error)}`);
continue;
}
for (const needle of check.contains) {
if (!text.includes(needle)) failures.push(`${check.path} does not contain ${needle}`);
}
}
if (failures.length > 0) {
console.error(JSON.stringify({ ok: false, component: "web-probe-sentinel-monitor-web", failures, valuesRedacted: true }, null, 2));
process.exit(1);
}
console.log(JSON.stringify({
ok: true,
component: "web-probe-sentinel-monitor-web",
stack: "vue3-vendored-runtime",
assets: checks.map((check) => check.path),
valuesRedacted: true,
}));