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"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"telesrv/internal/config"
|
"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"
|
const defaultAdminAPIAddr = "127.0.0.1:2599"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
@ -41,7 +47,10 @@ func run() error {
|
||||||
}
|
}
|
||||||
defer pool.Close()
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -71,6 +80,10 @@ type uiConfig struct {
|
||||||
Password string
|
Password string
|
||||||
Token string
|
Token string
|
||||||
SessionKey []byte
|
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
|
// Permissions is the right set a panel session is issued with, from
|
||||||
// TELESRV_ADMIN_UI_PERMISSIONS. The shipped default is the single wildcard
|
// TELESRV_ADMIN_UI_PERMISSIONS. The shipped default is the single wildcard
|
||||||
// entry, so introducing the permission model never locks an operator out of a
|
// entry, so introducing the permission model never locks an operator out of a
|
||||||
|
|
@ -118,6 +131,7 @@ func loadConfig() (uiConfig, error) {
|
||||||
SessionKey: sum[:],
|
SessionKey: sum[:],
|
||||||
Permissions: appCfg.AdminUIPermissions,
|
Permissions: appCfg.AdminUIPermissions,
|
||||||
HideThirdPartyVerification: appCfg.HideThirdPartyVerification,
|
HideThirdPartyVerification: appCfg.HideThirdPartyVerification,
|
||||||
|
BlobDir: appCfg.BlobDir,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -457,6 +457,66 @@ WHERE NOT is_bot
|
||||||
return n, nil
|
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 {
|
type BotRow struct {
|
||||||
ID int64
|
ID int64
|
||||||
Username string
|
Username string
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import (
|
||||||
|
|
||||||
"telesrv/internal/admin"
|
"telesrv/internal/admin"
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/hoststats"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed web/dist
|
//go:embed web/dist
|
||||||
|
|
@ -27,11 +28,12 @@ var webDist embed.FS
|
||||||
type server struct {
|
type server struct {
|
||||||
cfg uiConfig
|
cfg uiConfig
|
||||||
read *readStore
|
read *readStore
|
||||||
|
hostStats *hoststats.Poller
|
||||||
web fs.FS
|
web fs.FS
|
||||||
webServer http.Handler
|
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")
|
web, err := fs.Sub(webDist, "web/dist")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -39,6 +41,7 @@ func newServer(cfg uiConfig, read *readStore) (*server, error) {
|
||||||
return &server{
|
return &server{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
read: read,
|
read: read,
|
||||||
|
hostStats: hostStats,
|
||||||
web: web,
|
web: web,
|
||||||
webServer: http.FileServer(http.FS(web)),
|
webServer: http.FileServer(http.FS(web)),
|
||||||
}, nil
|
}, nil
|
||||||
|
|
@ -52,6 +55,7 @@ func (s *server) routes() http.Handler {
|
||||||
// itself, so nothing is stranded by protecting it.
|
// itself, so nothing is stranded by protecting it.
|
||||||
mux.Handle("POST /api/logout", s.requireAuthAPI(http.HandlerFunc(s.handleAPILogout)))
|
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/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", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsAPI)))
|
||||||
mux.Handle("GET /api/accounts/stats", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsStatsAPI)))
|
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)))
|
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) {
|
func (s *server) handleEmojiAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.read == nil {
|
if s.read == nil {
|
||||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ func TestSignedSessionRoundTripAndTamper(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSPAFallbackSmoke(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 {
|
if err != nil {
|
||||||
t.Fatalf("newServer: %v", err)
|
t.Fatalf("newServer: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -247,7 +247,7 @@ func TestFlexScalarsAcceptNumbersStringsAndBlanks(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewCollectibleRoutesRequireSession(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 {
|
if err != nil {
|
||||||
t.Fatalf("newServer: %v", err)
|
t.Fatalf("newServer: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ func panelServer(t *testing.T, permissions ...string) *server {
|
||||||
SessionKey: []byte(testSessionKey),
|
SessionKey: []byte(testSessionKey),
|
||||||
Password: "letmein",
|
Password: "letmein",
|
||||||
Permissions: permissions,
|
Permissions: permissions,
|
||||||
}, nil)
|
}, nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("newServer: %v", err)
|
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>
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/index-B6-NxcJd.js"></script>
|
<script type="module" crossorigin src="/assets/index-BKuHz-WL.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-98gjjW0y.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-C7ZZ0RF2.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type {
|
||||||
AccountStorageListResponse,
|
AccountStorageListResponse,
|
||||||
SharedDeviceGroupListResponse,
|
SharedDeviceGroupListResponse,
|
||||||
StorageStatsResponse,
|
StorageStatsResponse,
|
||||||
|
DashboardResponse,
|
||||||
AdminLoginResult,
|
AdminLoginResult,
|
||||||
AdminSession,
|
AdminSession,
|
||||||
BotDetail,
|
BotDetail,
|
||||||
|
|
@ -161,6 +162,7 @@ export const api = {
|
||||||
request<CollectibleUsernameListResponse>(`/api/collectible-usernames?${params.toString()}`),
|
request<CollectibleUsernameListResponse>(`/api/collectible-usernames?${params.toString()}`),
|
||||||
collectibleUsername: (id: string) =>
|
collectibleUsername: (id: string) =>
|
||||||
request<CollectibleUsernameDetail>(`/api/collectible-usernames/${encodeURIComponent(id)}`),
|
request<CollectibleUsernameDetail>(`/api/collectible-usernames/${encodeURIComponent(id)}`),
|
||||||
|
dashboard: () => request<DashboardResponse>("/api/dashboard"),
|
||||||
storageStats: () => request<StorageStatsResponse>("/api/storage/stats"),
|
storageStats: () => request<StorageStatsResponse>("/api/storage/stats"),
|
||||||
storageAccounts: (params: URLSearchParams) =>
|
storageAccounts: (params: URLSearchParams) =>
|
||||||
request<AccountStorageListResponse>(`/api/storage/accounts?${params.toString()}`),
|
request<AccountStorageListResponse>(`/api/storage/accounts?${params.toString()}`),
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,254 @@
|
||||||
import { ChevronRight, MessageSquareText, ShieldCheck, Users } from "lucide-react";
|
import {
|
||||||
import type { ReactNode } from "react";
|
Activity,
|
||||||
import { AppLink } from "../components/AppLink";
|
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 type { Navigate } from "../routing";
|
||||||
|
import { formatBytes, formatQuantity } from "../lib/format";
|
||||||
|
import type { DashboardResponse } from "../types";
|
||||||
|
|
||||||
export function Dashboard({ navigate }: { navigate: Navigate }) {
|
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 (
|
return (
|
||||||
<div className="dashboard-layout">
|
<div className="dashboard-layout">
|
||||||
<div className="command-grid">
|
{error && <Alert>{error}</Alert>}
|
||||||
<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} />
|
<Section title="Needs attention">
|
||||||
<Launcher icon={<MessageSquareText />} title={"Message Audit"} text={"Message boxes, updates, outbox state."} href="/messages" navigate={navigate} />
|
<StatTile
|
||||||
</div>
|
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>
|
</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,
|
icon,
|
||||||
title,
|
label,
|
||||||
text,
|
value,
|
||||||
|
sub,
|
||||||
|
tone = "neutral",
|
||||||
href,
|
href,
|
||||||
navigate
|
navigate
|
||||||
}: {
|
}: {
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
title: string;
|
label: string;
|
||||||
text: string;
|
value: string;
|
||||||
href: string;
|
sub?: string;
|
||||||
navigate: Navigate;
|
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 (
|
return (
|
||||||
<AppLink className="launcher" href={href} navigate={navigate}>
|
<a
|
||||||
<span className="launcher-icon">{icon}</span>
|
className={`stat-tile clickable${toneClass}`}
|
||||||
<span className="launcher-copy">
|
href={href}
|
||||||
<strong>{title}</strong>
|
onClick={(event) => {
|
||||||
<span>{text}</span>
|
event.preventDefault();
|
||||||
</span>
|
navigate(href);
|
||||||
<ChevronRight size={16} />
|
}}
|
||||||
</AppLink>
|
>
|
||||||
|
{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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,155 @@
|
||||||
gap: 14px;
|
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,
|
.overview-band,
|
||||||
.page-frame {
|
.page-frame {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
|
||||||
|
|
@ -694,6 +694,34 @@ export type StorageStatsResponse = {
|
||||||
BackendKind: string;
|
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 = {
|
export type AccountStorageRow = {
|
||||||
UserID: string;
|
UserID: string;
|
||||||
Username: 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