fix for overview panel
This commit is contained in:
parent
21d8e91756
commit
d16f34070d
21 changed files with 920 additions and 40 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
9
cmd/telesrv-admin/web/dist/assets/index-BKuHz-WL.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-BKuHz-WL.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
cmd/telesrv-admin/web/dist/assets/index-C7ZZ0RF2.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-C7ZZ0RF2.css
vendored
Normal file
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 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>
|
||||
|
|
|
|||
|
|
@ -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()}`),
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
79
internal/hoststats/cpu_unix.go
Normal file
79
internal/hoststats/cpu_unix.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
//go:build !windows
|
||||
|
||||
package hoststats
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// cpuSampler computes CPU busy% from successive cumulative /proc/stat
|
||||
// snapshots. The kernel's counters are monotonic totals since boot, so a
|
||||
// single read can't give a percentage -- only the delta between two reads
|
||||
// separated by real time can. sampleOnce (its only caller) already runs on
|
||||
// a fixed poll interval, so that interval doubles as the sampling window;
|
||||
// no internal sleep needed.
|
||||
type cpuSampler struct {
|
||||
prevTotal uint64
|
||||
prevIdle uint64
|
||||
have bool
|
||||
}
|
||||
|
||||
func (c *cpuSampler) sample() float64 {
|
||||
total, idle, err := readProcStatCPU()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
if !c.have {
|
||||
c.prevTotal, c.prevIdle = total, idle
|
||||
c.have = true
|
||||
return 0
|
||||
}
|
||||
deltaTotal := total - c.prevTotal
|
||||
deltaIdle := idle - c.prevIdle
|
||||
c.prevTotal, c.prevIdle = total, idle
|
||||
if deltaTotal == 0 {
|
||||
return 0
|
||||
}
|
||||
pct := (1 - float64(deltaIdle)/float64(deltaTotal)) * 100
|
||||
switch {
|
||||
case pct < 0:
|
||||
pct = 0
|
||||
case pct > 100:
|
||||
pct = 100
|
||||
}
|
||||
return pct
|
||||
}
|
||||
|
||||
// readProcStatCPU parses the aggregate "cpu " line: user nice system idle
|
||||
// iowait irq softirq steal guest guest_nice (guest/guest_nice already
|
||||
// double-counted inside user/nice on Linux, per `man proc`, so they're not
|
||||
// added again here). idle = idle + iowait, matching the convention `top`
|
||||
// and most load calculators use.
|
||||
func readProcStatCPU() (total, idle uint64, err error) {
|
||||
data, err := os.ReadFile("/proc/stat")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
line, _, _ := strings.Cut(string(data), "\n")
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 5 || fields[0] != "cpu" {
|
||||
return 0, 0, fmt.Errorf("hoststats: unexpected /proc/stat format")
|
||||
}
|
||||
values := make([]uint64, 0, len(fields)-1)
|
||||
for _, f := range fields[1:] {
|
||||
v, err := strconv.ParseUint(f, 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
values = append(values, v)
|
||||
total += v
|
||||
}
|
||||
idle = values[3]
|
||||
if len(values) > 4 {
|
||||
idle += values[4] // iowait
|
||||
}
|
||||
return total, idle, nil
|
||||
}
|
||||
65
internal/hoststats/cpu_windows.go
Normal file
65
internal/hoststats/cpu_windows.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
//go:build windows
|
||||
|
||||
package hoststats
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// GetSystemTimes isn't wrapped by x/sys/windows either -- same manual
|
||||
// kernel32.dll binding as GlobalMemoryStatusEx in mem_windows.go.
|
||||
var procGetSystemTimes = modkernel32.NewProc("GetSystemTimes")
|
||||
|
||||
// cpuSampler computes CPU busy% from successive cumulative GetSystemTimes
|
||||
// snapshots, mirroring cpu_unix.go's /proc/stat delta approach: the kernel
|
||||
// time value already includes idle time on Windows, so busy = (kernel -
|
||||
// idle) + user, and total = kernel + user.
|
||||
type cpuSampler struct {
|
||||
prevIdle, prevKernel, prevUser uint64
|
||||
have bool
|
||||
}
|
||||
|
||||
func (c *cpuSampler) sample() float64 {
|
||||
idle, kernel, user, ok := getSystemTimes()
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
if !c.have {
|
||||
c.prevIdle, c.prevKernel, c.prevUser = idle, kernel, user
|
||||
c.have = true
|
||||
return 0
|
||||
}
|
||||
deltaIdle := idle - c.prevIdle
|
||||
deltaTotal := (kernel - c.prevKernel) + (user - c.prevUser)
|
||||
c.prevIdle, c.prevKernel, c.prevUser = idle, kernel, user
|
||||
if deltaTotal == 0 {
|
||||
return 0
|
||||
}
|
||||
pct := (1 - float64(deltaIdle)/float64(deltaTotal)) * 100
|
||||
switch {
|
||||
case pct < 0:
|
||||
pct = 0
|
||||
case pct > 100:
|
||||
pct = 100
|
||||
}
|
||||
return pct
|
||||
}
|
||||
|
||||
func getSystemTimes() (idle, kernel, user uint64, ok bool) {
|
||||
var idleFT, kernelFT, userFT windows.Filetime
|
||||
r, _, _ := procGetSystemTimes.Call(
|
||||
uintptr(unsafe.Pointer(&idleFT)),
|
||||
uintptr(unsafe.Pointer(&kernelFT)),
|
||||
uintptr(unsafe.Pointer(&userFT)),
|
||||
)
|
||||
if r == 0 {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
return filetimeToUint64(idleFT), filetimeToUint64(kernelFT), filetimeToUint64(userFT), true
|
||||
}
|
||||
|
||||
func filetimeToUint64(ft windows.Filetime) uint64 {
|
||||
return uint64(ft.HighDateTime)<<32 | uint64(ft.LowDateTime)
|
||||
}
|
||||
19
internal/hoststats/disk_unix.go
Normal file
19
internal/hoststats/disk_unix.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
//go:build !windows
|
||||
|
||||
package hoststats
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
// diskFreeBytes returns free (available to an unprivileged caller, not
|
||||
// counting reserved blocks) and total bytes for the filesystem containing
|
||||
// path. Mirrors internal/app/files/diskspace_unix.go's localDiskFreeBytes --
|
||||
// duplicated locally rather than exported cross-package since this is a
|
||||
// three-line syscall wrapper, not shared logic worth coupling two packages
|
||||
// over.
|
||||
func diskFreeBytes(path string) (free, total int64, err error) {
|
||||
var st unix.Statfs_t
|
||||
if err := unix.Statfs(path, &st); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return int64(st.Bavail) * int64(st.Bsize), int64(st.Blocks) * int64(st.Bsize), nil
|
||||
}
|
||||
20
internal/hoststats/disk_windows.go
Normal file
20
internal/hoststats/disk_windows.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
//go:build windows
|
||||
|
||||
package hoststats
|
||||
|
||||
import "golang.org/x/sys/windows"
|
||||
|
||||
// diskFreeBytes returns free (available to the calling user) and total
|
||||
// bytes for the volume containing path. Mirrors
|
||||
// internal/app/files/diskspace_windows.go's localDiskFreeBytes.
|
||||
func diskFreeBytes(path string) (free, total int64, err error) {
|
||||
ptr, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
var freeAvail, totalBytes, totalFree uint64
|
||||
if err := windows.GetDiskFreeSpaceEx(ptr, &freeAvail, &totalBytes, &totalFree); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return int64(freeAvail), int64(totalBytes), nil
|
||||
}
|
||||
88
internal/hoststats/hoststats.go
Normal file
88
internal/hoststats/hoststats.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
// Package hoststats samples host-level CPU/RAM/disk usage for the admin
|
||||
// panel's dashboard. It intentionally reports the machine's own resources,
|
||||
// not the Go process's (runtime.MemStats already covers that elsewhere) --
|
||||
// on a single self-hosted box the two are the same box, but the metric an
|
||||
// operator wants here is "is this server about to fall over."
|
||||
package hoststats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Snapshot is the last successfully sampled host-resource reading. Ready is
|
||||
// false until the first sample completes, so callers can distinguish "0% CPU"
|
||||
// from "no data yet" instead of rendering a misleading zero on startup.
|
||||
type Snapshot struct {
|
||||
CPUPercent float64
|
||||
MemUsedBytes int64
|
||||
MemTotalBytes int64
|
||||
DiskFreeBytes int64
|
||||
DiskTotalBytes int64
|
||||
Ready bool
|
||||
}
|
||||
|
||||
// Poller periodically samples host stats and caches the last snapshot for
|
||||
// lock-cheap reads from HTTP handlers -- the same "background worker
|
||||
// refreshes, handler reads a cached value" split this codebase already uses
|
||||
// for the local blob-storage free-space guard.
|
||||
type Poller struct {
|
||||
diskPath string
|
||||
|
||||
mu sync.RWMutex
|
||||
snap Snapshot
|
||||
|
||||
cpu cpuSampler
|
||||
}
|
||||
|
||||
// NewPoller creates a poller that reports free/total disk space for the
|
||||
// filesystem containing diskPath (pass the server's data/blob directory, or
|
||||
// "." if it doesn't matter which volume).
|
||||
func NewPoller(diskPath string) *Poller {
|
||||
if diskPath == "" {
|
||||
diskPath = "."
|
||||
}
|
||||
return &Poller{diskPath: diskPath}
|
||||
}
|
||||
|
||||
// Snapshot returns the last sample. Safe to call concurrently with Run.
|
||||
func (p *Poller) Snapshot() Snapshot {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.snap
|
||||
}
|
||||
|
||||
// Run samples immediately, then on every tick of interval, until ctx is
|
||||
// canceled. CPU usage is a delta between successive samples, so the first
|
||||
// sample after startup reports 0% -- expected, not a bug, and Ready still
|
||||
// flips true for the memory/disk figures that don't need a delta.
|
||||
func (p *Poller) Run(ctx context.Context, interval time.Duration) {
|
||||
p.sampleOnce()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.sampleOnce()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Poller) sampleOnce() {
|
||||
var snap Snapshot
|
||||
snap.CPUPercent = p.cpu.sample()
|
||||
if used, total, err := memStats(); err == nil {
|
||||
snap.MemUsedBytes, snap.MemTotalBytes = used, total
|
||||
}
|
||||
if free, total, err := diskFreeBytes(p.diskPath); err == nil {
|
||||
snap.DiskFreeBytes, snap.DiskTotalBytes = free, total
|
||||
}
|
||||
snap.Ready = true
|
||||
|
||||
p.mu.Lock()
|
||||
p.snap = snap
|
||||
p.mu.Unlock()
|
||||
}
|
||||
61
internal/hoststats/mem_unix.go
Normal file
61
internal/hoststats/mem_unix.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
//go:build !windows
|
||||
|
||||
package hoststats
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// memStats reads host RAM usage from /proc/meminfo. used is derived from
|
||||
// MemAvailable (kernel's own "usable without swapping" estimate, accounts
|
||||
// for reclaimable cache/buffers) rather than MemTotal-MemFree, which would
|
||||
// count page cache as "used" and make a healthy box look starved.
|
||||
func memStats() (used, total int64, err error) {
|
||||
f, err := os.Open("/proc/meminfo")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var totalKB, availKB int64
|
||||
haveTotal, haveAvail := false, false
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
switch {
|
||||
case strings.HasPrefix(line, "MemTotal:"):
|
||||
totalKB, err = parseMeminfoKB(line)
|
||||
haveTotal = err == nil
|
||||
case strings.HasPrefix(line, "MemAvailable:"):
|
||||
availKB, err = parseMeminfoKB(line)
|
||||
haveAvail = err == nil
|
||||
}
|
||||
if haveTotal && haveAvail {
|
||||
break
|
||||
}
|
||||
}
|
||||
if scanErr := scanner.Err(); scanErr != nil {
|
||||
return 0, 0, scanErr
|
||||
}
|
||||
if !haveTotal || !haveAvail {
|
||||
return 0, 0, fmt.Errorf("hoststats: MemTotal/MemAvailable not found in /proc/meminfo")
|
||||
}
|
||||
total = totalKB * 1024
|
||||
used = total - availKB*1024
|
||||
if used < 0 {
|
||||
used = 0
|
||||
}
|
||||
return used, total, nil
|
||||
}
|
||||
|
||||
func parseMeminfoKB(line string) (int64, error) {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
return 0, fmt.Errorf("hoststats: malformed /proc/meminfo line %q", line)
|
||||
}
|
||||
return strconv.ParseInt(fields[1], 10, 64)
|
||||
}
|
||||
50
internal/hoststats/mem_windows.go
Normal file
50
internal/hoststats/mem_windows.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
//go:build windows
|
||||
|
||||
package hoststats
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// x/sys/windows doesn't wrap GlobalMemoryStatusEx (unlike GetDiskFreeSpaceEx,
|
||||
// which diskFreeBytes uses directly), so it's called through kernel32.dll by
|
||||
// hand -- the same LazyDLL/NewProc pattern the x/sys/windows package itself
|
||||
// uses internally for the calls it does wrap.
|
||||
var (
|
||||
modkernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
||||
procGlobalMemoryStatusEx = modkernel32.NewProc("GlobalMemoryStatusEx")
|
||||
)
|
||||
|
||||
// memoryStatusEx mirrors the Win32 MEMORYSTATUSEX struct. Field order and
|
||||
// sizes must match exactly -- this is passed by pointer straight to the
|
||||
// syscall.
|
||||
type memoryStatusEx struct {
|
||||
cbSize uint32
|
||||
dwMemoryLoad uint32
|
||||
ullTotalPhys uint64
|
||||
ullAvailPhys uint64
|
||||
ullTotalPageFile uint64
|
||||
ullAvailPageFile uint64
|
||||
ullTotalVirtual uint64
|
||||
ullAvailVirtual uint64
|
||||
ullAvailExtendedVirtual uint64
|
||||
}
|
||||
|
||||
// memStats reads host RAM usage via GlobalMemoryStatusEx.
|
||||
func memStats() (used, total int64, err error) {
|
||||
var m memoryStatusEx
|
||||
m.cbSize = uint32(unsafe.Sizeof(m))
|
||||
r, _, callErr := procGlobalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&m)))
|
||||
if r == 0 {
|
||||
return 0, 0, fmt.Errorf("hoststats: GlobalMemoryStatusEx: %w", callErr)
|
||||
}
|
||||
total = int64(m.ullTotalPhys)
|
||||
used = total - int64(m.ullAvailPhys)
|
||||
if used < 0 {
|
||||
used = 0
|
||||
}
|
||||
return used, total, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue