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 (
<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>
<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 (
<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;

View 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
}

View 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)
}

View 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
}

View 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
}

View 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()
}

View 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)
}

View 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
}