Skip to content

Commit 14ca28f

Browse files
committed
feat(testing): add live run dashboard
1 parent 770eb2f commit 14ca28f

5 files changed

Lines changed: 210 additions & 0 deletions

File tree

docs/testing/test-report-output-spec.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,3 +261,14 @@ The output must work offline and inline all CSS, JavaScript, session events, tex
261261
- collapsible original agent messages, tool calls, command output, and errors;
262262
- every attachment and the complete raw session; and
263263
- stable cross-reference anchors.
264+
265+
<a id="report-live-dashboard"></a>
266+
## 13. Live and historical dashboard
267+
268+
`run-plan --web` starts a read-only HTTP dashboard for case status and the
269+
native JSONL output of the orchestrator and every case agent. The browser polls
270+
incremental byte ranges so active output appears without rewriting session
271+
files. `dstack-test serve --plan <plan> --run-id <run-id>` exposes the same view
272+
for a completed or interrupted run. The server has no built-in authentication;
273+
non-loopback binding is permitted only on a trusted network or behind an
274+
authenticated tunnel.

tools/dstack-test/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ with repeatable `--agent-arg`. The runner writes only below:
3939
tools/dstack-test/dstack-test run-plan \
4040
--plan path/to/plan \
4141
--context run-context.json \
42+
--web \
4243
-- "Follow the environment restrictions in README.md"
4344
```
4445

@@ -52,6 +53,20 @@ The complete control conversation is stored in `orchestrator.jsonl`.
5253

5354
An interrupted run can be continued with the same run ID and `--resume`.
5455

56+
`--web` starts a read-only live dashboard on `127.0.0.1` and an automatically
57+
selected port. Use `--web-host 0.0.0.0 --web-port 8000` for remote access;
58+
there is no built-in authentication, so expose it only on a trusted network or
59+
through an authenticated tunnel. The dashboard shows case status and polls the
60+
native orchestrator/case JSONL files for near-real-time output.
61+
62+
After a run has finished, serve the same historical sessions with:
63+
64+
```bash
65+
tools/dstack-test/dstack-test serve \
66+
--plan path/to/plan --run-id run-20260723-001 \
67+
--host 127.0.0.1 --port 8000
68+
```
69+
5570
## Finalize and validate
5671

5772
```bash

tools/dstack-test/dstack-test

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ from typing import Any
2626

2727
import render
2828
import tomllib
29+
import web
2930

3031
TERMINAL_STATUS = ("PASS", "FAIL", "BLOCKED", "NOT_RUN", "SKIPPED")
3132

@@ -560,6 +561,59 @@ class FileControlServer:
560561
self.thread.join()
561562

562563

