feat(web): add storage dashboard and team retention settings

Add per-team storage breakdown table to the usage section (from
GET /api/v1/admin/usage teamStorage data). Add storage quota (MB)
and retention (hours) inline fields to the teams section, saving
via PUT /api/v1/teams/:id. Add i18n keys to all 21 locale files.
This commit is contained in:
SnapOtter
2026-06-13 21:20:12 +08:00
parent dd2a50799a
commit c7e9b1ddc6
23 changed files with 372 additions and 1 deletions
@@ -338,6 +338,8 @@ interface TeamEntry {
id: string;
name: string;
memberCount: number;
storageQuota: number | null;
retentionHours: number | null;
createdAt: string;
}
@@ -1916,6 +1918,10 @@ function TeamsSection() {
const [editingTeamId, setEditingTeamId] = useState<string | null>(null);
const [editingTeamName, setEditingTeamName] = useState("");
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [expandedTeamId, setExpandedTeamId] = useState<string | null>(null);
const [quotaMb, setQuotaMb] = useState("");
const [retention, setRetention] = useState("");
const [savingQuota, setSavingQuota] = useState(false);
const [actionMsg, setActionMsg] = useState<{ type: "success" | "error"; text: string } | null>(
null,
);
@@ -2006,6 +2012,45 @@ function TeamsSection() {
[loadTeams],
);
const handleExpandTeam = useCallback(
(tm: TeamEntry) => {
if (expandedTeamId === tm.id) {
setExpandedTeamId(null);
return;
}
setExpandedTeamId(tm.id);
setQuotaMb(
tm.storageQuota ? String(Math.round(tm.storageQuota / (1024 * 1024))) : "",
);
setRetention(tm.retentionHours ? String(tm.retentionHours) : "");
},
[expandedTeamId],
);
const handleSaveQuota = useCallback(
async (id: string) => {
setSavingQuota(true);
try {
const body: Record<string, number | null> = {};
const mbVal = quotaMb.trim() ? Number(quotaMb) : 0;
body.storageQuota = mbVal > 0 ? mbVal * 1024 * 1024 : null;
const retVal = retention.trim() ? Number(retention) : 0;
body.retentionHours = retVal > 0 ? retVal : null;
await apiPut(`/v1/teams/${id}`, body);
setActionMsg({ type: "success", text: t.settings.teams.quotaSaved });
setExpandedTeamId(null);
await loadTeams();
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to save";
setActionMsg({ type: "error", text: msg });
} finally {
setSavingQuota(false);
setTimeout(() => setActionMsg(null), 3000);
}
},
[quotaMb, retention, loadTeams],
);
if (loading) {
return (
<div className="flex items-center justify-center py-12">
@@ -2095,8 +2140,8 @@ function TeamsSection() {
</div>
) : (
teams.map((tm) => (
<Fragment key={tm.id}>
<div
key={tm.id}
className={cn(
"items-center px-4 py-3 border-b border-border last:border-0 last:rounded-b-lg hover:bg-muted/20 transition-colors",
isMobile ? "flex gap-3" : "grid grid-cols-[1fr_100px_60px] gap-2",
@@ -2173,6 +2218,17 @@ function TeamsSection() {
<Pencil className="h-3.5 w-3.5" />
{t.settings.teams.renameAction}
</button>
<button
type="button"
onClick={() => {
handleExpandTeam(tm);
setOpenMenuId(null);
}}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
>
<Settings className="h-3.5 w-3.5" />
{t.settings.heading}
</button>
<div className="border-t border-border my-1" />
<button
type="button"
@@ -2186,6 +2242,73 @@ function TeamsSection() {
)}
</div>
</div>
{expandedTeamId === tm.id && (
<div className="px-4 py-3 border-b border-border last:border-0 bg-muted/10 space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1">
<label
htmlFor={`quota-${tm.id}`}
className="text-xs font-medium text-muted-foreground"
>
{t.settings.teams.teamStorageQuota}
</label>
<input
id={`quota-${tm.id}`}
type="number"
min="0"
value={quotaMb}
onChange={(e) => setQuotaMb(e.target.value)}
placeholder="0"
className="w-full px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground"
/>
<p className="text-[11px] text-muted-foreground">
{t.settings.teams.teamStorageQuotaDesc}
</p>
</div>
<div className="space-y-1">
<label
htmlFor={`retention-${tm.id}`}
className="text-xs font-medium text-muted-foreground"
>
{t.settings.teams.teamRetentionHours}
</label>
<input
id={`retention-${tm.id}`}
type="number"
min="0"
value={retention}
onChange={(e) => setRetention(e.target.value)}
placeholder="0"
className="w-full px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground"
/>
<p className="text-[11px] text-muted-foreground">
{t.settings.teams.teamRetentionHoursDesc}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={savingQuota}
onClick={() => handleSaveQuota(tm.id)}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{savingQuota && (
<Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
)}
{t.common.save}
</button>
<button
type="button"
onClick={() => setExpandedTeamId(null)}
className="px-3 py-1.5 rounded-lg border border-border text-xs text-muted-foreground hover:bg-muted transition-colors"
>
{t.common.cancel}
</button>
</div>
</div>
)}
</Fragment>
))
)}
</div>
@@ -12,6 +12,7 @@ interface UsageData {
perUser: Array<{ username: string | null; runs: number; bytesIn: string }>;
durations: Array<{ pool: string; p50Ms: number | null; p95Ms: number | null }>;
storage: { libraryBytes: string; libraryFiles: number };
teamStorage?: Array<{ teamName: string; totalBytes: string; userCount: number }>;
}
export function UsageSection() {
@@ -260,6 +261,43 @@ export function UsageSection() {
</div>
</div>
</div>
{/* Storage by Team */}
{data.teamStorage && data.teamStorage.length > 0 && (
<div className="rounded-lg border border-border p-4 space-y-3">
<h4 className="text-sm font-semibold text-foreground">
{t.settings.usage.storageByTeam}
</h4>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-start py-1.5 text-xs font-medium text-muted-foreground">
{t.settings.usage.teamColumn}
</th>
<th className="text-end py-1.5 text-xs font-medium text-muted-foreground">
{t.settings.usage.storageColumn}
</th>
<th className="text-end py-1.5 text-xs font-medium text-muted-foreground">
{t.settings.usage.usersColumn}
</th>
</tr>
</thead>
<tbody>
{data.teamStorage.map((row) => (
<tr key={row.teamName} className="border-b border-border last:border-0">
<td className="py-1.5 text-foreground">{row.teamName}</td>
<td className="py-1.5 text-end text-muted-foreground font-mono">
{formatFileSize(Number(row.totalBytes))}
</td>
<td className="py-1.5 text-end text-muted-foreground font-mono">
{row.userCount}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
) : null}
</div>