diff --git a/webview/hugo/layouts/shortcodes/workstation-probe-panel.html b/webview/hugo/layouts/shortcodes/workstation-probe-panel.html index e94eb4e..3730fbe 100644 --- a/webview/hugo/layouts/shortcodes/workstation-probe-panel.html +++ b/webview/hugo/layouts/shortcodes/workstation-probe-panel.html @@ -63,10 +63,11 @@ {{- $data := $raw | transform.Unmarshal -}}
-
+

{{ $data.name | default $data.api }}

- {{ $data.api }} - loading + {{ $data.api }} + +
diff --git a/webview/hugo/static/workstation-probe/css/panel.css b/webview/hugo/static/workstation-probe/css/panel.css index e9e5860..f481d9c 100644 --- a/webview/hugo/static/workstation-probe/css/panel.css +++ b/webview/hugo/static/workstation-probe/css/panel.css @@ -94,28 +94,30 @@ .workstation-probe-panel-header { display: flex; align-items: center; - flex-wrap: wrap; + flex-wrap: nowrap; gap: 0.75rem; padding: 0.875rem 1.25rem; background: linear-gradient(180deg, var(--wsp-card-bg-hover) 0%, var(--wsp-card-bg) 100%); border-bottom: 1px solid var(--wsp-border); + overflow: hidden; } .workstation-probe-panel-header h3 { + min-width: 0; + max-width: min(20rem, 34vw); + flex: 0 0 auto; margin: 0; font-size: 1rem; font-weight: 600; font-family: var(--wsp-mono); color: var(--wsp-fg); letter-spacing: -0.01em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.workstation-probe-panel-header-uri { - font-size: 0.75rem; - color: var(--wsp-fg-dim); - font-family: var(--wsp-mono); -} - +.workstation-probe-panel-header-fact, .workstation-probe-panel-header-facts { min-width: 0; overflow: hidden; @@ -124,6 +126,16 @@ font-size: 0.75rem; color: var(--wsp-fg-dim); font-family: var(--wsp-mono); + flex: 0 0 auto; +} + +.workstation-probe-panel-header-uri { + max-width: min(36rem, 45vw); +} + +.workstation-probe-panel-header-fact[data-wsp-empty="true"], +.workstation-probe-panel-header-fact[data-wsp-header-hidden] { + display: none; } .workstation-probe-panel-meta { @@ -134,6 +146,7 @@ font-family: var(--wsp-mono); font-size: 0.75rem; color: var(--wsp-fg-dim); + flex: 0 0 auto; } .workstation-probe-status { @@ -212,20 +225,14 @@ display: inline-flex; align-items: center; gap: 0.25rem; + white-space: nowrap; } @media (max-width: 720px) { - .workstation-probe-panel-header-uri { - display: none; - } - - .workstation-probe-panel-header-facts { - flex-basis: 100%; - order: 3; - } - - .workstation-probe-panel-meta { - margin-left: auto; + .workstation-probe-panel-header { + gap: 0.625rem; + padding-left: 1rem; + padding-right: 1rem; } } @@ -523,21 +530,34 @@ * scanned at a glance to spot the disk that's about to fill up. */ .workstation-probe-sub-storage { - display: flex; - flex-direction: column; -} - -.workstation-probe-sub-row { - display: flex; + display: grid; + grid-template-columns: max-content minmax(80px, 160px) minmax(96px, 1fr) max-content; align-items: center; - gap: 0.75rem; - padding: 0.5rem 1.25rem; + column-gap: 0.75rem; font-family: var(--wsp-mono); font-size: 0.8125rem; line-height: 1.3; } -.workstation-probe-sub-row + .workstation-probe-sub-row { +.workstation-probe-sub-row { + display: contents; +} + +.workstation-probe-sub-row > * { + min-width: 0; + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.workstation-probe-sub-row > :first-child { + padding-left: 1.25rem; +} + +.workstation-probe-sub-row > :last-child { + padding-right: 1.25rem; +} + +.workstation-probe-sub-row + .workstation-probe-sub-row > * { border-top: 1px solid var(--wsp-border-soft); } @@ -547,21 +567,18 @@ letter-spacing: 0.04em; text-transform: uppercase; color: var(--wsp-fg); - min-width: 56px; + white-space: nowrap; } .workstation-probe-sub-row-path { color: var(--wsp-fg-dim); font-size: 0.75rem; - min-width: 80px; - max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .workstation-probe-sub-row-bar { - flex: 1; position: relative; height: 10px; display: flex; diff --git a/webview/hugo/static/workstation-probe/js/boot.js b/webview/hugo/static/workstation-probe/js/boot.js index 58f1b09..fa94056 100644 --- a/webview/hugo/static/workstation-probe/js/boot.js +++ b/webview/hugo/static/workstation-probe/js/boot.js @@ -17,9 +17,16 @@ const BOOTED_ATTR = 'data-workstation-probe-booted'; const CONFIG_ATTR = 'data-workstation-probe-config'; const REFRESH_SEL = '[data-workstation-probe-refresh]'; const URI_SEL = '[data-workstation-probe-uri]'; -const HEADER_FACTS_SEL = '[data-workstation-probe-header-facts]'; +const HEADER_SEL = '[data-workstation-probe-header]'; +const HEADER_FACT_SEL = '[data-workstation-probe-header-fact]'; +const HEADER_FACT_URI_SEL = '[data-workstation-probe-header-fact-uri]'; +const HEADER_FACT_TIME_SEL = '[data-workstation-probe-header-fact-server-time]'; +const HEADER_FACT_UPTIME_SEL = '[data-workstation-probe-header-fact-uptime]'; +const HEADER_FACT_PRIORITY_ATTR = 'data-wsp-header-priority'; +const HEADER_FACT_HIDDEN_ATTR = 'data-wsp-header-hidden'; const STALE_MULTIPLIER = 2.5; // panel dims when last fetch is older than refresh * N +const headerResizeObservers = new WeakMap(); function init() { const panels = document.querySelectorAll(`.${PANEL_CLASS}:not([${BOOTED_ATTR}])`); @@ -42,10 +49,11 @@ function bootOne(panel) { // Render the static shell. Inner DOM is rebuilt here so multiple panels // on the same page don't interfere with each other. panel.innerHTML = ` -
+

${escapeHtml(cfg.name || cfg.api)}

- ${escapeHtml(cfg.api || '')} - loading + ${escapeHtml(cfg.api || '')} + +
@@ -59,6 +67,7 @@ function bootOne(panel) { ${(cfg.modules || []).map((m) => renderSubShell(m)).join('')}
`; + setupHeaderAutoFit(panel); // Subscribe once; the poller deduplicates by api+refresh+query. subscribe(cfg, (state) => { @@ -99,9 +108,52 @@ function bootOne(panel) { if (state.data) { renderPanel(panel, cfg, state.data, state.profile); } + fitHeaderFacts(panel); }); } +function setupHeaderAutoFit(panel) { + const header = panel.querySelector(HEADER_SEL); + if (!header) return; + const fit = () => fitHeaderFacts(panel); + if (typeof ResizeObserver !== 'undefined') { + const observer = new ResizeObserver(fit); + observer.observe(header); + headerResizeObservers.set(panel, observer); + } else { + window.addEventListener('resize', fit, { passive: true }); + } + window.requestAnimationFrame(fit); +} + +function fitHeaderFacts(panel) { + const header = panel.querySelector(HEADER_SEL); + if (!header || !header.clientWidth) return; + + const facts = Array.from(header.querySelectorAll(HEADER_FACT_SEL)) + .filter((el) => el.getAttribute('data-wsp-empty') !== 'true'); + for (const el of facts) { + el.removeAttribute(HEADER_FACT_HIDDEN_ATTR); + } + + const hideOrder = facts + .slice() + .sort((a, b) => headerFactPriority(a) - headerFactPriority(b)); + for (const el of hideOrder) { + if (!headerOverflows(header)) break; + el.setAttribute(HEADER_FACT_HIDDEN_ATTR, 'true'); + } +} + +function headerFactPriority(el) { + const n = Number(el.getAttribute(HEADER_FACT_PRIORITY_ATTR)); + return Number.isFinite(n) ? n : 0; +} + +function headerOverflows(header) { + return header.scrollWidth > header.clientWidth + 1; +} + // renderSubShell produces the per-module shell. Each module picks a // body layout that matches its data shape: // cpu : per-core tile grid diff --git a/webview/hugo/static/workstation-probe/js/render.js b/webview/hugo/static/workstation-probe/js/render.js index e21c23c..b29285f 100644 --- a/webview/hugo/static/workstation-probe/js/render.js +++ b/webview/hugo/static/workstation-probe/js/render.js @@ -23,7 +23,8 @@ const STORAGE_SELECTOR = '[data-wsp-storage]'; const GPU_SELECTOR = '[data-wsp-gpu]'; const META_TEXT_SELECTOR = '[data-wsp-sub-meta-text]'; const MODULE_ATTR = 'data-workstation-probe-module'; -const HEADER_FACTS_SELECTOR = '[data-workstation-probe-header-facts]'; +const HEADER_FACT_TIME_SELECTOR = '[data-workstation-probe-header-fact-server-time]'; +const HEADER_FACT_UPTIME_SELECTOR = '[data-workstation-probe-header-fact-uptime]'; export function renderPanel(panel, _cfg, data, profile = null) { renderHeaderFacts(panel, data, profile); @@ -34,21 +35,31 @@ export function renderPanel(panel, _cfg, data, profile = null) { } function renderHeaderFacts(panel, data, profile) { - const el = panel.querySelector(HEADER_FACTS_SELECTOR); - if (!el) return; - const facts = []; + const timeEl = panel.querySelector(HEADER_FACT_TIME_SELECTOR); + const uptimeEl = panel.querySelector(HEADER_FACT_UPTIME_SELECTOR); + + let timeText = ''; if (data.server_time_local) { const zone = formatServerZone( profile?.server_timezone || data.server_timezone, profile?.server_timezone_offset_seconds ?? data.server_timezone_offset_seconds, ); - facts.push(zone ? `${data.server_time_local} ${zone}` : data.server_time_local); + timeText = zone ? `${data.server_time_local} ${zone}` : data.server_time_local; } + setHeaderFact(timeEl, timeText); + const uptime = hostUptimeSeconds(data, profile); - if (uptime > 0) { - facts.push(`up ${formatDuration(uptime)}`); + setHeaderFact(uptimeEl, uptime > 0 ? `up ${formatDuration(uptime)}` : ''); +} + +function setHeaderFact(el, text) { + if (!el) return; + el.textContent = text; + if (text) { + el.removeAttribute('data-wsp-empty'); + } else { + el.setAttribute('data-wsp-empty', 'true'); } - el.textContent = facts.length > 0 ? facts.join(' · ') : ''; } function renderSub(sub, mod, data, profile) { @@ -621,10 +632,11 @@ function renderStorage(sub, _valueEl, metaEl, _canvas, storage) { } // renderStorageRows fills the storage sub-panel's data-wsp-storage -// element with one row per mount. Each row is a flex container with -// fixed-width label slots and a flex-1 bar; the bar itself contains a -// thick coloured "used" segment and a thin grey "free" segment that -// share the same horizontal extent. +// element with one row per mount. CSS makes every row participate in +// one shared grid so alias, path, bar, and size columns align to the +// widest value in the list. The bar itself contains a thick coloured +// "used" segment and a thin grey "free" segment that share the same +// horizontal extent. function renderStorageRows(container, disks) { const html = disks.map((d) => { const pct = clamp(d.used_percent || 0, 0, 100); diff --git a/webview/hugo/test/expected/basic/index.html b/webview/hugo/test/expected/basic/index.html index 702f851..aebea58 100644 --- a/webview/hugo/test/expected/basic/index.html +++ b/webview/hugo/test/expected/basic/index.html @@ -19,10 +19,11 @@

Local workstation webview test

This page renders the local workstation-probe panel against http://localhost:19090.

-
+

localhost

- http://localhost:19090 - loading + http://localhost:19090 + +
diff --git a/webview/hugo/test/expected/basic/workstation-probe/css/panel.css b/webview/hugo/test/expected/basic/workstation-probe/css/panel.css index e9e5860..f481d9c 100644 --- a/webview/hugo/test/expected/basic/workstation-probe/css/panel.css +++ b/webview/hugo/test/expected/basic/workstation-probe/css/panel.css @@ -94,28 +94,30 @@ .workstation-probe-panel-header { display: flex; align-items: center; - flex-wrap: wrap; + flex-wrap: nowrap; gap: 0.75rem; padding: 0.875rem 1.25rem; background: linear-gradient(180deg, var(--wsp-card-bg-hover) 0%, var(--wsp-card-bg) 100%); border-bottom: 1px solid var(--wsp-border); + overflow: hidden; } .workstation-probe-panel-header h3 { + min-width: 0; + max-width: min(20rem, 34vw); + flex: 0 0 auto; margin: 0; font-size: 1rem; font-weight: 600; font-family: var(--wsp-mono); color: var(--wsp-fg); letter-spacing: -0.01em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.workstation-probe-panel-header-uri { - font-size: 0.75rem; - color: var(--wsp-fg-dim); - font-family: var(--wsp-mono); -} - +.workstation-probe-panel-header-fact, .workstation-probe-panel-header-facts { min-width: 0; overflow: hidden; @@ -124,6 +126,16 @@ font-size: 0.75rem; color: var(--wsp-fg-dim); font-family: var(--wsp-mono); + flex: 0 0 auto; +} + +.workstation-probe-panel-header-uri { + max-width: min(36rem, 45vw); +} + +.workstation-probe-panel-header-fact[data-wsp-empty="true"], +.workstation-probe-panel-header-fact[data-wsp-header-hidden] { + display: none; } .workstation-probe-panel-meta { @@ -134,6 +146,7 @@ font-family: var(--wsp-mono); font-size: 0.75rem; color: var(--wsp-fg-dim); + flex: 0 0 auto; } .workstation-probe-status { @@ -212,20 +225,14 @@ display: inline-flex; align-items: center; gap: 0.25rem; + white-space: nowrap; } @media (max-width: 720px) { - .workstation-probe-panel-header-uri { - display: none; - } - - .workstation-probe-panel-header-facts { - flex-basis: 100%; - order: 3; - } - - .workstation-probe-panel-meta { - margin-left: auto; + .workstation-probe-panel-header { + gap: 0.625rem; + padding-left: 1rem; + padding-right: 1rem; } } @@ -523,21 +530,34 @@ * scanned at a glance to spot the disk that's about to fill up. */ .workstation-probe-sub-storage { - display: flex; - flex-direction: column; -} - -.workstation-probe-sub-row { - display: flex; + display: grid; + grid-template-columns: max-content minmax(80px, 160px) minmax(96px, 1fr) max-content; align-items: center; - gap: 0.75rem; - padding: 0.5rem 1.25rem; + column-gap: 0.75rem; font-family: var(--wsp-mono); font-size: 0.8125rem; line-height: 1.3; } -.workstation-probe-sub-row + .workstation-probe-sub-row { +.workstation-probe-sub-row { + display: contents; +} + +.workstation-probe-sub-row > * { + min-width: 0; + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.workstation-probe-sub-row > :first-child { + padding-left: 1.25rem; +} + +.workstation-probe-sub-row > :last-child { + padding-right: 1.25rem; +} + +.workstation-probe-sub-row + .workstation-probe-sub-row > * { border-top: 1px solid var(--wsp-border-soft); } @@ -547,21 +567,18 @@ letter-spacing: 0.04em; text-transform: uppercase; color: var(--wsp-fg); - min-width: 56px; + white-space: nowrap; } .workstation-probe-sub-row-path { color: var(--wsp-fg-dim); font-size: 0.75rem; - min-width: 80px; - max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .workstation-probe-sub-row-bar { - flex: 1; position: relative; height: 10px; display: flex; diff --git a/webview/hugo/test/expected/basic/workstation-probe/js/boot.js b/webview/hugo/test/expected/basic/workstation-probe/js/boot.js index 58f1b09..fa94056 100644 --- a/webview/hugo/test/expected/basic/workstation-probe/js/boot.js +++ b/webview/hugo/test/expected/basic/workstation-probe/js/boot.js @@ -17,9 +17,16 @@ const BOOTED_ATTR = 'data-workstation-probe-booted'; const CONFIG_ATTR = 'data-workstation-probe-config'; const REFRESH_SEL = '[data-workstation-probe-refresh]'; const URI_SEL = '[data-workstation-probe-uri]'; -const HEADER_FACTS_SEL = '[data-workstation-probe-header-facts]'; +const HEADER_SEL = '[data-workstation-probe-header]'; +const HEADER_FACT_SEL = '[data-workstation-probe-header-fact]'; +const HEADER_FACT_URI_SEL = '[data-workstation-probe-header-fact-uri]'; +const HEADER_FACT_TIME_SEL = '[data-workstation-probe-header-fact-server-time]'; +const HEADER_FACT_UPTIME_SEL = '[data-workstation-probe-header-fact-uptime]'; +const HEADER_FACT_PRIORITY_ATTR = 'data-wsp-header-priority'; +const HEADER_FACT_HIDDEN_ATTR = 'data-wsp-header-hidden'; const STALE_MULTIPLIER = 2.5; // panel dims when last fetch is older than refresh * N +const headerResizeObservers = new WeakMap(); function init() { const panels = document.querySelectorAll(`.${PANEL_CLASS}:not([${BOOTED_ATTR}])`); @@ -42,10 +49,11 @@ function bootOne(panel) { // Render the static shell. Inner DOM is rebuilt here so multiple panels // on the same page don't interfere with each other. panel.innerHTML = ` -
+

${escapeHtml(cfg.name || cfg.api)}

- ${escapeHtml(cfg.api || '')} - loading + ${escapeHtml(cfg.api || '')} + +
@@ -59,6 +67,7 @@ function bootOne(panel) { ${(cfg.modules || []).map((m) => renderSubShell(m)).join('')}
`; + setupHeaderAutoFit(panel); // Subscribe once; the poller deduplicates by api+refresh+query. subscribe(cfg, (state) => { @@ -99,9 +108,52 @@ function bootOne(panel) { if (state.data) { renderPanel(panel, cfg, state.data, state.profile); } + fitHeaderFacts(panel); }); } +function setupHeaderAutoFit(panel) { + const header = panel.querySelector(HEADER_SEL); + if (!header) return; + const fit = () => fitHeaderFacts(panel); + if (typeof ResizeObserver !== 'undefined') { + const observer = new ResizeObserver(fit); + observer.observe(header); + headerResizeObservers.set(panel, observer); + } else { + window.addEventListener('resize', fit, { passive: true }); + } + window.requestAnimationFrame(fit); +} + +function fitHeaderFacts(panel) { + const header = panel.querySelector(HEADER_SEL); + if (!header || !header.clientWidth) return; + + const facts = Array.from(header.querySelectorAll(HEADER_FACT_SEL)) + .filter((el) => el.getAttribute('data-wsp-empty') !== 'true'); + for (const el of facts) { + el.removeAttribute(HEADER_FACT_HIDDEN_ATTR); + } + + const hideOrder = facts + .slice() + .sort((a, b) => headerFactPriority(a) - headerFactPriority(b)); + for (const el of hideOrder) { + if (!headerOverflows(header)) break; + el.setAttribute(HEADER_FACT_HIDDEN_ATTR, 'true'); + } +} + +function headerFactPriority(el) { + const n = Number(el.getAttribute(HEADER_FACT_PRIORITY_ATTR)); + return Number.isFinite(n) ? n : 0; +} + +function headerOverflows(header) { + return header.scrollWidth > header.clientWidth + 1; +} + // renderSubShell produces the per-module shell. Each module picks a // body layout that matches its data shape: // cpu : per-core tile grid diff --git a/webview/hugo/test/expected/basic/workstation-probe/js/render.js b/webview/hugo/test/expected/basic/workstation-probe/js/render.js index e21c23c..b29285f 100644 --- a/webview/hugo/test/expected/basic/workstation-probe/js/render.js +++ b/webview/hugo/test/expected/basic/workstation-probe/js/render.js @@ -23,7 +23,8 @@ const STORAGE_SELECTOR = '[data-wsp-storage]'; const GPU_SELECTOR = '[data-wsp-gpu]'; const META_TEXT_SELECTOR = '[data-wsp-sub-meta-text]'; const MODULE_ATTR = 'data-workstation-probe-module'; -const HEADER_FACTS_SELECTOR = '[data-workstation-probe-header-facts]'; +const HEADER_FACT_TIME_SELECTOR = '[data-workstation-probe-header-fact-server-time]'; +const HEADER_FACT_UPTIME_SELECTOR = '[data-workstation-probe-header-fact-uptime]'; export function renderPanel(panel, _cfg, data, profile = null) { renderHeaderFacts(panel, data, profile); @@ -34,21 +35,31 @@ export function renderPanel(panel, _cfg, data, profile = null) { } function renderHeaderFacts(panel, data, profile) { - const el = panel.querySelector(HEADER_FACTS_SELECTOR); - if (!el) return; - const facts = []; + const timeEl = panel.querySelector(HEADER_FACT_TIME_SELECTOR); + const uptimeEl = panel.querySelector(HEADER_FACT_UPTIME_SELECTOR); + + let timeText = ''; if (data.server_time_local) { const zone = formatServerZone( profile?.server_timezone || data.server_timezone, profile?.server_timezone_offset_seconds ?? data.server_timezone_offset_seconds, ); - facts.push(zone ? `${data.server_time_local} ${zone}` : data.server_time_local); + timeText = zone ? `${data.server_time_local} ${zone}` : data.server_time_local; } + setHeaderFact(timeEl, timeText); + const uptime = hostUptimeSeconds(data, profile); - if (uptime > 0) { - facts.push(`up ${formatDuration(uptime)}`); + setHeaderFact(uptimeEl, uptime > 0 ? `up ${formatDuration(uptime)}` : ''); +} + +function setHeaderFact(el, text) { + if (!el) return; + el.textContent = text; + if (text) { + el.removeAttribute('data-wsp-empty'); + } else { + el.setAttribute('data-wsp-empty', 'true'); } - el.textContent = facts.length > 0 ? facts.join(' · ') : ''; } function renderSub(sub, mod, data, profile) { @@ -621,10 +632,11 @@ function renderStorage(sub, _valueEl, metaEl, _canvas, storage) { } // renderStorageRows fills the storage sub-panel's data-wsp-storage -// element with one row per mount. Each row is a flex container with -// fixed-width label slots and a flex-1 bar; the bar itself contains a -// thick coloured "used" segment and a thin grey "free" segment that -// share the same horizontal extent. +// element with one row per mount. CSS makes every row participate in +// one shared grid so alias, path, bar, and size columns align to the +// widest value in the list. The bar itself contains a thick coloured +// "used" segment and a thin grey "free" segment that share the same +// horizontal extent. function renderStorageRows(container, disks) { const html = disks.map((d) => { const pct = clamp(d.used_percent || 0, 0, 100);