Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/pyfly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""

__version__ = "26.05.10"
__version__ = "26.05.11"
44 changes: 44 additions & 0 deletions src/pyfly/admin/static/css/admin.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/pyfly/admin/static/css/themes.css
Original file line number Diff line number Diff line change
Expand Up @@ -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"] {
Expand Down Expand Up @@ -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);
}
115 changes: 115 additions & 0 deletions src/pyfly/admin/static/js/components/empty-state.js
Original file line number Diff line number Diff line change
@@ -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 <path>/<line>/<circle> 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;
}
98 changes: 98 additions & 0 deletions src/pyfly/admin/static/js/components/skeleton.js
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading