1"use client";
2
3
14import { useQuery } from "@tanstack/react-query";
15import { Activity, Database, FileText, MessageSquare, NotebookText, Users, type LucideIcon } from "lucide-react";
16import { orgAdminApi } from "@/lib/org-admin";
17import { Section } from "@/components/settings/settings-shell";
18
19function formatBytes(n: number): string {
20 if (n < 1024) return `${n} B`;
21 const units = ["KB", "MB", "GB", "TB"];
22 let v = n / 1024;
23 let i = 0;
24 while (v >= 1024 && i < units.length - 1) {
25 v /= 1024;
26 i += 1;
27 }
28 return `${v.toFixed(1)} ${units[i]}`;
29}
30
31function Sparkline({ points, label, fmt }: { points: { day: string; value: number }[]; label: string; fmt: (n: number) => string }) {
32 const max = Math.max(1, ...points.map((p) => p.value));
33 return (
34 <div>
35 <div className="flex h-24 items-end gap-px" role="img" aria-label={label}>
36 {points.map((p) => (
37 <div
38 key={p.day}
39 title={`${p.day}: ${fmt(p.value)}`}
40 className="min-w-0 flex-1 rounded-t-sm bg-primary/60"
41 style={{ height: `${Math.max(2, (p.value / max) * 100)}%` }}
42 />
43 ))}
44 </div>
45 <div className="mt-1 flex justify-between text-[11px] text-muted-foreground">
46 <span>{points[0]?.day ?? ""}</span>
47 <span>{points[points.length - 1]?.day ?? ""}</span>
48 </div>
49 </div>
50 );
51}
52
53function Stat({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string | number }) {
54 return (
55 <div className="flex items-center gap-3 rounded-lg border border-border p-3">
56 <Icon className="size-4 text-muted-foreground" aria-hidden />
57 <div>
58 <div className="text-xs text-muted-foreground">{label}</div>
59 <div className="text-lg font-semibold">{value}</div>
60 </div>
61 </div>
62 );
63}
64
65export function AdminAnalyticsTab({ orgId }: { orgId: string }) {
66 const q = useQuery({
67 queryKey: ["orgs", orgId, "admin", "analytics"],
68 queryFn: () => orgAdminApi.analytics(orgId),
69 retry: false,
70 });
71
72 if (q.isLoading) {
73 return <p className="text-sm text-muted-foreground" role="status">Loading analytics…</p>;
74 }
75 if (q.isError) {
76 return <p className="text-sm text-destructive" role="alert">Could not load analytics.</p>;
77 }
78 const a = q.data!.data;
79
80 return (
81 <div className="space-y-6">
82 <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
83 <Stat icon={Database} label="Workspaces" value={a.workspaces} />
84 <Stat icon={Users} label="Members" value={a.members} />
85 <Stat icon={FileText} label="Pages (total)" value={a.pagesTotal} />
86 <Stat icon={NotebookText} label="New databases (30d)" value={a.newDatabases30d} />
87 <Stat icon={MessageSquare} label="Open comments" value={a.comments.open} />
88 <Stat icon={FileText} label="Files" value={a.files.count} />
89 </div>
90
91 <div className="grid gap-4 lg:grid-cols-2">
92 <Section title="Content mix" description="Pages by kind (non-trashed)">
93 {a.pagesTotal === 0 ? (
94 <p className="text-sm text-muted-foreground">No pages yet.</p>
95 ) : (
96 <dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3">
97 {Object.entries(a.pages).map(([kind, count]) => (
98 <div key={kind} className="flex justify-between gap-2">
99 <dt className="text-muted-foreground">{kind}</dt>
100 <dd className="font-medium">{count}</dd>
101 </div>
102 ))}
103 </dl>
104 )}
105 <dl className="mt-3 grid grid-cols-2 gap-x-6 gap-y-2 border-t border-border pt-3 text-sm">
106 <div className="flex justify-between gap-2">
107 <dt className="text-muted-foreground">New pages (7d)</dt>
108 <dd className="font-medium">{a.newPages.last7d}</dd>
109 </div>
110 <div className="flex justify-between gap-2">
111 <dt className="text-muted-foreground">New pages (30d)</dt>
112 <dd className="font-medium">{a.newPages.last30d}</dd>
113 </div>
114 <div className="flex justify-between gap-2">
115 <dt className="text-muted-foreground">New comments (30d)</dt>
116 <dd className="font-medium">{a.comments.last30d}</dd>
117 </div>
118 <div className="flex justify-between gap-2">
119 <dt className="text-muted-foreground">Files (30d)</dt>
120 <dd className="font-medium">{formatBytes(a.files.last30dBytes)}</dd>
121 </div>
122 <div className="flex justify-between gap-2">
123 <dt className="text-muted-foreground">Total storage</dt>
124 <dd className="font-medium">{formatBytes(a.files.storageBytes)}</dd>
125 </div>
126 <div className="flex justify-between gap-2">
127 <dt className="text-muted-foreground">Archived workspaces</dt>
128 <dd className="font-medium">{a.archivedWorkspaces}</dd>
129 </div>
130 </dl>
131 </Section>
132
133 <Section title="Top audit actions (30 days)" description={`${a.audit.last30d} audit events in the last 30 days`}>
134 {a.audit.topActions.length === 0 ? (
135 <p className="text-sm text-muted-foreground">No audit events in the last 30 days.</p>
136 ) : (
137 <ul className="space-y-1.5">
138 {a.audit.topActions.map((t) => (
139 <li key={t.action} className="flex items-center justify-between gap-2 text-sm">
140 <span className="inline-flex items-center gap-1.5 font-mono text-[13px]">
141 <Activity className="size-3.5 text-muted-foreground" aria-hidden />
142 {t.action}
143 </span>
144 <span className="font-medium tabular-nums">{t.count}</span>
145 </li>
146 ))}
147 </ul>
148 )}
149 </Section>
150 </div>
151
152 <div className="grid gap-4 lg:grid-cols-2">
153 <Section title="New pages (30 days)" description="Pages created per day">
154 <Sparkline
155 points={a.trends.newPages.map((p) => ({ day: p.day, value: p.count }))}
156 label="New pages trend over 30 days"
157 fmt={(n) => String(n)}
158 />
159 </Section>
160 <Section title="Storage growth (30 days)" description="File bytes created per day">
161 <Sparkline
162 points={a.trends.storage.map((p) => ({ day: p.day, value: p.bytes }))}
163 label="Storage growth trend over 30 days"
164 fmt={formatBytes}
165 />
166 </Section>
167 <Section title="Members joined (30 days)" description="Org members added per day">
168 <Sparkline
169 points={a.trends.members.map((p) => ({ day: p.day, value: p.count }))}
170 label="Member sign-up trend over 30 days"
171 fmt={(n) => String(n)}
172 />
173 </Section>
174 <Section title="Audit events (30 days)" description="Audit rows written per day">
175 <Sparkline
176 points={a.trends.audit.map((p) => ({ day: p.day, value: p.count }))}
177 label="Audit events trend over 30 days"
178 fmt={(n) => String(n)}
179 />
180 </Section>
181 </div>
182 </div>
183 );
184}
185