33from __future__ import annotations
44
55from html import escape
6- from typing import Any
6+ from typing import Any , Sequence
77
88
99_PAGE_CSS = """
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+
223382def 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
288532def _render_metric_card (metric : dict [str , Any ]) -> str :
0 commit comments