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 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,4 @@
1
+ $ErrorActionPreference = 'Stop'
2
+ $spotter = Join-Path $env:APPDATA 'npm\spotter.cmd'
3
+ & $spotter dashboard device --id fox-windows --name 'FOX Windows native' --host 127.0.0.1 --port 53940
4
+ exit $LASTEXITCODE
@@ -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.4.29",
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('&', '&amp;')
70
+ .replaceAll('<', '&lt;')
71
+ .replaceAll('>', '&gt;')
72
+ .replaceAll('"', '&quot;')
73
+ .replaceAll("'", '&#39;');
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
+ `;