added skeletons to admin page when data on dashboard and storage page are loading

This commit is contained in:
onysd 2026-09-07 18:40:56 +03:00
parent 87cebec9bd
commit 62849c532e
10 changed files with 193 additions and 41 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -23,8 +23,8 @@
})();
</script>
<script type="module" crossorigin src="/assets/index-DwjnA8dD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B7j2PW0q.css">
<script type="module" crossorigin src="/assets/index-C_av3Wxh.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bw0J_zQR.css">
</head>
<body>
<div id="root"></div>

View file

@ -72,11 +72,25 @@ export function StatusItem({ label, value, tone }: { label: string; value: strin
);
}
export function Metric({ label, value, tone = "neutral", mono = false }: { label: string; value: string; tone?: Tone; mono?: boolean }) {
export function Metric({
label,
value,
tone = "neutral",
mono = false,
loading = false
}: {
label: string;
value: string;
tone?: Tone;
mono?: boolean;
loading?: boolean;
}) {
return (
<div className={`metric ${tone}`}>
<span>{label}</span>
<strong className={mono ? "mono" : ""}>{value}</strong>
<strong className={mono ? "mono" : ""} aria-busy={loading || undefined}>
{loading ? <span className="skeleton skeleton-text" aria-label="Loading" /> : value}
</strong>
</div>
);
}
@ -119,6 +133,26 @@ export function EmptyRow({ colSpan }: { colSpan: number }) {
return <tr><td colSpan={colSpan} className="empty-cell">{"No results"}</td></tr>;
}
// Placeholder rows for a table that has nothing yet *because it is still
// loading* -- distinct from EmptyRow, which asserts the query genuinely
// returned nothing. Showing "No results" during the first fetch reads as a
// wrong answer rather than a pending one.
export function LoadingRow({ colSpan, rows = 3 }: { colSpan: number; rows?: number }) {
return (
<>
{Array.from({ length: rows }, (_, row) => (
<tr key={row} aria-busy="true">
{Array.from({ length: colSpan }, (_unused, cell) => (
<td key={cell}>
<span className="skeleton skeleton-text" aria-label={cell === 0 ? "Loading" : undefined} />
</td>
))}
</tr>
))}
</>
);
}
export function LoadingSurface({ label }: { label: string }) {
return <section className="surface"><div className="loading-line">{label}</div></section>;
}

View file

@ -58,7 +58,8 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
<StatTile
icon={<Flag />}
label="Pending reports"
value={counts ? formatQuantity(String(counts.PendingReports)) : "…"}
value={counts ? formatQuantity(String(counts.PendingReports)) : ""}
loading={!counts && !error}
tone={counts && counts.PendingReports > 0 ? "warn" : "good"}
href="/moderation"
navigate={navigate}
@ -66,7 +67,8 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
<StatTile
icon={<BadgeCheck />}
label="Verification requests"
value={counts ? formatQuantity(String(counts.PendingVerifications)) : "…"}
value={counts ? formatQuantity(String(counts.PendingVerifications)) : ""}
loading={!counts && !error}
tone={counts && counts.PendingVerifications > 0 ? "warn" : "good"}
href="/verification"
navigate={navigate}
@ -74,27 +76,44 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
</Section>
<Section title="People &amp; chats">
<StatTile icon={<Users />} label="Users" value={counts ? formatQuantity(String(counts.Users)) : "…"} href="/accounts" navigate={navigate} />
<StatTile
icon={<Users />}
label="Users"
value={counts ? formatQuantity(String(counts.Users)) : ""}
loading={!counts && !error}
href="/accounts"
navigate={navigate}
/>
<StatTile
icon={<Activity />}
label="Online now"
value={counts ? formatQuantity(String(counts.OnlineUsers)) : "…"}
value={counts ? formatQuantity(String(counts.OnlineUsers)) : ""}
loading={!counts && !error}
sub="last 5 min"
href="/accounts"
navigate={navigate}
/>
<StatTile icon={<Bot />} label="Bots" value={counts ? formatQuantity(String(counts.Bots)) : "…"} href="/bots" navigate={navigate} />
<StatTile
icon={<Bot />}
label="Bots"
value={counts ? formatQuantity(String(counts.Bots)) : ""}
loading={!counts && !error}
href="/bots"
navigate={navigate}
/>
<StatTile
icon={<Radio />}
label="Channels"
value={counts ? formatQuantity(String(counts.BroadcastChannels)) : "…"}
value={counts ? formatQuantity(String(counts.BroadcastChannels)) : ""}
loading={!counts && !error}
href="/channels"
navigate={navigate}
/>
<StatTile
icon={<UsersRound />}
label="Supergroups"
value={counts ? formatQuantity(String(counts.Supergroups)) : "…"}
value={counts ? formatQuantity(String(counts.Supergroups)) : ""}
loading={!counts && !error}
href="/channels"
navigate={navigate}
/>
@ -104,21 +123,24 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
<StatTile
icon={<Sticker />}
label="Sticker packs"
value={counts ? formatQuantity(String(counts.StickerSets)) : "…"}
value={counts ? formatQuantity(String(counts.StickerSets)) : ""}
loading={!counts && !error}
href="/stickers"
navigate={navigate}
/>
<StatTile
icon={<Smile />}
label="Emoji packs"
value={counts ? formatQuantity(String(counts.EmojiSets)) : "…"}
value={counts ? formatQuantity(String(counts.EmojiSets)) : ""}
loading={!counts && !error}
href="/emoji"
navigate={navigate}
/>
<StatTile
icon={<Film />}
label="GIFs"
value={counts ? formatQuantity(String(counts.Gifs)) : "…"}
value={counts ? formatQuantity(String(counts.Gifs)) : ""}
loading={!counts && !error}
sub="saved by users"
href="/gif-catalog"
navigate={navigate}
@ -126,7 +148,8 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
<StatTile
icon={<Database />}
label="Media storage used"
value={storage ? formatBytes(storage.PhysicalBytes) : "…"}
value={storage ? formatBytes(storage.PhysicalBytes) : ""}
loading={!storage && !error}
sub={storage ? `${storage.BackendKind} backend` : undefined}
href="/storage"
navigate={navigate}
@ -138,13 +161,15 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
icon={<Cpu />}
label="CPU load"
percent={host?.Ready ? host.CPUPercent : undefined}
valueText={host?.Ready ? `${host.CPUPercent.toFixed(0)}%` : "…"}
valueText={host?.Ready ? `${host.CPUPercent.toFixed(0)}%` : ""}
loading={!host?.Ready && !error}
/>
<UsageTile
icon={<MemoryStick />}
label="RAM used"
percent={host?.Ready && host.MemTotalBytes > 0 ? (host.MemUsedBytes / host.MemTotalBytes) * 100 : undefined}
valueText={host?.Ready ? formatBytes(String(host.MemUsedBytes)) : "…"}
valueText={host?.Ready ? formatBytes(String(host.MemUsedBytes)) : ""}
loading={!host?.Ready && !error}
sub={host?.Ready ? `of ${formatBytes(String(host.MemTotalBytes))}` : undefined}
/>
<UsageTile
@ -155,7 +180,12 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
? ((host.DiskTotalBytes - host.DiskFreeBytes) / host.DiskTotalBytes) * 100
: undefined
}
valueText={host?.Ready && host.DiskReady ? formatBytes(String(host.DiskFreeBytes)) : "…"}
// Skeleton only until the first host sample lands. After that a
// missing disk reading is a real state, not a pending one -- it can
// stay that way indefinitely, and a shimmer would promise a value
// that is never coming.
valueText={host?.Ready && host.DiskReady ? formatBytes(String(host.DiskFreeBytes)) : "—"}
loading={!host?.Ready && !error}
sub={host?.Ready && host.DiskReady ? `of ${formatBytes(String(host.DiskTotalBytes))}` : "no reading yet"}
warnAbove={85}
/>
@ -185,7 +215,8 @@ function StatTile({
sub,
tone = "neutral",
href,
navigate
navigate,
loading = false
}: {
icon: ReactNode;
label: string;
@ -194,6 +225,7 @@ function StatTile({
tone?: Tone;
href?: string;
navigate?: Navigate;
loading?: boolean;
}) {
const toneClass = tone === "neutral" ? "" : ` ${tone}`;
const body = (
@ -202,7 +234,9 @@ function StatTile({
<span className="stat-tile-icon">{icon}</span>
{tone === "warn" && <AlertTriangle size={15} className="stat-tile-open" />}
</div>
<div className="stat-tile-value">{value}</div>
<div className="stat-tile-value" aria-busy={loading || undefined}>
{loading ? <span className="skeleton skeleton-value" aria-label="Loading" /> : value}
</div>
<div className="stat-tile-label">{label}</div>
{sub && <div className="stat-tile-sub">{sub}</div>}
</>
@ -233,7 +267,8 @@ function UsageTile({
percent,
valueText,
sub,
warnAbove = 90
warnAbove = 90,
loading = false
}: {
icon: ReactNode;
label: string;
@ -241,6 +276,7 @@ function UsageTile({
valueText: string;
sub?: string;
warnAbove?: number;
loading?: boolean;
}) {
const clamped = percent === undefined ? 0 : Math.max(0, Math.min(100, percent));
const tone: Tone = percent === undefined ? "neutral" : percent >= warnAbove ? "danger" : percent >= warnAbove - 15 ? "warn" : "neutral";
@ -250,7 +286,9 @@ function UsageTile({
<div className="stat-tile-head">
<span className="stat-tile-icon">{icon}</span>
</div>
<div className="stat-tile-value">{valueText}</div>
<div className="stat-tile-value" aria-busy={loading || undefined}>
{loading ? <span className="skeleton skeleton-value" aria-label="Loading" /> : valueText}
</div>
<div className="stat-tile-label">{label}</div>
{sub && <div className="stat-tile-sub">{sub}</div>}
<div className="stat-tile-bar">

View file

@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, EmptyRow, Metric, PageFrame, QueryPanel, SectionHead } from "../components/ui";
import { Alert, EmptyRow, LoadingRow, Metric, PageFrame, QueryPanel, SectionHead } from "../components/ui";
import { displayUsername, formatBytes, formatQuantity } from "../lib/format";
import type { Navigate } from "../routing";
import type { AccountStorageRow, StorageStatsResponse } from "../types";
@ -40,6 +40,10 @@ function SortableHeader({
function StorageOverviewTab({ navigate }: { navigate: Navigate }) {
const [stats, setStats] = useState<StorageStatsResponse | null>(null);
// Tracked separately from `error`: loadStats deliberately swallows its
// failure so it can't block the account list, which would otherwise leave
// the metric skeletons shimmering forever on a stats-only outage.
const [statsFailed, setStatsFailed] = useState(false);
const [rows, setRows] = useState<AccountStorageRow[]>([]);
const [hasMore, setHasMore] = useState(false);
const [offset, setOffset] = useState(0);
@ -52,8 +56,10 @@ function StorageOverviewTab({ navigate }: { navigate: Navigate }) {
async function loadStats() {
try {
setStats(await api.storageStats());
setStatsFailed(false);
} catch {
// Stats are a header nicety; a failure here shouldn't block the list.
setStatsFailed(true);
}
}
@ -109,18 +115,39 @@ function StorageOverviewTab({ navigate }: { navigate: Navigate }) {
<>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={"Physical usage (on disk / S3)"} value={stats ? formatBytes(stats.PhysicalBytes) : "-"} />
<Metric label={"Logical usage (sum per account)"} value={stats ? formatBytes(stats.LogicalBytes) : "-"} />
<Metric label={"Saved by dedup"} value={formatBytes(String(dedupBytes))} tone={dedupBytes > 0 ? "good" : "neutral"} />
<Metric label={"Backend"} value={stats?.BackendKind ?? "-"} />
<Metric
label={"Physical usage (on disk / S3)"}
value={stats ? formatBytes(stats.PhysicalBytes) : "-"}
loading={!stats && !statsFailed}
/>
<Metric
label={"Logical usage (sum per account)"}
value={stats ? formatBytes(stats.LogicalBytes) : "-"}
loading={!stats && !statsFailed}
/>
<Metric
label={"Saved by dedup"}
// Derived from stats, so before they land this is a placeholder 0 --
// it has to shimmer like the rest rather than assert "0 B", which
// reads as a real measurement of nothing saved.
value={formatBytes(String(dedupBytes))}
loading={!stats && !statsFailed}
tone={dedupBytes > 0 ? "good" : "neutral"}
/>
<Metric label={"Backend"} value={stats?.BackendKind ?? "-"} loading={!stats && !statsFailed} />
</div>
<div className="metric-row">
<Metric label={"Documents"} value={stats ? formatQuantity(stats.DocumentCount) : "-"} />
<Metric label={"Photos"} value={stats ? formatQuantity(stats.PhotoCount) : "-"} />
<Metric label={"Accounts with media"} value={stats ? formatQuantity(stats.AccountCount) : "-"} />
<Metric label={"Documents"} value={stats ? formatQuantity(stats.DocumentCount) : "-"} loading={!stats && !statsFailed} />
<Metric label={"Photos"} value={stats ? formatQuantity(stats.PhotoCount) : "-"} loading={!stats && !statsFailed} />
<Metric
label={"Accounts with media"}
value={stats ? formatQuantity(stats.AccountCount) : "-"}
loading={!stats && !statsFailed}
/>
<Metric
label={"System/bundled content"}
value={stats ? formatBytes(stats.SystemBytes) : "-"}
loading={!stats && !statsFailed}
/>
</div>
@ -160,7 +187,7 @@ function StorageOverviewTab({ navigate }: { navigate: Navigate }) {
<td><button className="row-link" type="button" onClick={() => navigate(`/accounts/${row.UserID}`)}>{"Details"} <ChevronRight size={14} /></button></td>
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={5} />}
{rows.length === 0 && (busy ? <LoadingRow colSpan={5} /> : <EmptyRow colSpan={5} />)}
</tbody>
</table>
</div>

View file

@ -13,6 +13,11 @@
--panel: #ffffff;
--panel-subtle: #f7f9fc;
--panel-strong: #f1f5f9;
/* Loading placeholders. Kept as their own pair rather than reusing the
panel shades because the sheen has to read as lighter than the base in
both themes, and the panel ramp runs the opposite way in dark. */
--skeleton-base: #e6ebf2;
--skeleton-sheen: #f4f7fa;
--surface-soft: #f2f7fd;
--overlay: rgba(24, 34, 47, 0.42);
--topbar-bg: rgba(255, 255, 255, 0.94);
@ -100,6 +105,8 @@
--panel: #171f28;
--panel-subtle: #1c2530;
--panel-strong: #212c38;
--skeleton-base: #212c38;
--skeleton-sheen: #2e3d4c;
--surface-soft: #1a232d;
--overlay: rgba(5, 8, 12, 0.62);
--topbar-bg: rgba(21, 28, 36, 0.86);

View file

@ -111,6 +111,52 @@ button.stat-tile.clickable {
line-height: 1.1;
}
/* Loading placeholder. Sized in em so it occupies the same box as the text it
stands in for -- the tile must not resize when the real value arrives. */
.skeleton {
display: inline-block;
border-radius: 6px;
background-image: linear-gradient(
90deg,
var(--skeleton-base) 25%,
var(--skeleton-sheen) 37%,
var(--skeleton-base) 63%
);
background-size: 400% 100%;
animation: skeletonShimmer 1.4s ease-in-out infinite;
/* Nothing here is real content: keep it out of the accessibility tree and
out of copied text. The live region announcing "loading" belongs on the
container, not on every bar. */
user-select: none;
}
.skeleton-value {
height: 0.85em;
width: 2.75ch;
vertical-align: middle;
}
.skeleton-text {
height: 0.8em;
width: 6ch;
vertical-align: middle;
}
@keyframes skeletonShimmer {
from {
background-position: 100% 50%;
}
to {
background-position: 0 50%;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
}
}
.stat-tile.warn .stat-tile-value {
color: var(--warn);
}