refactor(frontend): add createTabStrip + migrate logs sub-tabs (#1464 step 1)
Adds @hive/shared/tabs.js — createTabStrip(tabbar, {defaultId, onShow}):
a hash-routed tab strip that resolves controls by `[data-tab="<id>"]`
inside the passed container and panels by `[data-tab-pane="<id>"]`,
toggling `.hive-tab--active` + aria-selected + the panel's `hidden`, and
firing onShow(id) for per-tab side-effects. Always hash-routed (deep-
linkable + back/forward), no element-resolver callbacks (convention over
config), per the #1464 design review.
Wires it up: @hive/shared exports `./tabs.js` + `./tabs.css`, and
common.css @imports tabs.css (component structure, not a swap target, so
inlining is fine — unlike theme.css).
Migrates the logs sub-tabs as the first consumer: markup uses
`.hive-tab`/`data-tab`/`data-tab-pane`, logs.css drops the duplicated
base styles (keeps only its `flex:1` layout delta), and logs.js swaps its
activeTab/showTab/hashchange for createTabStrip (onShow lazy-loads the
SYSTEM tab). Behaviour-preserving. aria-selected is now standardised.
This commit is contained in:
parent
55ee52b213
commit
e1c276a06e
7 changed files with 103 additions and 60 deletions
|
|
@ -2,6 +2,7 @@
|
||||||
Bundled in front of the page-specific rules via esbuild. */
|
Bundled in front of the page-specific rules via esbuild. */
|
||||||
@import "@hive/shared/base.css";
|
@import "@hive/shared/base.css";
|
||||||
@import "@hive/shared/terminal.css";
|
@import "@hive/shared/terminal.css";
|
||||||
|
@import "@hive/shared/tabs.css";
|
||||||
|
|
||||||
/* ─── global typography ─────────────────────────────────────────────
|
/* ─── global typography ─────────────────────────────────────────────
|
||||||
Element-level rules shared across all three pages (index, flow,
|
Element-level rules shared across all three pages (index, flow,
|
||||||
|
|
|
||||||
|
|
@ -7,29 +7,13 @@ body.logs-shell {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Base tab styling now lives in @hive/shared/tabs.css (.hive-tab*). The
|
||||||
|
logs strip only adds a layout delta: fill the header row next to the
|
||||||
|
← home back-link. */
|
||||||
.logs-tabbar {
|
.logs-tabbar {
|
||||||
display: flex;
|
|
||||||
gap: 0.2em;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logs-tab {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.35em 0.9em;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--subtext0);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.85em;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
transition: background 100ms, color 100ms;
|
|
||||||
}
|
|
||||||
.logs-tab:hover { background: var(--border); color: var(--fg); }
|
|
||||||
.logs-tab.logs-tab-active {
|
|
||||||
background: var(--border);
|
|
||||||
color: var(--purple);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logs-main {
|
.logs-main {
|
||||||
padding: 1.2em 1.5em 2em;
|
padding: 1.2em 1.5em 2em;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,17 +17,17 @@
|
||||||
points to the H0M3 hub (served at /), not the dashboard. -->
|
points to the H0M3 hub (served at /), not the dashboard. -->
|
||||||
<header class="logs-header">
|
<header class="logs-header">
|
||||||
<a class="logs-back" href="/">← home</a>
|
<a class="logs-back" href="/">← home</a>
|
||||||
<nav class="logs-tabbar" id="logs-tabbar" role="tablist">
|
<nav class="hive-tabbar logs-tabbar" id="logs-tabbar" role="tablist">
|
||||||
<a class="logs-tab" id="logs-tab-build" href="#build" role="tab"
|
<a class="hive-tab" id="logs-tab-build" href="#build" role="tab"
|
||||||
aria-controls="logs-pane-build" data-logs-tab="build">
|
aria-controls="logs-pane-build" data-tab="build">
|
||||||
<span class="logs-tab-label">BUILD</span>
|
<span class="logs-tab-label">BUILD</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="logs-tab" id="logs-tab-agent" href="#agent" role="tab"
|
<a class="hive-tab" id="logs-tab-agent" href="#agent" role="tab"
|
||||||
aria-controls="logs-pane-agent" data-logs-tab="agent">
|
aria-controls="logs-pane-agent" data-tab="agent">
|
||||||
<span class="logs-tab-label">AGENT</span>
|
<span class="logs-tab-label">AGENT</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="logs-tab" id="logs-tab-system" href="#system" role="tab"
|
<a class="hive-tab" id="logs-tab-system" href="#system" role="tab"
|
||||||
aria-controls="logs-pane-system" data-logs-tab="system">
|
aria-controls="logs-pane-system" data-tab="system">
|
||||||
<span class="logs-tab-label">SYSTEM</span>
|
<span class="logs-tab-label">SYSTEM</span>
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -39,7 +39,7 @@
|
||||||
nixos-container invocations across all agents, with
|
nixos-container invocations across all agents, with
|
||||||
click-to-expand full stdout+stderr. Backed by
|
click-to-expand full stdout+stderr. Backed by
|
||||||
GET /api/build-logs?limit=30. -->
|
GET /api/build-logs?limit=30. -->
|
||||||
<section class="logs-pane" id="logs-pane-build"
|
<section class="logs-pane" id="logs-pane-build" data-tab-pane="build"
|
||||||
role="tabpanel" aria-labelledby="logs-tab-build">
|
role="tabpanel" aria-labelledby="logs-tab-build">
|
||||||
<div class="logs-toolbar">
|
<div class="logs-toolbar">
|
||||||
<button type="button" class="btn btn-restart" id="build-refresh">↻ refresh</button>
|
<button type="button" class="btn btn-restart" id="build-refresh">↻ refresh</button>
|
||||||
|
|
@ -50,7 +50,7 @@
|
||||||
<!-- AGENT: journald viewer for a specific agent container.
|
<!-- AGENT: journald viewer for a specific agent container.
|
||||||
Agent selector + unit filter + line count. Backed by
|
Agent selector + unit filter + line count. Backed by
|
||||||
GET /api/journal/{agent}?unit=<unit>&lines=N. -->
|
GET /api/journal/{agent}?unit=<unit>&lines=N. -->
|
||||||
<section class="logs-pane" id="logs-pane-agent"
|
<section class="logs-pane" id="logs-pane-agent" data-tab-pane="agent"
|
||||||
role="tabpanel" aria-labelledby="logs-tab-agent">
|
role="tabpanel" aria-labelledby="logs-tab-agent">
|
||||||
<div class="logs-toolbar">
|
<div class="logs-toolbar">
|
||||||
<select id="agent-select" class="journal-unit"></select>
|
<select id="agent-select" class="journal-unit"></select>
|
||||||
|
|
@ -66,7 +66,7 @@
|
||||||
|
|
||||||
<!-- SYSTEM: host-side service logs. Shows the hive-c0re daemon
|
<!-- SYSTEM: host-side service logs. Shows the hive-c0re daemon
|
||||||
journal via GET /api/journal-host?unit=hive-c0re.service. -->
|
journal via GET /api/journal-host?unit=hive-c0re.service. -->
|
||||||
<section class="logs-pane" id="logs-pane-system"
|
<section class="logs-pane" id="logs-pane-system" data-tab-pane="system"
|
||||||
role="tabpanel" aria-labelledby="logs-tab-system">
|
role="tabpanel" aria-labelledby="logs-tab-system">
|
||||||
<div class="logs-toolbar">
|
<div class="logs-toolbar">
|
||||||
<select id="system-unit-select" class="journal-unit">
|
<select id="system-unit-select" class="journal-unit">
|
||||||
|
|
|
||||||
|
|
@ -22,37 +22,18 @@
|
||||||
import {
|
import {
|
||||||
$, el, fmtAgeSecs, openStream, initServerWarnings,
|
$, el, fmtAgeSecs, openStream, initServerWarnings,
|
||||||
} from './common.js';
|
} from './common.js';
|
||||||
|
import { createTabStrip } from '@hive/shared/tabs.js';
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
initServerWarnings();
|
initServerWarnings();
|
||||||
|
|
||||||
// ─── tab routing ──────────────────────────────────────────────────────
|
// ─── tab routing ──────────────────────────────────────────────────────
|
||||||
|
// The shared hash-routed tab strip (@hive/shared/tabs.js) handles the
|
||||||
const TABS = ['build', 'agent', 'system'];
|
// active toggle + aria-selected + pane visibility + the SYSTEM lazy
|
||||||
|
// fetch via onShow. Constructed in the init block below (after the
|
||||||
function activeTab() {
|
// fetch fns + element refs it depends on exist). `logTabs.active()`
|
||||||
const hash = location.hash.replace('#', '');
|
// replaces the old `activeTab()`.
|
||||||
return TABS.includes(hash) ? hash : 'build';
|
let logTabs;
|
||||||
}
|
|
||||||
|
|
||||||
function showTab(name) {
|
|
||||||
for (const t of TABS) {
|
|
||||||
const pane = document.getElementById('logs-pane-' + t);
|
|
||||||
const tab = document.getElementById('logs-tab-' + t);
|
|
||||||
const active = t === name;
|
|
||||||
if (pane) pane.hidden = !active;
|
|
||||||
if (tab) {
|
|
||||||
tab.classList.toggle('logs-tab-active', active);
|
|
||||||
tab.setAttribute('aria-selected', String(active));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('hashchange', () => {
|
|
||||||
const tab = activeTab();
|
|
||||||
showTab(tab);
|
|
||||||
if (tab === 'system') fetchSystem();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────
|
// ─── helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -252,7 +233,7 @@ import {
|
||||||
{
|
{
|
||||||
let buildRefreshTimer = null;
|
let buildRefreshTimer = null;
|
||||||
const debouncedRefreshBuild = () => {
|
const debouncedRefreshBuild = () => {
|
||||||
if (activeTab() !== 'build') return;
|
if (logTabs.active() !== 'build') return;
|
||||||
if (buildRefreshTimer) clearTimeout(buildRefreshTimer);
|
if (buildRefreshTimer) clearTimeout(buildRefreshTimer);
|
||||||
buildRefreshTimer = setTimeout(fetchBuild, 2000);
|
buildRefreshTimer = setTimeout(fetchBuild, 2000);
|
||||||
};
|
};
|
||||||
|
|
@ -389,11 +370,15 @@ import {
|
||||||
|
|
||||||
// ─── init ─────────────────────────────────────────────────────────────
|
// ─── init ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const tab = activeTab();
|
// Wire the shared tab strip now that fetchSystem + the element refs it
|
||||||
showTab(tab);
|
// needs are defined. Its initial show() paints the active pane and, if
|
||||||
|
// the deep-linked tab is SYSTEM, kicks off the lazy fetch via onShow.
|
||||||
|
logTabs = createTabStrip(document.getElementById('logs-tabbar'), {
|
||||||
|
defaultId: 'build',
|
||||||
|
onShow: (id) => { if (id === 'system') fetchSystem(); },
|
||||||
|
});
|
||||||
loadAgentList();
|
loadAgentList();
|
||||||
fetchBuild();
|
fetchBuild();
|
||||||
if (tab === 'system') fetchSystem();
|
|
||||||
|
|
||||||
// Tick the last-fetched timestamps every 30s so "fetched 1m ago" stays
|
// Tick the last-fetched timestamps every 30s so "fetched 1m ago" stays
|
||||||
// accurate without a manual refresh.
|
// accurate without a manual refresh.
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.js",
|
".": "./src/index.js",
|
||||||
"./terminal.js": "./src/terminal.js",
|
"./terminal.js": "./src/terminal.js",
|
||||||
|
"./tabs.js": "./src/tabs.js",
|
||||||
|
"./tabs.css": "./src/tabs.css",
|
||||||
"./colors.css": "./src/colors.css",
|
"./colors.css": "./src/colors.css",
|
||||||
"./theme.css": "./src/theme.css",
|
"./theme.css": "./src/theme.css",
|
||||||
"./base.css": "./src/base.css",
|
"./base.css": "./src/base.css",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
so this inherits theme-swap safety for free. Call sites keep a thin
|
so this inherits theme-swap safety for free. Call sites keep a thin
|
||||||
variant class for size/spacing deltas; richer affordances (count
|
variant class for size/spacing deltas; richer affordances (count
|
||||||
pills, the dashboard's responsive overflow ⋮ menu) layer on top in the
|
pills, the dashboard's responsive overflow ⋮ menu) layer on top in the
|
||||||
per-page CSS. See docs/web-ui/css-vars.md + #1464. */
|
per-page CSS. See docs/web-ui/css-vars.md. */
|
||||||
|
|
||||||
.hive-tabbar {
|
.hive-tabbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
71
frontend/packages/shared/src/tabs.js
Normal file
71
frontend/packages/shared/src/tabs.js
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
// Shared hash-routed tab strip — the generic behaviour behind the
|
||||||
|
// dashboard tabbar, the logs sub-tabs, and the agent stat-window
|
||||||
|
// selector. Pairs with `@hive/shared/tabs.css` (the .hive-tab* styles).
|
||||||
|
//
|
||||||
|
// Convention over configuration (no element-resolver callbacks):
|
||||||
|
// - Tab controls are the `[data-tab="<id>"]` elements inside the
|
||||||
|
// `tabbar` you pass in (any element — `<a>` or `<button>`; give
|
||||||
|
// them class `hive-tab`).
|
||||||
|
// - Panels are matched by `[data-tab-pane="<id>"]` anywhere in the
|
||||||
|
// document.
|
||||||
|
//
|
||||||
|
// Routing is always via `location.hash` so tabs are deep-linkable and
|
||||||
|
// honour back/forward. A page may only host one strip (one hash); if a
|
||||||
|
// second independent strip is ever needed we add a key prefix then.
|
||||||
|
//
|
||||||
|
// On show: toggles `.hive-tab--active` + `aria-selected` on each control
|
||||||
|
// and `hidden` on each panel, then fires `onShow(id)` for per-tab
|
||||||
|
// side-effects (lazy loads, etc.). See docs/web-ui/css-vars.md.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Element|null} tabbar container holding the `[data-tab]` controls
|
||||||
|
* @param {{ defaultId?: string, onShow?: (id: string) => void }} [opts]
|
||||||
|
* @returns {{ show: (id: string) => void, active: () => string|null }}
|
||||||
|
*/
|
||||||
|
export function createTabStrip(tabbar, opts = {}) {
|
||||||
|
const { defaultId, onShow } = opts;
|
||||||
|
if (!tabbar) {
|
||||||
|
return { show() {}, active: () => null };
|
||||||
|
}
|
||||||
|
const tabs = Array.from(tabbar.querySelectorAll('[data-tab]'));
|
||||||
|
const ids = tabs.map((t) => t.getAttribute('data-tab'));
|
||||||
|
const fallback = defaultId && ids.includes(defaultId) ? defaultId : ids[0];
|
||||||
|
|
||||||
|
const paneFor = (id) => document.querySelector(`[data-tab-pane="${id}"]`);
|
||||||
|
|
||||||
|
const active = () => {
|
||||||
|
const hash = location.hash.replace(/^#/, '');
|
||||||
|
return ids.includes(hash) ? hash : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const show = (id) => {
|
||||||
|
const target = ids.includes(id) ? id : fallback;
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const tid = tab.getAttribute('data-tab');
|
||||||
|
const on = tid === target;
|
||||||
|
tab.classList.toggle('hive-tab--active', on);
|
||||||
|
tab.setAttribute('aria-selected', String(on));
|
||||||
|
const pane = paneFor(tid);
|
||||||
|
if (pane) pane.hidden = !on;
|
||||||
|
}
|
||||||
|
if (typeof onShow === 'function') onShow(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drive every activation through the hash so deep-links + back/forward
|
||||||
|
// stay authoritative; the hashchange listener does the actual showing.
|
||||||
|
for (const tab of tabs) {
|
||||||
|
tab.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = tab.getAttribute('data-tab');
|
||||||
|
if (location.hash.replace(/^#/, '') === id) {
|
||||||
|
show(id); // same hash → no hashchange, re-show directly
|
||||||
|
} else {
|
||||||
|
location.hash = id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.addEventListener('hashchange', () => show(active()));
|
||||||
|
|
||||||
|
show(active()); // initial render from the current hash
|
||||||
|
return { show, active };
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue