fix for overview panel

This commit is contained in:
onysd 2026-08-07 04:26:56 +03:00
parent 21d8e91756
commit d16f34070d
21 changed files with 920 additions and 40 deletions

View file

@ -5,6 +5,7 @@ import type {
AccountStorageListResponse,
SharedDeviceGroupListResponse,
StorageStatsResponse,
DashboardResponse,
AdminLoginResult,
AdminSession,
BotDetail,
@ -161,6 +162,7 @@ export const api = {
request<CollectibleUsernameListResponse>(`/api/collectible-usernames?${params.toString()}`),
collectibleUsername: (id: string) =>
request<CollectibleUsernameDetail>(`/api/collectible-usernames/${encodeURIComponent(id)}`),
dashboard: () => request<DashboardResponse>("/api/dashboard"),
storageStats: () => request<StorageStatsResponse>("/api/storage/stats"),
storageAccounts: (params: URLSearchParams) =>
request<AccountStorageListResponse>(`/api/storage/accounts?${params.toString()}`),

View file

@ -1,41 +1,254 @@
import { ChevronRight, MessageSquareText, ShieldCheck, Users } from "lucide-react";
import type { ReactNode } from "react";
import { AppLink } from "../components/AppLink";
import {
Activity,
AlertTriangle,
BadgeCheck,
Bot,
Cpu,
Database,
Film,
Flag,
HardDrive,
MemoryStick,
Radio,
Smile,
Sticker,
Users,
UsersRound
} from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { api } from "../api";
import { Alert } from "../components/ui";
import type { Navigate } from "../routing";
import { formatBytes, formatQuantity } from "../lib/format";
import type { DashboardResponse } from "../types";
export function Dashboard({ navigate }: { navigate: Navigate }) {
const [data, setData] = useState<DashboardResponse | null>(null);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
async function load() {
try {
const res = await api.dashboard();
if (!cancelled) setData(res);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load dashboard");
}
}
void load();
// Refreshed periodically rather than once: counts and host load are the
// kind of numbers an operator leaves this page open to watch.
const timer = window.setInterval(() => void load(), 15000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, []);
const counts = data?.counts;
const storage = data?.storage;
const host = data?.host;
return (
<div className="dashboard-layout">
<div className="command-grid">
<Launcher icon={<Users />} title={"Accounts"} text={"Account status, premium, verification, sessions."} href="/accounts" navigate={navigate} />
<Launcher icon={<ShieldCheck />} title={"Supergroups and Channels"} text={"Public entities, member counts, verification state."} href="/channels" navigate={navigate} />
<Launcher icon={<MessageSquareText />} title={"Message Audit"} text={"Message boxes, updates, outbox state."} href="/messages" navigate={navigate} />
</div>
{error && <Alert>{error}</Alert>}
<Section title="Needs attention">
<StatTile
icon={<Flag />}
label="Pending reports"
value={counts ? formatQuantity(String(counts.PendingReports)) : "…"}
tone={counts && counts.PendingReports > 0 ? "warn" : "good"}
href="/moderation"
navigate={navigate}
/>
<StatTile
icon={<BadgeCheck />}
label="Verification requests"
value={counts ? formatQuantity(String(counts.PendingVerifications)) : "…"}
tone={counts && counts.PendingVerifications > 0 ? "warn" : "good"}
href="/verification"
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={<Activity />}
label="Online now"
value={counts ? formatQuantity(String(counts.OnlineUsers)) : "…"}
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={<Radio />}
label="Channels"
value={counts ? formatQuantity(String(counts.BroadcastChannels)) : "…"}
href="/channels"
navigate={navigate}
/>
<StatTile
icon={<UsersRound />}
label="Supergroups"
value={counts ? formatQuantity(String(counts.Supergroups)) : "…"}
href="/channels"
navigate={navigate}
/>
</Section>
<Section title="Content">
<StatTile
icon={<Sticker />}
label="Sticker packs"
value={counts ? formatQuantity(String(counts.StickerSets)) : "…"}
href="/stickers"
navigate={navigate}
/>
<StatTile
icon={<Smile />}
label="Emoji packs"
value={counts ? formatQuantity(String(counts.EmojiSets)) : "…"}
href="/emoji"
navigate={navigate}
/>
<StatTile icon={<Film />} label="GIFs" value={counts ? formatQuantity(String(counts.Gifs)) : "…"} sub="saved by users" />
<StatTile
icon={<Database />}
label="Media storage used"
value={storage ? formatBytes(storage.PhysicalBytes) : "…"}
sub={storage ? `${storage.BackendKind} backend` : undefined}
href="/storage"
navigate={navigate}
/>
</Section>
<Section title="Server health" hint={host?.Ready ? undefined : "waiting for first sample…"}>
<UsageTile
icon={<Cpu />}
label="CPU load"
percent={host?.Ready ? host.CPUPercent : undefined}
valueText={host?.Ready ? `${host.CPUPercent.toFixed(0)}%` : "…"}
/>
<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)) : "…"}
sub={host?.Ready ? `of ${formatBytes(String(host.MemTotalBytes))}` : undefined}
/>
<UsageTile
icon={<HardDrive />}
label="Disk free"
percent={
host?.Ready && host.DiskTotalBytes > 0
? ((host.DiskTotalBytes - host.DiskFreeBytes) / host.DiskTotalBytes) * 100
: undefined
}
valueText={host?.Ready ? formatBytes(String(host.DiskFreeBytes)) : "…"}
sub={host?.Ready ? `of ${formatBytes(String(host.DiskTotalBytes))}` : undefined}
warnAbove={85}
/>
</Section>
</div>
);
}
function Launcher({
function Section({ title, hint, children }: { title: string; hint?: string; children: ReactNode }) {
return (
<div className="dashboard-section">
<div className="dashboard-section-title">
{title}
{hint && <span>{hint}</span>}
</div>
<div className="dashboard-grid">{children}</div>
</div>
);
}
type Tone = "neutral" | "good" | "warn" | "danger";
function StatTile({
icon,
title,
text,
label,
value,
sub,
tone = "neutral",
href,
navigate
}: {
icon: ReactNode;
title: string;
text: string;
href: string;
navigate: Navigate;
label: string;
value: string;
sub?: string;
tone?: Tone;
href?: string;
navigate?: Navigate;
}) {
const toneClass = tone === "neutral" ? "" : ` ${tone}`;
const body = (
<>
<div className="stat-tile-head">
<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-label">{label}</div>
{sub && <div className="stat-tile-sub">{sub}</div>}
</>
);
if (href && navigate) {
return (
<a
className={`stat-tile clickable${toneClass}`}
href={href}
onClick={(event) => {
event.preventDefault();
navigate(href);
}}
>
{body}
</a>
);
}
return <div className={`stat-tile${toneClass}`}>{body}</div>;
}
// UsageTile renders a host metric with a fill bar instead of a click target --
// there's no page a CPU/RAM/disk reading opens to, unlike every entity/queue
// tile above.
function UsageTile({
icon,
label,
percent,
valueText,
sub,
warnAbove = 90
}: {
icon: ReactNode;
label: string;
percent?: number;
valueText: string;
sub?: string;
warnAbove?: number;
}) {
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 toneClass = tone === "neutral" ? "" : ` ${tone}`;
return (
<AppLink className="launcher" href={href} navigate={navigate}>
<span className="launcher-icon">{icon}</span>
<span className="launcher-copy">
<strong>{title}</strong>
<span>{text}</span>
</span>
<ChevronRight size={16} />
</AppLink>
<div className={`stat-tile${toneClass}`}>
<div className="stat-tile-head">
<span className="stat-tile-icon">{icon}</span>
</div>
<div className="stat-tile-value">{valueText}</div>
<div className="stat-tile-label">{label}</div>
{sub && <div className="stat-tile-sub">{sub}</div>}
<div className="stat-tile-bar">
<span style={{ width: `${clamped}%` }} />
</div>
</div>
);
}

View file

@ -4,6 +4,155 @@
gap: 14px;
}
.dashboard-section {
display: grid;
gap: 10px;
}
.dashboard-section-title {
display: flex;
align-items: baseline;
gap: 8px;
color: var(--heading);
font-size: 13px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: .04em;
}
.dashboard-section-title span {
color: var(--muted);
font-size: 11px;
font-weight: 600;
text-transform: none;
letter-spacing: normal;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 10px;
}
.stat-tile {
display: grid;
min-width: 0;
gap: 8px;
padding: 14px;
text-align: left;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
button.stat-tile {
cursor: pointer;
font: inherit;
color: inherit;
}
a.stat-tile.clickable,
button.stat-tile.clickable {
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
}
.stat-tile.clickable:hover {
border-color: var(--brand-tint-border);
box-shadow: var(--shadow);
transform: translateY(-1px);
}
.stat-tile-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.stat-tile-icon {
display: grid;
width: 30px;
height: 30px;
flex: 0 0 auto;
place-items: center;
color: var(--brand);
background: var(--brand-tint);
border: 1px solid var(--brand-tint-border);
border-radius: var(--radius-sm);
}
.stat-tile.warn .stat-tile-icon {
color: var(--warn);
background: var(--warn-tint);
border-color: var(--warn-border);
}
.stat-tile.danger .stat-tile-icon {
color: var(--danger);
background: var(--danger-tint);
border-color: var(--danger-border);
}
.stat-tile.good .stat-tile-icon {
color: var(--good);
background: var(--good-tint);
border-color: var(--good-border);
}
.stat-tile-open {
color: var(--muted);
}
.stat-tile-value {
color: var(--heading);
font-size: 24px;
font-weight: 800;
line-height: 1.1;
}
.stat-tile.warn .stat-tile-value {
color: var(--warn);
}
.stat-tile.danger .stat-tile-value {
color: var(--danger);
}
.stat-tile-label {
color: var(--text-soft);
font-size: 12px;
font-weight: 700;
}
.stat-tile-sub {
color: var(--muted);
font-size: 11px;
}
.stat-tile-bar {
overflow: hidden;
width: 100%;
height: 5px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 999px;
}
.stat-tile-bar > span {
display: block;
height: 100%;
background: var(--brand-2);
}
.stat-tile.warn .stat-tile-bar > span {
background: var(--warn);
}
.stat-tile.danger .stat-tile-bar > span {
background: var(--danger);
}
.overview-band,
.page-frame {
min-width: 0;

View file

@ -694,6 +694,34 @@ export type StorageStatsResponse = {
BackendKind: string;
};
export type DashboardCounts = {
Users: number;
OnlineUsers: number;
Bots: number;
BroadcastChannels: number;
Supergroups: number;
StickerSets: number;
EmojiSets: number;
Gifs: number;
PendingReports: number;
PendingVerifications: number;
};
export type HostStatsSnapshot = {
CPUPercent: number;
MemUsedBytes: number;
MemTotalBytes: number;
DiskFreeBytes: number;
DiskTotalBytes: number;
Ready: boolean;
};
export type DashboardResponse = {
counts: DashboardCounts;
storage: StorageStatsResponse;
host?: HostStatsSnapshot;
};
export type AccountStorageRow = {
UserID: string;
Username: string;