feat: add Vue monitor web sentinel dashboard
This commit is contained in:
@@ -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
@@ -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.
|
||||||
+7
File diff suppressed because one or more lines are too long
@@ -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, "'");
|
return escapeHtml(value).replace(/'/gu, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function jsonForScript(value: unknown): string {
|
||||||
|
return JSON.stringify(value).replace(/</gu, "\\u003c");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user