diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cc0725..0b9ec71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## v26.05.12 (2026-05-31) + +### Admin dashboard — responsive cards, fullscreen, navbar polish + +- **Fullscreen / expand cards.** New reusable affordance: the **Bean Graph** and + **Log Viewer** cards gain a maximize button that expands them to fill the + viewport (Escape to exit) — ideal for exploring a large dependency graph or a + busy log stream. The expanded card re-parents to the document root so it sits + above the page (the ⌘K palette, toasts and the bean detail panel still layer + correctly on top). +- **Responsive sizing.** The dependency graph now scales with the viewport + (was a fixed 600px) and re-lays out on window resize / fullscreen toggle; the + log output grows with the window instead of a fixed 600px cap. +- **Bean Graph** also gains a **Reset view** control (clears pan/zoom). +- **Loggers** view: an **Effective Level Distribution** bar (share of each level) + and the table now scrolls within a viewport-relative height with a sticky header. +- **Metrics** view: the metric-list card now stretches to the full height of the + detail panel (was capped at 520px) and scrolls internally, so it uses the + available vertical space. +- **Navbar & sidebar.** The sidebar brand is now exactly the navbar height so the + two top bars line up across the split; the brand shows the pyfly wordmark only. + The navbar gains a live auto-refresh status pill and tidier, grouped controls. + +--- + ## v26.05.11 (2026-05-31) ### Admin dashboard — loading skeletons & consistent empty states diff --git a/pyproject.toml b/pyproject.toml index 3097758..e5929b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "pyfly" # CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4); # git tag, GitHub release and human-readable display use leading-zero form # (v26.05.04) to match the Java/.NET/Go siblings. -version = "26.5.11" +version = "26.5.12" description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more." readme = "README.md" license = "Apache-2.0" diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py index 487aa95..88c3972 100644 --- a/src/pyfly/__init__.py +++ b/src/pyfly/__init__.py @@ -13,4 +13,4 @@ # limitations under the License. """PyFly — Enterprise Python Framework.""" -__version__ = "26.05.11" +__version__ = "26.05.12" diff --git a/src/pyfly/admin/static/css/admin.css b/src/pyfly/admin/static/css/admin.css index 87f04e7..28530ce 100644 --- a/src/pyfly/admin/static/css/admin.css +++ b/src/pyfly/admin/static/css/admin.css @@ -86,31 +86,38 @@ a:hover { .admin-sidebar-brand { display: flex; align-items: center; - gap: 12px; - padding: 16px 20px; + gap: 10px; + padding: 0 20px; + /* Exactly the navbar height so the two top bars line up across the split. */ + height: var(--admin-navbar-height); + flex-shrink: 0; border-bottom: 1px solid var(--admin-border-subtle); - min-height: var(--admin-navbar-height); + overflow: hidden; } .admin-sidebar-brand img { - height: 36px; + height: 30px; width: auto; object-fit: contain; + flex-shrink: 0; } .admin-sidebar-brand-divider { width: 1px; - height: 24px; + height: 22px; background: var(--admin-border-subtle); flex-shrink: 0; } .admin-sidebar-brand span { font-family: var(--admin-font-mono); - font-size: 0.95rem; + font-size: 0.9rem; font-weight: 600; color: var(--admin-sidebar-text-active); letter-spacing: -0.02em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .admin-sidebar-nav { @@ -228,7 +235,7 @@ a:hover { .admin-navbar-right { display: flex; align-items: center; - gap: 16px; + gap: 10px; } .admin-navbar-right .refresh-label { @@ -237,6 +244,43 @@ a:hover { color: var(--admin-text-muted); } +/* Live auto-refresh status pill */ +.navbar-status { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 4px 11px; + border-radius: 999px; + background: var(--admin-bg-subtle); + border: 1px solid var(--admin-border-subtle); + font-family: var(--admin-font-mono); + font-size: 0.72rem; + color: var(--admin-text-secondary); + white-space: nowrap; +} + +.navbar-status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--admin-success); + animation: trend-pulse 2s infinite; +} + +/* Thin separator between navbar control groups */ +.navbar-divider { + width: 1px; + height: 22px; + background: var(--admin-border); + flex-shrink: 0; +} + +@media (max-width: 768px) { + .navbar-status { + display: none; + } +} + .admin-content { flex: 1; overflow-y: auto; @@ -753,7 +797,9 @@ a:hover { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.5); - z-index: 200; + /* Above fullscreen cards (1200) so a node's detail panel shows over an + expanded graph; still below the palette (1300) and toasts (1400). */ + z-index: 1250; opacity: 0; transition: opacity 0.2s ease; pointer-events: none; @@ -774,7 +820,7 @@ a:hover { background: var(--admin-surface); border-left: 1px solid var(--admin-border); box-shadow: -4px 0 24px rgba(0, 0, 0, 0.3); - z-index: 210; + z-index: 1260; transform: translateX(100%); transition: transform 0.25s ease; display: flex; @@ -835,7 +881,8 @@ a:hover { position: fixed; top: 16px; right: 16px; - z-index: 1000; + /* Above fullscreen cards (1200) and the palette (1300). */ + z-index: 1400; display: flex; flex-direction: column; gap: 8px; @@ -1613,12 +1660,21 @@ a:hover { font-family: var(--admin-font-mono); font-size: 0.8rem; line-height: 1.8; - max-height: 600px; + /* Grow with the viewport instead of a fixed cap. */ + max-height: calc(100vh - 360px); + min-height: 280px; overflow-y: auto; padding: 12px 16px; border-top: 1px solid var(--admin-border); } +/* In fullscreen the output fills the expanded card. */ +.card-fullscreen .logfile-output { + max-height: none; + flex: 1; + min-height: 0; +} + .log-line { padding: 2px 0; display: flex; @@ -1794,7 +1850,8 @@ body.wallboard-mode .admin-content { .cmd-palette-overlay { position: fixed; inset: 0; - z-index: 500; + /* Above fullscreen cards (1200) so ⌘K works while a card is expanded. */ + z-index: 1300; display: flex; align-items: flex-start; justify-content: center; @@ -1928,18 +1985,25 @@ body.wallboard-mode .admin-content { .metrics-split { display: flex; gap: 16px; - align-items: flex-start; + /* Stretch so the metric-list card matches the detail card's height and the + list uses the full available vertical space (scrolls internally). */ + align-items: stretch; } .metrics-list-panel { width: 300px; min-width: 300px; flex-shrink: 0; + display: flex; + flex-direction: column; } .metrics-detail-panel { flex: 1; min-width: 0; + /* A height floor so the split (and the list beside it) isn't tiny before a + metric is selected; it grows with the detail content. */ + min-height: 460px; } @media (max-width: 768px) { @@ -2103,3 +2167,58 @@ body.wallboard-mode .admin-content { color: var(--admin-text-muted); font-size: 0.72rem; } + +/* ── Fullscreen / Expand cards ──────────────────────────────────── */ +.card-fs-btn { + color: var(--admin-text-muted); +} + +.card-fs-btn-floating { + position: absolute; + top: 10px; + right: 10px; + z-index: 5; + background: var(--admin-surface); + border: 1px solid var(--admin-border); +} + +.card-fs-btn-floating:hover { + background: var(--admin-surface-hover); + color: var(--admin-text); +} + +.card-fullscreen { + /* !important beats the inline position:relative the floating-button + anchor sets on the card, so fullscreen always pins to the viewport. */ + position: fixed !important; + inset: 0; + z-index: 1200; + margin: 0 !important; + border-radius: 0; + display: flex; + flex-direction: column; + box-shadow: var(--admin-shadow-lg); +} + +/* The body region grows to fill the fullscreen card. */ +.card-fullscreen > .admin-card-body { + flex: 1; + min-height: 0; +} + +/* Dim/lock the page behind a fullscreen card. */ +body.has-fullscreen { + overflow: hidden; +} + +/* Bean dependency graph: viewport-relative height, fills the card in fullscreen. */ +.bean-graph-body { + height: min(72vh, 820px); + min-height: 440px; +} + +.card-fullscreen .bean-graph-body { + height: auto; + flex: 1; + min-height: 0; +} diff --git a/src/pyfly/admin/static/js/app.js b/src/pyfly/admin/static/js/app.js index 49bafae..10de6b1 100644 --- a/src/pyfly/admin/static/js/app.js +++ b/src/pyfly/admin/static/js/app.js @@ -111,14 +111,21 @@ function renderNavbar() { navbar.appendChild(left); - // Right: refresh label + theme toggle + // Right: live status pill + search + controls const right = document.createElement('div'); right.className = 'admin-navbar-right'; - const refreshLabel = document.createElement('span'); - refreshLabel.className = 'refresh-label'; - refreshLabel.textContent = `refresh: ${settings.refreshInterval / 1000}s`; - right.appendChild(refreshLabel); + // Live auto-refresh indicator (pulsing dot + interval). + const statusPill = document.createElement('span'); + statusPill.className = 'navbar-status'; + statusPill.title = `Auto-refresh every ${settings.refreshInterval / 1000}s`; + const statusDot = document.createElement('span'); + statusDot.className = 'navbar-status-dot'; + statusPill.appendChild(statusDot); + const statusText = document.createElement('span'); + statusText.textContent = `Live · ${settings.refreshInterval / 1000}s`; + statusPill.appendChild(statusText); + right.appendChild(statusPill); // Command palette trigger (⌘K) const searchBtn = document.createElement('button'); @@ -137,6 +144,11 @@ function renderNavbar() { searchBtn.addEventListener('click', () => commandPalette && commandPalette.open()); right.appendChild(searchBtn); + // Divider between the search group and the icon controls. + const navDivider = document.createElement('span'); + navDivider.className = 'navbar-divider'; + right.appendChild(navDivider); + // Theme toggle button const themeBtn = document.createElement('button'); themeBtn.className = 'theme-toggle'; diff --git a/src/pyfly/admin/static/js/components/fullscreen.js b/src/pyfly/admin/static/js/components/fullscreen.js new file mode 100644 index 0000000..8812901 --- /dev/null +++ b/src/pyfly/admin/static/js/components/fullscreen.js @@ -0,0 +1,132 @@ +/** + * PyFly Admin — Fullscreen / Expand affordance for cards. + * + * Adds a maximize button to a card; toggling it makes the card fill the + * viewport (position: fixed) so large content — dependency graphs, log + * streams, charts — can be explored without being boxed into a fixed height. + * Pressing Escape exits. An optional onResize callback fires after each toggle + * (next frame, once layout has settled) so canvas/SVG content can re-measure. + */ + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +function svgIcon(paths) { + const svg = document.createElementNS(SVG_NS, 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', '2'); + svg.setAttribute('stroke-linecap', 'round'); + svg.setAttribute('stroke-linejoin', 'round'); + svg.setAttribute('aria-hidden', 'true'); + for (const d of paths) { + const p = document.createElementNS(SVG_NS, 'path'); + p.setAttribute('d', d); + svg.appendChild(p); + } + return svg; +} + +const EXPAND = [ + 'M8 3H5a2 2 0 0 0-2 2v3', + 'M21 8V5a2 2 0 0 0-2-2h-3', + 'M3 16v3a2 2 0 0 0 2 2h3', + 'M16 21h3a2 2 0 0 0 2-2v-3', +]; +const COLLAPSE = [ + 'M8 3v3a2 2 0 0 1-2 2H3', + 'M21 8h-3a2 2 0 0 1-2-2V3', + 'M3 16h3a2 2 0 0 1 2 2v3', + 'M16 21v-3a2 2 0 0 1 2-2h3', +]; + +/** + * Attach a fullscreen toggle to a card. + * + * @param {HTMLElement} card The .admin-card to expand. + * @param {object} [opts] + * @param {HTMLElement} [opts.anchor] Element to append the button into + * (e.g. a card header's right side). + * If omitted, the button floats at the + * card's top-right corner. + * @param {(isFullscreen: boolean) => void} [opts.onResize] Called after toggle. + * @param {string} [opts.label='Toggle fullscreen'] + * @returns {{ isFullscreen: () => boolean, exit: () => void, destroy: () => void }} + */ +export function attachFullscreen(card, { anchor = null, onResize = null, label = 'Toggle fullscreen' } = {}) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn-icon card-fs-btn'; + btn.setAttribute('aria-label', label); + btn.title = `${label} (Esc to exit)`; + btn.appendChild(svgIcon(EXPAND)); + + let isFs = false; + let placeholder = null; + let resizeRAF = null; + + function setFullscreen(on) { + if (on === isFs) return; + isFs = on; + if (on) { + // Re-parent to so position:fixed resolves against the + // viewport. A transformed ancestor (e.g. the view-enter wrapper, + // which keeps transform: translateY(0) after its animation) would + // otherwise become the containing block and clip the overlay to the + // content area. A comment node marks the original position. + placeholder = document.createComment('fullscreen-placeholder'); + card.parentNode.insertBefore(placeholder, card); + document.body.appendChild(card); + card.classList.add('card-fullscreen'); + document.body.classList.add('has-fullscreen'); + } else { + card.classList.remove('card-fullscreen'); + document.body.classList.remove('has-fullscreen'); + if (placeholder && placeholder.parentNode) { + placeholder.parentNode.insertBefore(card, placeholder); + placeholder.remove(); + } + placeholder = null; + } + btn.replaceChildren(svgIcon(on ? COLLAPSE : EXPAND)); + btn.setAttribute('aria-pressed', String(on)); + // Let the browser apply the new box, then notify the caller. Cancel any + // prior pending frame so a rapid toggle (or a toggle during teardown) + // can't fire a stale onResize after the view is gone. + if (resizeRAF) cancelAnimationFrame(resizeRAF); + resizeRAF = requestAnimationFrame(() => { + resizeRAF = null; + if (onResize) onResize(isFs); + }); + } + + btn.addEventListener('click', () => setFullscreen(!isFs)); + + function onKey(e) { + if (e.key === 'Escape' && isFs) setFullscreen(false); + } + document.addEventListener('keydown', onKey); + + if (anchor) { + anchor.appendChild(btn); + } else { + if (getComputedStyle(card).position === 'static') { + card.style.position = 'relative'; + } + btn.classList.add('card-fs-btn-floating'); + card.appendChild(btn); + } + + return { + isFullscreen: () => isFs, + exit: () => setFullscreen(false), + destroy: () => { + document.removeEventListener('keydown', onKey); + if (isFs) setFullscreen(false); + // setFullscreen(false) may have scheduled a frame; drop it so no + // stale onResize runs after teardown. + if (resizeRAF) { cancelAnimationFrame(resizeRAF); resizeRAF = null; } + btn.remove(); + }, + }; +} diff --git a/src/pyfly/admin/static/js/components/sidebar.js b/src/pyfly/admin/static/js/components/sidebar.js index 1a04aac..977922c 100644 --- a/src/pyfly/admin/static/js/components/sidebar.js +++ b/src/pyfly/admin/static/js/components/sidebar.js @@ -97,7 +97,7 @@ export function renderSidebar(container, currentRoute, options = {}) { // Clear previous content container.textContent = ''; - // Brand header + // Brand header — the pyfly wordmark logo (no secondary label). const brand = document.createElement('div'); brand.className = 'admin-sidebar-brand'; @@ -106,14 +106,6 @@ export function renderSidebar(container, currentRoute, options = {}) { logo.alt = 'PyFly'; brand.appendChild(logo); - const divider = document.createElement('div'); - divider.className = 'admin-sidebar-brand-divider'; - brand.appendChild(divider); - - const brandText = document.createElement('span'); - brandText.textContent = 'Admin Dashboard'; - brand.appendChild(brandText); - container.appendChild(brand); // Navigation diff --git a/src/pyfly/admin/static/js/views/bean-graph.js b/src/pyfly/admin/static/js/views/bean-graph.js index baf7c77..58f9c4f 100644 --- a/src/pyfly/admin/static/js/views/bean-graph.js +++ b/src/pyfly/admin/static/js/views/bean-graph.js @@ -17,6 +17,7 @@ /* global d3 */ import { createEmptyStateCard } from '../components/empty-state.js'; +import { attachFullscreen } from '../components/fullscreen.js'; import { skeletonCard } from '../components/skeleton.js'; const STEREOTYPE_COLORS = { @@ -628,7 +629,7 @@ export async function render(container, api) { const card = document.createElement('div'); card.className = 'admin-card'; const cardBody = document.createElement('div'); - cardBody.className = 'admin-card-body'; + cardBody.className = 'admin-card-body bean-graph-body'; cardBody.style.padding = '0'; cardBody.style.overflow = 'hidden'; cardBody.style.position = 'relative'; @@ -636,14 +637,15 @@ export async function render(container, api) { wrapper.appendChild(card); container.appendChild(wrapper); - const width = cardBody.clientWidth || 900; - const height = 600; + // Responsive dimensions (re-measured on resize / fullscreen toggle). + let width = cardBody.clientWidth || 900; + let height = cardBody.clientHeight || 600; - // Create SVG with D3 + // Create SVG with D3 — fills the card body; the viewBox tracks its pixel size. const svg = d3.select(cardBody) .append('svg') .attr('width', '100%') - .attr('height', height) + .attr('height', '100%') .attr('viewBox', `0 0 ${width} ${height}`); // Arrow marker for directed edges @@ -994,6 +996,45 @@ export async function render(container, api) { labels.attr('x', d => d.x).attr('y', d => d.y); }); + // ── Responsive resize + fullscreen ─────────────────────── + let _destroyed = false; + function resizeGraph() { + // A deferred resize must not revive a stopped simulation after teardown. + if (_destroyed || !cardBody.isConnected) return; + width = cardBody.clientWidth || width; + height = cardBody.clientHeight || height; + svg.attr('viewBox', `0 0 ${width} ${height}`); + simulation.force('center', d3.forceCenter(width / 2, height / 2)); + simulation.alpha(0.3).restart(); + } + + let _resizeRAF = null; + function onWindowResize() { + if (_resizeRAF) return; + _resizeRAF = requestAnimationFrame(() => { + _resizeRAF = null; + resizeGraph(); + }); + } + window.addEventListener('resize', onWindowResize); + + // Expand-to-fullscreen (re-measures the graph once the card has resized). + const fullscreen = attachFullscreen(card, { onResize: () => resizeGraph(), label: 'Expand graph' }); + + // Reset view (clears pan/zoom back to the default transform). + const resetBtn = document.createElement('button'); + resetBtn.type = 'button'; + resetBtn.className = 'btn btn-sm'; + resetBtn.textContent = 'Reset view'; + resetBtn.style.position = 'absolute'; + resetBtn.style.top = '10px'; + resetBtn.style.left = '10px'; + resetBtn.style.zIndex = '5'; + resetBtn.addEventListener('click', () => { + svg.transition().duration(400).call(zoom.transform, d3.zoomIdentity); + }); + card.appendChild(resetBtn); + // ── Legend ──────────────────────────────────────────────── const legendCard = document.createElement('div'); legendCard.className = 'admin-card'; @@ -1112,6 +1153,10 @@ export async function render(container, api) { // ── Cleanup ────────────────────────────────────────────── return function cleanup() { + _destroyed = true; + if (_resizeRAF) { cancelAnimationFrame(_resizeRAF); _resizeRAF = null; } + window.removeEventListener('resize', onWindowResize); + fullscreen.destroy(); simulation.stop(); detailPanel.destroy(); searchInput.removeEventListener('input', _onSearchInput); diff --git a/src/pyfly/admin/static/js/views/logfile.js b/src/pyfly/admin/static/js/views/logfile.js index 7df17d6..f19434e 100644 --- a/src/pyfly/admin/static/js/views/logfile.js +++ b/src/pyfly/admin/static/js/views/logfile.js @@ -12,6 +12,7 @@ import { createEmptyStateCard } from '../components/empty-state.js'; import { createFilterToolbar } from '../components/filter-toolbar.js'; +import { attachFullscreen } from '../components/fullscreen.js'; import { pageSkeleton } from '../components/skeleton.js'; import { showToast } from '../components/toast.js'; import { sse } from '../sse.js'; @@ -281,6 +282,15 @@ export async function render(container, api) { logCard.appendChild(logOutput); wrapper.appendChild(logCard); + // Expand the log output to fullscreen (button lives in the card header). + const fullscreen = attachFullscreen(logCard, { + anchor: logHeader, + label: 'Expand log output', + onResize: () => { + if (autoScrollCheck.checked) logOutput.scrollTop = logOutput.scrollHeight; + }, + }); + function updateStats() { totalVal.textContent = String(records.length); } @@ -344,5 +354,6 @@ export async function render(container, api) { // ── Cleanup ────────────────────────────────────────────── return function cleanup() { sse.disconnect('/logfile'); + fullscreen.destroy(); }; } diff --git a/src/pyfly/admin/static/js/views/loggers.js b/src/pyfly/admin/static/js/views/loggers.js index 75b1031..1869701 100644 --- a/src/pyfly/admin/static/js/views/loggers.js +++ b/src/pyfly/admin/static/js/views/loggers.js @@ -178,6 +178,68 @@ export async function render(container, api) { wrapper.appendChild(statsRow); + // ── Effective level distribution ───────────────────────── + const LEVEL_ORDER = ['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']; + if (loggerEntries.length > 0) { + const distCard = document.createElement('div'); + distCard.className = 'admin-card mb-lg'; + const distHeader = document.createElement('div'); + distHeader.className = 'admin-card-header'; + const distTitle = document.createElement('h3'); + distTitle.textContent = 'Effective Level Distribution'; + distHeader.appendChild(distTitle); + distCard.appendChild(distHeader); + const distBody = document.createElement('div'); + distBody.className = 'admin-card-body'; + + const bar = document.createElement('div'); + bar.className = 'status-bar'; + bar.setAttribute('aria-hidden', 'true'); + const legend = document.createElement('div'); + legend.className = 'status-legend'; + + const total = loggerEntries.length; + // Include any non-standard effective levels (e.g. NOTSET) so the bar + // segments and percentages sum to 100% rather than silently dropping them. + const extraLevels = Object.keys(levelCounts).filter((l) => !LEVEL_ORDER.includes(l)); + for (const lvl of [...LEVEL_ORDER, ...extraLevels]) { + const count = levelCounts[lvl] || 0; + if (count === 0) continue; + const pct = (count / total) * 100; + const color = LEVEL_COLORS[lvl] || 'var(--admin-text-muted)'; + + const seg = document.createElement('div'); + seg.className = 'status-bar-seg'; + seg.style.background = color; + seg.style.width = pct + '%'; + seg.title = `${lvl}: ${count}`; + bar.appendChild(seg); + + const item = document.createElement('div'); + item.className = 'status-legend-item'; + const dot = document.createElement('span'); + dot.className = 'status-legend-dot'; + dot.style.background = color; + item.appendChild(dot); + const lab = document.createElement('span'); + lab.textContent = lvl; + item.appendChild(lab); + const c = document.createElement('span'); + c.className = 'status-legend-count'; + c.textContent = String(count); + item.appendChild(c); + const p = document.createElement('span'); + p.className = 'status-legend-pct'; + p.textContent = `(${pct.toFixed(0)}%)`; + item.appendChild(p); + legend.appendChild(item); + } + distBody.appendChild(bar); + distBody.appendChild(legend); + distCard.appendChild(distBody); + wrapper.appendChild(distCard); + } + // ── Loggers table card ─────────────────────────────────── const tableCard = document.createElement('div'); tableCard.className = 'admin-card'; @@ -193,9 +255,12 @@ export async function render(container, api) { tableBody.className = 'admin-card-body'; tableBody.style.padding = '12px 20px 0'; - // Table container + // Table container — scrolls within a viewport-relative height so the + // toolbar, stats and distribution stay visible (sticky header). const tableWrap = document.createElement('div'); tableWrap.className = 'admin-table-wrapper'; + tableWrap.style.maxHeight = 'min(58vh, 620px)'; + tableWrap.style.overflowY = 'auto'; const table = document.createElement('table'); table.className = 'admin-table'; diff --git a/src/pyfly/admin/static/js/views/metrics.js b/src/pyfly/admin/static/js/views/metrics.js index e054b05..c2b8860 100644 --- a/src/pyfly/admin/static/js/views/metrics.js +++ b/src/pyfly/admin/static/js/views/metrics.js @@ -346,10 +346,15 @@ export async function render(container, api) { const leftBody = document.createElement('div'); leftBody.style.padding = '12px'; + leftBody.style.flex = '1'; + leftBody.style.minHeight = '0'; + leftBody.style.display = 'flex'; + leftBody.style.flexDirection = 'column'; - // Metric list container (scrollable) + // Metric list container — fills the panel height and scrolls internally. const metricList = document.createElement('div'); - metricList.style.maxHeight = '520px'; + metricList.style.flex = '1'; + metricList.style.minHeight = '0'; metricList.style.overflowY = 'auto'; let activeItem = null; diff --git a/uv.lock b/uv.lock index 9e5f77b..a9b4e0d 100644 --- a/uv.lock +++ b/uv.lock @@ -1589,7 +1589,7 @@ wheels = [ [[package]] name = "pyfly" -version = "26.5.11" +version = "26.5.12" source = { editable = "." } dependencies = [ { name = "pydantic" },