claude-spotter 1.4.29 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.ja.md +15 -0
- package/README.md +16 -0
- package/bin/spotter.mjs +7 -0
- package/docs/11_dashboard-operations.md +118 -0
- package/ops/dashboard/hub-config.json +1 -0
- package/ops/dashboard/launchd/dev.kitepon.spotter-dashboard-device.plist +21 -0
- package/ops/dashboard/launchd/dev.kitepon.spotter-dashboard-tunnel.plist +23 -0
- package/ops/dashboard/systemd/spotter-dashboard-device.service +13 -0
- package/ops/dashboard/systemd/spotter-dashboard-hub.service +12 -0
- package/ops/dashboard/systemd/spotter-dashboard-tunnel.service +14 -0
- package/ops/dashboard/windows/install-dashboard-tasks.ps1 +16 -0
- package/ops/dashboard/windows/spotter-dashboard-device.ps1 +4 -0
- package/ops/dashboard/windows/spotter-dashboard-tunnel.ps1 +7 -0
- package/package.json +3 -1
- package/src/cli/dashboard-cmd.mjs +129 -0
- package/src/dashboard/device-server.mjs +216 -0
- package/src/dashboard/hub-server.mjs +214 -0
- package/src/dashboard/model.mjs +14 -0
- package/src/dashboard/render.mjs +225 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.0 — 2026-08-04
|
|
4
|
+
|
|
5
|
+
- **評価結果を端末別Web dashboardで表示。** 各端末の`~/.spotter/evaluation.db`をrequestごとに読み、
|
|
6
|
+
`S/P/I/C/A/M`、提案率`P/S`、採用率`A/C`、project/tool内訳、非採用caseと詳細を
|
|
7
|
+
dependency-freeのserver-side HTMLで表示する。request、auditorへ渡したcontext、Throughline
|
|
8
|
+
observer snapshotは混同せず別欄に置く。
|
|
9
|
+
- **端末選択hubを追加。** 静的な4端末mapから一覧を表示し、`/devices/<id>/`を各device serverへ
|
|
10
|
+
proxyする。healthは一覧request時に各端末1回だけ取得し、offlineやtimeoutは該当端末だけ502にする。
|
|
11
|
+
DB同期、background monitor、retry queue、reconcilerは追加しない。
|
|
12
|
+
- **運用面を同梱。** `spotter dashboard device` / `hub`、Mac LaunchAgent、Linux systemd user、
|
|
13
|
+
Windows Task Scheduler、SSH reverse tunnel、main-server hub config、Caddy/Cloudflare Accessの
|
|
14
|
+
配備手順を追加した。評価データは端末内に留め、公開面だけをowner emailのAccessで保護する。
|
|
15
|
+
- **検証。** dashboard focused test 16件、fixture SQLiteからdevice HTTP・hub proxyまでの経路、
|
|
16
|
+
full suite 584件(582 pass / 2 platform skip)を通過した。
|
|
17
|
+
|
|
3
18
|
## 1.4.29 — 2026-08-04
|
|
4
19
|
|
|
5
20
|
- **提案率とtool採用率の端末内評価を追加。** Claude / Codexの成功UserPromptSubmitを母数に、
|
package/README.ja.md
CHANGED
|
@@ -223,6 +223,10 @@ spotter evaluation cases --outcome not-adopted
|
|
|
223
223
|
# 提案されたが同じturnで使われなかったtool itemを一覧
|
|
224
224
|
spotter evaluation case <observation-id>
|
|
225
225
|
# request、2種類の文脈、提案、利用、outcomeを確認
|
|
226
|
+
spotter dashboard device --id mac --name Mac
|
|
227
|
+
# この端末の評価DBを127.0.0.1:53940で配信
|
|
228
|
+
spotter dashboard hub --config dashboard-hub.json --host 172.18.0.1
|
|
229
|
+
# 端末一覧と/devices/<id>/の端末別proxyを配信
|
|
226
230
|
spotter codex risk-check --findings findings.json --host-agent claude
|
|
227
231
|
# Spotter finding を codex-sidecar に渡して read-only risk analysis
|
|
228
232
|
spotter codex review|explore|opinion --findings findings.json --host-agent claude
|
|
@@ -239,6 +243,17 @@ spotter auditor model-matrix --fixtures test/fixtures/auditor-model-matrix.v2.js
|
|
|
239
243
|
spotter uninstall # hook 登録を解除 (~/.spotter は残す)
|
|
240
244
|
```
|
|
241
245
|
|
|
246
|
+
### 端末別評価dashboard
|
|
247
|
+
|
|
248
|
+
dashboardはlocal-firstで動く。各端末が自身の`~/.spotter/evaluation.db`を読み、hubは固定の
|
|
249
|
+
端末・upstream対応だけを持つ。評価データをcloud DBへ複製しない。端末画面では
|
|
250
|
+
`S/P/I/C/A/M`、提案率、採用率、project/tool内訳、非採用case、別々に取得した2種類の文脈を
|
|
251
|
+
確認できる。health確認は端末一覧request時だけなので、端末がofflineでもbackground監視や
|
|
252
|
+
retry queueを作らず、その端末だけを切り離せる。
|
|
253
|
+
|
|
254
|
+
4端末のservice、reverse tunnel、Caddy/Cloudflare構成は
|
|
255
|
+
[docs/11_dashboard-operations.md](docs/11_dashboard-operations.md)を参照。
|
|
256
|
+
|
|
242
257
|
Codex risk dispatch を daemon から非同期に流す場合:
|
|
243
258
|
|
|
244
259
|
```bash
|
package/README.md
CHANGED
|
@@ -232,6 +232,10 @@ spotter evaluation cases --outcome not-adopted
|
|
|
232
232
|
# list proposed tools that were not used in the same turn
|
|
233
233
|
spotter evaluation case <observation-id>
|
|
234
234
|
# inspect request, Spotter context, Throughline snapshot, proposal, usage, and outcome
|
|
235
|
+
spotter dashboard device --id mac --name Mac
|
|
236
|
+
# serve this terminal's local evaluation DB on 127.0.0.1:53940
|
|
237
|
+
spotter dashboard hub --config dashboard-hub.json --host 172.18.0.1
|
|
238
|
+
# list terminals and proxy /devices/<id>/ to their local servers
|
|
235
239
|
spotter codex risk-check --findings findings.json --host-agent claude
|
|
236
240
|
# run read-only codex-sidecar risk analysis for Spotter findings
|
|
237
241
|
spotter codex review|explore|opinion --findings findings.json --host-agent claude
|
|
@@ -248,6 +252,18 @@ spotter auditor model-matrix --fixtures test/fixtures/auditor-model-matrix.v2.js
|
|
|
248
252
|
spotter uninstall # remove hooks from this project (leaves ~/.spotter intact)
|
|
249
253
|
```
|
|
250
254
|
|
|
255
|
+
### Device-routed evaluation dashboard
|
|
256
|
+
|
|
257
|
+
The dashboard is local-first. Every terminal reads its own `~/.spotter/evaluation.db`; the hub
|
|
258
|
+
keeps only a static device-to-upstream map and does not copy evaluation data into a cloud database.
|
|
259
|
+
The device view shows `S/P/I/C/A/M`, proposal and adoption rates, project/tool breakdowns,
|
|
260
|
+
non-adopted cases, and the two separately captured context sources. The hub checks health only
|
|
261
|
+
when the device list is requested, so an offline terminal is isolated without a background monitor
|
|
262
|
+
or retry queue.
|
|
263
|
+
|
|
264
|
+
The reference four-terminal service, reverse-tunnel, and Caddy/Cloudflare layout is documented in
|
|
265
|
+
[docs/11_dashboard-operations.md](docs/11_dashboard-operations.md).
|
|
266
|
+
|
|
251
267
|
Optional async Codex risk dispatch:
|
|
252
268
|
|
|
253
269
|
```bash
|
package/bin/spotter.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { runCodexHookCommand } from '../src/cli/codex-hook-cmd.mjs';
|
|
|
12
12
|
import { runAuditorCommand } from '../src/cli/auditor-cmd.mjs';
|
|
13
13
|
import { runDiagnosticsCommand } from '../src/cli/diagnostics-cmd.mjs';
|
|
14
14
|
import { runEvaluationCommand } from '../src/cli/evaluation-cmd.mjs';
|
|
15
|
+
import { runDashboardCommand } from '../src/cli/dashboard-cmd.mjs';
|
|
15
16
|
import { runFactoryDiagnostics } from '../src/cli/factory-diagnostics.mjs';
|
|
16
17
|
import { runDaemonStart } from '../src/cli/daemon-cmd.mjs';
|
|
17
18
|
import { runSessionStart } from '../src/hooks/session-start.mjs';
|
|
@@ -50,6 +51,9 @@ Usage:
|
|
|
50
51
|
spotter evaluation cases --outcome OUTCOME [filters] [--json]
|
|
51
52
|
spotter evaluation case OBSERVATION_ID [--json]
|
|
52
53
|
read saved proposal-adoption observations
|
|
54
|
+
spotter dashboard device --id ID [--name NAME] [--host HOST] [--port PORT] [--db PATH]
|
|
55
|
+
spotter dashboard hub --config FILE [--host HOST] [--port PORT]
|
|
56
|
+
serve the local device-routed evaluation dashboard
|
|
53
57
|
spotter codex risk-check --findings FILE
|
|
54
58
|
run read-only codex-sidecar risk analysis
|
|
55
59
|
spotter codex review|explore|opinion --findings FILE
|
|
@@ -132,6 +136,9 @@ async function main() {
|
|
|
132
136
|
case 'evaluation':
|
|
133
137
|
await runEvaluationCommand({ argv: rest });
|
|
134
138
|
return;
|
|
139
|
+
case 'dashboard':
|
|
140
|
+
await runDashboardCommand({ argv: rest });
|
|
141
|
+
return;
|
|
135
142
|
case 'daemon': {
|
|
136
143
|
const sub = rest[0];
|
|
137
144
|
if (sub === 'start') { await runDaemonStart({ argv: rest.slice(1) }); return; }
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Spotter評価dashboard運用
|
|
2
|
+
|
|
3
|
+
## 固定構成
|
|
4
|
+
|
|
5
|
+
各端末のdevice serverは`127.0.0.1:53940`だけで待ち受ける。main-serverのhubは
|
|
6
|
+
Docker Caddyから到達できる`172.18.0.1:53940`で待ち受ける。評価DBは各端末の
|
|
7
|
+
`~/.spotter/evaluation.db`をその場で読み、端末外へ複製しない。
|
|
8
|
+
|
|
9
|
+
| 端末 | device ID | main-server側upstream |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| main-server Ubuntu | `main-server` | `127.0.0.1:53940` |
|
|
12
|
+
| Mac | `mac` | `127.0.0.1:53941` |
|
|
13
|
+
| FOX WSL2 | `fox-wsl` | `127.0.0.1:53942` |
|
|
14
|
+
| FOX Windows native | `fox-windows` | `127.0.0.1:53943` |
|
|
15
|
+
|
|
16
|
+
hub設定の正本は`ops/dashboard/hub-config.json`である。hubは一覧request時に各upstreamの
|
|
17
|
+
healthを1回だけ確認し、端末をonline/offline表示する。常時監視、再試行queue、DB同期は行わない。
|
|
18
|
+
|
|
19
|
+
## Mac
|
|
20
|
+
|
|
21
|
+
`ops/dashboard/launchd/`の2 plistを`~/Library/LaunchAgents/`へ配置する。既存labelを更新する時は
|
|
22
|
+
`launchctl bootout gui/$(id -u) <plist>`の後、`launchctl bootstrap gui/$(id -u) <plist>`を実行する。
|
|
23
|
+
deviceはMac実DB、tunnelはmain-serverの`53941`へ接続する。
|
|
24
|
+
|
|
25
|
+
確認:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
curl --fail http://127.0.0.1:53940/_spotter/health
|
|
29
|
+
ssh main-server curl --fail http://127.0.0.1:53941/_spotter/health
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## main-server Ubuntu
|
|
33
|
+
|
|
34
|
+
`ops/dashboard/systemd/spotter-dashboard-device.service`と
|
|
35
|
+
`spotter-dashboard-hub.service`を`~/.config/systemd/user/`へ配置する。
|
|
36
|
+
`~/.config/spotter/dashboard-device.env`は次の2行、hub configはrepo正本をコピーする。
|
|
37
|
+
|
|
38
|
+
```ini
|
|
39
|
+
SPOTTER_DEVICE_ID=main-server
|
|
40
|
+
SPOTTER_DEVICE_NAME=main-server
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
systemctl --user daemon-reload
|
|
45
|
+
systemctl --user enable --now spotter-dashboard-device.service spotter-dashboard-hub.service
|
|
46
|
+
curl --fail http://127.0.0.1:53940/_spotter/health
|
|
47
|
+
curl --fail http://172.18.0.1:53940/
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
systemd user managerの`PATH`から`spotter`が見えない場合だけ、ユーザー環境の実際のnpm binを
|
|
51
|
+
unitの`Environment=PATH=...`として追加する。別の起動経路へfallbackしない。
|
|
52
|
+
|
|
53
|
+
## FOX WSL2
|
|
54
|
+
|
|
55
|
+
device unitとtunnel unitを`~/.config/systemd/user/`へ配置する。device env:
|
|
56
|
+
|
|
57
|
+
```ini
|
|
58
|
+
SPOTTER_DEVICE_ID=fox-wsl
|
|
59
|
+
SPOTTER_DEVICE_NAME=FOX-WSL2
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
tunnel env:
|
|
63
|
+
|
|
64
|
+
```ini
|
|
65
|
+
SPOTTER_REMOTE_FORWARD=127.0.0.1:53942:127.0.0.1:53940
|
|
66
|
+
SPOTTER_TUNNEL_TARGET=main-server
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
systemctl --user daemon-reload
|
|
71
|
+
systemctl --user enable --now spotter-dashboard-device.service spotter-dashboard-tunnel.service
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## FOX Windows native
|
|
75
|
+
|
|
76
|
+
管理者権限は不要。`ops/dashboard/windows/`を固定pathへ配置し、Windows側から解決できる
|
|
77
|
+
main-serverのSSH host名またはIPを明示して次を1回実行する。
|
|
78
|
+
|
|
79
|
+
```powershell
|
|
80
|
+
.\install-dashboard-tasks.ps1 -MainServer '<main-server-host-or-ip>'
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
登録される2 taskはログオン時にdevice serverとreverse tunnelを起動する。確認:
|
|
84
|
+
|
|
85
|
+
```powershell
|
|
86
|
+
Invoke-RestMethod http://127.0.0.1:53940/_spotter/health
|
|
87
|
+
Get-ScheduledTask -TaskName 'Spotter dashboard *' | Get-ScheduledTaskInfo
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## 公開経路
|
|
91
|
+
|
|
92
|
+
Caddyへ次を追加する。`spotter.kitepon.dev`はcase詳細に会話文脈を含むため、Cloudflare側では
|
|
93
|
+
同hostname全体をAccess applicationの対象にし、owner emailだけをallowする。
|
|
94
|
+
|
|
95
|
+
```caddyfile
|
|
96
|
+
spotter.kitepon.dev {
|
|
97
|
+
import cf-origin-tls
|
|
98
|
+
import security-headers-base
|
|
99
|
+
reverse_proxy 172.18.0.1:53940
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
既存Cloudflare Tunnelのcatch-all 404より前に、hostname `spotter.kitepon.dev`、service
|
|
104
|
+
`https://caddy:443`、`noTLSVerify: true`、`matchSNItoHost: true`のingressを追加する。
|
|
105
|
+
DNSは同tunnel UUIDの`cfargotunnel.com`へCNAMEする。
|
|
106
|
+
|
|
107
|
+
## 受入と切り分け
|
|
108
|
+
|
|
109
|
+
確認順序はdevice loopback、main-serverの各upstream、hub、Caddy、Cloudflare Tunnel、Accessとする。
|
|
110
|
+
端末がofflineならhub一覧のその端末だけがofflineになる。選択時の502は該当upstreamのserviceまたは
|
|
111
|
+
reverse tunnelを確認する。hubや別端末を再起動する必要はない。
|
|
112
|
+
|
|
113
|
+
公開受入:
|
|
114
|
+
|
|
115
|
+
1. 未認証`https://spotter.kitepon.dev/`がCloudflare Accessへredirectされる。
|
|
116
|
+
2. 認証後の`/`が4端末を表示する。
|
|
117
|
+
3. online端末のoverview、project/tool内訳、非採用case、case詳細を表示できる。
|
|
118
|
+
4. 1端末を停止しても一覧と他端末が表示でき、停止端末だけoffline/502になる。
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"devices":[{"id":"main-server","name":"main-server","upstream":"http://127.0.0.1:53940"},{"id":"mac","name":"Mac","upstream":"http://127.0.0.1:53941"},{"id":"fox-wsl","name":"FOX WSL2","upstream":"http://127.0.0.1:53942"},{"id":"fox-windows","name":"FOX Windows native","upstream":"http://127.0.0.1:53943"}]}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>Label</key><string>dev.kitepon.spotter-dashboard-device</string>
|
|
6
|
+
<key>ProgramArguments</key>
|
|
7
|
+
<array>
|
|
8
|
+
<string>/opt/homebrew/bin/spotter</string>
|
|
9
|
+
<string>dashboard</string><string>device</string>
|
|
10
|
+
<string>--id</string><string>mac</string>
|
|
11
|
+
<string>--name</string><string>Mac</string>
|
|
12
|
+
<string>--host</string><string>127.0.0.1</string>
|
|
13
|
+
<string>--port</string><string>53940</string>
|
|
14
|
+
</array>
|
|
15
|
+
<key>RunAtLoad</key><true/>
|
|
16
|
+
<key>KeepAlive</key><true/>
|
|
17
|
+
<key>ThrottleInterval</key><integer>10</integer>
|
|
18
|
+
<key>StandardOutPath</key><string>/Users/kite/.spotter/dashboard-device.log</string>
|
|
19
|
+
<key>StandardErrorPath</key><string>/Users/kite/.spotter/dashboard-device.log</string>
|
|
20
|
+
</dict>
|
|
21
|
+
</plist>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>Label</key><string>dev.kitepon.spotter-dashboard-tunnel</string>
|
|
6
|
+
<key>ProgramArguments</key>
|
|
7
|
+
<array>
|
|
8
|
+
<string>/usr/bin/ssh</string><string>-N</string>
|
|
9
|
+
<string>-o</string><string>BatchMode=yes</string>
|
|
10
|
+
<string>-o</string><string>ExitOnForwardFailure=yes</string>
|
|
11
|
+
<string>-o</string><string>ServerAliveInterval=30</string>
|
|
12
|
+
<string>-o</string><string>ServerAliveCountMax=3</string>
|
|
13
|
+
<string>-o</string><string>TCPKeepAlive=yes</string>
|
|
14
|
+
<string>-R</string><string>127.0.0.1:53941:127.0.0.1:53940</string>
|
|
15
|
+
<string>main-server</string>
|
|
16
|
+
</array>
|
|
17
|
+
<key>RunAtLoad</key><true/>
|
|
18
|
+
<key>KeepAlive</key><true/>
|
|
19
|
+
<key>ThrottleInterval</key><integer>10</integer>
|
|
20
|
+
<key>StandardOutPath</key><string>/Users/kite/.spotter/dashboard-tunnel.log</string>
|
|
21
|
+
<key>StandardErrorPath</key><string>/Users/kite/.spotter/dashboard-tunnel.log</string>
|
|
22
|
+
</dict>
|
|
23
|
+
</plist>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=Spotter evaluation dashboard device server
|
|
3
|
+
After=network.target
|
|
4
|
+
|
|
5
|
+
[Service]
|
|
6
|
+
Type=simple
|
|
7
|
+
EnvironmentFile=%h/.config/spotter/dashboard-device.env
|
|
8
|
+
ExecStart=/usr/bin/env spotter dashboard device --id ${SPOTTER_DEVICE_ID} --name ${SPOTTER_DEVICE_NAME} --host 127.0.0.1 --port 53940
|
|
9
|
+
Restart=on-failure
|
|
10
|
+
RestartSec=10
|
|
11
|
+
|
|
12
|
+
[Install]
|
|
13
|
+
WantedBy=default.target
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=Spotter evaluation dashboard hub
|
|
3
|
+
After=network.target
|
|
4
|
+
|
|
5
|
+
[Service]
|
|
6
|
+
Type=simple
|
|
7
|
+
ExecStart=/usr/bin/env spotter dashboard hub --config %h/.config/spotter/dashboard-hub.json --host 172.18.0.1 --port 53940
|
|
8
|
+
Restart=on-failure
|
|
9
|
+
RestartSec=10
|
|
10
|
+
|
|
11
|
+
[Install]
|
|
12
|
+
WantedBy=default.target
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=Spotter evaluation dashboard reverse tunnel
|
|
3
|
+
After=network-online.target
|
|
4
|
+
Wants=network-online.target
|
|
5
|
+
|
|
6
|
+
[Service]
|
|
7
|
+
Type=simple
|
|
8
|
+
EnvironmentFile=%h/.config/spotter/dashboard-tunnel.env
|
|
9
|
+
ExecStart=/usr/bin/ssh -N -o BatchMode=yes -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o TCPKeepAlive=yes -R ${SPOTTER_REMOTE_FORWARD} ${SPOTTER_TUNNEL_TARGET}
|
|
10
|
+
Restart=on-failure
|
|
11
|
+
RestartSec=10
|
|
12
|
+
|
|
13
|
+
[Install]
|
|
14
|
+
WantedBy=default.target
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
param([Parameter(Mandatory = $true)][string]$MainServer)
|
|
2
|
+
$ErrorActionPreference = 'Stop'
|
|
3
|
+
$ops = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
4
|
+
$powershell = "$env:WINDIR\System32\WindowsPowerShell\v1.0\powershell.exe"
|
|
5
|
+
$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Limited
|
|
6
|
+
$settings = New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero)
|
|
7
|
+
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME
|
|
8
|
+
|
|
9
|
+
$deviceAction = New-ScheduledTaskAction -Execute $powershell -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$ops\spotter-dashboard-device.ps1`""
|
|
10
|
+
Register-ScheduledTask -TaskName 'Spotter dashboard device' -Action $deviceAction -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
|
|
11
|
+
|
|
12
|
+
$tunnelAction = New-ScheduledTaskAction -Execute $powershell -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$ops\spotter-dashboard-tunnel.ps1`" -MainServer `"$MainServer`""
|
|
13
|
+
Register-ScheduledTask -TaskName 'Spotter dashboard tunnel' -Action $tunnelAction -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
|
|
14
|
+
|
|
15
|
+
Start-ScheduledTask -TaskName 'Spotter dashboard device'
|
|
16
|
+
Start-ScheduledTask -TaskName 'Spotter dashboard tunnel'
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
param([Parameter(Mandatory = $true)][string]$MainServer)
|
|
2
|
+
$ErrorActionPreference = 'Stop'
|
|
3
|
+
& "$env:WINDIR\System32\OpenSSH\ssh.exe" -N `
|
|
4
|
+
-o BatchMode=yes -o ExitOnForwardFailure=yes `
|
|
5
|
+
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o TCPKeepAlive=yes `
|
|
6
|
+
-R '127.0.0.1:53943:127.0.0.1:53940' $MainServer
|
|
7
|
+
exit $LASTEXITCODE
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-spotter",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -48,6 +48,8 @@
|
|
|
48
48
|
"bin",
|
|
49
49
|
"src",
|
|
50
50
|
"scripts",
|
|
51
|
+
"ops/dashboard",
|
|
52
|
+
"docs/11_dashboard-operations.md",
|
|
51
53
|
"README.md",
|
|
52
54
|
"LICENSE",
|
|
53
55
|
"CHANGELOG.md"
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
3
|
+
import { createDeviceServer } from '../dashboard/device-server.mjs';
|
|
4
|
+
import { createHubServer } from '../dashboard/hub-server.mjs';
|
|
5
|
+
|
|
6
|
+
const USAGE = `spotter dashboard — local evaluation dashboard servers
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
spotter dashboard device --id ID [--name NAME] [--host HOST] [--port PORT] [--db PATH]
|
|
10
|
+
spotter dashboard hub --config FILE [--host HOST] [--port PORT]
|
|
11
|
+
`;
|
|
12
|
+
|
|
13
|
+
export async function runDashboardCommand({
|
|
14
|
+
argv = process.argv.slice(2),
|
|
15
|
+
cwd = process.cwd(),
|
|
16
|
+
createDeviceServerFn = createDeviceServer,
|
|
17
|
+
createHubServerFn = createHubServer,
|
|
18
|
+
readFileFn = readFile,
|
|
19
|
+
writeOutput = (text) => process.stdout.write(text),
|
|
20
|
+
} = {}) {
|
|
21
|
+
const subcommand = argv[0];
|
|
22
|
+
if (subcommand === 'device') {
|
|
23
|
+
const options = parseOptions(argv.slice(1), { mode: 'device', cwd });
|
|
24
|
+
const server = createDeviceServerFn({
|
|
25
|
+
deviceId: options.id,
|
|
26
|
+
deviceName: options.name ?? options.id,
|
|
27
|
+
databasePath: options.databasePath,
|
|
28
|
+
});
|
|
29
|
+
await listen(server, options.port, options.host);
|
|
30
|
+
writeOutput(`spotter dashboard device listening on ${formatAddress(server.address())}\n`);
|
|
31
|
+
return server;
|
|
32
|
+
}
|
|
33
|
+
if (subcommand === 'hub') {
|
|
34
|
+
const options = parseOptions(argv.slice(1), { mode: 'hub', cwd });
|
|
35
|
+
const config = parseHubConfig(await readFileFn(options.configPath, 'utf8'));
|
|
36
|
+
const server = createHubServerFn({ devices: config.devices });
|
|
37
|
+
await listen(server, options.port, options.host);
|
|
38
|
+
writeOutput(`spotter dashboard hub listening on ${formatAddress(server.address())}\n`);
|
|
39
|
+
return server;
|
|
40
|
+
}
|
|
41
|
+
throw usageError();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseOptions(argv, { mode, cwd }) {
|
|
45
|
+
const values = { host: '127.0.0.1', port: 53940 };
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
48
|
+
const option = argv[index];
|
|
49
|
+
if (seen.has(option)) throw usageError();
|
|
50
|
+
seen.add(option);
|
|
51
|
+
if (option === '--id' && mode === 'device') values.id = requiredValue(argv, ++index, option);
|
|
52
|
+
else if (option === '--name' && mode === 'device') values.name = requiredValue(argv, ++index, option);
|
|
53
|
+
else if (option === '--db' && mode === 'device') {
|
|
54
|
+
const value = requiredValue(argv, ++index, option);
|
|
55
|
+
values.databasePath = isAbsolute(value) ? value : resolve(cwd, value);
|
|
56
|
+
} else if (option === '--config' && mode === 'hub') {
|
|
57
|
+
const value = requiredValue(argv, ++index, option);
|
|
58
|
+
values.configPath = isAbsolute(value) ? value : resolve(cwd, value);
|
|
59
|
+
} else if (option === '--host') {
|
|
60
|
+
values.host = requiredValue(argv, ++index, option);
|
|
61
|
+
} else if (option === '--port') {
|
|
62
|
+
values.port = parsePort(requiredValue(argv, ++index, option));
|
|
63
|
+
} else throw usageError();
|
|
64
|
+
}
|
|
65
|
+
if (mode === 'device' && values.id === undefined) throw usageError('--id is required');
|
|
66
|
+
if (mode === 'device' && /[/?#]/.test(values.id)) throw usageError('--id must be a path segment');
|
|
67
|
+
if (mode === 'hub' && values.configPath === undefined) throw usageError('--config is required');
|
|
68
|
+
return values;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseHubConfig(text) {
|
|
72
|
+
let value;
|
|
73
|
+
try { value = JSON.parse(text); } catch { throw usageError('dashboard hub config is not valid JSON'); }
|
|
74
|
+
if (!value || !Array.isArray(value.devices) || value.devices.length === 0) {
|
|
75
|
+
throw usageError('dashboard hub config requires devices');
|
|
76
|
+
}
|
|
77
|
+
const ids = new Set();
|
|
78
|
+
for (const device of value.devices) {
|
|
79
|
+
if (!device || typeof device.id !== 'string' || device.id.length === 0
|
|
80
|
+
|| typeof device.name !== 'string' || device.name.length === 0
|
|
81
|
+
|| typeof device.upstream !== 'string' || device.upstream.length === 0
|
|
82
|
+
|| ids.has(device.id)) throw usageError('dashboard hub config has an invalid device');
|
|
83
|
+
let upstream;
|
|
84
|
+
try { upstream = new URL(device.upstream); } catch { throw usageError('dashboard hub config has an invalid upstream'); }
|
|
85
|
+
if (upstream.protocol !== 'http:' && upstream.protocol !== 'https:') {
|
|
86
|
+
throw usageError('dashboard hub config has an invalid upstream');
|
|
87
|
+
}
|
|
88
|
+
ids.add(device.id);
|
|
89
|
+
}
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function requiredValue(argv, index, option) {
|
|
94
|
+
const value = argv[index];
|
|
95
|
+
if (typeof value !== 'string' || value.length === 0 || value.startsWith('--')) {
|
|
96
|
+
throw usageError(`${option} requires a value`);
|
|
97
|
+
}
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parsePort(value) {
|
|
102
|
+
const port = Number(value);
|
|
103
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) throw usageError('--port requires an integer from 1 to 65535');
|
|
104
|
+
return port;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function listen(server, port, host) {
|
|
108
|
+
return new Promise((resolveListen, reject) => {
|
|
109
|
+
const onError = (error) => { server.off('listening', onListening); reject(error); };
|
|
110
|
+
const onListening = () => { server.off('error', onError); resolveListen(); };
|
|
111
|
+
server.once('error', onError);
|
|
112
|
+
server.once('listening', onListening);
|
|
113
|
+
server.listen(port, host);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function formatAddress(address) {
|
|
118
|
+
if (typeof address === 'string') return address;
|
|
119
|
+
if (!address) return '(unknown)';
|
|
120
|
+
const host = address.family === 'IPv6' ? `[${address.address}]` : address.address;
|
|
121
|
+
return `http://${host}:${address.port}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function usageError(message = 'invalid dashboard arguments') {
|
|
125
|
+
const error = new Error(`${message}\n${USAGE}`);
|
|
126
|
+
error.stack = '';
|
|
127
|
+
error.exitCode = 2;
|
|
128
|
+
return error;
|
|
129
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { createEvaluationStore, defaultEvaluationStorePath } from '../core/evaluation-store.mjs';
|
|
3
|
+
import { buildDashboardModel } from './model.mjs';
|
|
4
|
+
import { renderDeviceDashboard } from './render.mjs';
|
|
5
|
+
|
|
6
|
+
const FILTER_NAMES = new Set(['project', 'from', 'to']);
|
|
7
|
+
|
|
8
|
+
export function createDeviceServer({
|
|
9
|
+
deviceId,
|
|
10
|
+
deviceName = deviceId,
|
|
11
|
+
databasePath = defaultEvaluationStorePath(),
|
|
12
|
+
createStoreFn = createEvaluationStore,
|
|
13
|
+
onError = (error) => console.error('Spotter device server request failed:', error),
|
|
14
|
+
} = {}) {
|
|
15
|
+
const id = requiredString(deviceId, 'deviceId');
|
|
16
|
+
const name = requiredString(deviceName, 'deviceName');
|
|
17
|
+
if (typeof createStoreFn !== 'function') throw new TypeError('createStoreFn must be a function');
|
|
18
|
+
if (typeof onError !== 'function') throw new TypeError('onError must be a function');
|
|
19
|
+
|
|
20
|
+
return createServer((request, response) => {
|
|
21
|
+
try {
|
|
22
|
+
handleRequest({ request, response, deviceId: id, deviceName: name, databasePath, createStoreFn });
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (!(error instanceof HttpError) || error.statusCode >= 500) onError(error);
|
|
25
|
+
if (response.headersSent) {
|
|
26
|
+
response.destroy();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const httpError = error instanceof HttpError
|
|
30
|
+
? error
|
|
31
|
+
: new HttpError(500, 'internal_error', 'device request failed', { cause: error });
|
|
32
|
+
sendJson(response, httpError.statusCode, { error: httpError.code, message: httpError.publicMessage }, httpError.headers);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function handleRequest({ request, response, deviceId, deviceName, databasePath, createStoreFn }) {
|
|
38
|
+
if (request.method !== 'GET') {
|
|
39
|
+
throw new HttpError(405, 'method_not_allowed', 'only GET is supported', { headers: { Allow: 'GET' } });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let url;
|
|
43
|
+
try {
|
|
44
|
+
url = new URL(request.url ?? '/', 'http://spotter.local');
|
|
45
|
+
} catch (error) {
|
|
46
|
+
throw new HttpError(400, 'invalid_url', 'request URL is invalid', { cause: error });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (url.pathname === '/_spotter/health') {
|
|
50
|
+
if (url.search !== '') throw badRequest('health endpoint does not accept query parameters');
|
|
51
|
+
sendJson(response, 200, { ok: true, deviceId });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const route = matchDeviceRoute(url.pathname);
|
|
56
|
+
if (route === null || route.deviceId !== deviceId) throw notFound();
|
|
57
|
+
const parsedFilters = parseFilters(url.searchParams);
|
|
58
|
+
const overviewPath = `/devices/${encodeURIComponent(deviceId)}/`;
|
|
59
|
+
|
|
60
|
+
let projection;
|
|
61
|
+
try {
|
|
62
|
+
projection = withStore({ createStoreFn, databasePath }, (store) => {
|
|
63
|
+
const model = buildDashboardModel(store, parsedFilters.store);
|
|
64
|
+
return {
|
|
65
|
+
model: {
|
|
66
|
+
...model,
|
|
67
|
+
notAdoptedCases: model.notAdoptedCases.map((item) => ({
|
|
68
|
+
...item,
|
|
69
|
+
href: caseHref(deviceId, item.observationId, parsedFilters.display),
|
|
70
|
+
})),
|
|
71
|
+
},
|
|
72
|
+
caseDetail: route.observationId === null ? null : store.getCase(route.observationId),
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
} catch (error) {
|
|
76
|
+
throw new HttpError(500, 'evaluation_store_error', 'evaluation store request failed', { cause: error });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (route.observationId !== null && projection.caseDetail === null) throw notFound('evaluation case was not found');
|
|
80
|
+
const html = renderDeviceDashboard({
|
|
81
|
+
devices: [{ id: deviceId, name: deviceName, online: true, href: overviewPath }],
|
|
82
|
+
selectedDeviceId: deviceId,
|
|
83
|
+
model: projection.model,
|
|
84
|
+
caseDetail: projection.caseDetail,
|
|
85
|
+
filters: parsedFilters.display,
|
|
86
|
+
action: overviewPath,
|
|
87
|
+
});
|
|
88
|
+
sendHtml(response, 200, html);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function withStore({ createStoreFn, databasePath }, operation) {
|
|
92
|
+
const store = createStoreFn({ databasePath });
|
|
93
|
+
try {
|
|
94
|
+
return operation(store);
|
|
95
|
+
} finally {
|
|
96
|
+
store.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function matchDeviceRoute(pathname) {
|
|
101
|
+
const overview = /^\/devices\/([^/]+)\/$/.exec(pathname);
|
|
102
|
+
if (overview) return { deviceId: decodePathSegment(overview[1]), observationId: null };
|
|
103
|
+
const detail = /^\/devices\/([^/]+)\/cases\/([^/]+)$/.exec(pathname);
|
|
104
|
+
if (detail) {
|
|
105
|
+
return {
|
|
106
|
+
deviceId: decodePathSegment(detail[1]),
|
|
107
|
+
observationId: decodePathSegment(detail[2]),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function decodePathSegment(value) {
|
|
114
|
+
try {
|
|
115
|
+
const decoded = decodeURIComponent(value);
|
|
116
|
+
if (decoded.length === 0) throw new Error('empty path segment');
|
|
117
|
+
return decoded;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
throw new HttpError(400, 'invalid_path', 'path contains invalid encoding', { cause: error });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseFilters(searchParams) {
|
|
124
|
+
for (const name of searchParams.keys()) {
|
|
125
|
+
if (!FILTER_NAMES.has(name)) throw badRequest(`unknown query parameter: ${name}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const display = {};
|
|
129
|
+
const store = {};
|
|
130
|
+
const project = singleValue(searchParams, 'project');
|
|
131
|
+
const from = singleValue(searchParams, 'from');
|
|
132
|
+
const to = singleValue(searchParams, 'to');
|
|
133
|
+
if (project !== null) {
|
|
134
|
+
if (project.length === 0) throw badRequest('project must not be empty');
|
|
135
|
+
display.project = project;
|
|
136
|
+
store.projectPath = project;
|
|
137
|
+
}
|
|
138
|
+
if (from !== null) {
|
|
139
|
+
display.from = from;
|
|
140
|
+
store.fromMs = parseTimestamp(from, 'from');
|
|
141
|
+
}
|
|
142
|
+
if (to !== null) {
|
|
143
|
+
display.to = to;
|
|
144
|
+
store.toMs = parseTimestamp(to, 'to');
|
|
145
|
+
}
|
|
146
|
+
if (store.fromMs !== undefined && store.toMs !== undefined && store.fromMs > store.toMs) {
|
|
147
|
+
throw badRequest('from must not be later than to');
|
|
148
|
+
}
|
|
149
|
+
return { display, store };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function singleValue(searchParams, name) {
|
|
153
|
+
const values = searchParams.getAll(name);
|
|
154
|
+
if (values.length > 1) throw badRequest(`${name} must be specified once`);
|
|
155
|
+
return values.length === 0 ? null : values[0];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function parseTimestamp(value, name) {
|
|
159
|
+
const milliseconds = Date.parse(value);
|
|
160
|
+
if (!Number.isFinite(milliseconds)) throw badRequest(`${name} must be an ISO timestamp`);
|
|
161
|
+
return milliseconds;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function caseHref(deviceId, observationId, displayFilters) {
|
|
165
|
+
const path = `/devices/${encodeURIComponent(deviceId)}/cases/${encodeURIComponent(observationId)}`;
|
|
166
|
+
const query = new URLSearchParams(displayFilters).toString();
|
|
167
|
+
return query.length === 0 ? path : `${path}?${query}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function sendHtml(response, statusCode, html) {
|
|
171
|
+
const body = Buffer.from(html, 'utf8');
|
|
172
|
+
response.writeHead(statusCode, {
|
|
173
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
174
|
+
'Content-Length': body.length,
|
|
175
|
+
'Cache-Control': 'no-store',
|
|
176
|
+
'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'",
|
|
177
|
+
'X-Content-Type-Options': 'nosniff',
|
|
178
|
+
});
|
|
179
|
+
response.end(body);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function sendJson(response, statusCode, value, extraHeaders = {}) {
|
|
183
|
+
const body = Buffer.from(`${JSON.stringify(value)}\n`, 'utf8');
|
|
184
|
+
response.writeHead(statusCode, {
|
|
185
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
186
|
+
'Content-Length': body.length,
|
|
187
|
+
'Cache-Control': 'no-store',
|
|
188
|
+
'X-Content-Type-Options': 'nosniff',
|
|
189
|
+
...extraHeaders,
|
|
190
|
+
});
|
|
191
|
+
response.end(body);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function requiredString(value, name) {
|
|
195
|
+
if (typeof value !== 'string' || value.length === 0) throw new TypeError(`${name} must be a non-empty string`);
|
|
196
|
+
return value;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function badRequest(message) {
|
|
200
|
+
return new HttpError(400, 'invalid_request', message);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function notFound(message = 'route was not found') {
|
|
204
|
+
return new HttpError(404, 'not_found', message);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
class HttpError extends Error {
|
|
208
|
+
constructor(statusCode, code, publicMessage, { cause, headers = {} } = {}) {
|
|
209
|
+
super(publicMessage, { cause });
|
|
210
|
+
this.name = 'HttpError';
|
|
211
|
+
this.statusCode = statusCode;
|
|
212
|
+
this.code = code;
|
|
213
|
+
this.publicMessage = publicMessage;
|
|
214
|
+
this.headers = headers;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
|
|
4
|
+
import { renderHubDashboard } from './render.mjs';
|
|
5
|
+
|
|
6
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
7
|
+
'connection',
|
|
8
|
+
'keep-alive',
|
|
9
|
+
'proxy-authenticate',
|
|
10
|
+
'proxy-authorization',
|
|
11
|
+
'proxy-connection',
|
|
12
|
+
'te',
|
|
13
|
+
'trailer',
|
|
14
|
+
'transfer-encoding',
|
|
15
|
+
'upgrade',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 1_500;
|
|
19
|
+
const DEFAULT_PROXY_TIMEOUT_MS = 30_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create the main-server hub for device-routed dashboard requests.
|
|
23
|
+
*
|
|
24
|
+
* `devices` is static for the lifetime of the server. Health is deliberately
|
|
25
|
+
* sampled only while rendering the device list; no background monitor or retry
|
|
26
|
+
* path exists here.
|
|
27
|
+
*/
|
|
28
|
+
export function createHubServer({
|
|
29
|
+
devices,
|
|
30
|
+
probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS,
|
|
31
|
+
fetchFn = fetch,
|
|
32
|
+
proxyTimeoutMs = DEFAULT_PROXY_TIMEOUT_MS,
|
|
33
|
+
onError = (error) => console.error('Spotter hub server request failed:', error),
|
|
34
|
+
} = {}) {
|
|
35
|
+
const configuredDevices = normalizeDevices(devices);
|
|
36
|
+
const byId = new Map(configuredDevices.map((device) => [device.id, device]));
|
|
37
|
+
assertPositiveTimeout(probeTimeoutMs, 'probeTimeoutMs');
|
|
38
|
+
assertPositiveTimeout(proxyTimeoutMs, 'proxyTimeoutMs');
|
|
39
|
+
if (typeof fetchFn !== 'function') throw new TypeError('fetchFn must be a function');
|
|
40
|
+
if (typeof onError !== 'function') throw new TypeError('onError must be a function');
|
|
41
|
+
|
|
42
|
+
return http.createServer((request, response) => {
|
|
43
|
+
handleRequest({
|
|
44
|
+
request,
|
|
45
|
+
response,
|
|
46
|
+
devices: configuredDevices,
|
|
47
|
+
byId,
|
|
48
|
+
probeTimeoutMs,
|
|
49
|
+
fetchFn,
|
|
50
|
+
proxyTimeoutMs,
|
|
51
|
+
}).catch((error) => {
|
|
52
|
+
onError(error);
|
|
53
|
+
if (response.headersSent) {
|
|
54
|
+
response.destroy();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
sendText(response, 500, 'Internal Server Error\n');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function handleRequest({ request, response, devices, byId, probeTimeoutMs, fetchFn, proxyTimeoutMs }) {
|
|
63
|
+
if (request.method !== 'GET') {
|
|
64
|
+
sendText(response, 404, 'Not Found\n');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const requestUrl = new URL(request.url ?? '/', 'http://spotter.invalid');
|
|
69
|
+
if (requestUrl.pathname === '/' || requestUrl.pathname === '/devices/') {
|
|
70
|
+
const statuses = await Promise.all(devices.map(async (device) => ({
|
|
71
|
+
id: device.id,
|
|
72
|
+
name: device.name,
|
|
73
|
+
href: `/devices/${encodeURIComponent(device.id)}/`,
|
|
74
|
+
online: await probeDevice(device, probeTimeoutMs, fetchFn),
|
|
75
|
+
})));
|
|
76
|
+
const html = renderHubDashboard({ devices: statuses });
|
|
77
|
+
response.writeHead(200, {
|
|
78
|
+
'cache-control': 'no-store',
|
|
79
|
+
'content-type': 'text/html; charset=utf-8',
|
|
80
|
+
});
|
|
81
|
+
response.end(html);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const deviceId = deviceIdFromPath(requestUrl.pathname);
|
|
86
|
+
const device = deviceId === null ? null : byId.get(deviceId);
|
|
87
|
+
if (!device) {
|
|
88
|
+
sendText(response, 404, 'Not Found\n');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
proxyGet({ request, response, requestUrl, device, timeoutMs: proxyTimeoutMs });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function probeDevice(device, timeoutMs, fetchFn) {
|
|
96
|
+
try {
|
|
97
|
+
const response = await fetchFn(new URL('/_spotter/health', device.upstream), {
|
|
98
|
+
method: 'GET',
|
|
99
|
+
redirect: 'manual',
|
|
100
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
101
|
+
});
|
|
102
|
+
await response.body?.cancel();
|
|
103
|
+
return response.ok;
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function proxyGet({ request, response, requestUrl, device, timeoutMs }) {
|
|
110
|
+
const target = new URL(`${requestUrl.pathname}${requestUrl.search}`, device.upstream);
|
|
111
|
+
const headers = withoutHopByHopHeaders(request.headers);
|
|
112
|
+
delete headers['content-length'];
|
|
113
|
+
headers.host = target.host;
|
|
114
|
+
|
|
115
|
+
const upstreamRequest = clientFor(target).request(target, {
|
|
116
|
+
method: 'GET',
|
|
117
|
+
headers,
|
|
118
|
+
agent: false,
|
|
119
|
+
}, (upstreamResponse) => {
|
|
120
|
+
upstreamResponse.on('error', () => {
|
|
121
|
+
if (response.headersSent) response.destroy();
|
|
122
|
+
else sendText(response, 502, 'Bad Gateway\n');
|
|
123
|
+
});
|
|
124
|
+
response.writeHead(
|
|
125
|
+
upstreamResponse.statusCode ?? 502,
|
|
126
|
+
withoutHopByHopHeaders(upstreamResponse.headers),
|
|
127
|
+
);
|
|
128
|
+
upstreamResponse.pipe(response);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
upstreamRequest.setTimeout(timeoutMs, () => {
|
|
132
|
+
upstreamRequest.destroy(new Error('device proxy request timed out'));
|
|
133
|
+
});
|
|
134
|
+
upstreamRequest.on('error', () => {
|
|
135
|
+
if (response.headersSent) response.destroy();
|
|
136
|
+
else sendText(response, 502, 'Bad Gateway\n');
|
|
137
|
+
});
|
|
138
|
+
response.on('close', () => {
|
|
139
|
+
if (!upstreamRequest.destroyed) upstreamRequest.destroy();
|
|
140
|
+
});
|
|
141
|
+
upstreamRequest.end();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function withoutHopByHopHeaders(headers) {
|
|
145
|
+
const connectionTokens = String(headers.connection ?? '')
|
|
146
|
+
.split(',')
|
|
147
|
+
.map((value) => value.trim().toLowerCase())
|
|
148
|
+
.filter(Boolean);
|
|
149
|
+
const excluded = new Set([...HOP_BY_HOP_HEADERS, ...connectionTokens]);
|
|
150
|
+
return Object.fromEntries(
|
|
151
|
+
Object.entries(headers).filter(([name, value]) => (
|
|
152
|
+
value !== undefined && !excluded.has(name.toLowerCase())
|
|
153
|
+
)),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function deviceIdFromPath(pathname) {
|
|
158
|
+
const match = /^\/devices\/([^/]+)(?:\/.*)?$/.exec(pathname);
|
|
159
|
+
if (!match) return null;
|
|
160
|
+
try {
|
|
161
|
+
return decodeURIComponent(match[1]);
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function normalizeDevices(devices) {
|
|
168
|
+
if (!Array.isArray(devices)) throw new TypeError('devices must be an array');
|
|
169
|
+
const ids = new Set();
|
|
170
|
+
return devices.map((device, index) => {
|
|
171
|
+
if (!device || typeof device !== 'object') {
|
|
172
|
+
throw new TypeError(`devices[${index}] must be an object`);
|
|
173
|
+
}
|
|
174
|
+
const { id, name, upstream } = device;
|
|
175
|
+
if (typeof id !== 'string' || id.length === 0 || /[/?#]/.test(id)) {
|
|
176
|
+
throw new TypeError(`devices[${index}].id must be a non-empty path segment`);
|
|
177
|
+
}
|
|
178
|
+
if (ids.has(id)) throw new TypeError(`duplicate device id: ${id}`);
|
|
179
|
+
ids.add(id);
|
|
180
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
181
|
+
throw new TypeError(`devices[${index}].name must be a non-empty string`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let upstreamUrl;
|
|
185
|
+
try {
|
|
186
|
+
upstreamUrl = new URL(upstream);
|
|
187
|
+
} catch {
|
|
188
|
+
throw new TypeError(`devices[${index}].upstream must be an absolute HTTP URL`);
|
|
189
|
+
}
|
|
190
|
+
if (!['http:', 'https:'].includes(upstreamUrl.protocol)
|
|
191
|
+
|| upstreamUrl.username || upstreamUrl.password
|
|
192
|
+
|| upstreamUrl.pathname !== '/' || upstreamUrl.search || upstreamUrl.hash) {
|
|
193
|
+
throw new TypeError(`devices[${index}].upstream must be an HTTP origin URL`);
|
|
194
|
+
}
|
|
195
|
+
return Object.freeze({ id, name, upstream: upstreamUrl });
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function clientFor(url) {
|
|
200
|
+
return url.protocol === 'https:' ? https : http;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function assertPositiveTimeout(value, name) {
|
|
204
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
205
|
+
throw new TypeError(`${name} must be a positive number`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function sendText(response, statusCode, body) {
|
|
210
|
+
response.writeHead(statusCode, {
|
|
211
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
212
|
+
});
|
|
213
|
+
response.end(body);
|
|
214
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Dashboard projection over the host-local evaluation store. This adapter
|
|
2
|
+
// deliberately reuses the store's saved metric definitions instead of
|
|
3
|
+
// recalculating proposal or adoption rates for the presentation layer.
|
|
4
|
+
|
|
5
|
+
export function buildDashboardModel(store, filters = {}) {
|
|
6
|
+
const summary = store.summarize(filters);
|
|
7
|
+
const notAdoptedCases = store.listCases({ ...filters, outcome: 'not_adopted' });
|
|
8
|
+
return {
|
|
9
|
+
totals: summary.totals,
|
|
10
|
+
byProject: summary.byProject,
|
|
11
|
+
byTool: summary.byTool,
|
|
12
|
+
notAdoptedCases,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Pure, dependency-free HTML rendering for the device-routed evaluation viewer.
|
|
2
|
+
// This module deliberately renders only the caller-provided projection: it never
|
|
3
|
+
// opens EvaluationStore or asks Throughline for context.
|
|
4
|
+
|
|
5
|
+
const METRIC_KEYS = ['S', 'P', 'I', 'C', 'A', 'M'];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Render a complete dashboard document.
|
|
9
|
+
*
|
|
10
|
+
* The renderer accepts the dashboard projection as plain data. `overview` may be
|
|
11
|
+
* either an EvaluationStore summary (`totals`, `byProject`, `byTool`) or the
|
|
12
|
+
* equivalent display-oriented fields (`metrics`, `projects`, `tools`).
|
|
13
|
+
*/
|
|
14
|
+
export function renderDashboard({
|
|
15
|
+
title = 'Spotter evaluation dashboard',
|
|
16
|
+
devices = [],
|
|
17
|
+
selectedDeviceId = null,
|
|
18
|
+
device = null,
|
|
19
|
+
overview = {},
|
|
20
|
+
// `model` is the D1 projection: { totals, byProject, byTool,
|
|
21
|
+
// notAdoptedCases }. Top-level fields are accepted as well so a device
|
|
22
|
+
// server can spread that projection without an adapter.
|
|
23
|
+
model = null,
|
|
24
|
+
totals,
|
|
25
|
+
byProject,
|
|
26
|
+
byTool,
|
|
27
|
+
notAdoptedCases,
|
|
28
|
+
cases,
|
|
29
|
+
caseDetail = null,
|
|
30
|
+
filters = {},
|
|
31
|
+
action = '',
|
|
32
|
+
} = {}) {
|
|
33
|
+
const selectedId = selectedDeviceId ?? device?.id ?? null;
|
|
34
|
+
const selectedDevice = device ?? devices.find((item) => item?.id === selectedId) ?? null;
|
|
35
|
+
const pageTitle = selectedDevice?.name ? `${selectedDevice.name} — ${title}` : title;
|
|
36
|
+
const dashboardModel = model ?? ((totals || byProject || byTool || notAdoptedCases)
|
|
37
|
+
? { totals, byProject, byTool, notAdoptedCases }
|
|
38
|
+
: overview);
|
|
39
|
+
const renderedCases = cases ?? dashboardModel?.notAdoptedCases ?? [];
|
|
40
|
+
return `<!doctype html>
|
|
41
|
+
<html lang="ja">
|
|
42
|
+
<head>
|
|
43
|
+
<meta charset="utf-8">
|
|
44
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
45
|
+
<title>${escapeHtml(pageTitle)}</title>
|
|
46
|
+
<style>${DASHBOARD_CSS}</style>
|
|
47
|
+
</head>
|
|
48
|
+
<body>
|
|
49
|
+
<main class="dashboard">
|
|
50
|
+
<header class="page-header">
|
|
51
|
+
<p class="eyebrow">Spotter</p>
|
|
52
|
+
<h1>${escapeHtml(title)}</h1>
|
|
53
|
+
${selectedDevice ? `<p class="subtitle">端末: ${escapeHtml(selectedDevice.name ?? selectedDevice.id)}</p>` : '<p class="subtitle">端末を選択してください。</p>'}
|
|
54
|
+
</header>
|
|
55
|
+
${renderDevices(devices, selectedId)}
|
|
56
|
+
${selectedDevice ? renderDeviceContent({ overview: dashboardModel, cases: renderedCases, caseDetail, filters, action }) : ''}
|
|
57
|
+
</main>
|
|
58
|
+
</body>
|
|
59
|
+
</html>`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Explicit aliases keep device and hub callers from needing to know document
|
|
63
|
+
// composition details. Both remain pure functions.
|
|
64
|
+
export const renderDeviceDashboard = renderDashboard;
|
|
65
|
+
export const renderHubDashboard = renderDashboard;
|
|
66
|
+
|
|
67
|
+
export function escapeHtml(value) {
|
|
68
|
+
return String(value ?? '')
|
|
69
|
+
.replaceAll('&', '&')
|
|
70
|
+
.replaceAll('<', '<')
|
|
71
|
+
.replaceAll('>', '>')
|
|
72
|
+
.replaceAll('"', '"')
|
|
73
|
+
.replaceAll("'", ''');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function renderDevices(devices, selectedId) {
|
|
77
|
+
if (!Array.isArray(devices) || devices.length === 0) return '';
|
|
78
|
+
return `<nav class="device-nav" aria-label="端末一覧">
|
|
79
|
+
<h2>端末</h2>
|
|
80
|
+
<ul>${devices.map((device) => {
|
|
81
|
+
const id = String(device?.id ?? '');
|
|
82
|
+
const selected = id === selectedId;
|
|
83
|
+
const online = device?.online === true || device?.status === 'online';
|
|
84
|
+
const label = escapeHtml(device?.name ?? id);
|
|
85
|
+
const href = safeHref(device?.href) ?? `?device=${encodeURIComponent(id)}`;
|
|
86
|
+
return `<li><a${selected ? ' aria-current="page"' : ''} class="device ${online ? 'online' : 'offline'}" href="${escapeHtml(href)}"><span class="status-dot" aria-hidden="true"></span>${label}<small>${online ? 'online' : 'offline'}</small></a></li>`;
|
|
87
|
+
}).join('')}</ul>
|
|
88
|
+
</nav>`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function renderDeviceContent({ overview, cases, caseDetail, filters, action }) {
|
|
92
|
+
const totals = overview?.totals ?? overview?.metrics ?? overview ?? {};
|
|
93
|
+
const projects = normaliseBreakdown(overview?.byProject ?? overview?.projects);
|
|
94
|
+
const tools = normaliseBreakdown(overview?.byTool ?? overview?.tools);
|
|
95
|
+
const nonAdopted = Array.isArray(cases) ? cases : [];
|
|
96
|
+
return `${renderFilters(filters, action)}
|
|
97
|
+
<section aria-labelledby="overview-heading">
|
|
98
|
+
<div class="section-heading"><h2 id="overview-heading">概要</h2>${renderRates(totals)}</div>
|
|
99
|
+
<div class="metrics" aria-label="評価メトリクス">${METRIC_KEYS.map((key) => `<article class="metric"><span>${key}</span><strong>${escapeHtml(metric(totals, key))}</strong></article>`).join('')}</div>
|
|
100
|
+
</section>
|
|
101
|
+
${renderBreakdown('project-breakdown', 'project別内訳', projects)}
|
|
102
|
+
${renderBreakdown('tool-breakdown', 'tool別内訳', tools)}
|
|
103
|
+
${renderCases(nonAdopted)}
|
|
104
|
+
${caseDetail ? renderCaseDetail(caseDetail) : ''}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function renderFilters(filters, action) {
|
|
108
|
+
const project = filters?.project ?? filters?.projectPath ?? '';
|
|
109
|
+
const from = filters?.from ?? '';
|
|
110
|
+
const to = filters?.to ?? '';
|
|
111
|
+
return `<form class="filters" method="get" action="${escapeHtml(safeHref(action) ?? '')}">
|
|
112
|
+
<label>project <input name="project" value="${escapeHtml(project)}"></label>
|
|
113
|
+
<label>from <input name="from" type="datetime-local" value="${escapeHtml(from)}"></label>
|
|
114
|
+
<label>to <input name="to" type="datetime-local" value="${escapeHtml(to)}"></label>
|
|
115
|
+
<button type="submit">絞り込む</button>
|
|
116
|
+
</form>`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function renderRates(summary) {
|
|
120
|
+
const proposal = rate(metricNumber(summary, 'P'), metricNumber(summary, 'S'));
|
|
121
|
+
const adoption = rate(metricNumber(summary, 'A'), metricNumber(summary, 'C'));
|
|
122
|
+
return `<dl class="rates"><div><dt>提案率 P/S</dt><dd>${escapeHtml(proposal)}</dd></div><div><dt>採用率 A/C</dt><dd>${escapeHtml(adoption)}</dd></div></dl>`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function renderBreakdown(id, title, rows) {
|
|
126
|
+
return `<section aria-labelledby="${id}">
|
|
127
|
+
<h2 id="${id}">${title}</h2>
|
|
128
|
+
${rows.length === 0 ? '<p class="empty">該当するデータはありません。</p>' : `<div class="table-wrap"><table><thead><tr><th scope="col">項目</th>${METRIC_KEYS.map((key) => `<th scope="col">${key}</th>`).join('')}<th scope="col">提案率</th><th scope="col">採用率</th></tr></thead><tbody>${rows.map(([label, summary]) => `<tr><th scope="row">${escapeHtml(label)}</th>${METRIC_KEYS.map((key) => `<td>${escapeHtml(metric(summary, key))}</td>`).join('')}<td>${escapeHtml(rate(metricNumber(summary, 'P'), metricNumber(summary, 'S')))}</td><td>${escapeHtml(rate(metricNumber(summary, 'A'), metricNumber(summary, 'C')))}</td></tr>`).join('')}</tbody></table></div>`}
|
|
129
|
+
</section>`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function renderCases(cases) {
|
|
133
|
+
return `<section aria-labelledby="cases-heading">
|
|
134
|
+
<h2 id="cases-heading">非採用case</h2>
|
|
135
|
+
${cases.length === 0 ? '<p class="empty">非採用caseはありません。</p>' : `<div class="table-wrap"><table><thead><tr><th scope="col">記録時刻</th><th scope="col">project</th><th scope="col">tool</th><th scope="col">outcome</th></tr></thead><tbody>${cases.map((item) => {
|
|
136
|
+
const label = escapeHtml(formatDate(item?.recordedAtMs ?? item?.recordedAt));
|
|
137
|
+
const caseHref = safeHref(item?.href) ?? (item?.observationId ? `?case=${encodeURIComponent(item.observationId)}` : null);
|
|
138
|
+
const project = escapeHtml(item?.projectPath ?? item?.project ?? '');
|
|
139
|
+
const tool = escapeHtml(item?.toolId ?? item?.tool ?? '');
|
|
140
|
+
const outcome = escapeHtml(item?.outcome ?? 'not_adopted');
|
|
141
|
+
return `<tr><td>${caseHref ? `<a href="${escapeHtml(caseHref)}">${label}</a>` : label}</td><td>${project}</td><td>${tool}</td><td>${outcome}</td></tr>`;
|
|
142
|
+
}).join('')}</tbody></table></div>`}
|
|
143
|
+
</section>`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function renderCaseDetail(item) {
|
|
147
|
+
return `<section class="case-detail" aria-labelledby="case-detail-heading">
|
|
148
|
+
<h2 id="case-detail-heading">case詳細</h2>
|
|
149
|
+
<dl class="metadata"><div><dt>observation</dt><dd>${escapeHtml(item.observationId)}</dd></div><div><dt>session</dt><dd>${escapeHtml(item.sessionId)}</dd></div><div><dt>host</dt><dd>${escapeHtml(item.host)}</dd></div></dl>
|
|
150
|
+
${renderTextBlock('request', 'request', item.requestText)}
|
|
151
|
+
${renderTextBlock('auditor-context', 'auditorへ渡したcontext', item.auditorSeenContext)}
|
|
152
|
+
${renderTextBlock('observer-context', 'Throughline observer snapshot', formatSnapshot(item.observerSnapshot, item.observerContextStatus))}
|
|
153
|
+
${renderJsonBlock('proposal-ids', '提案ID', item.proposedToolIds)}
|
|
154
|
+
${renderJsonBlock('used-ids', '利用ID', item.usedToolIds)}
|
|
155
|
+
${renderJsonBlock('item-outcomes', 'item結果', item.items)}
|
|
156
|
+
</section>`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function renderTextBlock(id, title, value) {
|
|
160
|
+
return `<section aria-labelledby="${id}"><h3 id="${id}">${title}</h3><pre>${escapeHtml(value === null || value === undefined || value === '' ? '(none)' : value)}</pre></section>`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function renderJsonBlock(id, title, value) {
|
|
164
|
+
return renderTextBlock(id, title, JSON.stringify(value ?? [], null, 2));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function normaliseBreakdown(value) {
|
|
168
|
+
if (Array.isArray(value)) return value.map((item) => [item.label ?? item.name ?? item.projectPath ?? item.toolId ?? '', item.summary ?? item.metrics ?? item]);
|
|
169
|
+
if (value && typeof value === 'object') return Object.entries(value);
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function metric(summary, key) {
|
|
174
|
+
return String(metricNumber(summary, key));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function metricNumber(summary, key) {
|
|
178
|
+
const value = Number(summary?.[key]);
|
|
179
|
+
return Number.isFinite(value) ? value : 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function rate(numerator, denominator) {
|
|
183
|
+
if (denominator === 0) return 'n/a';
|
|
184
|
+
return `${Math.round((numerator / denominator) * 100)}% (${numerator}/${denominator})`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function formatDate(value) {
|
|
188
|
+
if (typeof value === 'number' && Number.isFinite(value)) return new Date(value).toISOString();
|
|
189
|
+
return value === null || value === undefined || value === '' ? '(unknown)' : String(value);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function formatSnapshot(snapshot, status) {
|
|
193
|
+
if (snapshot === null || snapshot === undefined) return status ? `(status: ${status})` : '(none)';
|
|
194
|
+
return JSON.stringify(snapshot, null, 2);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function safeHref(value) {
|
|
198
|
+
if (typeof value !== 'string' || value.length === 0) return null;
|
|
199
|
+
try {
|
|
200
|
+
const url = new URL(value, 'https://spotter.invalid');
|
|
201
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
|
|
202
|
+
return value.startsWith('/') || value.startsWith('?') || /^https?:\/\//i.test(value) ? value : null;
|
|
203
|
+
} catch { return null; }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const DASHBOARD_CSS = `
|
|
207
|
+
:root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, sans-serif; color: #19212b; background: #f5f7fa; }
|
|
208
|
+
* { box-sizing: border-box; }
|
|
209
|
+
body { margin: 0; }
|
|
210
|
+
.dashboard { max-width: 1200px; margin: 0 auto; padding: 2rem clamp(1rem, 4vw, 3rem) 4rem; }
|
|
211
|
+
.page-header { border-bottom: 1px solid #d7dce2; margin-bottom: 1.5rem; }
|
|
212
|
+
.eyebrow { color: #3565a0; font-weight: 700; letter-spacing: .08em; margin: 0; text-transform: uppercase; }
|
|
213
|
+
h1 { margin: .25rem 0; font-size: clamp(1.75rem, 4vw, 2.5rem); } h2 { margin-top: 2rem; } h3 { margin-bottom: .5rem; }
|
|
214
|
+
.subtitle, .empty { color: #52606d; }
|
|
215
|
+
.device-nav ul { display: flex; flex-wrap: wrap; gap: .5rem; list-style: none; margin: .5rem 0; padding: 0; }
|
|
216
|
+
.device { align-items: center; border: 1px solid #ccd4dd; border-radius: 999px; color: inherit; display: inline-flex; gap: .45rem; padding: .45rem .7rem; text-decoration: none; }
|
|
217
|
+
.device[aria-current="page"] { border-color: #1c63b8; box-shadow: 0 0 0 2px #b8d6fa; }.device small { color: #52606d; }.status-dot { background: #a03333; border-radius: 50%; height: .55rem; width: .55rem; }.online .status-dot { background: #16803c; }
|
|
218
|
+
.filters { align-items: end; display: flex; flex-wrap: wrap; gap: .75rem; margin: 1.5rem 0; }.filters label { display: grid; font-size: .85rem; gap: .25rem; }.filters input, button { border: 1px solid #aeb8c4; border-radius: .35rem; font: inherit; padding: .45rem; }button { background: #1c63b8; color: white; cursor: pointer; }
|
|
219
|
+
.section-heading { align-items: baseline; display: flex; flex-wrap: wrap; gap: 1rem; justify-content: space-between; }.rates { display: flex; gap: 1.25rem; margin: 0; }.rates div { display: flex; gap: .35rem; }.rates dt { color: #52606d; }.rates dd { font-weight: 700; margin: 0; }
|
|
220
|
+
.metrics { display: grid; gap: .75rem; grid-template-columns: repeat(6, minmax(90px, 1fr)); }.metric { background: #fff; border: 1px solid #d7dce2; border-radius: .5rem; padding: .8rem; }.metric span { color: #52606d; display: block; }.metric strong { font-size: 1.5rem; }
|
|
221
|
+
.table-wrap { overflow-x: auto; }table { border-collapse: collapse; min-width: 680px; width: 100%; }th, td { border-bottom: 1px solid #d7dce2; padding: .6rem; text-align: left; vertical-align: top; }thead { background: #eaf0f6; }tbody tr:nth-child(even) { background: #fbfcfd; }
|
|
222
|
+
.case-detail { border-top: 2px solid #1c63b8; margin-top: 2.5rem; }.metadata { display: flex; flex-wrap: wrap; gap: 1rem; }.metadata div { min-width: 12rem; }.metadata dt { color: #52606d; }.metadata dd { margin: .2rem 0; overflow-wrap: anywhere; }pre { background: #1e2935; color: #e6edf3; margin: 0; overflow-x: auto; padding: 1rem; white-space: pre-wrap; word-break: break-word; }
|
|
223
|
+
@media (max-width: 700px) { .dashboard { padding-top: 1rem; }.metrics { grid-template-columns: repeat(3, 1fr); }.filters { align-items: stretch; flex-direction: column; }.filters input, button { width: 100%; }.rates { flex-direction: column; gap: .25rem; } }
|
|
224
|
+
@media (prefers-color-scheme: dark) { :root { color: #e8edf2; background: #111827; }.page-header, th, td { border-color: #364152; }.metric { background: #182230; border-color: #364152; }.subtitle, .empty, .device small, .rates dt, .metric span, .metadata dt { color: #b6c2cf; }thead { background: #243244; }tbody tr:nth-child(even) { background: #151f2c; }.device { border-color: #526173; } }
|
|
225
|
+
`;
|