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

@ -17,8 +17,14 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/config"
"telesrv/internal/hoststats"
)
// hostStatsPollInterval is how often the dashboard's CPU/RAM/disk snapshot
// refreshes. A few seconds is frequent enough for an operator glancing at
// the panel without polling disk/proc on every tick.
const hostStatsPollInterval = 5 * time.Second
const defaultAdminAPIAddr = "127.0.0.1:2599"
func main() {
@ -41,7 +47,10 @@ func run() error {
}
defer pool.Close()
srv, err := newServer(cfg, newReadStore(pool))
hs := hoststats.NewPoller(cfg.BlobDir)
go hs.Run(ctx, hostStatsPollInterval)
srv, err := newServer(cfg, newReadStore(pool), hs)
if err != nil {
return err
}
@ -71,6 +80,10 @@ type uiConfig struct {
Password string
Token string
SessionKey []byte
// BlobDir is the local blob-storage root, reused only to pick which
// filesystem the dashboard's disk-free reading statfs's -- irrelevant when
// TELESRV_BLOB_BACKEND=s3, where disk space isn't the storage constraint.
BlobDir string
// Permissions is the right set a panel session is issued with, from
// TELESRV_ADMIN_UI_PERMISSIONS. The shipped default is the single wildcard
// entry, so introducing the permission model never locks an operator out of a
@ -118,6 +131,7 @@ func loadConfig() (uiConfig, error) {
SessionKey: sum[:],
Permissions: appCfg.AdminUIPermissions,
HideThirdPartyVerification: appCfg.HideThirdPartyVerification,
BlobDir: appCfg.BlobDir,
}, nil
}

View file

@ -457,6 +457,66 @@ WHERE NOT is_bot
return n, nil
}
// DashboardCounts is the entity/queue counts shown on the admin panel's
// overview page. Each field is its own COUNT(*)/GROUP BY against an indexed
// column, run sequentially -- this endpoint is opened occasionally by an
// operator, not on a request hot path, so the extra round trips cost far
// less than the complexity of parallelizing them.
type DashboardCounts struct {
Users int64
OnlineUsers int64
Bots int64
BroadcastChannels int64
Supergroups int64
StickerSets int64
EmojiSets int64
Gifs int64
PendingReports int64
PendingVerifications int64
}
func (s *readStore) DashboardCounts(ctx context.Context) (DashboardCounts, error) {
var out DashboardCounts
var err error
if out.Users, err = s.CountAccounts(ctx); err != nil {
return out, err
}
if out.OnlineUsers, err = s.CountOnlineAccounts(ctx); err != nil {
return out, err
}
if err := s.pool.QueryRow(ctx, `
SELECT count(*) FROM users WHERE is_bot AND deleted_at IS NULL`).Scan(&out.Bots); err != nil {
return out, fmt.Errorf("count bots: %w", err)
}
if err := s.pool.QueryRow(ctx, `
SELECT count(*) FILTER (WHERE broadcast), count(*) FILTER (WHERE megagroup)
FROM channels WHERE NOT deleted AND NOT monoforum`).Scan(&out.BroadcastChannels, &out.Supergroups); err != nil {
return out, fmt.Errorf("count channels: %w", err)
}
if err := s.pool.QueryRow(ctx, `
SELECT count(*) FILTER (WHERE set_kind = 'stickers'), count(*) FILTER (WHERE set_kind = 'emoji')
FROM sticker_sets WHERE deleted = false`).Scan(&out.StickerSets, &out.EmojiSets); err != nil {
return out, fmt.Errorf("count sticker sets: %w", err)
}
// There's no global GIF catalog -- a GIF is just a document a user saved to
// their personal collection (messages.saveGif). This counts distinct
// documents saved by anyone, the closest thing to "how many GIFs does this
// server know about."
if err := s.pool.QueryRow(ctx, `
SELECT count(DISTINCT document_id) FROM user_sticker_collections WHERE kind = 'gif'`).Scan(&out.Gifs); err != nil {
return out, fmt.Errorf("count gifs: %w", err)
}
if err := s.pool.QueryRow(ctx, `
SELECT count(*) FROM moderation_cases WHERE status NOT IN ('resolved', 'dismissed')`).Scan(&out.PendingReports); err != nil {
return out, fmt.Errorf("count pending moderation cases: %w", err)
}
if err := s.pool.QueryRow(ctx, `
SELECT count(*) FROM verification_applications WHERE status IN ('submitted', 'in_review')`).Scan(&out.PendingVerifications); err != nil {
return out, fmt.Errorf("count pending verification applications: %w", err)
}
return out, nil
}
type BotRow struct {
ID int64
Username string

View file

@ -19,6 +19,7 @@ import (
"telesrv/internal/admin"
"telesrv/internal/domain"
"telesrv/internal/hoststats"
)
//go:embed web/dist
@ -27,11 +28,12 @@ var webDist embed.FS
type server struct {
cfg uiConfig
read *readStore
hostStats *hoststats.Poller
web fs.FS
webServer http.Handler
}
func newServer(cfg uiConfig, read *readStore) (*server, error) {
func newServer(cfg uiConfig, read *readStore, hostStats *hoststats.Poller) (*server, error) {
web, err := fs.Sub(webDist, "web/dist")
if err != nil {
return nil, err
@ -39,6 +41,7 @@ func newServer(cfg uiConfig, read *readStore) (*server, error) {
return &server{
cfg: cfg,
read: read,
hostStats: hostStats,
web: web,
webServer: http.FileServer(http.FS(web)),
}, nil
@ -52,6 +55,7 @@ func (s *server) routes() http.Handler {
// itself, so nothing is stranded by protecting it.
mux.Handle("POST /api/logout", s.requireAuthAPI(http.HandlerFunc(s.handleAPILogout)))
mux.Handle("GET /api/session", s.requireAuthAPI(http.HandlerFunc(s.handleSession)))
mux.Handle("GET /api/dashboard", s.requireAuthAPI(http.HandlerFunc(s.handleDashboardAPI)))
mux.Handle("GET /api/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsAPI)))
mux.Handle("GET /api/accounts/stats", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsStatsAPI)))
mux.Handle("GET /api/accounts/shared-devices", s.requireAuthAPI(http.HandlerFunc(s.handleSharedDeviceGroupsAPI)))
@ -264,6 +268,34 @@ func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
})
}
// handleDashboardAPI backs the overview page: entity/queue counts from
// Postgres, blob storage usage, and the last host CPU/RAM/disk sample (0/not
// ready until the poller's first tick after startup).
func (s *server) handleDashboardAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
counts, err := s.read.DashboardCounts(r.Context())
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
storage, err := s.read.StorageStats(r.Context())
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
resp := map[string]any{
"counts": counts,
"storage": storage,
}
if s.hostStats != nil {
resp["host"] = s.hostStats.Snapshot()
}
writeJSON(w, http.StatusOK, resp)
}
func (s *server) handleEmojiAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")

View file

@ -32,7 +32,7 @@ func TestSignedSessionRoundTripAndTamper(t *testing.T) {
}
func TestSPAFallbackSmoke(t *testing.T) {
srv, err := newServer(uiConfig{SessionKey: []byte("01234567890123456789012345678901")}, nil)
srv, err := newServer(uiConfig{SessionKey: []byte("01234567890123456789012345678901")}, nil, nil)
if err != nil {
t.Fatalf("newServer: %v", err)
}
@ -247,7 +247,7 @@ func TestFlexScalarsAcceptNumbersStringsAndBlanks(t *testing.T) {
}
func TestNewCollectibleRoutesRequireSession(t *testing.T) {
srv, err := newServer(uiConfig{SessionKey: []byte("01234567890123456789012345678901")}, nil)
srv, err := newServer(uiConfig{SessionKey: []byte("01234567890123456789012345678901")}, nil, nil)
if err != nil {
t.Fatalf("newServer: %v", err)
}

View file

@ -22,7 +22,7 @@ func panelServer(t *testing.T, permissions ...string) *server {
SessionKey: []byte(testSessionKey),
Password: "letmein",
Permissions: permissions,
}, nil)
}, nil, nil)
if err != nil {
t.Fatalf("newServer: %v", err)
}

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;