564+
def dashboard_state(plan: render.Plan, run_id: str) -> dict[str, Any]:
565+
run_path = plan.root / "results" / run_id / "run.json"
566+
run = render.load_json(run_path) if run_path.is_file() else {}
567+
counts = {status: 0 for status in (*TERMINAL_STATUS, "RUNNING", "PENDING")}
568+
cases = []
569+
for case in plan.cases:
570+
result_path = case.path / "results" / run_id / "result.json"
571+
session_path = case.path / "results" / run_id / "session.jsonl"
572+
if result_path.is_file():
573+
status = str(render.load_json(result_path).get("status", "PENDING"))
574+
else:
575+
status = "RUNNING" if session_path.is_file() else "PENDING"
576+
counts[status] = counts.get(status, 0) + 1
577+
cases.append({"id": case.id, "title": case.title, "status": status})
578+
orchestrator = plan.root / "results" / run_id / "orchestrator.jsonl"
579+
return {
580+
"title": plan.index.get("title", plan.index["id"]),
581+
"run_id": run_id,
582+
"run_status": run.get("status", "RUNNING"),
583+
"orchestrator_status": "PASS"
584+
if run_path.is_file()
585+
else ("RUNNING" if orchestrator.is_file() else "PENDING"),
586+
"counts": counts,
587+
"cases": cases,
588+
}
589+
590+
591+
def dashboard_log(
592+
plan: render.Plan, run_id: str, agent: str, offset: int
593+
) -> dict[str, Any]:
594+
if agent == "orchestrator":
595+
path = plan.root / "results" / run_id / "orchestrator.jsonl"
596+
elif agent.startswith("case:"):
597+
case = find_case(plan, agent.removeprefix("case:"))
598+
path = case.path / "results" / run_id / "session.jsonl"
599+
else:
600+
raise DstackTestError("unknown agent session")
601+
if not path.is_file():
602+
return {"text": "", "next_offset": 0, "reset": offset > 0}
603+
size = path.stat().st_size
604+
reset = offset > size
605+
with path.open("rb") as source:
606+
source.seek(0 if reset else offset)
607+
data = source.read(1024 * 1024)
608+
next_offset = source.tell()
609+
return {
610+
"text": data.decode("utf-8", errors="replace"),
611+
"next_offset": next_offset,
612+
"reset": reset,
613+
"eof": next_offset >= size,
614+
}
615+
616+
563617
def write_sha256s(root: Path) -> None:
564618
entries: list[str] = []
565619
for path in sorted(
@@ -806,6 +860,9 @@ def build_parser() -> argparse.ArgumentParser:
806860
run_plan.add_argument("--agent-arg", action="append", default=[])
807861
run_plan.add_argument("--overwrite", action="store_true")
808862
run_plan.add_argument("--resume", action="store_true")
863+
run_plan.add_argument("--web", action="store_true", help="serve a live dashboard")
864+
run_plan.add_argument("--web-host", default="127.0.0.1")
865+
run_plan.add_argument("--web-port", type=int, default=0)
809866
run_plan.add_argument("prompt", nargs=argparse.REMAINDER)
810867

811868
sub.add_parser("next-case", help="return the next unresolved controlled case")
@@ -835,6 +892,11 @@ def build_parser() -> argparse.ArgumentParser:
835892
)
836893
add_common_plan(render_parser)
837894
render_parser.add_argument("--output", type=Path, required=True)
895+
896+
serve = sub.add_parser("serve", help="serve a live or historical run dashboard")
897+
add_common_plan(serve)
898+
serve.add_argument("--host", default="127.0.0.1")
899+
serve.add_argument("--port", type=int, default=8000)
838900
return parser
839901

840902

@@ -908,6 +970,21 @@ def main(argv: list[str] | None = None) -> int:
908970
control_dir = run_dir / "control"
909971
server = FileControlServer(state, control_dir)
910972
server.start()
973+
dashboard = None
974+
if args.web:
975+
dashboard = web.Dashboard(
976+
lambda: dashboard_state(plan, args.run_id),
977+
lambda agent, offset: dashboard_log(plan, args.run_id, agent, offset),
978+
args.web_host,
979+
args.web_port,
980+
)
981+
dashboard.start()
982+
web_host, web_port = dashboard.address
983+
print(
984+
f"dashboard: http://{web_host}:{web_port}/",
985+
file=sys.stderr,
986+
flush=True,
987+
)
911988
prompt = orchestration_prompt(plan, prompt_text(args))
912989
(run_dir / "orchestrator-prompt.md").write_text(prompt, encoding="utf-8")
913990
environment = os.environ.copy()
@@ -931,6 +1008,8 @@ def main(argv: list[str] | None = None) -> int:
9311008
finally:
9321009
server.close()
9331010
shutil.rmtree(control_dir, ignore_errors=True)
1011+
if dashboard:
1012+
dashboard.close()
9341013
events, detected_model = validate_session(run_dir / "orchestrator.jsonl")
9351014
atomic_json(
9361015
run_dir / "orchestrator-runner.json",
@@ -968,6 +1047,21 @@ def main(argv: list[str] | None = None) -> int:
9681047
"output": str(args.output),
9691048
"bytes": args.output.stat().st_size,
9701049
}
1050+
elif args.subcommand == "serve":
1051+
dashboard = web.Dashboard(
1052+
lambda: dashboard_state(plan, args.run_id),
1053+
lambda agent, offset: dashboard_log(plan, args.run_id, agent, offset),
1054+
args.host,
1055+
args.port,
1056+
)
1057+
dashboard.start()
1058+
host, port = dashboard.address
1059+
print(f"dashboard: http://{host}:{port}/", file=sys.stderr, flush=True)
1060+
try:
1061+
dashboard.thread.join()
1062+
except KeyboardInterrupt:
1063+
dashboard.close()
1064+
return 0
9711065
else:
9721066
raise AssertionError(args.subcommand)
9731067
print(json.dumps(value, ensure_ascii=False, indent=2))

