dscan-security 0.1.0__py3-none-any.whl
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.
- dscan/__init__.py +20 -0
- dscan/cli.py +263 -0
- dscan/dashboard/__init__.py +10 -0
- dscan/dashboard/server.py +184 -0
- dscan/dashboard/templates/index.html +233 -0
- dscan/redactor.py +186 -0
- dscan/scanner.py +327 -0
- dscan/tracer.py +126 -0
- dscan/trail.py +388 -0
- dscan/watcher.py +339 -0
- dscan_security-0.1.0.dist-info/METADATA +168 -0
- dscan_security-0.1.0.dist-info/RECORD +14 -0
- dscan_security-0.1.0.dist-info/WHEEL +4 -0
- dscan_security-0.1.0.dist-info/entry_points.txt +2 -0
dscan/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""dscan — an open source agent security suite.
|
|
2
|
+
|
|
3
|
+
Wrap your agent with :func:`watch` to trace, redact, and scan its
|
|
4
|
+
behavior, then inspect everything in a local dashboard::
|
|
5
|
+
|
|
6
|
+
from dscan import watch
|
|
7
|
+
|
|
8
|
+
@watch
|
|
9
|
+
async def my_agent(task: str):
|
|
10
|
+
... # your agent code unchanged
|
|
11
|
+
|
|
12
|
+
# then, from the shell:
|
|
13
|
+
# dscan dashboard # localhost:4321
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from dscan.watcher import watch
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
__all__ = ["watch", "__version__"]
|
dscan/cli.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""dscan command-line interface.
|
|
2
|
+
|
|
3
|
+
Defines the ``dscan`` Click command group and its subcommands: ``scan``
|
|
4
|
+
(static prompt/MCP analysis via :mod:`dscan.scanner`), ``trail``
|
|
5
|
+
(call-chain detection via :mod:`dscan.trail`), ``dashboard`` (launches
|
|
6
|
+
:mod:`dscan.dashboard.server`), and ``watch`` (a usage reminder). All
|
|
7
|
+
output is rendered with ``rich``. The package entry point ``dscan``
|
|
8
|
+
resolves to :func:`main`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
|
|
21
|
+
from dscan import __version__
|
|
22
|
+
from dscan.trail import TrailAnalyzer
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
_SEVERITY_ORDER = ["high", "medium", "low"]
|
|
27
|
+
_SEVERITY_STYLE = {"high": "bold red", "medium": "#f59e0b", "low": "cyan"}
|
|
28
|
+
|
|
29
|
+
# Trail severities, lowest to highest, plus per-row table styles.
|
|
30
|
+
_TRAIL_RANK = {"low": 0, "medium": 1, "high": 2, "critical": 3}
|
|
31
|
+
_TRAIL_DISPLAY_ORDER = ["critical", "high", "medium", "low"]
|
|
32
|
+
_TRAIL_ROW_STYLE = {"critical": "red", "high": "#f59e0b", "medium": "yellow", "low": None}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _ok(message: str) -> None:
|
|
36
|
+
console.print(f"[green]✓[/green] {message}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _warn(message: str) -> None:
|
|
40
|
+
console.print(f"[#f59e0b]⚠[/#f59e0b] {message}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _err(message: str) -> None:
|
|
44
|
+
console.print(f"[red]✗[/red] {message}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@click.group()
|
|
48
|
+
@click.version_option(__version__, prog_name="dscan")
|
|
49
|
+
def main() -> None:
|
|
50
|
+
"""dscan — an open source agent security suite.
|
|
51
|
+
|
|
52
|
+
Trace and redact your agent's tool calls (@watch), statically scan
|
|
53
|
+
prompts and MCP configs (dscan scan), and inspect everything in a
|
|
54
|
+
local dashboard (dscan dashboard).
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@main.command()
|
|
59
|
+
def watch() -> None:
|
|
60
|
+
"""Show how to instrument an agent (it's a decorator, not a command)."""
|
|
61
|
+
_warn("Add @watch to your agent function. See README for usage.")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@main.command()
|
|
65
|
+
@click.argument("path", required=False, default=".")
|
|
66
|
+
@click.option(
|
|
67
|
+
"--prompt",
|
|
68
|
+
"prompt_file",
|
|
69
|
+
type=click.Path(exists=True, dir_okay=False),
|
|
70
|
+
default=None,
|
|
71
|
+
help="Scan a single system-prompt file instead of a directory.",
|
|
72
|
+
)
|
|
73
|
+
def scan(path: str, prompt_file: str | None) -> None:
|
|
74
|
+
"""Statically analyze agent configs and system prompts."""
|
|
75
|
+
from dscan.scanner import scan_directory, scan_file
|
|
76
|
+
|
|
77
|
+
findings = scan_file(prompt_file) if prompt_file else scan_directory(path)
|
|
78
|
+
_render_findings(findings)
|
|
79
|
+
if any(f.severity == "high" for f in findings):
|
|
80
|
+
sys.exit(1)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@main.command()
|
|
84
|
+
@click.option("--host", default="127.0.0.1", show_default=True, help="Host to bind.")
|
|
85
|
+
@click.option("--port", default=4321, show_default=True, help="Port to bind.")
|
|
86
|
+
@click.option(
|
|
87
|
+
"--open/--no-open",
|
|
88
|
+
"open_browser",
|
|
89
|
+
default=True,
|
|
90
|
+
show_default=True,
|
|
91
|
+
help="Open the dashboard in a browser.",
|
|
92
|
+
)
|
|
93
|
+
def dashboard(host: str, port: int, open_browser: bool) -> None:
|
|
94
|
+
"""Launch the local trace dashboard."""
|
|
95
|
+
with console.status(
|
|
96
|
+
f"Starting dashboard at localhost:{port}...", spinner="dots"
|
|
97
|
+
):
|
|
98
|
+
from dscan.dashboard.server import serve
|
|
99
|
+
|
|
100
|
+
_ok(f"Dashboard at [cyan]http://{host}:{port}[/cyan] [dim](Ctrl-C to stop)[/dim]")
|
|
101
|
+
serve(host=host, port=port, open_browser=open_browser)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@main.command()
|
|
105
|
+
@click.argument("path")
|
|
106
|
+
@click.option(
|
|
107
|
+
"--min-severity",
|
|
108
|
+
type=click.Choice(["low", "medium", "high", "critical"]),
|
|
109
|
+
default="low",
|
|
110
|
+
show_default=True,
|
|
111
|
+
help="Hide findings below this severity (display only; exit code still "
|
|
112
|
+
"reflects any high/critical finding).",
|
|
113
|
+
)
|
|
114
|
+
@click.option(
|
|
115
|
+
"--json",
|
|
116
|
+
"as_json",
|
|
117
|
+
is_flag=True,
|
|
118
|
+
default=False,
|
|
119
|
+
help="Output raw JSON instead of a rich table.",
|
|
120
|
+
)
|
|
121
|
+
def trail(path: str, min_severity: str, as_json: bool) -> None:
|
|
122
|
+
"""Detect suspicious tool-call chains (CWAT) in trace files.
|
|
123
|
+
|
|
124
|
+
PATH is a trace file (.ndjson) or a directory of trace files.
|
|
125
|
+
"""
|
|
126
|
+
target = Path(path)
|
|
127
|
+
if not target.exists():
|
|
128
|
+
_err(f"path not found: {path}")
|
|
129
|
+
sys.exit(2)
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
traces = _load_trace_dicts(target)
|
|
133
|
+
except OSError as exc: # pragma: no cover - defensive
|
|
134
|
+
_err(f"could not read traces from {path}: {exc}")
|
|
135
|
+
sys.exit(2)
|
|
136
|
+
|
|
137
|
+
# Analyze each session independently so chains never bridge unrelated
|
|
138
|
+
# agent runs (a read in one session + a send in another is not exfil).
|
|
139
|
+
analyzer = TrailAnalyzer()
|
|
140
|
+
all_findings: list = []
|
|
141
|
+
for session in _group_by_session(traces):
|
|
142
|
+
all_findings.extend(analyzer.analyze(session))
|
|
143
|
+
|
|
144
|
+
threshold = _TRAIL_RANK[min_severity]
|
|
145
|
+
shown = [f for f in all_findings if _TRAIL_RANK.get(f.severity, 0) >= threshold]
|
|
146
|
+
total_calls = len(traces)
|
|
147
|
+
|
|
148
|
+
if as_json:
|
|
149
|
+
console.print_json(data=[f.to_dict() for f in shown])
|
|
150
|
+
elif not all_findings:
|
|
151
|
+
_ok(f"No issues found in {total_calls} tool calls")
|
|
152
|
+
elif not shown:
|
|
153
|
+
console.print(
|
|
154
|
+
f"[dim]No findings at or above {min_severity.upper()} — "
|
|
155
|
+
f"{len(all_findings)} lower-severity finding(s) hidden.[/dim]"
|
|
156
|
+
)
|
|
157
|
+
else:
|
|
158
|
+
_render_trail(shown, total_calls)
|
|
159
|
+
|
|
160
|
+
if any(f.severity in ("high", "critical") for f in all_findings):
|
|
161
|
+
sys.exit(1)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _read_ndjson(path: Path) -> list[dict]:
|
|
165
|
+
traces: list[dict] = []
|
|
166
|
+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
167
|
+
line = line.strip()
|
|
168
|
+
if not line:
|
|
169
|
+
continue
|
|
170
|
+
try:
|
|
171
|
+
obj = json.loads(line)
|
|
172
|
+
except (json.JSONDecodeError, ValueError):
|
|
173
|
+
continue # skip malformed lines
|
|
174
|
+
if isinstance(obj, dict):
|
|
175
|
+
traces.append(obj)
|
|
176
|
+
return traces
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _load_trace_dicts(target: Path) -> list[dict]:
|
|
180
|
+
files = [target] if target.is_file() else sorted(target.glob("*.ndjson"))
|
|
181
|
+
traces: list[dict] = []
|
|
182
|
+
for file in files:
|
|
183
|
+
traces.extend(_read_ndjson(file))
|
|
184
|
+
return traces
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _group_by_session(traces: list[dict]) -> list[list[dict]]:
|
|
188
|
+
groups: dict[str, list[dict]] = {}
|
|
189
|
+
order: list[str] = []
|
|
190
|
+
for trace in traces:
|
|
191
|
+
sid = str(trace.get("session_id") or "")
|
|
192
|
+
if sid not in groups:
|
|
193
|
+
groups[sid] = []
|
|
194
|
+
order.append(sid)
|
|
195
|
+
groups[sid].append(trace)
|
|
196
|
+
return [
|
|
197
|
+
sorted(groups[sid], key=lambda t: str(t.get("ts") or "")) for sid in order
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _render_trail(findings: list, total_calls: int) -> None:
|
|
202
|
+
table = Table(header_style="bold")
|
|
203
|
+
table.add_column("Severity")
|
|
204
|
+
table.add_column("Pattern")
|
|
205
|
+
table.add_column("Tools Involved")
|
|
206
|
+
table.add_column("Message")
|
|
207
|
+
table.add_column("Confidence", justify="right")
|
|
208
|
+
for severity in _TRAIL_DISPLAY_ORDER:
|
|
209
|
+
for f in (x for x in findings if x.severity == severity):
|
|
210
|
+
table.add_row(
|
|
211
|
+
severity.upper(),
|
|
212
|
+
f.pattern,
|
|
213
|
+
" → ".join(f.calls_involved),
|
|
214
|
+
f.message,
|
|
215
|
+
f"{round(f.confidence * 100)}%",
|
|
216
|
+
style=_TRAIL_ROW_STYLE.get(severity),
|
|
217
|
+
)
|
|
218
|
+
console.print(table)
|
|
219
|
+
console.print(
|
|
220
|
+
f"[bold]{len(findings)}[/bold] findings across "
|
|
221
|
+
f"[bold]{total_calls}[/bold] tool calls analysed"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _render_findings(findings: list) -> None:
|
|
226
|
+
if not findings:
|
|
227
|
+
_ok("No findings.")
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
for severity in _SEVERITY_ORDER:
|
|
231
|
+
group = [f for f in findings if f.severity == severity]
|
|
232
|
+
if not group:
|
|
233
|
+
continue
|
|
234
|
+
table = Table(
|
|
235
|
+
title=f"{severity.upper()} ({len(group)})",
|
|
236
|
+
title_style=_SEVERITY_STYLE[severity],
|
|
237
|
+
header_style="bold",
|
|
238
|
+
title_justify="left",
|
|
239
|
+
)
|
|
240
|
+
table.add_column("Rule")
|
|
241
|
+
table.add_column("File")
|
|
242
|
+
table.add_column("Line", justify="right")
|
|
243
|
+
table.add_column("Message")
|
|
244
|
+
table.add_column("Snippet")
|
|
245
|
+
for f in sorted(group, key=lambda x: (x.file, x.line, x.rule)):
|
|
246
|
+
table.add_row(
|
|
247
|
+
f.rule,
|
|
248
|
+
Path(f.file).name,
|
|
249
|
+
str(f.line),
|
|
250
|
+
f.message,
|
|
251
|
+
f.snippet,
|
|
252
|
+
)
|
|
253
|
+
console.print(table)
|
|
254
|
+
|
|
255
|
+
high = sum(1 for f in findings if f.severity == "high")
|
|
256
|
+
if high:
|
|
257
|
+
_err(f"{high} high-severity finding(s).")
|
|
258
|
+
else:
|
|
259
|
+
_warn(f"{len(findings)} finding(s), none high severity.")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
if __name__ == "__main__":
|
|
263
|
+
main()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""dscan dashboard — local web UI for inspecting agent traces.
|
|
2
|
+
|
|
3
|
+
This package holds the dashboard's aiohttp server (:mod:`dscan.dashboard.server`)
|
|
4
|
+
and its single HTML template. The server reads NDJSON traces from
|
|
5
|
+
``~/.dscan/traces`` (or ``DSCAN_TRACES_DIR``) and exposes them at
|
|
6
|
+
``localhost:4321`` over a small JSON API plus a self-contained page that
|
|
7
|
+
renders sessions, redacted tool calls, and trail findings. It depends
|
|
8
|
+
only on ``aiohttp`` and ``aiofiles``; there are no external front-end
|
|
9
|
+
dependencies.
|
|
10
|
+
"""
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Dashboard web server.
|
|
2
|
+
|
|
3
|
+
A small aiohttp app that reads NDJSON traces and serves a local UI plus
|
|
4
|
+
a JSON API:
|
|
5
|
+
|
|
6
|
+
- ``GET /`` — the dashboard HTML with trace data injected.
|
|
7
|
+
- ``GET /api/traces`` — all traces (newest first).
|
|
8
|
+
- ``GET /api/traces/{session_id}`` — one session's detail.
|
|
9
|
+
|
|
10
|
+
Traces are read from ``DSCAN_TRACES_DIR`` (default ``~/.dscan/traces``).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import aiofiles
|
|
22
|
+
from aiohttp import web
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"read_traces",
|
|
26
|
+
"build_sessions",
|
|
27
|
+
"compute_stats",
|
|
28
|
+
"make_app",
|
|
29
|
+
"serve",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
_TEMPLATE = Path(__file__).parent / "templates" / "index.html"
|
|
33
|
+
|
|
34
|
+
# Typed application key for the configured traces directory.
|
|
35
|
+
_TRACES_DIR_KEY: web.AppKey[Any] = web.AppKey("traces_dir", object)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _resolve_dir(traces_dir: str | os.PathLike[str] | None) -> Path:
|
|
39
|
+
if traces_dir is not None:
|
|
40
|
+
return Path(traces_dir)
|
|
41
|
+
env = os.environ.get("DSCAN_TRACES_DIR")
|
|
42
|
+
if env:
|
|
43
|
+
return Path(env)
|
|
44
|
+
return Path.home() / ".dscan" / "traces"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _utc_today() -> str:
|
|
48
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def read_traces(traces_dir: str | os.PathLike[str] | None = None) -> list[dict[str, Any]]:
|
|
52
|
+
"""Read and parse every NDJSON trace, newest (by ``ts``) first.
|
|
53
|
+
|
|
54
|
+
Malformed lines are skipped rather than raising.
|
|
55
|
+
"""
|
|
56
|
+
directory = _resolve_dir(traces_dir)
|
|
57
|
+
if not directory.is_dir():
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
traces: list[dict[str, Any]] = []
|
|
61
|
+
for path in sorted(directory.glob("*.ndjson")):
|
|
62
|
+
async with aiofiles.open(path, encoding="utf-8") as f:
|
|
63
|
+
content = await f.read()
|
|
64
|
+
for line in content.splitlines():
|
|
65
|
+
line = line.strip()
|
|
66
|
+
if not line:
|
|
67
|
+
continue
|
|
68
|
+
try:
|
|
69
|
+
obj = json.loads(line)
|
|
70
|
+
except (json.JSONDecodeError, ValueError):
|
|
71
|
+
continue
|
|
72
|
+
if isinstance(obj, dict):
|
|
73
|
+
traces.append(obj)
|
|
74
|
+
|
|
75
|
+
traces.sort(key=lambda t: str(t.get("ts") or ""), reverse=True)
|
|
76
|
+
return traces
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build_sessions(traces: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
80
|
+
"""Group traces into sessions, newest session first."""
|
|
81
|
+
sessions: dict[str, dict[str, Any]] = {}
|
|
82
|
+
for trace in traces:
|
|
83
|
+
sid = str(trace.get("session_id") or "unknown")
|
|
84
|
+
session = sessions.get(sid)
|
|
85
|
+
if session is None:
|
|
86
|
+
session = {
|
|
87
|
+
"session_id": sid,
|
|
88
|
+
"agent": trace.get("agent", "agent"),
|
|
89
|
+
"ts": trace.get("ts", ""),
|
|
90
|
+
"flagged": False,
|
|
91
|
+
"calls": [],
|
|
92
|
+
}
|
|
93
|
+
sessions[sid] = session
|
|
94
|
+
session["calls"].append(trace)
|
|
95
|
+
if trace.get("flagged"):
|
|
96
|
+
session["flagged"] = True
|
|
97
|
+
if str(trace.get("ts") or "") > str(session["ts"] or ""):
|
|
98
|
+
session["ts"] = trace.get("ts", "")
|
|
99
|
+
|
|
100
|
+
result = list(sessions.values())
|
|
101
|
+
for session in result:
|
|
102
|
+
session["calls"].sort(key=lambda c: str(c.get("ts") or ""))
|
|
103
|
+
session["count"] = len(session["calls"])
|
|
104
|
+
result.sort(key=lambda s: str(s.get("ts") or ""), reverse=True)
|
|
105
|
+
return result
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def compute_stats(traces: list[dict[str, Any]]) -> dict[str, int]:
|
|
109
|
+
"""Top-bar stats: calls today, total flagged, agents active, and the
|
|
110
|
+
count of CRITICAL trail findings today (distinct from secrets flags)."""
|
|
111
|
+
today = _utc_today()
|
|
112
|
+
todays = [t for t in traces if str(t.get("ts") or "").startswith(today)]
|
|
113
|
+
return {
|
|
114
|
+
"total_calls_today": len(todays),
|
|
115
|
+
"flagged": sum(1 for t in traces if t.get("flagged")),
|
|
116
|
+
"agents_active": len({t.get("agent") for t in todays}),
|
|
117
|
+
"critical": sum(
|
|
118
|
+
1
|
|
119
|
+
for t in todays
|
|
120
|
+
for f in (t.get("trail_findings") or [])
|
|
121
|
+
if isinstance(f, dict) and f.get("severity") == "critical"
|
|
122
|
+
),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# --------------------------------------------------------------------------
|
|
127
|
+
# HTTP handlers
|
|
128
|
+
# --------------------------------------------------------------------------
|
|
129
|
+
async def _index(request: web.Request) -> web.Response:
|
|
130
|
+
traces = await read_traces(request.app[_TRACES_DIR_KEY])
|
|
131
|
+
async with aiofiles.open(_TEMPLATE, encoding="utf-8") as f:
|
|
132
|
+
template = await f.read()
|
|
133
|
+
data = json.dumps(traces).replace("<", "\\u003c")
|
|
134
|
+
html = template.replace("__DSCAN_DATA__", data)
|
|
135
|
+
return web.Response(text=html, content_type="text/html")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def _traces(request: web.Request) -> web.Response:
|
|
139
|
+
return web.json_response(await read_traces(request.app[_TRACES_DIR_KEY]))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def _session(request: web.Request) -> web.Response:
|
|
143
|
+
sid = request.match_info["session_id"]
|
|
144
|
+
sessions = build_sessions(await read_traces(request.app[_TRACES_DIR_KEY]))
|
|
145
|
+
for session in sessions:
|
|
146
|
+
if session["session_id"] == sid:
|
|
147
|
+
return web.json_response(session)
|
|
148
|
+
return web.json_response({"error": "session not found"}, status=404)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def make_app(traces_dir: str | os.PathLike[str] | None = None) -> web.Application:
|
|
152
|
+
"""Build the dashboard aiohttp application."""
|
|
153
|
+
app = web.Application()
|
|
154
|
+
app[_TRACES_DIR_KEY] = traces_dir
|
|
155
|
+
app.add_routes(
|
|
156
|
+
[
|
|
157
|
+
web.get("/", _index),
|
|
158
|
+
web.get("/api/traces", _traces),
|
|
159
|
+
web.get("/api/traces/{session_id}", _session),
|
|
160
|
+
]
|
|
161
|
+
)
|
|
162
|
+
return app
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def serve(
|
|
166
|
+
host: str = "127.0.0.1",
|
|
167
|
+
port: int = 4321,
|
|
168
|
+
*,
|
|
169
|
+
open_browser: bool = True,
|
|
170
|
+
traces_dir: str | os.PathLike[str] | None = None,
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Run the dashboard server (blocking)."""
|
|
173
|
+
app = make_app(traces_dir)
|
|
174
|
+
|
|
175
|
+
if open_browser:
|
|
176
|
+
|
|
177
|
+
async def _open(_: web.Application) -> None:
|
|
178
|
+
import webbrowser
|
|
179
|
+
|
|
180
|
+
webbrowser.open(f"http://{host}:{port}")
|
|
181
|
+
|
|
182
|
+
app.on_startup.append(_open)
|
|
183
|
+
|
|
184
|
+
web.run_app(app, host=host, port=port, print=None)
|