tracelink 0.2.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.
- tracelink/__init__.py +44 -0
- tracelink/_paths.py +41 -0
- tracelink/_time.py +27 -0
- tracelink/cli.py +74 -0
- tracelink/dashboard/__init__.py +38 -0
- tracelink/dashboard/_paths_resolve.py +102 -0
- tracelink/dashboard/api.py +107 -0
- tracelink/dashboard/server.py +248 -0
- tracelink/dashboard/static/app.js +460 -0
- tracelink/dashboard/static/index.html +104 -0
- tracelink/dashboard/static/style.css +448 -0
- tracelink/dev_endpoint.py +276 -0
- tracelink/middleware.py +102 -0
- tracelink/sanitize.py +37 -0
- tracelink/tracer.py +419 -0
- tracelink/types.py +102 -0
- tracelink-0.2.0.dist-info/METADATA +76 -0
- tracelink-0.2.0.dist-info/RECORD +21 -0
- tracelink-0.2.0.dist-info/WHEEL +5 -0
- tracelink-0.2.0.dist-info/entry_points.txt +2 -0
- tracelink-0.2.0.dist-info/top_level.txt +1 -0
tracelink/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TraceLink — Python core tracer.
|
|
3
|
+
|
|
4
|
+
Unified debug logging for FastAPI / Starlette apps. Writes to the same
|
|
5
|
+
NDJSON + readable file pair as the TypeScript core, so frontend and
|
|
6
|
+
backend events are correlated by traceId.
|
|
7
|
+
|
|
8
|
+
Quick start:
|
|
9
|
+
from tracelink import debug_tracer
|
|
10
|
+
debug_tracer.start_scope('delete-work')
|
|
11
|
+
debug_tracer.entry('router.py:delete', 'Delete work', {'id': 123}, scope='delete-work')
|
|
12
|
+
debug_tracer.end_scope('delete-work')
|
|
13
|
+
|
|
14
|
+
For automatic scope sync from frontend, also register the middleware:
|
|
15
|
+
from tracelink import TraceMiddleware
|
|
16
|
+
app.add_middleware(TraceMiddleware)
|
|
17
|
+
|
|
18
|
+
Layer system — see docs/LAYERS.md (English) / docs/LAYERS.zh-CN.md (Chinese)
|
|
19
|
+
for the rules on custom X-* layers.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from .tracer import DebugTracer, debug_tracer
|
|
23
|
+
from .types import TraceLayer
|
|
24
|
+
from .middleware import TraceMiddleware
|
|
25
|
+
from .types import (
|
|
26
|
+
BUILTIN_LAYERS,
|
|
27
|
+
is_builtin_layer,
|
|
28
|
+
is_custom_layer,
|
|
29
|
+
normalize_layer,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__version__ = "0.2.0"
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"DebugTracer",
|
|
36
|
+
"debug_tracer",
|
|
37
|
+
"TraceMiddleware",
|
|
38
|
+
"TraceLayer",
|
|
39
|
+
"BUILTIN_LAYERS",
|
|
40
|
+
"is_builtin_layer",
|
|
41
|
+
"is_custom_layer",
|
|
42
|
+
"normalize_layer",
|
|
43
|
+
"__version__",
|
|
44
|
+
]
|
tracelink/_paths.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Resolve log file paths from env / project root."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def resolve_log_dir() -> Path:
|
|
8
|
+
"""Resolve the project root where .cursor/ and .RandP/ live.
|
|
9
|
+
|
|
10
|
+
Priority:
|
|
11
|
+
1. TRACELINK_DIR env (absolute path)
|
|
12
|
+
2. Current working directory
|
|
13
|
+
3. Walk up from cwd until we find a package.json or pyproject.toml
|
|
14
|
+
"""
|
|
15
|
+
env_dir = os.getenv("TRACELINK_DIR")
|
|
16
|
+
if env_dir:
|
|
17
|
+
return Path(env_dir)
|
|
18
|
+
|
|
19
|
+
cwd = Path.cwd()
|
|
20
|
+
# Walk up looking for project markers
|
|
21
|
+
for parent in [cwd, *cwd.parents]:
|
|
22
|
+
if any((parent / m).exists() for m in ("package.json", "pyproject.toml", "Cargo.toml", "go.mod")):
|
|
23
|
+
return parent
|
|
24
|
+
|
|
25
|
+
return cwd
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def cursor_dir() -> Path:
|
|
29
|
+
return resolve_log_dir() / ".cursor"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def readable_dir() -> Path:
|
|
33
|
+
return resolve_log_dir() / ".RandP"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def ndjson_path() -> Path:
|
|
37
|
+
return cursor_dir() / "debug.log"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def readable_path() -> Path:
|
|
41
|
+
return readable_dir() / "debug.log"
|
tracelink/_time.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Time and ID helpers."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def now_ms() -> int:
|
|
9
|
+
return int(time.time() * 1000)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def format_ts(ts: int | None = None) -> str:
|
|
13
|
+
if ts is None:
|
|
14
|
+
ts = now_ms()
|
|
15
|
+
dt = datetime.fromtimestamp(ts / 1000)
|
|
16
|
+
return f"[{dt.strftime('%H:%M:%S')}.{dt.microsecond // 1000:03d}]"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def make_trace_id(scope: str) -> str:
|
|
20
|
+
"""Generate traceId with format `<scope>-<timestamp6>-<rand3>`."""
|
|
21
|
+
ts_suffix = str(now_ms())[-6:]
|
|
22
|
+
rand_suffix = os.urandom(2).hex()[:3]
|
|
23
|
+
return f"{scope}-{ts_suffix}-{rand_suffix}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def make_span_id(counter: int) -> str:
|
|
27
|
+
return f"span-{counter}"
|
tracelink/cli.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""TraceLink CLI — top-level `tracelink ...` entry point.
|
|
2
|
+
|
|
3
|
+
Subcommands (v0.3):
|
|
4
|
+
board Launch the dev-time 看板 (per docs/03-prd.md §10).
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
tracelink board [--port 8766] [--host 127.0.0.1] [--open]
|
|
8
|
+
python -m tracelink board
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import sys
|
|
15
|
+
from typing import List, Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _cmd_board(args: argparse.Namespace) -> int:
|
|
19
|
+
# Lazy import — keeps `tracelink --help` cheap and lets the dashboard
|
|
20
|
+
# module stay self-contained.
|
|
21
|
+
from .dashboard import serve_board
|
|
22
|
+
|
|
23
|
+
return serve_board(
|
|
24
|
+
host=args.host,
|
|
25
|
+
port=args.port,
|
|
26
|
+
auto_open=args.open,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
31
|
+
parser = argparse.ArgumentParser(
|
|
32
|
+
prog="tracelink",
|
|
33
|
+
description="TraceLink dev-time CLI (v0.3+)",
|
|
34
|
+
)
|
|
35
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
36
|
+
|
|
37
|
+
p_board = sub.add_parser(
|
|
38
|
+
"board",
|
|
39
|
+
help="Launch the dev-time 看板 (per docs/03-prd.md §10).",
|
|
40
|
+
description=(
|
|
41
|
+
"Serve a local browser-based board that reads the project's "
|
|
42
|
+
".cursor/debug.log and .RandP/debug.log files, lets you switch "
|
|
43
|
+
"scopes at runtime, and filter historical events by scope."
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
p_board.add_argument(
|
|
47
|
+
"--port",
|
|
48
|
+
type=int,
|
|
49
|
+
default=8766,
|
|
50
|
+
help="Port to bind the board server (default: 8766)",
|
|
51
|
+
)
|
|
52
|
+
p_board.add_argument(
|
|
53
|
+
"--host",
|
|
54
|
+
default="127.0.0.1",
|
|
55
|
+
help="Host to bind (default: 127.0.0.1 — localhost only by design)",
|
|
56
|
+
)
|
|
57
|
+
p_board.add_argument(
|
|
58
|
+
"--open",
|
|
59
|
+
action="store_true",
|
|
60
|
+
help="Open the board in your default browser after starting",
|
|
61
|
+
)
|
|
62
|
+
p_board.set_defaults(func=_cmd_board)
|
|
63
|
+
|
|
64
|
+
return parser
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
68
|
+
parser = build_parser()
|
|
69
|
+
args = parser.parse_args(argv)
|
|
70
|
+
return args.func(args)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
sys.exit(main())
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""TraceLink dev-time dashboard (board).
|
|
2
|
+
|
|
3
|
+
Public surface:
|
|
4
|
+
serve_board(...) — start the stdlib HTTP server (CLI uses this)
|
|
5
|
+
fastapi_router — drop-in FastAPI router (embed in your app)
|
|
6
|
+
|
|
7
|
+
The CLI is `tracelink board` (see `tracelink.cli`). The package directory
|
|
8
|
+
is `dashboard/` (architecture name); the user-facing command is `board`
|
|
9
|
+
(product name). This keeps the package aligned with future dashboard
|
|
10
|
+
expansion (analysis, AI summary, etc.) while the CLI stays short.
|
|
11
|
+
|
|
12
|
+
Static UI is bundled at `dashboard/static/`. No build step required.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .server import serve_board, main
|
|
16
|
+
from ._paths_resolve import (
|
|
17
|
+
scope_state_path,
|
|
18
|
+
load_scope_state,
|
|
19
|
+
save_scope_state,
|
|
20
|
+
is_scope_enabled,
|
|
21
|
+
scan_observed_scopes,
|
|
22
|
+
load_ndjson_events,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Optional FastAPI router (None if FastAPI not installed)
|
|
26
|
+
from .api import fastapi_router
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"serve_board",
|
|
30
|
+
"main",
|
|
31
|
+
"fastapi_router",
|
|
32
|
+
"scope_state_path",
|
|
33
|
+
"load_scope_state",
|
|
34
|
+
"save_scope_state",
|
|
35
|
+
"is_scope_enabled",
|
|
36
|
+
"scan_observed_scopes",
|
|
37
|
+
"load_ndjson_events",
|
|
38
|
+
]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Internal path resolvers for the dashboard.
|
|
2
|
+
|
|
3
|
+
Kept separate from server/api so both can import without circular deps.
|
|
4
|
+
Scope state lives at `.RandP/.scope_state.json` (sibling to debug.log),
|
|
5
|
+
deliberately OUTSIDE the log file pair (per PRD §10.5 — does not modify
|
|
6
|
+
log file schema).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, List, Set
|
|
15
|
+
|
|
16
|
+
from .._paths import ndjson_path, readable_dir
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
SCOPE_STATE_FILENAME = ".scope_state.json"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def scope_state_path() -> Path:
|
|
25
|
+
"""Path to the persisted scope state JSON file."""
|
|
26
|
+
return readable_dir() / SCOPE_STATE_FILENAME
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_scope_state() -> Dict[str, bool]:
|
|
30
|
+
"""Read scope state JSON. Empty dict == 'all on' (matches tracer default)."""
|
|
31
|
+
p = scope_state_path()
|
|
32
|
+
if not p.exists():
|
|
33
|
+
return {}
|
|
34
|
+
try:
|
|
35
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
36
|
+
except Exception:
|
|
37
|
+
return {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def save_scope_state(state: Dict[str, bool]) -> None:
|
|
41
|
+
p = scope_state_path()
|
|
42
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
p.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_scope_enabled(state: Dict[str, bool], scope: str) -> bool:
|
|
47
|
+
"""Mirror of DebugTracer._is_scope_enabled at the persisted layer.
|
|
48
|
+
|
|
49
|
+
Fallback order: explicit scope -> '*' default -> True (all on).
|
|
50
|
+
"""
|
|
51
|
+
if scope in state:
|
|
52
|
+
return bool(state[scope])
|
|
53
|
+
if "*" in state:
|
|
54
|
+
return bool(state["*"])
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def scan_observed_scopes() -> List[str]:
|
|
59
|
+
"""Walk the NDJSON file once, return unique scope names in encounter order."""
|
|
60
|
+
seen: List[str] = []
|
|
61
|
+
seen_set: Set[str] = set()
|
|
62
|
+
nd = ndjson_path()
|
|
63
|
+
if not nd.exists():
|
|
64
|
+
return seen
|
|
65
|
+
try:
|
|
66
|
+
with nd.open("r", encoding="utf-8") as f:
|
|
67
|
+
for line in f:
|
|
68
|
+
line = line.strip()
|
|
69
|
+
if not line:
|
|
70
|
+
continue
|
|
71
|
+
try:
|
|
72
|
+
obj = json.loads(line)
|
|
73
|
+
except Exception:
|
|
74
|
+
continue
|
|
75
|
+
sc = obj.get("scope")
|
|
76
|
+
if sc and sc not in seen_set:
|
|
77
|
+
seen_set.add(sc)
|
|
78
|
+
seen.append(sc)
|
|
79
|
+
except Exception as e:
|
|
80
|
+
logger.debug("scan_observed_scopes failed: %s", e)
|
|
81
|
+
return seen
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load_ndjson_events() -> List[Dict[str, Any]]:
|
|
85
|
+
"""Load every parseable event from the NDJSON file (used for tests)."""
|
|
86
|
+
nd = ndjson_path()
|
|
87
|
+
if not nd.exists():
|
|
88
|
+
return []
|
|
89
|
+
out: List[Dict[str, Any]] = []
|
|
90
|
+
try:
|
|
91
|
+
with nd.open("r", encoding="utf-8") as f:
|
|
92
|
+
for line in f:
|
|
93
|
+
line = line.strip()
|
|
94
|
+
if not line:
|
|
95
|
+
continue
|
|
96
|
+
try:
|
|
97
|
+
out.append(json.loads(line))
|
|
98
|
+
except Exception:
|
|
99
|
+
continue
|
|
100
|
+
except Exception:
|
|
101
|
+
return []
|
|
102
|
+
return out
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""FastAPI router for the TraceLink dashboard.
|
|
2
|
+
|
|
3
|
+
Same endpoints as `server.py` — drop into a FastAPI app for projects that
|
|
4
|
+
already have one running. The standalone `tracelink board` CLI uses the
|
|
5
|
+
stdlib server in `server.py`, not this router.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
from tracelink.dashboard import fastapi_router
|
|
10
|
+
|
|
11
|
+
app = FastAPI()
|
|
12
|
+
app.include_router(fastapi_router)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .._paths import cursor_dir, ndjson_path, readable_dir, readable_path
|
|
21
|
+
from ._paths_resolve import (
|
|
22
|
+
is_scope_enabled,
|
|
23
|
+
load_scope_state,
|
|
24
|
+
save_scope_state,
|
|
25
|
+
scan_observed_scopes,
|
|
26
|
+
scope_state_path,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
fastapi_router = None
|
|
31
|
+
try:
|
|
32
|
+
from fastapi import APIRouter, Request, Response
|
|
33
|
+
|
|
34
|
+
fastapi_router = APIRouter(prefix="/__board/api", tags=["tracelink-board"])
|
|
35
|
+
|
|
36
|
+
@fastapi_router.get("/logs")
|
|
37
|
+
async def _logs(request: Request) -> Response:
|
|
38
|
+
fmt = request.query_params.get("format", "ndjson").lower()
|
|
39
|
+
if fmt == "readable":
|
|
40
|
+
rd = readable_path()
|
|
41
|
+
content = rd.read_text(encoding="utf-8") if rd.exists() else ""
|
|
42
|
+
return Response(content, media_type="text/plain; charset=utf-8")
|
|
43
|
+
nd = ndjson_path()
|
|
44
|
+
content = nd.read_text(encoding="utf-8") if nd.exists() else ""
|
|
45
|
+
return Response(content, media_type="application/x-ndjson; charset=utf-8")
|
|
46
|
+
|
|
47
|
+
@fastapi_router.get("/scopes")
|
|
48
|
+
async def _scopes() -> Response:
|
|
49
|
+
observed = scan_observed_scopes()
|
|
50
|
+
state = load_scope_state()
|
|
51
|
+
projected = {sc: is_scope_enabled(state, sc) for sc in observed}
|
|
52
|
+
projected["*"] = is_scope_enabled(state, "*")
|
|
53
|
+
return Response(
|
|
54
|
+
json.dumps({"scopes": projected, "observed": observed}, ensure_ascii=False),
|
|
55
|
+
media_type="application/json; charset=utf-8",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
@fastapi_router.get("/paths")
|
|
59
|
+
async def _paths() -> Response:
|
|
60
|
+
return Response(
|
|
61
|
+
json.dumps({
|
|
62
|
+
"cursor_dir": str(cursor_dir()),
|
|
63
|
+
"readable_dir": str(readable_dir()),
|
|
64
|
+
"ndjson_path": str(ndjson_path()),
|
|
65
|
+
"readable_path": str(readable_path()),
|
|
66
|
+
"scope_state": str(scope_state_path()),
|
|
67
|
+
}, ensure_ascii=False),
|
|
68
|
+
media_type="application/json; charset=utf-8",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
@fastapi_router.post("/scopes")
|
|
72
|
+
async def _set_scope(request: Request) -> Response:
|
|
73
|
+
try:
|
|
74
|
+
payload: Any = await request.json()
|
|
75
|
+
except Exception:
|
|
76
|
+
return Response('{"error":"bad json"}', status_code=400,
|
|
77
|
+
media_type="application/json")
|
|
78
|
+
scope = payload.get("scope")
|
|
79
|
+
enabled = payload.get("enabled")
|
|
80
|
+
if not isinstance(scope, str) or not isinstance(enabled, bool):
|
|
81
|
+
return Response('{"error":"bad payload"}', status_code=400,
|
|
82
|
+
media_type="application/json")
|
|
83
|
+
state = load_scope_state()
|
|
84
|
+
state[scope] = enabled
|
|
85
|
+
save_scope_state(state)
|
|
86
|
+
try:
|
|
87
|
+
from ..tracer import debug_tracer
|
|
88
|
+
if enabled:
|
|
89
|
+
debug_tracer.enable_scope(scope)
|
|
90
|
+
else:
|
|
91
|
+
debug_tracer.disable_scope(scope)
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
return Response('{"ok":true}', media_type="application/json")
|
|
95
|
+
|
|
96
|
+
@fastapi_router.delete("/logs")
|
|
97
|
+
async def _clear() -> Response:
|
|
98
|
+
for p in (ndjson_path(), readable_path()):
|
|
99
|
+
try:
|
|
100
|
+
if p.exists():
|
|
101
|
+
p.unlink()
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
return Response(status_code=204)
|
|
105
|
+
|
|
106
|
+
except ImportError:
|
|
107
|
+
pass
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Stdlib HTTP server for the TraceLink dashboard.
|
|
2
|
+
|
|
3
|
+
Used by the `tracelink board` CLI. Endpoints:
|
|
4
|
+
|
|
5
|
+
GET /__board/api/logs?format=ndjson|readable
|
|
6
|
+
GET /__board/api/scopes
|
|
7
|
+
GET /__board/api/paths
|
|
8
|
+
POST /__board/api/scopes {scope, enabled}
|
|
9
|
+
DELETE /__board/api/logs
|
|
10
|
+
|
|
11
|
+
GET /__board/... static UI (index.html, app.js, style.css)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import sys
|
|
20
|
+
import threading
|
|
21
|
+
import webbrowser
|
|
22
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Dict, List, Optional
|
|
25
|
+
from urllib.parse import parse_qs, urlparse
|
|
26
|
+
|
|
27
|
+
from .._paths import cursor_dir, ndjson_path, readable_dir, readable_path
|
|
28
|
+
from ._paths_resolve import (
|
|
29
|
+
is_scope_enabled,
|
|
30
|
+
load_ndjson_events,
|
|
31
|
+
load_scope_state,
|
|
32
|
+
save_scope_state,
|
|
33
|
+
scan_observed_scopes,
|
|
34
|
+
scope_state_path,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
API_PREFIX = "/__board/api"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Static file serving (sandboxed to dashboard/static/)
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
def _static_root() -> Path:
|
|
47
|
+
return Path(__file__).parent / "static"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _resolve_static_path(url_path: str) -> Optional[Path]:
|
|
51
|
+
"""Map '/' -> static/index.html, '/app.js' -> static/app.js. Block traversal."""
|
|
52
|
+
rel = url_path.lstrip("/")
|
|
53
|
+
if rel in ("", "/"):
|
|
54
|
+
rel = "index.html"
|
|
55
|
+
root = _static_root().resolve()
|
|
56
|
+
target = (root / rel).resolve()
|
|
57
|
+
try:
|
|
58
|
+
target.relative_to(root)
|
|
59
|
+
except ValueError:
|
|
60
|
+
return None # path traversal attempt
|
|
61
|
+
return target if target.is_file() else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Handler
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
def make_handler() -> type:
|
|
69
|
+
class BoardHandler(BaseHTTPRequestHandler):
|
|
70
|
+
# Quieter logs (override for full tracing if needed)
|
|
71
|
+
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
# ----- helpers -----
|
|
75
|
+
def _send_json(self, code: int, body: Any) -> None:
|
|
76
|
+
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
77
|
+
self.send_response(code)
|
|
78
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
79
|
+
self.send_header("Content-Length", str(len(data)))
|
|
80
|
+
self.send_header("Cache-Control", "no-store")
|
|
81
|
+
self.end_headers()
|
|
82
|
+
self.wfile.write(data)
|
|
83
|
+
|
|
84
|
+
def _send_bytes(self, code: int, body: bytes, ctype: str) -> None:
|
|
85
|
+
self.send_response(code)
|
|
86
|
+
self.send_header("Content-Type", ctype)
|
|
87
|
+
self.send_header("Content-Length", str(len(body)))
|
|
88
|
+
self.send_header("Cache-Control", "no-store")
|
|
89
|
+
self.end_headers()
|
|
90
|
+
self.wfile.write(body)
|
|
91
|
+
|
|
92
|
+
# ----- GET -----
|
|
93
|
+
def do_GET(self) -> None: # noqa: N802
|
|
94
|
+
if self.path.startswith(f"{API_PREFIX}/logs"):
|
|
95
|
+
qs = parse_qs(urlparse(self.path).query)
|
|
96
|
+
fmt = (qs.get("format", ["ndjson"])[0] or "ndjson").lower()
|
|
97
|
+
if fmt == "readable":
|
|
98
|
+
rd = readable_path()
|
|
99
|
+
content = rd.read_text(encoding="utf-8") if rd.exists() else ""
|
|
100
|
+
self._send_bytes(200, content.encode("utf-8"),
|
|
101
|
+
"text/plain; charset=utf-8")
|
|
102
|
+
else:
|
|
103
|
+
nd = ndjson_path()
|
|
104
|
+
content = nd.read_text(encoding="utf-8") if nd.exists() else ""
|
|
105
|
+
self._send_bytes(200, content.encode("utf-8"),
|
|
106
|
+
"application/x-ndjson; charset=utf-8")
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
if self.path == f"{API_PREFIX}/scopes":
|
|
110
|
+
observed = scan_observed_scopes()
|
|
111
|
+
state = load_scope_state()
|
|
112
|
+
projected: Dict[str, bool] = {sc: is_scope_enabled(state, sc) for sc in observed}
|
|
113
|
+
projected["*"] = is_scope_enabled(state, "*")
|
|
114
|
+
self._send_json(200, {"scopes": projected, "observed": observed})
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
if self.path == f"{API_PREFIX}/paths":
|
|
118
|
+
self._send_json(200, {
|
|
119
|
+
"cursor_dir": str(cursor_dir()),
|
|
120
|
+
"readable_dir": str(readable_dir()),
|
|
121
|
+
"ndjson_path": str(ndjson_path()),
|
|
122
|
+
"readable_path": str(readable_path()),
|
|
123
|
+
"scope_state": str(scope_state_path()),
|
|
124
|
+
})
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
# ----- static UI -----
|
|
128
|
+
static = _resolve_static_path(self.path)
|
|
129
|
+
if static is None:
|
|
130
|
+
self._send_bytes(404, b"not found", "text/plain; charset=utf-8")
|
|
131
|
+
return
|
|
132
|
+
ctype = (
|
|
133
|
+
"text/html; charset=utf-8" if static.suffix == ".html" else
|
|
134
|
+
"application/javascript; charset=utf-8" if static.suffix == ".js" else
|
|
135
|
+
"text/css; charset=utf-8" if static.suffix == ".css" else
|
|
136
|
+
"application/octet-stream"
|
|
137
|
+
)
|
|
138
|
+
try:
|
|
139
|
+
self._send_bytes(200, static.read_bytes(), ctype)
|
|
140
|
+
except FileNotFoundError:
|
|
141
|
+
self._send_bytes(404, b"not found", "text/plain; charset=utf-8")
|
|
142
|
+
|
|
143
|
+
# ----- POST -----
|
|
144
|
+
def do_POST(self) -> None: # noqa: N802
|
|
145
|
+
if self.path != f"{API_PREFIX}/scopes":
|
|
146
|
+
self._send_bytes(404, b"not found", "text/plain; charset=utf-8")
|
|
147
|
+
return
|
|
148
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
149
|
+
raw = self.rfile.read(length).decode("utf-8") if length else ""
|
|
150
|
+
try:
|
|
151
|
+
payload = json.loads(raw) if raw else {}
|
|
152
|
+
except Exception:
|
|
153
|
+
self._send_json(400, {"error": "bad json"})
|
|
154
|
+
return
|
|
155
|
+
scope = payload.get("scope")
|
|
156
|
+
enabled = payload.get("enabled")
|
|
157
|
+
if not isinstance(scope, str) or not isinstance(enabled, bool):
|
|
158
|
+
self._send_json(400, {"error": "expected {scope: str, enabled: bool}"})
|
|
159
|
+
return
|
|
160
|
+
state = load_scope_state()
|
|
161
|
+
state[scope] = bool(enabled)
|
|
162
|
+
save_scope_state(state)
|
|
163
|
+
# Best-effort live push to in-process tracer
|
|
164
|
+
try:
|
|
165
|
+
from ..tracer import debug_tracer
|
|
166
|
+
if enabled:
|
|
167
|
+
debug_tracer.enable_scope(scope)
|
|
168
|
+
else:
|
|
169
|
+
debug_tracer.disable_scope(scope)
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
self._send_json(200, {"ok": True, "scope": scope, "enabled": enabled})
|
|
173
|
+
|
|
174
|
+
# ----- DELETE -----
|
|
175
|
+
def do_DELETE(self) -> None: # noqa: N802
|
|
176
|
+
if self.path != f"{API_PREFIX}/logs":
|
|
177
|
+
self._send_bytes(404, b"not found", "text/plain; charset=utf-8")
|
|
178
|
+
return
|
|
179
|
+
for p in (ndjson_path(), readable_path()):
|
|
180
|
+
try:
|
|
181
|
+
if p.exists():
|
|
182
|
+
p.unlink()
|
|
183
|
+
except Exception as e:
|
|
184
|
+
logger.debug("delete failed: %s", e)
|
|
185
|
+
self.send_response(204)
|
|
186
|
+
self.end_headers()
|
|
187
|
+
|
|
188
|
+
return BoardHandler
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
# Standalone runner
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
def serve_board(
|
|
196
|
+
host: str = "127.0.0.1",
|
|
197
|
+
port: int = 8766,
|
|
198
|
+
auto_open: bool = False,
|
|
199
|
+
) -> int:
|
|
200
|
+
"""Start the dashboard server. Blocks until KeyboardInterrupt."""
|
|
201
|
+
if not _static_root().is_dir():
|
|
202
|
+
print(
|
|
203
|
+
f"[tracelink.dashboard] ERROR: static dir not found: {_static_root()}\n"
|
|
204
|
+
" → Did the dashboard/static/ folder ship with the package?",
|
|
205
|
+
file=sys.stderr,
|
|
206
|
+
)
|
|
207
|
+
return 2
|
|
208
|
+
handler = make_handler()
|
|
209
|
+
try:
|
|
210
|
+
server = ThreadingHTTPServer((host, port), handler)
|
|
211
|
+
except OSError as e:
|
|
212
|
+
print(f"[tracelink.dashboard] bind {host}:{port} failed: {e}", file=sys.stderr)
|
|
213
|
+
return 1
|
|
214
|
+
|
|
215
|
+
url = f"http://{host}:{port}/"
|
|
216
|
+
print(
|
|
217
|
+
f"[tracelink.dashboard] listening on {url}\n"
|
|
218
|
+
f" ndjson : {ndjson_path()}\n"
|
|
219
|
+
f" readable : {readable_path()}\n"
|
|
220
|
+
f" state : {scope_state_path()}\n"
|
|
221
|
+
" press Ctrl-C to stop.",
|
|
222
|
+
file=sys.stderr,
|
|
223
|
+
)
|
|
224
|
+
if auto_open:
|
|
225
|
+
threading.Timer(0.4, lambda: webbrowser.open(url)).start()
|
|
226
|
+
try:
|
|
227
|
+
server.serve_forever()
|
|
228
|
+
except KeyboardInterrupt:
|
|
229
|
+
print("\n[tracelink.dashboard] shutting down.", file=sys.stderr)
|
|
230
|
+
finally:
|
|
231
|
+
server.server_close()
|
|
232
|
+
return 0
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
236
|
+
parser = argparse.ArgumentParser(
|
|
237
|
+
prog="python -m tracelink.dashboard",
|
|
238
|
+
description="TraceLink dev-time dashboard (per docs/03-prd.md §10).",
|
|
239
|
+
)
|
|
240
|
+
parser.add_argument("--port", type=int, default=8766)
|
|
241
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
242
|
+
parser.add_argument("--open", action="store_true")
|
|
243
|
+
args = parser.parse_args(argv)
|
|
244
|
+
return serve_board(args.host, args.port, args.open)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
if __name__ == "__main__":
|
|
248
|
+
sys.exit(main())
|