s3 support

This commit is contained in:
onysd 2026-08-04 23:09:11 +03:00
parent fa5cfaf14d
commit 03f10b66ee
53 changed files with 2796 additions and 102 deletions

View file

@ -4,6 +4,8 @@ import type {
AccountRatingDetail,
AccountRatingListResponse,
AccountStatsResponse,
AccountStorageListResponse,
StorageStatsResponse,
AdminLoginResult,
AdminSession,
BotDetail,
@ -165,6 +167,9 @@ export const api = {
request<AccountRatingListResponse>(`/api/account-ratings?${params.toString()}`),
accountRating: (userID: string) =>
request<AccountRatingDetail>(`/api/account-ratings/${encodeURIComponent(userID)}`),
storageStats: () => request<StorageStatsResponse>("/api/storage/stats"),
storageAccounts: (params: URLSearchParams) =>
request<AccountStorageListResponse>(`/api/storage/accounts?${params.toString()}`),
verificationApplications: (params: URLSearchParams) =>
request<VerificationApplicationListResponse>(`/api/verification/applications?${params.toString()}`),
// The application id is an int64 decimal string end to end, so it is never

View file

@ -99,6 +99,7 @@ export function Shell({
)}
<NavLink icon={<AtSign size={16} />} href="/collectible-usernames" route={route} navigate={navigate}>{"NFT Usernames"}</NavLink>
<NavLink icon={<Trophy size={16} />} href="/account-ratings" route={route} navigate={navigate}>{"Account Rating"}</NavLink>
<NavLink icon={<Database size={16} />} href="/storage" route={route} navigate={navigate}>{"Storage"}</NavLink>
<NavLink icon={<Gift size={16} />} href="/gifts" route={route} navigate={navigate}>{"Star Gifts"}</NavLink>
<NavLink icon={<Send size={16} />} href="/give-gifts" route={route} navigate={navigate}>{"Give Gifts"}</NavLink>
<NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{"Stickers"}</NavLink>

View file

@ -154,6 +154,26 @@ export function toSmallestUnits(value: string, currency: string): string | null
return digits === "" ? "0" : digits;
}
// formatBytes renders a byte count (as the JSON-string int64 the API sends)
// in the largest unit that keeps it readable. Parses the decimal string
// directly rather than through toNumeric first so precision past
// Number.MAX_SAFE_INTEGER isn't silently lost before the division.
export function formatBytes(value: string): string {
const raw = (value ?? "").trim();
if (!raw || !/^\d+$/.test(raw)) return "0 B";
const bytes = Number(raw);
if (!Number.isFinite(bytes)) return `${raw} B`;
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
let size = bytes;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit++;
}
const precision = unit === 0 ? 0 : 1;
return `${size.toFixed(precision)} ${units[unit]}`;
}
export function formatSigned(value: string): string {
const raw = (value ?? "").trim();
if (!raw) return "0";

View file

@ -19,6 +19,7 @@ import { StickerSetsPage } from "./StickerSetsPage";
import { GiveGiftsPage } from "./GiveGiftsPage";
import { ModerationCaseDetailPage } from "./ModerationCaseDetailPage";
import { ModerationCasesPage } from "./ModerationCasesPage";
import { StoragePage } from "./StoragePage";
import { BotVerificationPage } from "./BotVerificationPage";
import { BotVerificationRequestPage } from "./BotVerificationRequestPage";
import { VerificationDetailPage } from "./VerificationDetailPage";
@ -84,6 +85,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
if (route.path === "/account-ratings") {
return <AccountRatingsPage navigate={navigate} />;
}
if (route.path === "/storage") {
return <StoragePage navigate={navigate} />;
}
if (accountID) {
return <AccountDetailPage id={Number(accountID)} navigate={navigate} />;
}

View file

@ -0,0 +1,118 @@
import { ChevronDown, Loader2, RefreshCw } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Alert, EmptyRow, Metric, PageFrame } from "../components/ui";
import { displayUsername, formatBytes, formatQuantity } from "../lib/format";
import type { Navigate } from "../routing";
import type { AccountStorageRow, StorageStatsResponse } from "../types";
export function StoragePage({ navigate: _navigate }: { navigate: Navigate }) {
const [stats, setStats] = useState<StorageStatsResponse | null>(null);
const [rows, setRows] = useState<AccountStorageRow[]>([]);
const [hasMore, setHasMore] = useState(false);
const [offset, setOffset] = useState(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function loadStats() {
try {
setStats(await api.storageStats());
} catch {
// Stats are a header nicety; a failure here shouldn't block the list.
}
}
async function loadAccounts(next = false) {
setBusy(true);
setError("");
const at = next ? offset : 0;
const params = new URLSearchParams({ limit: "50", offset: String(at) });
try {
const result = await api.storageAccounts(params);
const page = result.rows ?? [];
setRows((current) => (next ? [...current, ...page] : page));
setOffset(result.next_offset);
setHasMore(Boolean(result.has_more));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
function refresh() {
void loadStats();
void loadAccounts(false);
}
useEffect(() => {
refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Physical is what actually consumes disk/S3 (deduplicated); logical is the
// sum of what the per-account table below adds up to. They legitimately
// differ when the same content is shared by more than one document/photo.
const dedupBytes = stats ? Math.max(0, Number(stats.LogicalBytes) - Number(stats.PhysicalBytes)) : 0;
return (
<PageFrame
title={"Storage"}
eyebrow={"Media / Storage usage"}
actions={
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={"Physical usage (on disk / S3)"} value={stats ? formatBytes(stats.PhysicalBytes) : "-"} />
<Metric label={"Logical usage (sum per account)"} value={stats ? formatBytes(stats.LogicalBytes) : "-"} />
<Metric label={"Saved by dedup"} value={formatBytes(String(dedupBytes))} tone={dedupBytes > 0 ? "good" : "neutral"} />
<Metric label={"Backend"} value={stats?.BackendKind ?? "-"} />
</div>
<div className="metric-row">
<Metric label={"Documents"} value={stats ? formatQuantity(stats.DocumentCount) : "-"} />
<Metric label={"Photos"} value={stats ? formatQuantity(stats.PhotoCount) : "-"} />
<Metric label={"Accounts with media"} value={stats ? formatQuantity(stats.AccountCount) : "-"} />
<Metric
label={"Unattributed"}
value={stats ? formatBytes(stats.UnattributedBytes) : "-"}
tone={stats && Number(stats.UnattributedBytes) > 0 ? "warn" : "neutral"}
/>
</div>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"User ID"}</th>
<th>{"Account"}</th>
<th>{"Storage used"}</th>
<th>{"Files"}</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.UserID}>
<td className="mono">{row.UserID}</td>
<td>{displayUsername(row.Username) || row.FirstName || "-"}</td>
<td className="mono">{formatBytes(row.Bytes)}</td>
<td className="mono">{formatQuantity(row.FileCount)}</td>
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={4} />}
</tbody>
</table>
</div>
{hasMore && (
<div className="toolbar">
<button className="btn icon-text" type="button" onClick={() => loadAccounts(true)} disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <ChevronDown size={15} />} {"Load more"}
</button>
</div>
)}
</PageFrame>
);
}

View file

@ -21,6 +21,7 @@ export function routeTitle(pathname: string): string {
if (pathname.startsWith("/verification")) return "Official Verification";
if (pathname.startsWith("/collectible-usernames")) return "Collectible Usernames";
if (pathname.startsWith("/account-ratings")) return "Account Rating";
if (pathname.startsWith("/storage")) return "Storage";
if (pathname.startsWith("/accounts")) return "Accounts";
if (pathname.startsWith("/channels")) return "Supergroups and Channels";
if (pathname.startsWith("/bots")) return "Bots";
@ -39,6 +40,7 @@ export function routeSubtitle(pathname: string): string {
if (pathname.startsWith("/verification")) return "Console / Verification";
if (pathname.startsWith("/collectible-usernames")) return "Console / Collectible usernames";
if (pathname.startsWith("/account-ratings")) return "Console / Account rating";
if (pathname.startsWith("/storage")) return "Console / Storage";
if (pathname.startsWith("/accounts")) return "Console / Accounts";
if (pathname.startsWith("/channels")) return "Console / Channels";
if (pathname.startsWith("/bots")) return "Console / Bots";

View file

@ -789,6 +789,30 @@ export type AccountStatsResponse = {
online: number;
};
export type StorageStatsResponse = {
PhysicalBytes: string;
LogicalBytes: string;
UnattributedBytes: string;
DocumentCount: string;
PhotoCount: string;
AccountCount: string;
BackendKind: string;
};
export type AccountStorageRow = {
UserID: string;
Username: string;
FirstName: string;
Bytes: string;
FileCount: string;
};
export type AccountStorageListResponse = {
rows: AccountStorageRow[] | null;
has_more: boolean;
next_offset: number;
};
export type ChannelListResponse = {
query: string;
limit: number;