Skip to content

Commit f2ee833

Browse files
mchestrclaude
andcommitted
feat: add admin observability dashboard
Add a new System Overview page at /admin/observability that provides at-a-glance visibility into system health, user activity, and resource usage. Features: - Service status grid showing configuration state of all integrations - Summary stats cards (configured services, users, wrapped status, LLM usage) - Activity trend chart (7-day LLM requests and costs) - Top users widget (by LLM usage cost over 30 days) - Real-time panels with auto-refresh: - Active Plex streams (via Tautulli) - Download queues (Sonarr/Radarr) - Storage and library info - Media requests (Overseerr) - Quick access links to other admin pages - Secondary stats row (total LLM cost, maintenance queue) Technical: - Server action for aggregated observability data - API routes for real-time panel data with rate limiting - TanStack Query for client-side data fetching with auto-refresh - Comprehensive unit tests (114 tests across 7 test files) - E2E tests (16 tests) with resilient selectors for various states 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent c0f1368 commit f2ee833

25 files changed

+4823
-3
lines changed
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
"use server"
2+
3+
import { requireAdmin } from "@/lib/admin"
4+
import { prisma } from "@/lib/prisma"
5+
import { getAdminSettings } from "./admin-settings"
6+
7+
export interface ServiceStatus {
8+
configured: boolean
9+
name: string
10+
description: string
11+
}
12+
13+
export interface ActivityTrendPoint {
14+
date: string
15+
requests: number
16+
cost: number
17+
tokens: number
18+
}
19+
20+
export interface TopUser {
21+
userId: string
22+
name: string
23+
email: string
24+
image: string | null
25+
requests: number
26+
cost: number
27+
tokens: number
28+
}
29+
30+
export interface ObservabilityData {
31+
services: {
32+
plex: ServiceStatus
33+
tautulli: ServiceStatus
34+
overseerr: ServiceStatus
35+
sonarr: ServiceStatus
36+
radarr: ServiceStatus
37+
discord: ServiceStatus
38+
llm: ServiceStatus
39+
}
40+
users: {
41+
total: number
42+
admins: number
43+
regular: number
44+
}
45+
wrapped: {
46+
completed: number
47+
generating: number
48+
pending: number
49+
failed: number
50+
}
51+
llm: {
52+
requests24h: number
53+
cost24h: number
54+
totalCost: number
55+
}
56+
maintenance: {
57+
pendingCandidates: number
58+
approvedCandidates: number
59+
totalDeletions: number
60+
}
61+
activityTrend: ActivityTrendPoint[]
62+
topUsers: TopUser[]
63+
}
64+
65+
/**
66+
* Get observability dashboard data (admin only)
67+
*/
68+
export async function getObservabilityData(): Promise<ObservabilityData> {
69+
await requireAdmin()
70+
71+
const now = new Date()
72+
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)
73+
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
74+
75+
const [
76+
settings,
77+
userCounts,
78+
wrappedCounts,
79+
llmStats24h,
80+
llmStatsTotal,
81+
maintenanceStats,
82+
activityTrendRaw,
83+
topUsersRaw,
84+
] = await Promise.all([
85+
getAdminSettings(),
86+
// User counts
87+
prisma.user.groupBy({
88+
by: ["isAdmin"],
89+
_count: true,
90+
}),
91+
// Wrapped status counts
92+
prisma.plexWrapped.groupBy({
93+
by: ["status"],
94+
_count: true,
95+
}),
96+
// LLM usage last 24 hours
97+
prisma.lLMUsage.aggregate({
98+
where: {
99+
createdAt: { gte: yesterday },
100+
},
101+
_count: true,
102+
_sum: { cost: true },
103+
}),
104+
// Total LLM cost
105+
prisma.lLMUsage.aggregate({
106+
_sum: { cost: true },
107+
}),
108+
// Maintenance stats
109+
Promise.all([
110+
prisma.maintenanceCandidate.count({ where: { reviewStatus: "PENDING" } }),
111+
prisma.maintenanceCandidate.count({ where: { reviewStatus: "APPROVED" } }),
112+
prisma.maintenanceCandidate.count({ where: { reviewStatus: "DELETED" } }),
113+
]),
114+
// 7-day activity trend
115+
prisma.lLMUsage.findMany({
116+
where: {
117+
createdAt: { gte: sevenDaysAgo },
118+
},
119+
select: {
120+
createdAt: true,
121+
cost: true,
122+
totalTokens: true,
123+
},
124+
}),
125+
// Top users by LLM usage (last 30 days)
126+
prisma.lLMUsage.groupBy({
127+
by: ["userId"],
128+
where: {
129+
createdAt: { gte: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) },
130+
},
131+
_count: true,
132+
_sum: {
133+
cost: true,
134+
totalTokens: true,
135+
},
136+
orderBy: {
137+
_sum: {
138+
cost: "desc",
139+
},
140+
},
141+
take: 5,
142+
}),
143+
])
144+
145+
// Calculate user counts
146+
const adminCount = userCounts.find((u) => u.isAdmin === true)?._count || 0
147+
const regularCount = userCounts.find((u) => u.isAdmin === false)?._count || 0
148+
149+
// Calculate wrapped counts
150+
const wrappedStatusMap = wrappedCounts.reduce(
151+
(acc, item) => {
152+
acc[item.status] = item._count
153+
return acc
154+
},
155+
{} as Record<string, number>
156+
)
157+
158+
// Process activity trend - aggregate by date
159+
const activityByDate = new Map<string, { requests: number; cost: number; tokens: number }>()
160+
for (const record of activityTrendRaw) {
161+
const dateKey = record.createdAt.toISOString().split("T")[0]
162+
const existing = activityByDate.get(dateKey) || { requests: 0, cost: 0, tokens: 0 }
163+
activityByDate.set(dateKey, {
164+
requests: existing.requests + 1,
165+
cost: existing.cost + (record.cost || 0),
166+
tokens: existing.tokens + (record.totalTokens || 0),
167+
})
168+
}
169+
const activityTrend: ActivityTrendPoint[] = Array.from(activityByDate.entries())
170+
.map(([date, data]) => ({ date, ...data }))
171+
.sort((a, b) => a.date.localeCompare(b.date))
172+
173+
// Get user details for top users
174+
const topUserIds = topUsersRaw.map((u) => u.userId)
175+
const userDetails = await prisma.user.findMany({
176+
where: { id: { in: topUserIds } },
177+
select: { id: true, name: true, email: true, image: true },
178+
})
179+
const userDetailsMap = new Map(userDetails.map((u) => [u.id, u]))
180+
181+
const topUsers: TopUser[] = topUsersRaw.map((u) => {
182+
const user = userDetailsMap.get(u.userId)
183+
return {
184+
userId: u.userId,
185+
name: user?.name || "Unknown",
186+
email: user?.email || "",
187+
image: user?.image || null,
188+
requests: u._count,
189+
cost: u._sum.cost || 0,
190+
tokens: u._sum.totalTokens || 0,
191+
}
192+
})
193+
194+
return {
195+
services: {
196+
plex: {
197+
configured: !!settings.plexServer,
198+
name: "Plex",
199+
description: "Media server",
200+
},
201+
tautulli: {
202+
configured: !!settings.tautulli,
203+
name: "Tautulli",
204+
description: "Plex monitoring",
205+
},
206+
overseerr: {
207+
configured: !!settings.overseerr,
208+
name: "Overseerr",
209+
description: "Request management",
210+
},
211+
sonarr: {
212+
configured: !!settings.sonarr,
213+
name: "Sonarr",
214+
description: "TV show management",
215+
},
216+
radarr: {
217+
configured: !!settings.radarr,
218+
name: "Radarr",
219+
description: "Movie management",
220+
},
221+
discord: {
222+
configured: !!settings.discordIntegration?.isEnabled,
223+
name: "Discord",
224+
description: "Bot integration",
225+
},
226+
llm: {
227+
configured: !!settings.llmProvider || !!settings.chatLLMProvider,
228+
name: "LLM Provider",
229+
description: "AI generation",
230+
},
231+
},
232+
users: {
233+
total: adminCount + regularCount,
234+
admins: adminCount,
235+
regular: regularCount,
236+
},
237+
wrapped: {
238+
completed: wrappedStatusMap["completed"] || 0,
239+
generating: wrappedStatusMap["generating"] || 0,
240+
pending: wrappedStatusMap["pending"] || 0,
241+
failed: wrappedStatusMap["failed"] || 0,
242+
},
243+
llm: {
244+
requests24h: llmStats24h._count || 0,
245+
cost24h: llmStats24h._sum.cost || 0,
246+
totalCost: llmStatsTotal._sum.cost || 0,
247+
},
248+
maintenance: {
249+
pendingCandidates: maintenanceStats[0],
250+
approvedCandidates: maintenanceStats[1],
251+
totalDeletions: maintenanceStats[2],
252+
},
253+
activityTrend,
254+
topUsers,
255+
}
256+
}

