added skeletons to admin page when data on dashboard and storage page are loading
This commit is contained in:
parent
87cebec9bd
commit
62849c532e
10 changed files with 193 additions and 41 deletions
File diff suppressed because one or more lines are too long
1
cmd/telesrv-admin/web/dist/assets/index-Bw0J_zQR.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-Bw0J_zQR.css
vendored
Normal file
File diff suppressed because one or more lines are too long
9
cmd/telesrv-admin/web/dist/assets/index-C_av3Wxh.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-C_av3Wxh.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -23,8 +23,8 @@
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/index-DwjnA8dD.js"></script>
|
<script type="module" crossorigin src="/assets/index-C_av3Wxh.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-B7j2PW0q.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Bw0J_zQR.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
return (
|
||||||
<div className={`metric ${tone}`}>
|
<div className={`metric ${tone}`}>
|
||||||
<span>{label}</span>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -119,6 +133,26 @@ export function EmptyRow({ colSpan }: { colSpan: number }) {
|
||||||
return <tr><td colSpan={colSpan} className="empty-cell">{"No results"}</td></tr>;
|
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 }) {
|
export function LoadingSurface({ label }: { label: string }) {
|
||||||
return <section className="surface"><div className="loading-line">{label}</div></section>;
|
return <section className="surface"><div className="loading-line">{label}</div></section>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,8 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
|
||||||
<StatTile
|
<StatTile
|
||||||
icon={<Flag />}
|
icon={<Flag />}
|
||||||
label="Pending reports"
|
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"}
|
tone={counts && counts.PendingReports > 0 ? "warn" : "good"}
|
||||||
href="/moderation"
|
href="/moderation"
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
|
|
@ -66,7 +67,8 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
|
||||||
<StatTile
|
<StatTile
|
||||||
icon={<BadgeCheck />}
|
icon={<BadgeCheck />}
|
||||||
label="Verification requests"
|
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"}
|
tone={counts && counts.PendingVerifications > 0 ? "warn" : "good"}
|
||||||
href="/verification"
|
href="/verification"
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
|
|
@ -74,27 +76,44 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="People & chats">
|
<Section title="People & 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
|
<StatTile
|
||||||
icon={<Activity />}
|
icon={<Activity />}
|
||||||
label="Online now"
|
label="Online now"
|
||||||
value={counts ? formatQuantity(String(counts.OnlineUsers)) : "…"}
|
value={counts ? formatQuantity(String(counts.OnlineUsers)) : ""}
|
||||||
|
loading={!counts && !error}
|
||||||
sub="last 5 min"
|
sub="last 5 min"
|
||||||
href="/accounts"
|
href="/accounts"
|
||||||
navigate={navigate}
|
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
|
<StatTile
|
||||||
icon={<Radio />}
|
icon={<Radio />}
|
||||||
label="Channels"
|
label="Channels"
|
||||||
value={counts ? formatQuantity(String(counts.BroadcastChannels)) : "…"}
|
value={counts ? formatQuantity(String(counts.BroadcastChannels)) : ""}
|
||||||
|
loading={!counts && !error}
|
||||||
href="/channels"
|
href="/channels"
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
icon={<UsersRound />}
|
icon={<UsersRound />}
|
||||||
label="Supergroups"
|
label="Supergroups"
|
||||||
value={counts ? formatQuantity(String(counts.Supergroups)) : "…"}
|
value={counts ? formatQuantity(String(counts.Supergroups)) : ""}
|
||||||
|
loading={!counts && !error}
|
||||||
href="/channels"
|
href="/channels"
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
/>
|
/>
|
||||||
|
|
@ -104,21 +123,24 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
|
||||||
<StatTile
|
<StatTile
|
||||||
icon={<Sticker />}
|
icon={<Sticker />}
|
||||||
label="Sticker packs"
|
label="Sticker packs"
|
||||||
value={counts ? formatQuantity(String(counts.StickerSets)) : "…"}
|
value={counts ? formatQuantity(String(counts.StickerSets)) : ""}
|
||||||
|
loading={!counts && !error}
|
||||||
href="/stickers"
|
href="/stickers"
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
icon={<Smile />}
|
icon={<Smile />}
|
||||||
label="Emoji packs"
|
label="Emoji packs"
|
||||||
value={counts ? formatQuantity(String(counts.EmojiSets)) : "…"}
|
value={counts ? formatQuantity(String(counts.EmojiSets)) : ""}
|
||||||
|
loading={!counts && !error}
|
||||||
href="/emoji"
|
href="/emoji"
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
icon={<Film />}
|
icon={<Film />}
|
||||||
label="GIFs"
|
label="GIFs"
|
||||||
value={counts ? formatQuantity(String(counts.Gifs)) : "…"}
|
value={counts ? formatQuantity(String(counts.Gifs)) : ""}
|
||||||
|
loading={!counts && !error}
|
||||||
sub="saved by users"
|
sub="saved by users"
|
||||||
href="/gif-catalog"
|
href="/gif-catalog"
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
|
|
@ -126,7 +148,8 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
|
||||||
<StatTile
|
<StatTile
|
||||||
icon={<Database />}
|
icon={<Database />}
|
||||||
label="Media storage used"
|
label="Media storage used"
|
||||||
value={storage ? formatBytes(storage.PhysicalBytes) : "…"}
|
value={storage ? formatBytes(storage.PhysicalBytes) : ""}
|
||||||
|
loading={!storage && !error}
|
||||||
sub={storage ? `${storage.BackendKind} backend` : undefined}
|
sub={storage ? `${storage.BackendKind} backend` : undefined}
|
||||||
href="/storage"
|
href="/storage"
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
|
|
@ -138,13 +161,15 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
|
||||||
icon={<Cpu />}
|
icon={<Cpu />}
|
||||||
label="CPU load"
|
label="CPU load"
|
||||||
percent={host?.Ready ? host.CPUPercent : undefined}
|
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
|
<UsageTile
|
||||||
icon={<MemoryStick />}
|
icon={<MemoryStick />}
|
||||||
label="RAM used"
|
label="RAM used"
|
||||||
percent={host?.Ready && host.MemTotalBytes > 0 ? (host.MemUsedBytes / host.MemTotalBytes) * 100 : undefined}
|
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}
|
sub={host?.Ready ? `of ${formatBytes(String(host.MemTotalBytes))}` : undefined}
|
||||||
/>
|
/>
|
||||||
<UsageTile
|
<UsageTile
|
||||||
|
|
@ -155,7 +180,12 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
|
||||||
? ((host.DiskTotalBytes - host.DiskFreeBytes) / host.DiskTotalBytes) * 100
|
? ((host.DiskTotalBytes - host.DiskFreeBytes) / host.DiskTotalBytes) * 100
|
||||||
: undefined
|
: 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"}
|
sub={host?.Ready && host.DiskReady ? `of ${formatBytes(String(host.DiskTotalBytes))}` : "no reading yet"}
|
||||||
warnAbove={85}
|
warnAbove={85}
|
||||||
/>
|
/>
|
||||||
|
|
@ -185,7 +215,8 @@ function StatTile({
|
||||||
sub,
|
sub,
|
||||||
tone = "neutral",
|
tone = "neutral",
|
||||||
href,
|
href,
|
||||||
navigate
|
navigate,
|
||||||
|
loading = false
|
||||||
}: {
|
}: {
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -194,6 +225,7 @@ function StatTile({
|
||||||
tone?: Tone;
|
tone?: Tone;
|
||||||
href?: string;
|
href?: string;
|
||||||
navigate?: Navigate;
|
navigate?: Navigate;
|
||||||
|
loading?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const toneClass = tone === "neutral" ? "" : ` ${tone}`;
|
const toneClass = tone === "neutral" ? "" : ` ${tone}`;
|
||||||
const body = (
|
const body = (
|
||||||
|
|
@ -202,7 +234,9 @@ function StatTile({
|
||||||
<span className="stat-tile-icon">{icon}</span>
|
<span className="stat-tile-icon">{icon}</span>
|
||||||
{tone === "warn" && <AlertTriangle size={15} className="stat-tile-open" />}
|
{tone === "warn" && <AlertTriangle size={15} className="stat-tile-open" />}
|
||||||
</div>
|
</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>
|
<div className="stat-tile-label">{label}</div>
|
||||||
{sub && <div className="stat-tile-sub">{sub}</div>}
|
{sub && <div className="stat-tile-sub">{sub}</div>}
|
||||||
</>
|
</>
|
||||||
|
|
@ -233,7 +267,8 @@ function UsageTile({
|
||||||
percent,
|
percent,
|
||||||
valueText,
|
valueText,
|
||||||
sub,
|
sub,
|
||||||
warnAbove = 90
|
warnAbove = 90,
|
||||||
|
loading = false
|
||||||
}: {
|
}: {
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -241,6 +276,7 @@ function UsageTile({
|
||||||
valueText: string;
|
valueText: string;
|
||||||
sub?: string;
|
sub?: string;
|
||||||
warnAbove?: number;
|
warnAbove?: number;
|
||||||
|
loading?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const clamped = percent === undefined ? 0 : Math.max(0, Math.min(100, percent));
|
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";
|
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">
|
<div className="stat-tile-head">
|
||||||
<span className="stat-tile-icon">{icon}</span>
|
<span className="stat-tile-icon">{icon}</span>
|
||||||
</div>
|
</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>
|
<div className="stat-tile-label">{label}</div>
|
||||||
{sub && <div className="stat-tile-sub">{sub}</div>}
|
{sub && <div className="stat-tile-sub">{sub}</div>}
|
||||||
<div className="stat-tile-bar">
|
<div className="stat-tile-bar">
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { api, errorMessage } from "../api";
|
import { api, errorMessage } from "../api";
|
||||||
import { ActionButton } from "../components/ActionButton";
|
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 { displayUsername, formatBytes, formatQuantity } from "../lib/format";
|
||||||
import type { Navigate } from "../routing";
|
import type { Navigate } from "../routing";
|
||||||
import type { AccountStorageRow, StorageStatsResponse } from "../types";
|
import type { AccountStorageRow, StorageStatsResponse } from "../types";
|
||||||
|
|
@ -40,6 +40,10 @@ function SortableHeader({
|
||||||
|
|
||||||
function StorageOverviewTab({ navigate }: { navigate: Navigate }) {
|
function StorageOverviewTab({ navigate }: { navigate: Navigate }) {
|
||||||
const [stats, setStats] = useState<StorageStatsResponse | null>(null);
|
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 [rows, setRows] = useState<AccountStorageRow[]>([]);
|
||||||
const [hasMore, setHasMore] = useState(false);
|
const [hasMore, setHasMore] = useState(false);
|
||||||
const [offset, setOffset] = useState(0);
|
const [offset, setOffset] = useState(0);
|
||||||
|
|
@ -52,8 +56,10 @@ function StorageOverviewTab({ navigate }: { navigate: Navigate }) {
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
try {
|
try {
|
||||||
setStats(await api.storageStats());
|
setStats(await api.storageStats());
|
||||||
|
setStatsFailed(false);
|
||||||
} catch {
|
} catch {
|
||||||
// Stats are a header nicety; a failure here shouldn't block the list.
|
// 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>}
|
{error && <Alert>{error}</Alert>}
|
||||||
<div className="metric-row">
|
<div className="metric-row">
|
||||||
<Metric label={"Physical usage (on disk / S3)"} value={stats ? formatBytes(stats.PhysicalBytes) : "-"} />
|
<Metric
|
||||||
<Metric label={"Logical usage (sum per account)"} value={stats ? formatBytes(stats.LogicalBytes) : "-"} />
|
label={"Physical usage (on disk / S3)"}
|
||||||
<Metric label={"Saved by dedup"} value={formatBytes(String(dedupBytes))} tone={dedupBytes > 0 ? "good" : "neutral"} />
|
value={stats ? formatBytes(stats.PhysicalBytes) : "-"}
|
||||||
<Metric label={"Backend"} value={stats?.BackendKind ?? "-"} />
|
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>
|
||||||
<div className="metric-row">
|
<div className="metric-row">
|
||||||
<Metric label={"Documents"} value={stats ? formatQuantity(stats.DocumentCount) : "-"} />
|
<Metric label={"Documents"} value={stats ? formatQuantity(stats.DocumentCount) : "-"} loading={!stats && !statsFailed} />
|
||||||
<Metric label={"Photos"} value={stats ? formatQuantity(stats.PhotoCount) : "-"} />
|
<Metric label={"Photos"} value={stats ? formatQuantity(stats.PhotoCount) : "-"} loading={!stats && !statsFailed} />
|
||||||
<Metric label={"Accounts with media"} value={stats ? formatQuantity(stats.AccountCount) : "-"} />
|
<Metric
|
||||||
|
label={"Accounts with media"}
|
||||||
|
value={stats ? formatQuantity(stats.AccountCount) : "-"}
|
||||||
|
loading={!stats && !statsFailed}
|
||||||
|
/>
|
||||||
<Metric
|
<Metric
|
||||||
label={"System/bundled content"}
|
label={"System/bundled content"}
|
||||||
value={stats ? formatBytes(stats.SystemBytes) : "-"}
|
value={stats ? formatBytes(stats.SystemBytes) : "-"}
|
||||||
|
loading={!stats && !statsFailed}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<td><button className="row-link" type="button" onClick={() => navigate(`/accounts/${row.UserID}`)}>{"Details"} <ChevronRight size={14} /></button></td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{rows.length === 0 && <EmptyRow colSpan={5} />}
|
{rows.length === 0 && (busy ? <LoadingRow colSpan={5} /> : <EmptyRow colSpan={5} />)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@
|
||||||
--panel: #ffffff;
|
--panel: #ffffff;
|
||||||
--panel-subtle: #f7f9fc;
|
--panel-subtle: #f7f9fc;
|
||||||
--panel-strong: #f1f5f9;
|
--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;
|
--surface-soft: #f2f7fd;
|
||||||
--overlay: rgba(24, 34, 47, 0.42);
|
--overlay: rgba(24, 34, 47, 0.42);
|
||||||
--topbar-bg: rgba(255, 255, 255, 0.94);
|
--topbar-bg: rgba(255, 255, 255, 0.94);
|
||||||
|
|
@ -100,6 +105,8 @@
|
||||||
--panel: #171f28;
|
--panel: #171f28;
|
||||||
--panel-subtle: #1c2530;
|
--panel-subtle: #1c2530;
|
||||||
--panel-strong: #212c38;
|
--panel-strong: #212c38;
|
||||||
|
--skeleton-base: #212c38;
|
||||||
|
--skeleton-sheen: #2e3d4c;
|
||||||
--surface-soft: #1a232d;
|
--surface-soft: #1a232d;
|
||||||
--overlay: rgba(5, 8, 12, 0.62);
|
--overlay: rgba(5, 8, 12, 0.62);
|
||||||
--topbar-bg: rgba(21, 28, 36, 0.86);
|
--topbar-bg: rgba(21, 28, 36, 0.86);
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,52 @@ button.stat-tile.clickable {
|
||||||
line-height: 1.1;
|
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 {
|
.stat-tile.warn .stat-tile-value {
|
||||||
color: var(--warn);
|
color: var(--warn);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue