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

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,7 +23,7 @@
})();
</script>
<script type="module" crossorigin src="/assets/index-D0qBFBU-.js"></script>
<script type="module" crossorigin src="/assets/index-CmjlUwWc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-EkAGiEK9.css">
</head>
<body>

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;