diff --git a/CHANGELOG.md b/CHANGELOG.md index eea66a9..1cc0725 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## v26.05.11 (2026-05-31) + +### Admin dashboard — loading skeletons & consistent empty states + +A cross-cutting polish pass over every view: + +- **Skeleton loaders.** While a view fetches its data it now shows a shimmer + skeleton that mirrors the eventual layout (stat cards + table/cards) instead of + a bare spinner, so the page doesn't jump and loads feel faster. New reusable + `components/skeleton.js` (`pageSkeleton`, `skeletonStatCards`, `skeletonTable`, + `skeletonCard`, `skeletonLine`); theme-aware sheen; honours + `prefers-reduced-motion`. +- **Consistent empty / error states.** "No data" and "failed to load" panels are + now a single iconographic component (`components/empty-state.js` — + `createEmptyState` / `createEmptyStateCard`) with a fitting icon, a clear title + and a helpful sentence, replacing the ad-hoc title+text blocks scattered across + views. Errors use a danger-tinted alert icon and preserve the error message. +- Applied across all 17 views; behaviour is otherwise unchanged (data handling, + SSE/chart lifecycle and cleanup are untouched). Verified live across every view + with zero console errors. + +--- + ## v26.05.10 (2026-05-31) ### Admin dashboard — HTTP request analytics on Traces diff --git a/pyproject.toml b/pyproject.toml index df303fc..3097758 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.10" +version = "26.5.11" 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 6e35aaa..487aa95 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.10" +__version__ = "26.05.11" diff --git a/src/pyfly/admin/static/css/admin.css b/src/pyfly/admin/static/css/admin.css index 48fc530..87f04e7 100644 --- a/src/pyfly/admin/static/css/admin.css +++ b/src/pyfly/admin/static/css/admin.css @@ -1023,6 +1023,50 @@ a:hover { to { transform: rotate(360deg); } } +/* ── Skeleton Loaders ───────────────────────────────────────────── */ +.skeleton { + position: relative; + overflow: hidden; + background: var(--admin-surface-hover); + border-radius: var(--admin-radius-sm); +} + +.skeleton::after { + content: ''; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient( + 90deg, + transparent, + var(--admin-skeleton-sheen), + transparent + ); + animation: skeleton-shimmer 1.4s infinite; +} + +@keyframes skeleton-shimmer { + 100% { transform: translateX(100%); } +} + +.skeleton-line { + height: 12px; + margin-bottom: 10px; +} + +.skeleton-line:last-child { + margin-bottom: 0; +} + +.skeleton-stat { + height: 92px; + border-radius: var(--admin-radius-lg); +} + +@media (prefers-reduced-motion: reduce) { + .skeleton::after { animation: none; } +} + /* Inline spinner for text */ .loading-inline { display: inline-flex; diff --git a/src/pyfly/admin/static/css/themes.css b/src/pyfly/admin/static/css/themes.css index 455f9b6..58acc3f 100644 --- a/src/pyfly/admin/static/css/themes.css +++ b/src/pyfly/admin/static/css/themes.css @@ -41,6 +41,8 @@ --admin-font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; --admin-card-bg: #16241a; --admin-transition: 150ms ease; + /* Skeleton-loading sheen swept across placeholder blocks. */ + --admin-skeleton-sheen: rgba(255, 255, 255, 0.06); } [data-theme="light"] { @@ -74,4 +76,5 @@ --admin-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04); --admin-card-bg: #ffffff; --admin-shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.1); + --admin-skeleton-sheen: rgba(15, 23, 42, 0.06); } diff --git a/src/pyfly/admin/static/js/components/empty-state.js b/src/pyfly/admin/static/js/components/empty-state.js new file mode 100644 index 0000000..42395e4 --- /dev/null +++ b/src/pyfly/admin/static/js/components/empty-state.js @@ -0,0 +1,115 @@ +/** + * PyFly Admin — Empty / Error State. + * + * A consistent, iconographic placeholder for "no data" and error states, + * replacing the ad-hoc title+text blocks scattered across views. + */ + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +/** + * Icon path sets (24x24, stroke-based). Keyed by semantic name. + * Each entry is an array of // descriptors. + */ +const ICONS = { + // Empty inbox / tray + inbox: ['path:M22 12h-6l-2 3h-4l-2-3H2', 'path:M5.45 5.11L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z'], + // Warning triangle (errors) + alert: ['path:M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z', 'line:12 9 12 13', 'line:12 17 12.01 17'], + // Magnifier (no matches) + search: ['circle:11 11 8', 'line:21 21 16.65 16.65'], + // Database / data + database: ['path:M21 5c0 1.66-4.03 3-9 3S3 6.66 3 5s4.03-3 9-3 9 1.34 9 3z', 'path:M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5', 'path:M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3'], + // Activity / metrics + activity: ['path:M22 12h-4l-3 9L9 3l-3 9H2'], + // Server / instances + server: ['path:M5 3h14a2 2 0 012 2v4a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2z', 'path:M5 13h14a2 2 0 012 2v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4a2 2 0 012-2z', 'line:6 7 6.01 7', 'line:6 17 6.01 17'], + // Plug / disconnected + plug: ['path:M12 22v-5', 'path:M9 8V2', 'path:M15 8V2', 'path:M18 8v3a6 6 0 01-12 0V8z'], +}; + +/** + * Build an SVG element from an icon descriptor list. + * @param {string} name + * @returns {SVGElement} + */ +function buildIcon(name) { + 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', '1.75'); + svg.setAttribute('stroke-linecap', 'round'); + svg.setAttribute('stroke-linejoin', 'round'); + svg.setAttribute('aria-hidden', 'true'); + + const parts = ICONS[name] || ICONS.inbox; + for (const spec of parts) { + const [kind, coords] = spec.split(':'); + if (kind === 'path') { + const p = document.createElementNS(SVG_NS, 'path'); + p.setAttribute('d', coords); + svg.appendChild(p); + } else if (kind === 'line') { + const [x1, y1, x2, y2] = coords.split(' '); + const l = document.createElementNS(SVG_NS, 'line'); + l.setAttribute('x1', x1); l.setAttribute('y1', y1); + l.setAttribute('x2', x2); l.setAttribute('y2', y2); + svg.appendChild(l); + } else if (kind === 'circle') { + const [cx, cy, r] = coords.split(' '); + const c = document.createElementNS(SVG_NS, 'circle'); + c.setAttribute('cx', cx); c.setAttribute('cy', cy); c.setAttribute('r', r); + svg.appendChild(c); + } + } + return svg; +} + +/** + * Create a consistent empty/error state block. + * @param {object} opts + * @param {string} [opts.icon='inbox'] One of the ICONS keys. + * @param {string} opts.title Headline. + * @param {string} [opts.text] Supporting description. + * @param {'muted'|'danger'} [opts.tone='muted'] Icon tint. + * @returns {HTMLElement} + */ +export function createEmptyState({ icon = 'inbox', title, text, tone = 'muted' } = {}) { + const wrap = document.createElement('div'); + wrap.className = 'empty-state'; + + const svg = buildIcon(icon); + if (tone === 'danger') svg.style.color = 'var(--admin-danger)'; + wrap.appendChild(svg); + + if (title) { + const t = document.createElement('div'); + t.className = 'empty-state-title'; + t.textContent = title; + wrap.appendChild(t); + } + if (text) { + const p = document.createElement('div'); + p.className = 'empty-state-text'; + p.textContent = text; + wrap.appendChild(p); + } + return wrap; +} + +/** + * Create an empty/error state wrapped in a card (the common full-width + * "no data" / "failed to load" panel). + * @param {object} opts Same as createEmptyState. + * @returns {HTMLElement} + */ +export function createEmptyStateCard(opts) { + const card = document.createElement('div'); + card.className = 'admin-card'; + const body = document.createElement('div'); + body.className = 'admin-card-body'; + body.appendChild(createEmptyState(opts)); + card.appendChild(body); + return card; +} diff --git a/src/pyfly/admin/static/js/components/skeleton.js b/src/pyfly/admin/static/js/components/skeleton.js new file mode 100644 index 0000000..06721d8 --- /dev/null +++ b/src/pyfly/admin/static/js/components/skeleton.js @@ -0,0 +1,98 @@ +/** + * PyFly Admin — Skeleton Loaders. + * + * Lightweight shimmer placeholders shown while a view fetches its data. + * They mirror the eventual layout (stat cards + table, etc.) so the page + * doesn't jump, and read as more polished than a bare spinner. + * + * All builders return detached DOM nodes; callers append and later remove + * them (or call replaceChildren) once real content is ready. + */ + +/** + * A single shimmer line. + * @param {string} [width='100%'] + * @param {string} [height='12px'] + * @returns {HTMLElement} + */ +export function skeletonLine(width = '100%', height = '12px') { + const el = document.createElement('div'); + el.className = 'skeleton skeleton-line'; + el.style.width = width; + el.style.height = height; + return el; +} + +/** + * A responsive row of skeleton stat cards. + * @param {number} [count=4] Number of cards (maps to the .grid-N utility). + * @returns {HTMLElement} + */ +export function skeletonStatCards(count = 4) { + const cols = count === 2 ? 2 : count === 3 ? 3 : 4; + const row = document.createElement('div'); + row.className = `grid-${cols} mb-lg`; + for (let i = 0; i < count; i++) { + const card = document.createElement('div'); + card.className = 'skeleton skeleton-stat'; + row.appendChild(card); + } + return row; +} + +/** + * A card containing skeleton rows, approximating a data table. + * @param {object} [opts] + * @param {number} [opts.rows=6] + * @returns {HTMLElement} + */ +export function skeletonTable({ rows = 6 } = {}) { + const card = document.createElement('div'); + card.className = 'admin-card'; + const body = document.createElement('div'); + body.className = 'admin-card-body'; + // A heavier header line, then lighter body rows. + const head = skeletonLine('40%', '18px'); + head.style.marginBottom = '20px'; + body.appendChild(head); + for (let i = 0; i < rows; i++) { + const line = skeletonLine(i % 3 === 0 ? '70%' : '100%', '15px'); + line.style.marginBottom = '14px'; + body.appendChild(line); + } + card.appendChild(body); + return card; +} + +/** + * A generic card with a few text lines (for non-tabular views). + * @param {object} [opts] + * @param {number} [opts.lines=4] + * @returns {HTMLElement} + */ +export function skeletonCard({ lines = 4 } = {}) { + const card = document.createElement('div'); + card.className = 'admin-card'; + const body = document.createElement('div'); + body.className = 'admin-card-body'; + for (let i = 0; i < lines; i++) { + body.appendChild(skeletonLine(i === 0 ? '50%' : `${70 + (i * 7) % 30}%`, '14px')); + } + card.appendChild(body); + return card; +} + +/** + * Composite page skeleton: a stat-card row plus a table card — the most + * common admin view shape. + * @param {object} [opts] + * @param {number} [opts.stats=4] + * @param {number} [opts.rows=6] + * @returns {DocumentFragment} + */ +export function pageSkeleton({ stats = 4, rows = 6 } = {}) { + const frag = document.createDocumentFragment(); + if (stats > 0) frag.appendChild(skeletonStatCards(stats)); + frag.appendChild(skeletonTable({ rows })); + return frag; +} diff --git a/src/pyfly/admin/static/js/views/bean-graph.js b/src/pyfly/admin/static/js/views/bean-graph.js index 6ccdae7..baf7c77 100644 --- a/src/pyfly/admin/static/js/views/bean-graph.js +++ b/src/pyfly/admin/static/js/views/bean-graph.js @@ -16,6 +16,9 @@ /* global d3 */ +import { createEmptyStateCard } from '../components/empty-state.js'; +import { skeletonCard } from '../components/skeleton.js'; + const STEREOTYPE_COLORS = { service: '#3b82f6', controller: '#8b5cf6', @@ -26,28 +29,6 @@ const STEREOTYPE_COLORS = { none: '#64748b', }; -/** - * Build an error/empty card using safe DOM methods only. - */ -function _buildMessageCard(title, text) { - const card = document.createElement('div'); - card.className = 'admin-card'; - const body = document.createElement('div'); - body.className = 'admin-card-body empty-state'; - const h = document.createElement('div'); - h.className = 'empty-state-title'; - h.textContent = title; - body.appendChild(h); - if (text) { - const p = document.createElement('div'); - p.className = 'empty-state-text'; - p.textContent = text; - body.appendChild(p); - } - card.appendChild(body); - return card; -} - /* ── Helpers ─────────────────────────────────────────────────── */ /** @@ -546,19 +527,35 @@ export async function render(container, api) { header.appendChild(headerLeft); wrapper.appendChild(header); + // Loading skeleton (graph area is non-tabular) + const loader = document.createElement('div'); + loader.appendChild(skeletonCard({ lines: 6 })); + wrapper.appendChild(loader); + container.appendChild(wrapper); + // Fetch graph data let data; try { data = await api.get('/beans/graph'); } catch (err) { - wrapper.appendChild(_buildMessageCard('Failed to load graph', err.message)); - container.appendChild(wrapper); + wrapper.removeChild(loader); + wrapper.appendChild(createEmptyStateCard({ + icon: 'alert', + tone: 'danger', + title: 'Failed to load graph', + text: err.message, + })); return; } + wrapper.removeChild(loader); + if (!data.nodes || data.nodes.length === 0) { - wrapper.appendChild(_buildMessageCard('No beans registered')); - container.appendChild(wrapper); + wrapper.appendChild(createEmptyStateCard({ + icon: 'activity', + title: 'No beans registered', + text: 'No beans are registered in the application context to visualize.', + })); return; } diff --git a/src/pyfly/admin/static/js/views/beans.js b/src/pyfly/admin/static/js/views/beans.js index c4e5236..2b524a2 100644 --- a/src/pyfly/admin/static/js/views/beans.js +++ b/src/pyfly/admin/static/js/views/beans.js @@ -10,6 +10,8 @@ * GET /admin/api/beans/{name} -> bean detail object */ +import { createEmptyStateCard } from '../components/empty-state.js'; +import { pageSkeleton } from '../components/skeleton.js'; import { createTable } from '../components/table.js'; /* ── Helpers ──────────────────────────────────────────────────── */ @@ -558,9 +560,9 @@ export async function render(container, api) { wrapper.appendChild(header); - // Loading + // Loading skeleton (stat cards + a table) const loader = document.createElement('div'); - loader.className = 'loading-spinner'; + loader.appendChild(pageSkeleton({ stats: 4, rows: 8 })); wrapper.appendChild(loader); container.appendChild(wrapper); @@ -570,16 +572,12 @@ export async function render(container, api) { data = await api.get('/beans'); } catch (err) { wrapper.removeChild(loader); - const errCard = document.createElement('div'); - errCard.className = 'admin-card'; - const errBody = document.createElement('div'); - errBody.className = 'admin-card-body empty-state'; - const errText = document.createElement('div'); - errText.className = 'empty-state-text'; - errText.textContent = 'Failed to load beans: ' + err.message; - errBody.appendChild(errText); - errCard.appendChild(errBody); - wrapper.appendChild(errCard); + wrapper.appendChild(createEmptyStateCard({ + icon: 'alert', + tone: 'danger', + title: 'Failed to load beans', + text: err.message, + })); return; } diff --git a/src/pyfly/admin/static/js/views/caches.js b/src/pyfly/admin/static/js/views/caches.js index 9e449d4..378593a 100644 --- a/src/pyfly/admin/static/js/views/caches.js +++ b/src/pyfly/admin/static/js/views/caches.js @@ -10,7 +10,9 @@ * POST /admin/api/caches/{name}/evict -> { cleared } | { evicted, key } | { error } */ +import { createEmptyStateCard } from '../components/empty-state.js'; import { createFilterToolbar } from '../components/filter-toolbar.js'; +import { pageSkeleton } from '../components/skeleton.js'; import { showToast } from '../components/toast.js'; /* ── Render ───────────────────────────────────────────────────── */ @@ -57,9 +59,9 @@ export async function render(container, api) { header.appendChild(headerRight); wrapper.appendChild(header); - // Loading + // Loading skeleton (stat cards + keys table) const loader = document.createElement('div'); - loader.className = 'loading-spinner'; + loader.appendChild(pageSkeleton({ stats: 4, rows: 6 })); wrapper.appendChild(loader); container.appendChild(wrapper); @@ -69,16 +71,12 @@ export async function render(container, api) { data = await api.get('/caches'); } catch (err) { wrapper.removeChild(loader); - const errCard = document.createElement('div'); - errCard.className = 'admin-card'; - const errBody = document.createElement('div'); - errBody.className = 'admin-card-body empty-state'; - const errText = document.createElement('div'); - errText.className = 'empty-state-text'; - errText.textContent = 'Failed to load cache data: ' + err.message; - errBody.appendChild(errText); - errCard.appendChild(errBody); - wrapper.appendChild(errCard); + wrapper.appendChild(createEmptyStateCard({ + icon: 'alert', + tone: 'danger', + title: 'Failed to load cache data', + text: err.message, + })); return; } @@ -86,16 +84,11 @@ export async function render(container, api) { // ── Cache not available ────────────────────────────────── if (!data.available) { - const infoCard = document.createElement('div'); - infoCard.className = 'admin-card'; - const infoBody = document.createElement('div'); - infoBody.className = 'admin-card-body empty-state'; - const infoText = document.createElement('div'); - infoText.className = 'empty-state-text'; - infoText.textContent = 'No cache adapter is configured. Register a CacheAdapter bean to enable caching.'; - infoBody.appendChild(infoText); - infoCard.appendChild(infoBody); - wrapper.appendChild(infoCard); + wrapper.appendChild(createEmptyStateCard({ + icon: 'database', + title: 'No cache adapter configured', + text: 'Register a CacheAdapter bean to enable caching.', + })); return; } diff --git a/src/pyfly/admin/static/js/views/config.js b/src/pyfly/admin/static/js/views/config.js index 923da18..5c8fb7b 100644 --- a/src/pyfly/admin/static/js/views/config.js +++ b/src/pyfly/admin/static/js/views/config.js @@ -8,7 +8,9 @@ * -> { groups: { "pyfly.web": { port: 8080, adapter: "auto" }, ... } } */ +import { createEmptyStateCard } from '../components/empty-state.js'; import { createFilterToolbar } from '../components/filter-toolbar.js'; +import { pageSkeleton } from '../components/skeleton.js'; /* ── Helpers ──────────────────────────────────────────────────── */ @@ -257,9 +259,9 @@ export async function render(container, api) { header.appendChild(headerLeft); wrapper.appendChild(header); - // Loading + // Loading skeleton (two stat cards + property rows) const loader = document.createElement('div'); - loader.className = 'loading-spinner'; + loader.appendChild(pageSkeleton({ stats: 2, rows: 6 })); wrapper.appendChild(loader); container.appendChild(wrapper); @@ -269,16 +271,12 @@ export async function render(container, api) { configData = await api.get('/config'); } catch (err) { wrapper.removeChild(loader); - const errCard = document.createElement('div'); - errCard.className = 'admin-card'; - const errBody = document.createElement('div'); - errBody.className = 'admin-card-body empty-state'; - const errText = document.createElement('div'); - errText.className = 'empty-state-text'; - errText.textContent = 'Failed to load configuration: ' + err.message; - errBody.appendChild(errText); - errCard.appendChild(errBody); - wrapper.appendChild(errCard); + wrapper.appendChild(createEmptyStateCard({ + icon: 'alert', + tone: 'danger', + title: 'Failed to load configuration', + text: err.message, + })); return; } @@ -288,16 +286,11 @@ export async function render(container, api) { const groupNames = Object.keys(groups); if (groupNames.length === 0) { - const emptyCard = document.createElement('div'); - emptyCard.className = 'admin-card'; - const emptyBody = document.createElement('div'); - emptyBody.className = 'admin-card-body empty-state'; - const emptyText = document.createElement('div'); - emptyText.className = 'empty-state-text'; - emptyText.textContent = 'No configuration groups found'; - emptyBody.appendChild(emptyText); - emptyCard.appendChild(emptyBody); - wrapper.appendChild(emptyCard); + wrapper.appendChild(createEmptyStateCard({ + icon: 'inbox', + title: 'No configuration groups found', + text: 'No application configuration properties were exposed by this service.', + })); return; } diff --git a/src/pyfly/admin/static/js/views/cqrs.js b/src/pyfly/admin/static/js/views/cqrs.js index f8163ad..8e1f335 100644 --- a/src/pyfly/admin/static/js/views/cqrs.js +++ b/src/pyfly/admin/static/js/views/cqrs.js @@ -9,6 +9,8 @@ * GET /admin/api/cqrs -> { handlers: [...], total: N, pipeline: {...} } */ +import { createEmptyStateCard } from '../components/empty-state.js'; +import { pageSkeleton } from '../components/skeleton.js'; import { createTable } from '../components/table.js'; /* ── Helpers ──────────────────────────────────────────────────── */ @@ -73,9 +75,9 @@ export async function render(container, api) { header.appendChild(headerLeft); wrapper.appendChild(header); - // Loading + // Loading skeleton (three stat cards + a table) const loader = document.createElement('div'); - loader.className = 'loading-spinner'; + loader.appendChild(pageSkeleton({ stats: 3, rows: 6 })); wrapper.appendChild(loader); container.appendChild(wrapper); @@ -85,16 +87,12 @@ export async function render(container, api) { data = await api.get('/cqrs'); } catch (err) { wrapper.removeChild(loader); - const errCard = document.createElement('div'); - errCard.className = 'admin-card'; - const errBody = document.createElement('div'); - errBody.className = 'admin-card-body empty-state'; - const errText = document.createElement('div'); - errText.className = 'empty-state-text'; - errText.textContent = 'Failed to load CQRS data: ' + err.message; - errBody.appendChild(errText); - errCard.appendChild(errBody); - wrapper.appendChild(errCard); + wrapper.appendChild(createEmptyStateCard({ + icon: 'alert', + tone: 'danger', + title: 'Failed to load CQRS data', + text: err.message, + })); return; } diff --git a/src/pyfly/admin/static/js/views/environment.js b/src/pyfly/admin/static/js/views/environment.js index 69ec950..f50bae8 100644 --- a/src/pyfly/admin/static/js/views/environment.js +++ b/src/pyfly/admin/static/js/views/environment.js @@ -8,8 +8,10 @@ * -> { active_profiles: [...], properties: {...}, sources: [...] } */ -import { createTable } from '../components/table.js'; +import { createEmptyStateCard } from '../components/empty-state.js'; import { createFilterToolbar } from '../components/filter-toolbar.js'; +import { pageSkeleton } from '../components/skeleton.js'; +import { createTable } from '../components/table.js'; /* ── Helpers ──────────────────────────────────────────────────── */ @@ -126,9 +128,9 @@ export async function render(container, api) { header.appendChild(headerLeft); wrapper.appendChild(header); - // Loading + // Loading skeleton (profiles + sources stat cards, properties table) const loader = document.createElement('div'); - loader.className = 'loading-spinner'; + loader.appendChild(pageSkeleton({ stats: 2, rows: 8 })); wrapper.appendChild(loader); container.appendChild(wrapper); @@ -138,16 +140,12 @@ export async function render(container, api) { envData = await api.get('/env'); } catch (err) { wrapper.removeChild(loader); - const errCard = document.createElement('div'); - errCard.className = 'admin-card'; - const errBody = document.createElement('div'); - errBody.className = 'admin-card-body empty-state'; - const errText = document.createElement('div'); - errText.className = 'empty-state-text'; - errText.textContent = 'Failed to load environment data: ' + err.message; - errBody.appendChild(errText); - errCard.appendChild(errBody); - wrapper.appendChild(errCard); + wrapper.appendChild(createEmptyStateCard({ + icon: 'alert', + tone: 'danger', + title: 'Failed to load environment data', + text: err.message, + })); return; } diff --git a/src/pyfly/admin/static/js/views/health.js b/src/pyfly/admin/static/js/views/health.js index be17a34..53a0a3e 100644 --- a/src/pyfly/admin/static/js/views/health.js +++ b/src/pyfly/admin/static/js/views/health.js @@ -8,6 +8,8 @@ * SSE stream: /health (event type "health") */ +import { createEmptyStateCard } from '../components/empty-state.js'; +import { pageSkeleton } from '../components/skeleton.js'; import { createStatusBadge } from '../components/status-badge.js'; import { sse } from '../sse.js'; @@ -231,9 +233,9 @@ export async function render(container, api) { header.appendChild(headerLeft); wrapper.appendChild(header); - // Loading + // Loading skeleton (overall status stat cards + component indicator rows) const loader = document.createElement('div'); - loader.className = 'loading-spinner'; + loader.appendChild(pageSkeleton({ stats: 4, rows: 5 })); wrapper.appendChild(loader); container.appendChild(wrapper); @@ -243,16 +245,12 @@ export async function render(container, api) { healthData = await api.get('/health'); } catch (err) { wrapper.removeChild(loader); - const errCard = document.createElement('div'); - errCard.className = 'admin-card'; - const errBody = document.createElement('div'); - errBody.className = 'admin-card-body empty-state'; - const errText = document.createElement('div'); - errText.className = 'empty-state-text'; - errText.textContent = 'Failed to load health data: ' + err.message; - errBody.appendChild(errText); - errCard.appendChild(errBody); - wrapper.appendChild(errCard); + wrapper.appendChild(createEmptyStateCard({ + icon: 'alert', + tone: 'danger', + title: 'Failed to load health data', + text: err.message, + })); return; } @@ -312,16 +310,11 @@ export async function render(container, api) { componentTree.appendChild(buildComponentCard(name, comp)); } } else { - const noComp = document.createElement('div'); - noComp.className = 'admin-card'; - const noCompBody = document.createElement('div'); - noCompBody.className = 'admin-card-body empty-state'; - const noCompText = document.createElement('div'); - noCompText.className = 'empty-state-text'; - noCompText.textContent = 'No health components registered'; - noCompBody.appendChild(noCompText); - noComp.appendChild(noCompBody); - componentTree.appendChild(noComp); + componentTree.appendChild(createEmptyStateCard({ + icon: 'activity', + title: 'No health components registered', + text: 'This application has not registered any health indicators.', + })); } treeSection.appendChild(componentTree); diff --git a/src/pyfly/admin/static/js/views/instances.js b/src/pyfly/admin/static/js/views/instances.js index 6dbe91c..001da4b 100644 --- a/src/pyfly/admin/static/js/views/instances.js +++ b/src/pyfly/admin/static/js/views/instances.js @@ -8,6 +8,8 @@ * Data source: GET /admin/api/instances */ +import { createEmptyStateCard } from '../components/empty-state.js'; +import { pageSkeleton } from '../components/skeleton.js'; import { createStatusBadge } from '../components/status-badge.js'; /* -- Helpers --------------------------------------------------------- */ @@ -153,9 +155,9 @@ export async function render(container, api) { header.appendChild(headerLeft); wrapper.appendChild(header); - // Loading spinner + // Loading skeleton (stat cards + instance list) const loader = document.createElement('div'); - loader.className = 'loading-spinner'; + loader.appendChild(pageSkeleton({ stats: 3, rows: 5 })); wrapper.appendChild(loader); container.appendChild(wrapper); @@ -165,20 +167,12 @@ export async function render(container, api) { data = await api.get('/instances'); } catch (err) { wrapper.removeChild(loader); - const errCard = document.createElement('div'); - errCard.className = 'admin-card'; - const errBody = document.createElement('div'); - errBody.className = 'admin-card-body empty-state'; - const errTitle = document.createElement('div'); - errTitle.className = 'empty-state-title'; - errTitle.textContent = 'Failed to load instances'; - errBody.appendChild(errTitle); - const errText = document.createElement('div'); - errText.className = 'empty-state-text'; - errText.textContent = err.message; - errBody.appendChild(errText); - errCard.appendChild(errBody); - wrapper.appendChild(errCard); + wrapper.appendChild(createEmptyStateCard({ + icon: 'alert', + tone: 'danger', + title: 'Failed to load instances', + text: err.message, + })); return; } @@ -188,21 +182,11 @@ export async function render(container, api) { // -- Empty state -- if (instances.length === 0) { - const emptyCard = document.createElement('div'); - emptyCard.className = 'admin-card'; - const emptyBody = document.createElement('div'); - emptyBody.className = 'admin-card-body empty-state'; - const emptyTitle = document.createElement('div'); - emptyTitle.className = 'empty-state-title'; - emptyTitle.textContent = 'No instances registered'; - emptyBody.appendChild(emptyTitle); - const emptyText = document.createElement('div'); - emptyText.className = 'empty-state-text'; - emptyText.textContent = - 'Register application instances via the admin server API or configure static discovery.'; - emptyBody.appendChild(emptyText); - emptyCard.appendChild(emptyBody); - wrapper.appendChild(emptyCard); + wrapper.appendChild(createEmptyStateCard({ + icon: 'server', + title: 'No instances registered', + text: 'Register application instances via the admin server API or configure static discovery.', + })); return; } diff --git a/src/pyfly/admin/static/js/views/logfile.js b/src/pyfly/admin/static/js/views/logfile.js index c9a6b6f..7df17d6 100644 --- a/src/pyfly/admin/static/js/views/logfile.js +++ b/src/pyfly/admin/static/js/views/logfile.js @@ -10,7 +10,9 @@ * SSE /admin/api/sse/logfile -> event type "log" */ +import { createEmptyStateCard } from '../components/empty-state.js'; import { createFilterToolbar } from '../components/filter-toolbar.js'; +import { pageSkeleton } from '../components/skeleton.js'; import { showToast } from '../components/toast.js'; import { sse } from '../sse.js'; @@ -139,9 +141,9 @@ export async function render(container, api) { header.appendChild(headerRight); wrapper.appendChild(header); - // Loading + // Loading skeleton (two stat cards + a log table) const loader = document.createElement('div'); - loader.className = 'loading-spinner'; + loader.appendChild(pageSkeleton({ stats: 2, rows: 8 })); wrapper.appendChild(loader); container.appendChild(wrapper); @@ -151,32 +153,23 @@ export async function render(container, api) { data = await api.get('/logfile'); } catch (err) { wrapper.removeChild(loader); - const errCard = document.createElement('div'); - errCard.className = 'admin-card'; - const errBody = document.createElement('div'); - errBody.className = 'admin-card-body empty-state'; - const errText = document.createElement('div'); - errText.className = 'empty-state-text'; - errText.textContent = 'Failed to load log data: ' + err.message; - errBody.appendChild(errText); - errCard.appendChild(errBody); - wrapper.appendChild(errCard); + wrapper.appendChild(createEmptyStateCard({ + icon: 'alert', + tone: 'danger', + title: 'Failed to load log data', + text: err.message, + })); return; } wrapper.removeChild(loader); if (!data.available) { - const infoCard = document.createElement('div'); - infoCard.className = 'admin-card'; - const infoBody = document.createElement('div'); - infoBody.className = 'admin-card-body empty-state'; - const infoText = document.createElement('div'); - infoText.className = 'empty-state-text'; - infoText.textContent = 'Log file viewing is not configured'; - infoBody.appendChild(infoText); - infoCard.appendChild(infoBody); - wrapper.appendChild(infoCard); + wrapper.appendChild(createEmptyStateCard({ + icon: 'plug', + title: 'Log viewing not configured', + text: 'Enable the in-memory log handler to stream application logs here.', + })); return; } diff --git a/src/pyfly/admin/static/js/views/loggers.js b/src/pyfly/admin/static/js/views/loggers.js index 1010fd8..75b1031 100644 --- a/src/pyfly/admin/static/js/views/loggers.js +++ b/src/pyfly/admin/static/js/views/loggers.js @@ -10,8 +10,10 @@ * Action: POST /admin/api/loggers/{name} body: { level: "DEBUG" } */ -import { showToast } from '../components/toast.js'; +import { createEmptyStateCard } from '../components/empty-state.js'; import { createFilterToolbar } from '../components/filter-toolbar.js'; +import { pageSkeleton } from '../components/skeleton.js'; +import { showToast } from '../components/toast.js'; /* ── Helpers ──────────────────────────────────────────────────── */ @@ -76,9 +78,9 @@ export async function render(container, api) { header.appendChild(headerLeft); wrapper.appendChild(header); - // Loading + // Loading skeleton (two stat cards + a table) const loader = document.createElement('div'); - loader.className = 'loading-spinner'; + loader.appendChild(pageSkeleton({ stats: 2, rows: 8 })); wrapper.appendChild(loader); container.appendChild(wrapper); @@ -88,16 +90,12 @@ export async function render(container, api) { loggersData = await api.get('/loggers'); } catch (err) { wrapper.removeChild(loader); - const errCard = document.createElement('div'); - errCard.className = 'admin-card'; - const errBody = document.createElement('div'); - errBody.className = 'admin-card-body empty-state'; - const errText = document.createElement('div'); - errText.className = 'empty-state-text'; - errText.textContent = 'Failed to load loggers: ' + err.message; - errBody.appendChild(errText); - errCard.appendChild(errBody); - wrapper.appendChild(errCard); + wrapper.appendChild(createEmptyStateCard({ + icon: 'alert', + tone: 'danger', + title: 'Failed to load loggers', + text: err.message, + })); return; } diff --git a/src/pyfly/admin/static/js/views/mappings.js b/src/pyfly/admin/static/js/views/mappings.js index 9767416..73ad196 100644 --- a/src/pyfly/admin/static/js/views/mappings.js +++ b/src/pyfly/admin/static/js/views/mappings.js @@ -9,6 +9,8 @@ * GET /admin/api/mappings -> { mappings: [...], total: N } */ +import { createEmptyStateCard } from '../components/empty-state.js'; +import { pageSkeleton } from '../components/skeleton.js'; import { createMethodBadge } from '../components/status-badge.js'; import { createTable } from '../components/table.js'; @@ -190,9 +192,9 @@ export async function render(container, api) { header.appendChild(headerLeft); wrapper.appendChild(header); - // Loading + // Loading skeleton (method stat cards + mappings table) const loader = document.createElement('div'); - loader.className = 'loading-spinner'; + loader.appendChild(pageSkeleton({ stats: 4, rows: 8 })); wrapper.appendChild(loader); container.appendChild(wrapper); @@ -202,16 +204,12 @@ export async function render(container, api) { data = await api.get('/mappings'); } catch (err) { wrapper.removeChild(loader); - const errCard = document.createElement('div'); - errCard.className = 'admin-card'; - const errBody = document.createElement('div'); - errBody.className = 'admin-card-body empty-state'; - const errText = document.createElement('div'); - errText.className = 'empty-state-text'; - errText.textContent = 'Failed to load mappings: ' + err.message; - errBody.appendChild(errText); - errCard.appendChild(errBody); - wrapper.appendChild(errCard); + wrapper.appendChild(createEmptyStateCard({ + icon: 'alert', + tone: 'danger', + title: 'Failed to load mappings', + text: err.message, + })); return; } @@ -269,16 +267,11 @@ export async function render(container, api) { // ── Empty state ────────────────────────────────────────────── if (total === 0) { - const emptyCard = document.createElement('div'); - emptyCard.className = 'admin-card'; - const emptyBody = document.createElement('div'); - emptyBody.className = 'admin-card-body empty-state'; - const emptyText = document.createElement('div'); - emptyText.className = 'empty-state-text'; - emptyText.textContent = 'No request mappings'; - emptyBody.appendChild(emptyText); - emptyCard.appendChild(emptyBody); - wrapper.appendChild(emptyCard); + wrapper.appendChild(createEmptyStateCard({ + icon: 'inbox', + title: 'No request mappings', + text: 'No HTTP routes are registered in this application.', + })); return; } diff --git a/src/pyfly/admin/static/js/views/metrics.js b/src/pyfly/admin/static/js/views/metrics.js index 43f6f98..e054b05 100644 --- a/src/pyfly/admin/static/js/views/metrics.js +++ b/src/pyfly/admin/static/js/views/metrics.js @@ -15,7 +15,9 @@ /* global Chart */ import { createLineChart } from '../charts.js'; +import { createEmptyStateCard } from '../components/empty-state.js'; import { createFilterToolbar } from '../components/filter-toolbar.js'; +import { pageSkeleton } from '../components/skeleton.js'; /* ── Constants ────────────────────────────────────────────────── */ @@ -200,9 +202,9 @@ export async function render(container, api) { header.appendChild(headerLeft); wrapper.appendChild(header); - // Loading + // Loading skeleton (3 stat cards + a metric list) const loader = document.createElement('div'); - loader.className = 'loading-spinner'; + loader.appendChild(pageSkeleton({ stats: 3, rows: 6 })); wrapper.appendChild(loader); container.appendChild(wrapper); @@ -220,16 +222,12 @@ export async function render(container, api) { } } catch (err) { wrapper.removeChild(loader); - const errCard = document.createElement('div'); - errCard.className = 'admin-card'; - const errBody = document.createElement('div'); - errBody.className = 'admin-card-body empty-state'; - const errText = document.createElement('div'); - errText.className = 'empty-state-text'; - errText.textContent = 'Failed to load metrics: ' + err.message; - errBody.appendChild(errText); - errCard.appendChild(errBody); - wrapper.appendChild(errCard); + wrapper.appendChild(createEmptyStateCard({ + icon: 'alert', + tone: 'danger', + title: 'Failed to load metrics', + text: err.message, + })); return () => {}; } @@ -237,16 +235,11 @@ export async function render(container, api) { // If metrics not available at all if (data.available === false) { - const infoCard = document.createElement('div'); - infoCard.className = 'admin-card'; - const infoBody = document.createElement('div'); - infoBody.className = 'admin-card-body empty-state'; - const infoText = document.createElement('div'); - infoText.className = 'empty-state-text'; - infoText.textContent = 'Metrics are not available.'; - infoBody.appendChild(infoText); - infoCard.appendChild(infoBody); - wrapper.appendChild(infoCard); + wrapper.appendChild(createEmptyStateCard({ + icon: 'activity', + title: 'Metrics not available', + text: 'The metrics registry is not enabled for this application.', + })); return () => {}; } @@ -254,16 +247,11 @@ export async function render(container, api) { const hasPrometheus = data.has_prometheus || false; if (names.length === 0) { - const emptyCard = document.createElement('div'); - emptyCard.className = 'admin-card'; - const emptyBody = document.createElement('div'); - emptyBody.className = 'admin-card-body empty-state'; - const emptyText = document.createElement('div'); - emptyText.className = 'empty-state-text'; - emptyText.textContent = 'No metrics registered'; - emptyBody.appendChild(emptyText); - emptyCard.appendChild(emptyBody); - wrapper.appendChild(emptyCard); + wrapper.appendChild(createEmptyStateCard({ + icon: 'activity', + title: 'No metrics registered', + text: 'No metrics have been published by this application yet.', + })); return () => {}; } diff --git a/src/pyfly/admin/static/js/views/overview.js b/src/pyfly/admin/static/js/views/overview.js index ea73af4..d800e7a 100644 --- a/src/pyfly/admin/static/js/views/overview.js +++ b/src/pyfly/admin/static/js/views/overview.js @@ -8,6 +8,8 @@ */ import { DonutChart, GaugeChart, createLineChart, createGaugeChart, createBarChart } from '../charts.js'; +import { createEmptyStateCard } from '../components/empty-state.js'; +import { pageSkeleton } from '../components/skeleton.js'; import { createStatusBadge } from '../components/status-badge.js'; import { sse } from '../sse.js'; @@ -150,9 +152,9 @@ export async function render(container, api) { header.appendChild(headerLeft); wrapper.appendChild(header); - // Loading + // Loading skeleton (4 stat cards + charts/content) const loader = document.createElement('div'); - loader.className = 'loading-spinner'; + loader.appendChild(pageSkeleton({ stats: 4, rows: 5 })); wrapper.appendChild(loader); container.appendChild(wrapper); @@ -161,20 +163,12 @@ export async function render(container, api) { data = await api.get('/overview'); } catch (err) { wrapper.removeChild(loader); - const errCard = document.createElement('div'); - errCard.className = 'admin-card'; - const errBody = document.createElement('div'); - errBody.className = 'admin-card-body empty-state'; - const errTitle = document.createElement('div'); - errTitle.className = 'empty-state-title'; - errTitle.textContent = 'Failed to load overview'; - errBody.appendChild(errTitle); - const errText = document.createElement('div'); - errText.className = 'empty-state-text'; - errText.textContent = err.message; - errBody.appendChild(errText); - errCard.appendChild(errBody); - wrapper.appendChild(errCard); + wrapper.appendChild(createEmptyStateCard({ + icon: 'alert', + tone: 'danger', + title: 'Failed to load overview', + text: err.message, + })); return; } diff --git a/src/pyfly/admin/static/js/views/runtime.js b/src/pyfly/admin/static/js/views/runtime.js index 9f9ac17..352ac0f 100644 --- a/src/pyfly/admin/static/js/views/runtime.js +++ b/src/pyfly/admin/static/js/views/runtime.js @@ -12,6 +12,8 @@ /* global Chart */ import { createLineChart, cssVar, hexToRgba } from '../charts.js'; +import { createEmptyStateCard } from '../components/empty-state.js'; +import { pageSkeleton } from '../components/skeleton.js'; import { sse } from '../sse.js'; /* ── Constants ──────────────────────────────────────────────── */ @@ -192,7 +194,7 @@ export async function render(container, api) { // ── Loading ────────────────────────────────────────────── const loader = document.createElement('div'); - loader.className = 'loading-spinner'; + loader.appendChild(pageSkeleton({ stats: 4, rows: 5 })); wrapper.appendChild(loader); container.appendChild(wrapper); @@ -201,20 +203,12 @@ export async function render(container, api) { data = await api.get('/runtime'); } catch (err) { wrapper.removeChild(loader); - const errCard = document.createElement('div'); - errCard.className = 'admin-card'; - const errBody = document.createElement('div'); - errBody.className = 'admin-card-body empty-state'; - const errTitle = document.createElement('div'); - errTitle.className = 'empty-state-title'; - errTitle.textContent = 'Failed to load runtime data'; - errBody.appendChild(errTitle); - const errText = document.createElement('div'); - errText.className = 'empty-state-text'; - errText.textContent = err.message; - errBody.appendChild(errText); - errCard.appendChild(errBody); - wrapper.appendChild(errCard); + wrapper.appendChild(createEmptyStateCard({ + icon: 'alert', + tone: 'danger', + title: 'Failed to load runtime data', + text: err.message, + })); return; } diff --git a/src/pyfly/admin/static/js/views/scheduled.js b/src/pyfly/admin/static/js/views/scheduled.js index f143dcb..8c55021 100644 --- a/src/pyfly/admin/static/js/views/scheduled.js +++ b/src/pyfly/admin/static/js/views/scheduled.js @@ -8,6 +8,8 @@ * GET /admin/api/scheduled -> { tasks: [...], total: N } */ +import { createEmptyStateCard } from '../components/empty-state.js'; +import { pageSkeleton } from '../components/skeleton.js'; import { createTable } from '../components/table.js'; /* ── Helpers ──────────────────────────────────────────────────── */ @@ -75,9 +77,9 @@ export async function render(container, api) { header.appendChild(headerLeft); wrapper.appendChild(header); - // Loading + // Loading skeleton (one stat card + a table) const loader = document.createElement('div'); - loader.className = 'loading-spinner'; + loader.appendChild(pageSkeleton({ stats: 1, rows: 6 })); wrapper.appendChild(loader); container.appendChild(wrapper); @@ -87,16 +89,12 @@ export async function render(container, api) { data = await api.get('/scheduled'); } catch (err) { wrapper.removeChild(loader); - const errCard = document.createElement('div'); - errCard.className = 'admin-card'; - const errBody = document.createElement('div'); - errBody.className = 'admin-card-body empty-state'; - const errText = document.createElement('div'); - errText.className = 'empty-state-text'; - errText.textContent = 'Failed to load scheduled tasks: ' + err.message; - errBody.appendChild(errText); - errCard.appendChild(errBody); - wrapper.appendChild(errCard); + wrapper.appendChild(createEmptyStateCard({ + icon: 'alert', + tone: 'danger', + title: 'Failed to load scheduled tasks', + text: err.message, + })); return; } @@ -128,16 +126,11 @@ export async function render(container, api) { // ── Empty state ────────────────────────────────────────────── if (total === 0) { - const emptyCard = document.createElement('div'); - emptyCard.className = 'admin-card'; - const emptyBody = document.createElement('div'); - emptyBody.className = 'admin-card-body empty-state'; - const emptyText = document.createElement('div'); - emptyText.className = 'empty-state-text'; - emptyText.textContent = 'No scheduled tasks'; - emptyBody.appendChild(emptyText); - emptyCard.appendChild(emptyBody); - wrapper.appendChild(emptyCard); + wrapper.appendChild(createEmptyStateCard({ + icon: 'inbox', + title: 'No scheduled tasks', + text: 'No @scheduled methods are registered in this application.', + })); return; } diff --git a/src/pyfly/admin/static/js/views/traces.js b/src/pyfly/admin/static/js/views/traces.js index 85cb10f..e212dc7 100644 --- a/src/pyfly/admin/static/js/views/traces.js +++ b/src/pyfly/admin/static/js/views/traces.js @@ -12,6 +12,8 @@ */ import { createBarChart } from '../charts.js'; +import { createEmptyStateCard } from '../components/empty-state.js'; +import { pageSkeleton } from '../components/skeleton.js'; import { createMethodBadge } from '../components/status-badge.js'; import { sse } from '../sse.js'; @@ -399,9 +401,9 @@ export async function render(container, api) { header.appendChild(headerRight); wrapper.appendChild(header); - // Loading + // Loading skeleton (four stat cards + analytics + a table) const loader = document.createElement('div'); - loader.className = 'loading-spinner'; + loader.appendChild(pageSkeleton({ stats: 4, rows: 6 })); wrapper.appendChild(loader); container.appendChild(wrapper); @@ -411,16 +413,12 @@ export async function render(container, api) { data = await api.get('/traces?limit=500'); } catch (err) { wrapper.removeChild(loader); - const errCard = document.createElement('div'); - errCard.className = 'admin-card'; - const errBody = document.createElement('div'); - errBody.className = 'admin-card-body empty-state'; - const errText = document.createElement('div'); - errText.className = 'empty-state-text'; - errText.textContent = 'Failed to load traces: ' + err.message; - errBody.appendChild(errText); - errCard.appendChild(errBody); - wrapper.appendChild(errCard); + wrapper.appendChild(createEmptyStateCard({ + icon: 'alert', + tone: 'danger', + title: 'Failed to load traces', + text: err.message, + })); return () => {}; } diff --git a/src/pyfly/admin/static/js/views/transactions.js b/src/pyfly/admin/static/js/views/transactions.js index d795c55..131f95f 100644 --- a/src/pyfly/admin/static/js/views/transactions.js +++ b/src/pyfly/admin/static/js/views/transactions.js @@ -9,6 +9,9 @@ * GET /admin/api/transactions -> { sagas, tcc, saga_count, tcc_count, total, in_flight } */ +import { createEmptyStateCard } from '../components/empty-state.js'; +import { pageSkeleton } from '../components/skeleton.js'; + /* ── Helpers ──────────────────────────────────────────────────── */ /** @@ -429,9 +432,9 @@ export async function render(container, api) { header.appendChild(headerLeft); wrapper.appendChild(header); - // Loading + // Loading skeleton (four stat cards + a table) const loader = document.createElement('div'); - loader.className = 'loading-spinner'; + loader.appendChild(pageSkeleton({ stats: 4, rows: 6 })); wrapper.appendChild(loader); container.appendChild(wrapper); @@ -441,16 +444,12 @@ export async function render(container, api) { data = await api.get('/transactions'); } catch (err) { wrapper.removeChild(loader); - const errCard = document.createElement('div'); - errCard.className = 'admin-card'; - const errBody = document.createElement('div'); - errBody.className = 'admin-card-body empty-state'; - const errText = document.createElement('div'); - errText.className = 'empty-state-text'; - errText.textContent = 'Failed to load transaction data: ' + err.message; - errBody.appendChild(errText); - errCard.appendChild(errBody); - wrapper.appendChild(errCard); + wrapper.appendChild(createEmptyStateCard({ + icon: 'alert', + tone: 'danger', + title: 'Failed to load transaction data', + text: err.message, + })); return; } @@ -488,16 +487,11 @@ export async function render(container, api) { sagaSection.appendChild(sagaHeader); if (sagaCount === 0) { - const empty = document.createElement('div'); - empty.className = 'admin-card'; - const emptyBody = document.createElement('div'); - emptyBody.className = 'admin-card-body empty-state'; - const emptyText = document.createElement('div'); - emptyText.className = 'empty-state-text'; - emptyText.textContent = 'No saga definitions registered'; - emptyBody.appendChild(emptyText); - empty.appendChild(emptyBody); - sagaSection.appendChild(empty); + sagaSection.appendChild(createEmptyStateCard({ + icon: 'inbox', + title: 'No sagas registered', + text: 'No @saga definitions are registered in this application.', + })); } else { for (const saga of data.sagas) { sagaSection.appendChild(renderSagaCard(saga)); @@ -522,16 +516,11 @@ export async function render(container, api) { tccSection.appendChild(tccHeader); if (tccCount === 0) { - const empty = document.createElement('div'); - empty.className = 'admin-card'; - const emptyBody = document.createElement('div'); - emptyBody.className = 'admin-card-body empty-state'; - const emptyText = document.createElement('div'); - emptyText.className = 'empty-state-text'; - emptyText.textContent = 'No TCC definitions registered'; - emptyBody.appendChild(emptyText); - empty.appendChild(emptyBody); - tccSection.appendChild(empty); + tccSection.appendChild(createEmptyStateCard({ + icon: 'inbox', + title: 'No TCC transactions registered', + text: 'No @tcc definitions are registered in this application.', + })); } else { for (const tcc of data.tcc) { tccSection.appendChild(renderTccCard(tcc)); diff --git a/uv.lock b/uv.lock index af2d523..9e5f77b 100644 --- a/uv.lock +++ b/uv.lock @@ -1589,7 +1589,7 @@ wheels = [ [[package]] name = "pyfly" -version = "26.5.10" +version = "26.5.11" source = { editable = "." } dependencies = [ { name = "pydantic" },