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

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-B6-NxcJd.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-98gjjW0y.css">
<script type="module" crossorigin src="/assets/index-BKuHz-WL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C7ZZ0RF2.css">
</head>
<body>
<div id="root"></div>

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;