Skip to content

Commit b67f58a

Browse files
committed
metrics: move repo switching into browser dashboard
1 parent f516332 commit b67f58a

8 files changed

Lines changed: 381 additions & 48 deletions

File tree

‎app/periphery/cli/main.py‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ class _HelpFormatter(argparse.RawDescriptionHelpFormatter):
260260

261261
_METRICS_HELP = dedent(
262262
"""\
263-
Generate lightweight metrics snapshots, write local artifacts, and browse repos with arrow keys.
263+
Generate lightweight metrics snapshots, write local artifacts, and open one browser dashboard that switches repos with arrow keys.
264264
265265
Example:
266266
shellbrain metrics
@@ -385,7 +385,7 @@ def build_parser() -> argparse.ArgumentParser:
385385
metrics_parser = subparsers.add_parser(
386386
"metrics",
387387
help="Browse Shellbrain metrics across repos.",
388-
description="Generate local metrics snapshots and browse one repo at a time in the terminal.",
388+
description="Generate local metrics snapshots and open one browser dashboard for all repos.",
389389
epilog=_METRICS_HELP,
390390
formatter_class=_HelpFormatter,
391391
)
@@ -869,10 +869,9 @@ def _run_metrics_command(args: argparse.Namespace) -> int:
869869
from app.boot.admin_db import get_optional_admin_db_dsn
870870
from app.boot.db import get_optional_db_dsn
871871
from app.periphery.db.engine import get_engine
872-
from app.periphery.metrics.artifacts import write_metrics_artifacts
872+
from app.periphery.metrics.artifacts import write_metrics_artifacts, write_metrics_index_artifact
873873
from app.periphery.metrics.browser import open_metrics_dashboard
874-
from app.periphery.metrics.pager import present_metrics_repo_pager
875-
from app.periphery.metrics.render_html import render_metrics_dashboard
874+
from app.periphery.metrics.render_html import render_metrics_browser_dashboard, render_metrics_dashboard
876875
from app.periphery.metrics.service import build_metrics_snapshot, list_metrics_repo_ids
877876

878877
if bool(getattr(args, "repo_id", None) or getattr(args, "repo_root", None) or getattr(args, "no_sync", False)):
@@ -901,11 +900,17 @@ def _run_metrics_command(args: argparse.Namespace) -> int:
901900
paths = write_metrics_artifacts(repo_id=repo_id, snapshot=snapshot, html=html)
902901
entries.append({"snapshot": snapshot, "paths": paths})
903902

903+
overview_path = write_metrics_index_artifact(
904+
html=render_metrics_browser_dashboard([entry["snapshot"] for entry in entries])
905+
)
906+
opened_dashboard = bool(open_metrics_dashboard(overview_path))
904907
print(f"Generated Shellbrain metrics for {len(entries)} repos")
905908
print(f"Window: last {window_days} days")
906909
print("Artifacts: updated in place")
907-
print("Browser: press 'o' in the viewer to open the current dashboard")
908-
present_metrics_repo_pager(entries=entries, open_dashboard=open_metrics_dashboard)
910+
if opened_dashboard:
911+
print("Browser: opened dashboard; use left/right arrow keys in the browser to switch repos")
912+
else:
913+
print(f"Browser: could not open automatically; open {overview_path}")
909914
return 0
910915
except (RuntimeError, ValueError) as exc:
911916
print(str(exc), file=sys.stderr)

‎app/periphery/metrics/artifacts.py‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,18 @@
1414
_NON_ALNUM = re.compile(r"[^a-z0-9]+")
1515

1616

17+
def get_metrics_root_dir() -> Path:
18+
"""Return the machine-owned root directory for metrics browser artifacts."""
19+
20+
return get_shellbrain_home() / "reports" / "metrics"
21+
22+
1723
def get_metrics_artifact_dir(*, repo_id: str) -> Path:
1824
"""Return the machine-owned artifact directory for one repo's metrics outputs."""
1925

2026
normalized = _NON_ALNUM.sub("-", repo_id.lower()).strip("-") or "repo"
2127
digest = hashlib.sha1(repo_id.encode("utf-8")).hexdigest()[:8]
22-
return get_shellbrain_home() / "reports" / "metrics" / f"{normalized}-{digest}"
28+
return get_metrics_root_dir() / f"{normalized}-{digest}"
2329

2430

2531
def write_metrics_artifacts(*, repo_id: str, snapshot: dict[str, Any], html: str) -> dict[str, Path]:
@@ -42,3 +48,14 @@ def write_metrics_artifacts(*, repo_id: str, snapshot: dict[str, Any], html: str
4248
"md_path": md_path,
4349
"html_path": html_path,
4450
}
51+
52+
53+
def write_metrics_index_artifact(*, html: str) -> Path:
54+
"""Write the combined browser dashboard for all repo metrics snapshots."""
55+
56+
artifact_dir = get_metrics_root_dir()
57+
artifact_dir.mkdir(parents=True, exist_ok=True)
58+
59+
html_path = artifact_dir / "index.html"
60+
html_path.write_text(html, encoding="utf-8")
61+
return html_path

‎app/periphery/metrics/render_html.py‎

Lines changed: 260 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
from html import escape
6-
from typing import Any
6+
from typing import Any, Sequence
77

88

99
_PAGE_CSS = """
@@ -220,9 +220,266 @@
220220
"""
221221

222222

223+
_BROWSER_CSS = """
224+
.browser-shell {
225+
max-width: 1440px;
226+
margin: 0 auto;
227+
padding: 24px 28px 64px;
228+
}
229+
230+
.browser-masthead {
231+
display: flex;
232+
justify-content: space-between;
233+
gap: 24px;
234+
align-items: flex-end;
235+
padding-bottom: 18px;
236+
border-bottom: 1px solid var(--border);
237+
}
238+
239+
.browser-masthead h1 {
240+
font-size: 28px;
241+
font-weight: 700;
242+
letter-spacing: -0.03em;
243+
}
244+
245+
.browser-meta {
246+
display: flex;
247+
gap: 8px;
248+
align-items: center;
249+
flex-wrap: wrap;
250+
}
251+
252+
.browser-keyhint {
253+
margin-top: 12px;
254+
color: var(--muted);
255+
font-size: 12px;
256+
}
257+
258+
.repo-tab-row {
259+
margin-top: 18px;
260+
display: flex;
261+
gap: 8px;
262+
flex-wrap: wrap;
263+
}
264+
265+
.repo-tab {
266+
appearance: none;
267+
border: 1px solid var(--border);
268+
border-radius: 999px;
269+
background: var(--surface);
270+
color: var(--text-dim);
271+
padding: 8px 12px;
272+
font: inherit;
273+
font-size: 12px;
274+
cursor: pointer;
275+
transition: background 140ms ease, color 140ms ease, border-color 140ms ease;
276+
}
277+
278+
.repo-tab.is-active {
279+
background: var(--accent);
280+
border-color: var(--accent);
281+
color: white;
282+
}
283+
284+
.repo-browser {
285+
margin-top: 20px;
286+
}
287+
288+
.repo-panel[hidden] {
289+
display: none !important;
290+
}
291+
"""
292+
293+
294+
_BROWSER_SCRIPT = """
295+
<script>
296+
(() => {
297+
const panels = Array.from(document.querySelectorAll(".repo-panel"));
298+
const tabs = Array.from(document.querySelectorAll(".repo-tab"));
299+
const position = document.getElementById("repo-position");
300+
const status = document.getElementById("repo-status");
301+
if (!panels.length) {
302+
return;
303+
}
304+
305+
let index = 0;
306+
307+
const show = (nextIndex) => {
308+
index = (nextIndex + panels.length) % panels.length;
309+
panels.forEach((panel, panelIndex) => {
310+
const active = panelIndex === index;
311+
panel.hidden = !active;
312+
panel.setAttribute("aria-hidden", active ? "false" : "true");
313+
});
314+
tabs.forEach((tab, tabIndex) => {
315+
const active = tabIndex === index;
316+
tab.classList.toggle("is-active", active);
317+
tab.setAttribute("aria-pressed", active ? "true" : "false");
318+
tab.tabIndex = active ? 0 : -1;
319+
});
320+
321+
const activePanel = panels[index];
322+
const repoId = activePanel.dataset.repoId || "";
323+
const tone = activePanel.dataset.tone || "neutral";
324+
const statusText = activePanel.dataset.status || "unknown";
325+
326+
if (position) {
327+
position.textContent = `${index + 1} / ${panels.length}`;
328+
}
329+
if (status) {
330+
status.className = `pill ${tone}`;
331+
status.textContent = statusText.replaceAll("_", " ");
332+
status.setAttribute("aria-label", `${repoId} status ${statusText}`);
333+
}
334+
document.title = `Shellbrain Metrics - ${repoId}`;
335+
if (window.history && window.history.replaceState) {
336+
window.history.replaceState(null, "", `#repo-${index + 1}`);
337+
}
338+
};
339+
340+
document.addEventListener("keydown", (event) => {
341+
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) {
342+
return;
343+
}
344+
345+
const target = event.target;
346+
const tagName = target && target.tagName ? target.tagName.toLowerCase() : "";
347+
if (tagName === "input" || tagName === "textarea" || tagName === "select") {
348+
return;
349+
}
350+
if (target && target.isContentEditable) {
351+
return;
352+
}
353+
354+
if (event.key === "ArrowRight") {
355+
event.preventDefault();
356+
show(index + 1);
357+
}
358+
if (event.key === "ArrowLeft") {
359+
event.preventDefault();
360+
show(index - 1);
361+
}
362+
});
363+
364+
tabs.forEach((tab, tabIndex) => {
365+
tab.addEventListener("click", () => show(tabIndex));
366+
});
367+
368+
const hashMatch = window.location.hash.match(/^#repo-(\\d+)$/);
369+
if (hashMatch) {
370+
const parsed = Number(hashMatch[1]);
371+
if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= panels.length) {
372+
index = parsed - 1;
373+
}
374+
}
375+
376+
show(index);
377+
})();
378+
</script>
379+
"""
380+
381+
223382
def render_metrics_dashboard(snapshot: dict[str, Any]) -> str:
224383
"""Render one self-contained HTML dashboard from a metrics snapshot."""
225384

385+
return f"""<!DOCTYPE html>
386+
<html lang="en">
387+
<head>
388+
<meta charset="UTF-8">
389+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
390+
<title>Shellbrain Metrics</title>
391+
<link rel="icon" href="data:,">
392+
<style>{_PAGE_CSS}</style>
393+
</head>
394+
<body>
395+
<div class="shell">
396+
{_render_snapshot_content(snapshot)}
397+
</div>
398+
</body>
399+
</html>
400+
"""
401+
402+
403+
def render_metrics_browser_dashboard(snapshots: Sequence[dict[str, Any]]) -> str:
404+
"""Render one browser dashboard that switches repos with in-page arrow-key navigation."""
405+
406+
normalized = [snapshot for snapshot in snapshots if isinstance(snapshot, dict)]
407+
if not normalized:
408+
return """<!DOCTYPE html>
409+
<html lang="en">
410+
<head>
411+
<meta charset="UTF-8">
412+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
413+
<title>Shellbrain Metrics</title>
414+
<link rel="icon" href="data:,">
415+
<style>body{font-family:system-ui,sans-serif;padding:24px}</style>
416+
</head>
417+
<body><p>No metrics snapshots are available.</p></body>
418+
</html>
419+
"""
420+
421+
first_snapshot = normalized[0]
422+
tabs = "".join(
423+
f"<button type=\"button\" class=\"repo-tab{' is-active' if index == 0 else ''}\" aria-pressed=\"{'true' if index == 0 else 'false'}\">{escape(str(snapshot['repo_id']))}</button>"
424+
for index, snapshot in enumerate(normalized)
425+
)
426+
panels = "".join(
427+
_render_browser_panel(snapshot=snapshot, index=index)
428+
for index, snapshot in enumerate(normalized)
429+
)
430+
first_tone = _tone_for_status(str(first_snapshot["status"]))
431+
432+
return f"""<!DOCTYPE html>
433+
<html lang="en">
434+
<head>
435+
<meta charset="UTF-8">
436+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
437+
<title>Shellbrain Metrics - {escape(str(first_snapshot["repo_id"]))}</title>
438+
<link rel="icon" href="data:,">
439+
<style>{_PAGE_CSS}{_BROWSER_CSS}</style>
440+
</head>
441+
<body>
442+
<div class="browser-shell">
443+
<header class="browser-masthead">
444+
<div>
445+
<p class="eyebrow">Shellbrain Metrics</p>
446+
<h1>All Repos</h1>
447+
<div class="subtle">Opened automatically. Use the left and right arrow keys in this page to switch repos.</div>
448+
</div>
449+
<div class="browser-meta">
450+
<span id="repo-position" class="pill neutral">1 / {len(normalized)}</span>
451+
<span id="repo-status" class="pill {first_tone}">{escape(str(first_snapshot["status"]).replace("_", " "))}</span>
452+
</div>
453+
</header>
454+
<div class="browser-keyhint">Left/Right arrow keys switch repos in this page. No terminal input is required.</div>
455+
<nav class="repo-tab-row" aria-label="Tracked repos">
456+
{tabs}
457+
</nav>
458+
<main class="repo-browser">
459+
{panels}
460+
</main>
461+
</div>
462+
{_BROWSER_SCRIPT}
463+
</body>
464+
</html>
465+
"""
466+
467+
468+
def _render_browser_panel(*, snapshot: dict[str, Any], index: int) -> str:
469+
"""Render one repo panel for the multi-repo browser dashboard."""
470+
471+
tone = _tone_for_status(str(snapshot["status"]))
472+
hidden_attr = "" if index == 0 else " hidden"
473+
return f"""
474+
<section class="repo-panel" data-repo-id="{escape(str(snapshot["repo_id"]))}" data-status="{escape(str(snapshot["status"]))}" data-tone="{tone}" aria-hidden="{'false' if index == 0 else 'true'}"{hidden_attr}>
475+
{_render_snapshot_content(snapshot)}
476+
</section>
477+
"""
478+
479+
480+
def _render_snapshot_content(snapshot: dict[str, Any]) -> str:
481+
"""Render the shared snapshot content used by single-repo and multi-repo dashboards."""
482+
226483
status_tone = _tone_for_status(str(snapshot["status"]))
227484
alert_html = ""
228485
if snapshot["alerts"]:
@@ -238,17 +495,7 @@ def render_metrics_dashboard(snapshot: dict[str, Any]) -> str:
238495
generated_at = escape(str(snapshot["generated_at"]).replace("T", " ").replace("+00:00", " UTC"))
239496
current_window = snapshot["current_window"]
240497

241-
return f"""<!DOCTYPE html>
242-
<html lang="en">
243-
<head>
244-
<meta charset="UTF-8">
245-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
246-
<title>Shellbrain Metrics</title>
247-
<link rel="icon" href="data:,">
248-
<style>{_PAGE_CSS}</style>
249-
</head>
250-
<body>
251-
<div class="shell">
498+
return f"""
252499
<section class="status-strip {status_tone}">
253500
<div>
254501
<p class="eyebrow">Shellbrain Metrics</p>
@@ -279,10 +526,7 @@ def render_metrics_dashboard(snapshot: dict[str, Any]) -> str:
279526
{metric_cards}
280527
</section>
281528
</main>
282-
</div>
283-
</body>
284-
</html>
285-
"""
529+
"""
286530

287531

288532
def _render_metric_card(metric: dict[str, Any]) -> str:

0 commit comments

Comments
 (0)