1"use client";
2
3
8import { useQuery } from "@tanstack/react-query";
9import { Database, FileText, NotebookText, User, Users } from "lucide-react";
10import { orgAdminApi } from "@/lib/org-admin";
11import { Section } from "@/components/settings/settings-shell";
12
13function formatBytes(n: number): string {
14 if (n < 1024) return `${n} B`;
15 const units = ["KB", "MB", "GB", "TB"];
16 let v = n / 1024;
17 let i = 0;
18 while (v >= 1024 && i < units.length - 1) {
19 v /= 1024;
20 i += 1;
21 }
22 return `${v.toFixed(1)} ${units[i]}`;
23}
24
25
27function Sparkline({ points, label, fmt }: { points: { day: string; value: number }[]; label: string; fmt: (n: number) => string }) {
28 const max = Math.max(1, ...points.map((p) => p.value));
29 return (
30 <div>
31 <div className="flex h-24 items-end gap-px" role="img" aria-label={label}>
32 {points.map((p) => (
33 <div
34 key={p.day}
35 title={`${p.day}: ${fmt(p.value)}`}
36 className="min-w-0 flex-1 rounded-t-sm bg-primary/60"
37 style={{ height: `${Math.max(2, (p.value / max) * 100)}%` }}
38 />
39 ))}
40 </div>
41 <div className="mt-1 flex justify-between text-[11px] text-muted-foreground">
42 <span>{points[0]?.day ?? ""}</span>
43 <span>{points[points.length - 1]?.day ?? ""}</span>
44 </div>
45 </div>
46 );
47}
48
49function Stat({ icon: Icon, label, value }: { icon: typeof User; label: string; value: string | number }) {
50 return (
51 <div className="flex items-center gap-3 rounded-lg border border-border p-3">
52 <Icon className="size-4 text-muted-foreground" aria-hidden />
53 <div>
54 <div className="text-xs text-muted-foreground">{label}</div>
55 <div className="text-lg font-semibold">{value}</div>
56 </div>
57 </div>
58 );
59}
60
61export function AdminOverviewTab({ orgId }: { orgId: string }) {
62 const q = useQuery({
63 queryKey: ["orgs", orgId, "admin", "overview"],
64 queryFn: () => orgAdminApi.overview(orgId),
65 retry: false,
66 });
67
68 if (q.isLoading) {
69 return <p className="text-sm text-muted-foreground" role="status">Loading overview…</p>;
70 }
71 if (q.isError) {
72 return <p className="text-sm text-destructive" role="alert">Could not load the overview.</p>;
73 }
74 const o = q.data!;
75
76 return (
77 <div className="space-y-6">
78 <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
79 <Stat icon={Users} label="Members" value={o.memberCount} />
80 <Stat icon={User} label="Active (30d)" value={o.activeMemberCount} />
81 <Stat icon={Database} label="Workspaces" value={o.workspaceCount} />
82 <Stat icon={FileText} label="Pages" value={o.pageCount} />
83 <Stat icon={NotebookText} label="Databases" value={o.databaseCount} />
84 <Stat icon={FileText} label="Files" value={o.fileCount} />
85 </div>
86
87 <div className="grid gap-4 lg:grid-cols-2">
88 <Section title="Storage (30 days)" description={`Current total: ${formatBytes(o.storageBytes)}`}>
89 <Sparkline
90 points={o.storageTrend.map((p) => ({ day: p.day, value: p.bytes }))}
91 label="Storage trend over 30 days"
92 fmt={formatBytes}
93 />
94 </Section>
95 <Section title="Audit events (30 days)" description="Rows written to the org audit log per day">
96 <Sparkline
97 points={o.auditTrend.map((p) => ({ day: p.day, value: p.count }))}
98 label="Audit events trend over 30 days"
99 fmt={(n) => String(n)}
100 />
101 </Section>
102 </div>
103
104 <Section
105 title="Effective policy"
106 description="The plan caps security policy — see the Policies tab to change it."
107 >
108 <dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3">
109 <div className="flex justify-between gap-2">
110 <dt className="text-muted-foreground">Max file size</dt>
111 <dd className="font-medium">{o.policy.maxFileSizeMB ? `${o.policy.maxFileSizeMB} MB` : "plan default"}</dd>
112 </div>
113 <div className="flex justify-between gap-2">
114 <dt className="text-muted-foreground">2FA</dt>
115 <dd className="font-medium">{o.policy.require2FA ? "required" : "off"}</dd>
116 </div>
117 <div className="flex justify-between gap-2">
118 <dt className="text-muted-foreground">SSO enforced</dt>
119 <dd className="font-medium">{o.policy.ssoEnforced ? "on" : "off"}</dd>
120 </div>
121 <div className="flex justify-between gap-2">
122 <dt className="text-muted-foreground">Guest public share</dt>
123 <dd className="font-medium">{o.policy.disableGuestPublicShare ? "disabled" : "allowed"}</dd>
124 </div>
125 <div className="flex justify-between gap-2">
126 <dt className="text-muted-foreground">External embeds</dt>
127 <dd className="font-medium">{o.policy.allowExternalEmbeds ? "allowed" : "disabled"}</dd>
128 </div>
129 <div className="flex justify-between gap-2">
130 <dt className="text-muted-foreground">Session lifetime</dt>
131 <dd className="font-medium">{o.policy.sessionLifetimeHours ? `${o.policy.sessionLifetimeHours} h` : "default"}</dd>
132 </div>
133 </dl>
134 </Section>
135 </div>
136 );
137}
138