actions/admin/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,7 @@ export {
4242

4343
// Combined settings
4444
export { getAdminSettings } from "./admin-settings"
45+
46+
// Observability dashboard
47+
export { getObservabilityData } from "./admin-observability"
48+
export type { ObservabilityData, ServiceStatus, ActivityTrendPoint, TopUser } from "./admin-observability"
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
export default function ObservabilityLoading() {
2+
return (
3+
<div className="p-4 sm:p-6">
4+
<div className="max-w-7xl mx-auto">
5+
{/* Header skeleton */}
6+
<div className="mb-6">
7+
<div className="h-8 w-48 bg-slate-700 rounded animate-pulse mb-2" />
8+
<div className="h-4 w-96 bg-slate-800 rounded animate-pulse" />
9+
</div>
10+
11+
{/* Summary Stats skeleton */}
12+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
13+
{[...Array(4)].map((_, i) => (
14+
<div
15+
key={i}
16+
className="bg-slate-800/50 border border-slate-700 rounded-lg p-4"
17+
>
18+
<div className="h-4 w-24 bg-slate-700 rounded animate-pulse mb-2" />
19+
<div className="h-8 w-16 bg-slate-700 rounded animate-pulse mb-1" />
20+
<div className="h-3 w-32 bg-slate-800 rounded animate-pulse" />
21+
</div>
22+
))}
23+
</div>
24+
25+
{/* Service Status Grid skeleton */}
26+
<div className="bg-slate-800/30 border border-slate-700 rounded-lg p-4 mb-6">
27+
<div className="flex items-center justify-between mb-4">
28+
<div className="h-5 w-32 bg-slate-700 rounded animate-pulse" />
29+
<div className="h-4 w-24 bg-slate-800 rounded animate-pulse" />
30+
</div>
31+
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-7 gap-3">
32+
{[...Array(7)].map((_, i) => (
33+
<div
34+
key={i}
35+
className="bg-slate-800/50 border border-slate-700 rounded-lg p-4"
36+
>
37+
<div className="flex items-center gap-3">
38+
<div className="w-5 h-5 bg-slate-700 rounded animate-pulse" />
39+
<div className="flex-1">
40+
<div className="h-4 w-16 bg-slate-700 rounded animate-pulse mb-1" />
41+
<div className="h-3 w-20 bg-slate-800 rounded animate-pulse" />
42+
</div>
43+
</div>
44+
</div>
45+
))}
46+
</div>
47+
</div>
48+
49+
{/* Secondary Stats skeleton */}
50+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
51+
{[...Array(3)].map((_, i) => (
52+
<div
53+
key={i}
54+
className="bg-slate-800/50 border border-slate-700 rounded-lg p-4"
55+
>
56+
<div className="h-4 w-24 bg-slate-700 rounded animate-pulse mb-2" />
57+
<div className="h-6 w-20 bg-slate-700 rounded animate-pulse mb-1" />
58+
<div className="h-3 w-28 bg-slate-800 rounded animate-pulse" />
59+
</div>
60+
))}
61+
</div>
62+
63+
{/* Quick Links skeleton */}
64+
<div className="mb-6">
65+
<div className="h-5 w-28 bg-slate-700 rounded animate-pulse mb-4" />
66+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
67+
{[...Array(6)].map((_, i) => (
68+
<div
69+
key={i}
70+
className="bg-slate-800/50 border border-slate-700 rounded-lg p-4"
71+
>
72+
<div className="flex items-start gap-3">
73+
<div className="w-5 h-5 bg-slate-700 rounded animate-pulse" />
74+
<div className="flex-1">
75+
<div className="h-4 w-28 bg-slate-700 rounded animate-pulse mb-2" />
76+
<div className="h-3 w-40 bg-slate-800 rounded animate-pulse" />
77+
</div>
78+
</div>
79+
</div>
80+
))}
81+
</div>
82+
</div>
83+
</div>
84+
</div>
85+
)
86+
}

0 commit comments

Comments
 (0)