tools/dstack-test/tests/test_dstack_test.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,14 @@ def test_validate_and_render_fixture(self) -> None:
8181
self.assertIn("198.51.100.27:45678", output)
8282
self.assertIn("session-tc-gw-pp-001-event-2", output)
8383

84+
def test_dashboard_exposes_historical_status_and_log(self) -> None:
85+
plan = render.load_plan(FIXTURE)
86+
state = dstack_test.dashboard_state(plan, "run-demo")
87+
self.assertEqual(state["cases"][0]["status"], "PASS")
88+
log = dstack_test.dashboard_log(plan, "run-demo", "case:tc-gw-pp-001", 0)
89+
self.assertGreater(log["next_offset"], 0)
90+
self.assertIn("thread.started", log["text"])
91+
8492
def test_finalize_rebuilds_run_summary(self) -> None:
8593
with tempfile.TemporaryDirectory() as temporary:
8694
plan_path = self.copy_fixture(Path(temporary))

tools/dstack-test/web.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# SPDX-FileCopyrightText: © 2026 Phala Network <dstack@phala.network>
2+
# SPDX-License-Identifier: Apache-2.0
3+
"""Embedded live dashboard for dstack test runs."""
4+
5+
import http.server
6+
import json
7+
import threading
8+
import urllib.parse
9+
from typing import Any, Callable
10+
11+
HTML = r"""<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>dstack test</title><style>
12+
:root{color-scheme:dark;font:14px system-ui;background:#0b1020;color:#dce5ff}body{margin:0}header{padding:16px 22px;background:#121a30}h1{margin:0;font-size:20px}.meta{color:#91a4cc}.grid{display:grid;grid-template-columns:360px 1fr;min-height:calc(100vh - 65px)}aside,main{padding:16px}aside{border-right:1px solid #273453}.case{display:block;width:100%;text-align:left;background:#151e35;color:inherit;border:1px solid #293858;border-radius:8px;padding:10px;margin:7px 0;cursor:pointer}.case:hover,.active{border-color:#6da7ff}.badge{float:right;border-radius:10px;padding:2px 7px;font-size:11px}.PASS{background:#126b47}.FAIL{background:#8b2733}.SKIPPED,.BLOCKED{background:#74551b}.RUNNING{background:#215a91}.PENDING{background:#39445d}.summary{display:flex;gap:6px;flex-wrap:wrap}.summary span{background:#1a2540;padding:5px 8px;border-radius:12px}button{color:inherit}pre{white-space:pre-wrap;word-break:break-word;background:#070b14;border:1px solid #273453;border-radius:8px;padding:14px;height:calc(100vh - 135px);overflow:auto}.toolbar{display:flex;gap:10px;align-items:center}@media(max-width:800px){.grid{grid-template-columns:1fr}}</style></head><body><header><h1 id="title">dstack test</h1><div class="meta" id="meta">Connecting…</div></header><div class="grid"><aside><div class="summary" id="summary"></div><div id="cases"></div></aside><main><div class="toolbar"><strong id="agent">Plan orchestrator</strong><button onclick="resetLog()">Reload history</button><label><input id="follow" type="checkbox" checked> follow</label></div><pre id="log"></pre></main></div><script>
13+
let selected='orchestrator',offset=0;const log=document.querySelector('#log');const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
14+
function pick(id,label){selected=id;offset=0;log.textContent='';document.querySelector('#agent').textContent=label;pollLog()}function resetLog(){offset=0;log.textContent='';pollLog()}
15+
async function pollState(){try{const s=await(await fetch('/api/state')).json();title.textContent=s.title;meta.textContent=`run ${s.run_id} · ${s.run_status}`;summary.innerHTML=Object.entries(s.counts).filter(x=>x[1]).map(x=>`<span>${esc(x[0])} ${x[1]}</span>`).join('');let h=`<button class="case" onclick="pick('orchestrator','Plan orchestrator')">Plan orchestrator<span class="badge ${s.orchestrator_status}">${s.orchestrator_status}</span></button>`;for(const c of s.cases)h+=`<button class="case" onclick="pick('case:${esc(c.id)}','${esc(c.id)}')">${esc(c.id)}<span class="badge ${c.status}">${c.status}</span><br><small>${esc(c.title)}</small></button>`;cases.innerHTML=h}catch(e){meta.textContent=e}}
16+
async function pollLog(){try{const r=await(await fetch(`/api/log?agent=${encodeURIComponent(selected)}&offset=${offset}`)).json();if(r.reset){offset=0;log.textContent=''}if(r.text){log.textContent+=r.text;offset=r.next_offset;if(follow.checked)log.scrollTop=log.scrollHeight}}catch(e){}}
17+
setInterval(pollState,1000);setInterval(pollLog,500);pollState();pollLog();</script></body></html>"""
18+
19+
20+
class Dashboard:
21+
"""Serve a read-only live run dashboard."""
22+
23+
def __init__(
24+
self,
25+
state: Callable[[], dict[str, Any]],
26+
log: Callable[[str, int], dict[str, Any]],
27+
host: str,
28+
port: int,
29+
):
30+
class Handler(http.server.BaseHTTPRequestHandler):
31+
def log_message(self, _format: str, *_args: Any) -> None:
32+
pass
33+
34+
def reply(self, value: Any, status: int = 200) -> None:
35+
data = json.dumps(value, ensure_ascii=False).encode()
36+
self.send_response(status)
37+
self.send_header("Content-Type", "application/json; charset=utf-8")
38+
self.send_header("Cache-Control", "no-store")
39+
self.send_header("Content-Length", str(len(data)))
40+
self.end_headers()
41+
self.wfile.write(data)
42+
43+
def do_GET(self) -> None:
44+
parsed = urllib.parse.urlparse(self.path)
45+
if parsed.path == "/":
46+
data = HTML.encode()
47+
self.send_response(200)
48+
self.send_header("Content-Type", "text/html; charset=utf-8")
49+
self.send_header("Content-Length", str(len(data)))
50+
self.end_headers()
51+
self.wfile.write(data)
52+
elif parsed.path == "/api/state":
53+
self.reply(state())
54+
elif parsed.path == "/api/log":
55+
query = urllib.parse.parse_qs(parsed.query)
56+
try:
57+
self.reply(
58+
log(
59+
query.get("agent", [""])[0],
60+
int(query.get("offset", ["0"])[0]),
61+
)
62+
)
63+
except Exception as error: # noqa: BLE001 - API boundary
64+
self.reply({"error": str(error)}, 400)
65+
else:
66+
self.send_error(404)
67+
68+
self.server = http.server.ThreadingHTTPServer((host, port), Handler)
69+
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
70+
71+
@property
72+
def address(self) -> tuple[str, int]:
73+
host, port = self.server.server_address[:2]
74+
return str(host), int(port)
75+
76+
def start(self) -> None:
77+
self.thread.start()
78+
79+
def close(self) -> None:
80+
self.server.shutdown()
81+
self.server.server_close()
82+
self.thread.join()

0 commit comments

Comments
 (0)