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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue