diff --git a/actions/admin/admin-config.ts b/actions/admin/admin-config.ts index f76c6a41..51ff645a 100644 --- a/actions/admin/admin-config.ts +++ b/actions/admin/admin-config.ts @@ -94,22 +94,42 @@ export async function getConfig() { await requireAdmin() try { - const config = await prisma.config.findUnique({ + // First try to find existing config (fast path for most requests) + let config = await prisma.config.findUnique({ where: { id: "config" }, }) - // If config doesn't exist, create it with defaults - if (!config) { - return await prisma.config.create({ - data: { + // If config exists, return it + if (config) { + return config + } + + // Config doesn't exist, try to create it with upsert + try { + config = await prisma.config.upsert({ + where: { id: "config" }, + update: {}, + create: { id: "config", llmDisabled: false, wrappedEnabled: true, }, }) - } + return config + } catch (upsertError) { + // Handle race condition: another request created it between our check and upsert + // Just fetch the now-existing record + const existingConfig = await prisma.config.findUnique({ + where: { id: "config" }, + }) + + if (existingConfig) { + return existingConfig + } - return config + // If we still can't find it, something is wrong + throw upsertError + } } catch (error) { logger.error("Error getting config", error) // Return default config if there's an error diff --git a/actions/admin/admin-observability.ts b/actions/admin/admin-observability.ts new file mode 100644 index 00000000..2021ba48 --- /dev/null +++ b/actions/admin/admin-observability.ts @@ -0,0 +1,256 @@ +"use server" + +import { requireAdmin } from "@/lib/admin" +import { prisma } from "@/lib/prisma" +import { getAdminSettings } from "./admin-settings" + +export interface ServiceStatus { + configured: boolean + name: string + description: string +} + +export interface ActivityTrendPoint { + date: string + requests: number + cost: number + tokens: number +} + +export interface TopUser { + userId: string + name: string + email: string + image: string | null + requests: number + cost: number + tokens: number +} + +export interface ObservabilityData { + services: { + plex: ServiceStatus + tautulli: ServiceStatus + overseerr: ServiceStatus + sonarr: ServiceStatus + radarr: ServiceStatus + discord: ServiceStatus + llm: ServiceStatus + } + users: { + total: number + admins: number + regular: number + } + wrapped: { + completed: number + generating: number + pending: number + failed: number + } + llm: { + requests24h: number + cost24h: number + totalCost: number + } + maintenance: { + pendingCandidates: number + approvedCandidates: number + totalDeletions: number + } + activityTrend: ActivityTrendPoint[] + topUsers: TopUser[] +} + +/** + * Get observability dashboard data (admin only) + */ +export async function getObservabilityData(): Promise { + await requireAdmin() + + const now = new Date() + const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000) + const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) + + const [ + settings, + userCounts, + wrappedCounts, + llmStats24h, + llmStatsTotal, + maintenanceStats, + activityTrendRaw, + topUsersRaw, + ] = await Promise.all([ + getAdminSettings(), + // User counts + prisma.user.groupBy({ + by: ["isAdmin"], + _count: true, + }), + // Wrapped status counts + prisma.plexWrapped.groupBy({ + by: ["status"], + _count: true, + }), + // LLM usage last 24 hours + prisma.lLMUsage.aggregate({ + where: { + createdAt: { gte: yesterday }, + }, + _count: true, + _sum: { cost: true }, + }), + // Total LLM cost + prisma.lLMUsage.aggregate({ + _sum: { cost: true }, + }), + // Maintenance stats + Promise.all([ + prisma.maintenanceCandidate.count({ where: { reviewStatus: "PENDING" } }), + prisma.maintenanceCandidate.count({ where: { reviewStatus: "APPROVED" } }), + prisma.maintenanceCandidate.count({ where: { reviewStatus: "DELETED" } }), + ]), + // 7-day activity trend + prisma.lLMUsage.findMany({ + where: { + createdAt: { gte: sevenDaysAgo }, + }, + select: { + createdAt: true, + cost: true, + totalTokens: true, + }, + }), + // Top users by LLM usage (last 30 days) + prisma.lLMUsage.groupBy({ + by: ["userId"], + where: { + createdAt: { gte: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) }, + }, + _count: true, + _sum: { + cost: true, + totalTokens: true, + }, + orderBy: { + _sum: { + cost: "desc", + }, + }, + take: 5, + }), + ]) + + // Calculate user counts + const adminCount = userCounts.find((u) => u.isAdmin === true)?._count || 0 + const regularCount = userCounts.find((u) => u.isAdmin === false)?._count || 0 + + // Calculate wrapped counts + const wrappedStatusMap = wrappedCounts.reduce( + (acc, item) => { + acc[item.status] = item._count + return acc + }, + {} as Record + ) + + // Process activity trend - aggregate by date + const activityByDate = new Map() + for (const record of activityTrendRaw) { + const dateKey = record.createdAt.toISOString().split("T")[0] + const existing = activityByDate.get(dateKey) || { requests: 0, cost: 0, tokens: 0 } + activityByDate.set(dateKey, { + requests: existing.requests + 1, + cost: existing.cost + (record.cost || 0), + tokens: existing.tokens + (record.totalTokens || 0), + }) + } + const activityTrend: ActivityTrendPoint[] = Array.from(activityByDate.entries()) + .map(([date, data]) => ({ date, ...data })) + .sort((a, b) => a.date.localeCompare(b.date)) + + // Get user details for top users + const topUserIds = topUsersRaw.map((u) => u.userId) + const userDetails = await prisma.user.findMany({ + where: { id: { in: topUserIds } }, + select: { id: true, name: true, email: true, image: true }, + }) + const userDetailsMap = new Map(userDetails.map((u) => [u.id, u])) + + const topUsers: TopUser[] = topUsersRaw.map((u) => { + const user = userDetailsMap.get(u.userId) + return { + userId: u.userId, + name: user?.name || "Unknown", + email: user?.email || "", + image: user?.image || null, + requests: u._count, + cost: u._sum.cost || 0, + tokens: u._sum.totalTokens || 0, + } + }) + + return { + services: { + plex: { + configured: !!settings.plexServer, + name: "Plex", + description: "Media server", + }, + tautulli: { + configured: !!settings.tautulli, + name: "Tautulli", + description: "Plex monitoring", + }, + overseerr: { + configured: !!settings.overseerr, + name: "Overseerr", + description: "Request management", + }, + sonarr: { + configured: !!settings.sonarr, + name: "Sonarr", + description: "TV show management", + }, + radarr: { + configured: !!settings.radarr, + name: "Radarr", + description: "Movie management", + }, + discord: { + configured: !!settings.discordIntegration?.isEnabled, + name: "Discord", + description: "Bot integration", + }, + llm: { + configured: !!settings.llmProvider || !!settings.chatLLMProvider, + name: "LLM Provider", + description: "AI generation", + }, + }, + users: { + total: adminCount + regularCount, + admins: adminCount, + regular: regularCount, + }, + wrapped: { + completed: wrappedStatusMap["completed"] || 0, + generating: wrappedStatusMap["generating"] || 0, + pending: wrappedStatusMap["pending"] || 0, + failed: wrappedStatusMap["failed"] || 0, + }, + llm: { + requests24h: llmStats24h._count || 0, + cost24h: llmStats24h._sum.cost || 0, + totalCost: llmStatsTotal._sum.cost || 0, + }, + maintenance: { + pendingCandidates: maintenanceStats[0], + approvedCandidates: maintenanceStats[1], + totalDeletions: maintenanceStats[2], + }, + activityTrend, + topUsers, + } +} diff --git a/actions/admin/index.ts b/actions/admin/index.ts index 3edb0780..59161f97 100644 --- a/actions/admin/index.ts +++ b/actions/admin/index.ts @@ -42,3 +42,7 @@ export { // Combined settings export { getAdminSettings } from "./admin-settings" + +// Observability dashboard +export { getObservabilityData } from "./admin-observability" +export type { ObservabilityData, ServiceStatus, ActivityTrendPoint, TopUser } from "./admin-observability" diff --git a/actions/user-queries.ts b/actions/user-queries.ts index e2ad8f43..79d42943 100644 --- a/actions/user-queries.ts +++ b/actions/user-queries.ts @@ -441,17 +441,24 @@ export async function getUserActivityTimeline( const { page = 1, pageSize = 10 } = options try { + // Calculate how many items to fetch from each source + // To support proper pagination, we need to fetch enough items to cover + // the requested page plus buffer. Since items are interleaved by timestamp, + // we need to fetch up to (page * pageSize) + buffer from each source. + const maxItemsNeeded = page * pageSize + pageSize + const fetchLimit = maxItemsNeeded + // Fetch both data sources and counts in parallel const [discordLogs, mediaMarks, discordCount, mediaMarkCount] = await Promise.all([ prisma.discordCommandLog.findMany({ where: { userId }, orderBy: { createdAt: "desc" }, - take: pageSize * 2, // Fetch extra to merge properly + take: fetchLimit, }), prisma.userMediaMark.findMany({ where: { userId }, orderBy: { markedAt: "desc" }, - take: pageSize * 2, + take: fetchLimit, }), prisma.discordCommandLog.count({ where: { userId } }), prisma.userMediaMark.count({ where: { userId } }), diff --git a/app/admin/observability/loading.tsx b/app/admin/observability/loading.tsx new file mode 100644 index 00000000..e588c1fd --- /dev/null +++ b/app/admin/observability/loading.tsx @@ -0,0 +1,86 @@ +export default function ObservabilityLoading() { + return ( +
+
+ {/* Header skeleton */} +
+
+
+
+ + {/* Summary Stats skeleton */} +
+ {[...Array(4)].map((_, i) => ( +
+
+
+
+
+ ))} +
+ + {/* Service Status Grid skeleton */} +
+
+
+
+
+
+ {[...Array(7)].map((_, i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+
+ + {/* Secondary Stats skeleton */} +
+ {[...Array(3)].map((_, i) => ( +
+
+
+
+
+ ))} +
+ + {/* Quick Links skeleton */} +
+
+
+ {[...Array(6)].map((_, i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+
+
+
+ ) +} diff --git a/app/admin/observability/page.tsx b/app/admin/observability/page.tsx new file mode 100644 index 00000000..10a361cb --- /dev/null +++ b/app/admin/observability/page.tsx @@ -0,0 +1,362 @@ +import { getObservabilityData } from "@/actions/admin" +import { ActiveSessionsPanel } from "@/components/admin/observability/active-sessions-panel" +import { ActivityTrendChart } from "@/components/admin/observability/activity-trend-chart" +import { DownloadQueuesPanel } from "@/components/admin/observability/download-queues-panel" +import { RequestsPanel } from "@/components/admin/observability/requests-panel" +import { ServiceStatusGrid } from "@/components/admin/observability/service-status-grid" +import { StoragePanel } from "@/components/admin/observability/storage-panel" +import { TopUsersWidget } from "@/components/admin/observability/top-users-widget" +import Link from "next/link" + +export const dynamic = "force-dynamic" + +export default async function ObservabilityPage() { + const data = await getObservabilityData() + + const configuredServicesCount = Object.values(data.services).filter((s) => s.configured).length + const totalServicesCount = Object.values(data.services).length + + return ( +
+
+ {/* Header */} +
+

System Overview

+

+ At-a-glance visibility into system health, user activity, and resource usage +

+
+ + {/* Summary Stats */} +
+ {/* Configured Services */} +
+
Configured Services
+
+ {configuredServicesCount}/{totalServicesCount} +
+
+ {configuredServicesCount === totalServicesCount + ? "All services configured" + : `${totalServicesCount - configuredServicesCount} not configured`} +
+
+ + {/* Total Users */} + +
Total Users
+
+ {data.users.total} +
+
+ {data.users.admins} admins, {data.users.regular} regular +
+ + + {/* Wrapped Status */} + +
Wrapped Status
+
+ {data.wrapped.completed} + completed +
+
+ {data.wrapped.generating > 0 && ( + {data.wrapped.generating} generating + )} + {data.wrapped.generating > 0 && data.wrapped.pending > 0 && ", "} + {data.wrapped.pending > 0 && `${data.wrapped.pending} pending`} + {data.wrapped.generating === 0 && data.wrapped.pending === 0 && "All complete"} +
+ + + {/* LLM Usage (24h) */} + +
LLM Usage (24h)
+
+ {data.llm.requests24h} + requests +
+
+ ${data.llm.cost24h.toFixed(4)} cost today +
+ +
+ + {/* Service Status Grid */} +
+ +
+ + {/* Activity & Top Users Section */} +
+ {/* Activity Trend Chart */} +
+
+

Activity Trend (7 Days)

+ + View details + + + + +
+
+ +
+
+ + {/* Top Users */} +
+
+

Top Users (30 Days)

+

By LLM usage cost

+
+ +
+
+ + {/* Real-Time Panels */} +
+ {/* Active Sessions */} +
+
+
+

Active Streams

+

Live Plex sessions

+
+
+ + Auto-refresh +
+
+ +
+ + {/* Download Queues */} +
+
+
+

Download Queue

+

Sonarr & Radarr

+
+
+ + Auto-refresh +
+
+ +
+
+ + {/* Storage & Requests Row */} +
+ {/* Storage & Libraries */} +
+
+
+

Storage & Libraries

+

Disk usage and content

+
+
+ + 1m refresh +
+
+ +
+ + {/* Media Requests */} +
+
+
+

Media Requests

+

Overseerr request status

+
+
+ + 1m refresh +
+
+ +
+
+ + {/* Secondary Stats Row */} +
+ {/* Total LLM Cost */} + +
Total LLM Cost
+
+ ${data.llm.totalCost.toFixed(2)} +
+
+ View cost analysis + + + +
+ + + {/* Maintenance Queue */} + +
Maintenance Queue
+
+ {data.maintenance.pendingCandidates} + pending review +
+
+ {data.maintenance.approvedCandidates} approved, {data.maintenance.totalDeletions} deleted +
+ + + {/* Quick Settings Access */} + +
Settings
+
+ Configure +
+
+ Manage integrations + + + +
+ +
+ + {/* Quick Links Grid */} +
+

Quick Access

+
+ + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + + } + /> +
+
+
+
+ ) +} + +function QuickLinkCard({ + href, + title, + description, + icon, +}: { + href: string + title: string + description: string + icon: React.ReactNode +}) { + return ( + +
+
+ {icon} +
+
+

+ {title} +

+

+ {description} +

+
+ + + +
+ + ) +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 49db0222..c23273d4 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -3,6 +3,6 @@ import { redirect } from "next/navigation" export const dynamic = 'force-dynamic' export default async function AdminDashboard() { - redirect("/admin/users") + redirect("/admin/observability") } diff --git a/app/admin/settings/page.tsx b/app/admin/settings/page.tsx index baeb7546..ceb64742 100644 --- a/app/admin/settings/page.tsx +++ b/app/admin/settings/page.tsx @@ -68,7 +68,7 @@ export default async function SettingsPage() {
{/* Header */}
-

Settings

+

Settings

Manage application configuration and system settings

diff --git a/app/api/observability/queues/route.ts b/app/api/observability/queues/route.ts new file mode 100644 index 00000000..f3e735ce --- /dev/null +++ b/app/api/observability/queues/route.ts @@ -0,0 +1,200 @@ +import { getRadarrQueue } from "@/lib/connections/radarr" +import { getSonarrQueue } from "@/lib/connections/sonarr" +import { prisma } from "@/lib/prisma" +import { requireAdminAPI } from "@/lib/security/api-helpers" +import { ErrorCode, getStatusCode, logError } from "@/lib/security/error-handler" +import { adminRateLimiter } from "@/lib/security/rate-limit" +import { radarrQueueResponseSchema, type RadarrQueueRecord } from "@/lib/validations/radarr" +import { sonarrQueueResponseSchema, type SonarrQueueRecord } from "@/lib/validations/sonarr" +import { NextRequest, NextResponse } from "next/server" + +export interface QueueItem { + id: number + source: "sonarr" | "radarr" + title: string + status: "downloading" | "queued" | "paused" | "failed" | "completed" + progress: number + size: number + sizeLeft: number + estimatedCompletionTime: string | null + quality: string +} + +export interface QueuesResponse { + available: boolean + items: QueueItem[] + sonarrConfigured: boolean + radarrConfigured: boolean + sonarrUrl?: string + radarrUrl?: string + error?: string +} + +export const dynamic = "force-dynamic" + +export async function GET(request: NextRequest) { + try { + // Apply rate limiting + const rateLimitResponse = await adminRateLimiter(request) + if (rateLimitResponse) { + return rateLimitResponse + } + + // Require admin authentication + const authResult = await requireAdminAPI(request) + if (authResult.response) { + return authResult.response + } + + // Check configurations + const [sonarr, radarr] = await Promise.all([ + prisma.sonarr.findFirst({ where: { isActive: true } }), + prisma.radarr.findFirst({ where: { isActive: true } }), + ]) + + const sonarrConfigured = !!sonarr + const radarrConfigured = !!radarr + + if (!sonarrConfigured && !radarrConfigured) { + return NextResponse.json({ + available: false, + items: [], + sonarrConfigured: false, + radarrConfigured: false, + error: "Neither Sonarr nor Radarr configured", + } satisfies QueuesResponse) + } + + const items: QueueItem[] = [] + + // Helper to extract records from validated queue response + function extractSonarrRecords(data: unknown): SonarrQueueRecord[] { + const validated = sonarrQueueResponseSchema.safeParse(data) + if (!validated.success) { + logError("OBSERVABILITY_SONARR_QUEUE_VALIDATION", validated.error) + return [] + } + return Array.isArray(validated.data) ? validated.data : validated.data.records || [] + } + + function extractRadarrRecords(data: unknown): RadarrQueueRecord[] { + const validated = radarrQueueResponseSchema.safeParse(data) + if (!validated.success) { + logError("OBSERVABILITY_RADARR_QUEUE_VALIDATION", validated.error) + return [] + } + return Array.isArray(validated.data) ? validated.data : validated.data.records || [] + } + + // Fetch Sonarr queue + if (sonarr) { + try { + const result = await getSonarrQueue({ + name: sonarr.name, + url: sonarr.url, + apiKey: sonarr.apiKey, + }) + + if (result.success && result.data) { + const records = extractSonarrRecords(result.data) + for (const item of records) { + items.push({ + id: item.id, + source: "sonarr", + title: item.series?.title + ? `${item.series.title} - ${item.episode?.title || `S${item.episode?.seasonNumber}E${item.episode?.episodeNumber}`}` + : item.title || "Unknown", + status: mapStatus(item.status, item.trackedDownloadStatus), + progress: calculateProgress(item.size, item.sizeleft), + size: item.size || 0, + sizeLeft: item.sizeleft || 0, + estimatedCompletionTime: item.estimatedCompletionTime || null, + quality: item.quality?.quality?.name || "Unknown", + }) + } + } else if (!result.success) { + logError("OBSERVABILITY_SONARR_QUEUE", new Error(result.error)) + } + } catch (error) { + logError("OBSERVABILITY_SONARR_QUEUE", error) + } + } + + // Fetch Radarr queue + if (radarr) { + try { + const result = await getRadarrQueue({ + name: radarr.name, + url: radarr.url, + apiKey: radarr.apiKey, + }) + + if (result.success && result.data) { + const records = extractRadarrRecords(result.data) + for (const item of records) { + items.push({ + id: item.id, + source: "radarr", + title: item.movie?.title || item.title || "Unknown", + status: mapStatus(item.status, item.trackedDownloadStatus), + progress: calculateProgress(item.size, item.sizeleft), + size: item.size || 0, + sizeLeft: item.sizeleft || 0, + estimatedCompletionTime: item.estimatedCompletionTime || null, + quality: item.quality?.quality?.name || "Unknown", + }) + } + } else if (!result.success) { + logError("OBSERVABILITY_RADARR_QUEUE", new Error(result.error)) + } + } catch (error) { + logError("OBSERVABILITY_RADARR_QUEUE", error) + } + } + + // Sort by progress (downloading items first) + items.sort((a, b) => { + if (a.status === "downloading" && b.status !== "downloading") return -1 + if (b.status === "downloading" && a.status !== "downloading") return 1 + return b.progress - a.progress + }) + + return NextResponse.json({ + available: true, + items, + sonarrConfigured, + radarrConfigured, + sonarrUrl: sonarr ? (sonarr.publicUrl || sonarr.url) : undefined, + radarrUrl: radarr ? (radarr.publicUrl || radarr.url) : undefined, + } satisfies QueuesResponse) + } catch (error) { + logError("OBSERVABILITY_QUEUES", error) + return NextResponse.json( + { + available: false, + items: [], + sonarrConfigured: false, + radarrConfigured: false, + error: "Failed to fetch queues", + } satisfies QueuesResponse, + { status: getStatusCode(ErrorCode.INTERNAL_ERROR) } + ) + } +} + +function mapStatus( + status: string | undefined, + trackedStatus: string | undefined +): QueueItem["status"] { + if (trackedStatus === "warning" || trackedStatus === "error") return "failed" + if (status === "completed") return "completed" + if (status === "paused") return "paused" + if (status === "downloading") return "downloading" + return "queued" +} + +function calculateProgress(size: number | undefined, sizeLeft: number | undefined): number { + if (!size || size === 0) return 0 + const left = sizeLeft || 0 + return Math.round(((size - left) / size) * 100) +} diff --git a/app/api/observability/requests/route.ts b/app/api/observability/requests/route.ts new file mode 100644 index 00000000..120a6fcf --- /dev/null +++ b/app/api/observability/requests/route.ts @@ -0,0 +1,227 @@ +import { getAllOverseerrRequests, getOverseerrMediaDetails } from "@/lib/connections/overseerr" +import { prisma } from "@/lib/prisma" +import { requireAdminAPI } from "@/lib/security/api-helpers" +import { ErrorCode, getStatusCode, logError } from "@/lib/security/error-handler" +import { adminRateLimiter } from "@/lib/security/rate-limit" +import { NextRequest, NextResponse } from "next/server" + +interface OverseerrRequest { + id: number + status: number + type: string + createdAt?: string + media?: { + id?: number + tmdbId?: number + mediaType?: string + } + requestedBy?: { + displayName?: string + email?: string + } +} + +// Simple in-memory cache for media titles to avoid rate limiting +const titleCache = new Map() +const CACHE_TTL_MS = 10 * 60 * 1000 // 10 minutes + +function getCachedTitle(tmdbId: number, mediaType: string): string | null { + const key = `${mediaType}:${tmdbId}` + const cached = titleCache.get(key) + if (cached && cached.expiresAt > Date.now()) { + return cached.title + } + if (cached) { + titleCache.delete(key) + } + return null +} + +function setCachedTitle(tmdbId: number, mediaType: string, title: string): void { + const key = `${mediaType}:${tmdbId}` + titleCache.set(key, { title, expiresAt: Date.now() + CACHE_TTL_MS }) +} + +export interface RequestItem { + id: number + type: "movie" | "tv" + title: string + status: "pending" | "approved" | "available" | "declined" + requestedBy: string + requestedAt: string + tmdbId?: number +} + +export interface RequestsStatsResponse { + available: boolean + configured: boolean + recentRequests: RequestItem[] + stats: { + pending: number + approved: number + available: number + declined: number + total: number + } + overseerrUrl?: string + error?: string +} + +export const dynamic = "force-dynamic" + +export async function GET(request: NextRequest) { + try { + // Apply rate limiting + const rateLimitResponse = await adminRateLimiter(request) + if (rateLimitResponse) { + return rateLimitResponse + } + + // Require admin authentication + const authResult = await requireAdminAPI(request) + if (authResult.response) { + return authResult.response + } + + // Check if Overseerr is configured + const overseerr = await prisma.overseerr.findFirst({ where: { isActive: true } }) + + if (!overseerr) { + return NextResponse.json({ + available: false, + configured: false, + recentRequests: [], + stats: { pending: 0, approved: 0, available: 0, declined: 0, total: 0 }, + error: "Overseerr not configured", + } satisfies RequestsStatsResponse) + } + + try { + // Fetch recent requests (last 50 to get good stats) + const requestsData = await getAllOverseerrRequests( + { + name: overseerr.name, + url: overseerr.url, + apiKey: overseerr.apiKey, + }, + 50 + ) + + const requests: OverseerrRequest[] = requestsData.results || [] + + // Calculate stats + const stats = { + pending: 0, + approved: 0, + available: 0, + declined: 0, + total: requests.length, + } + + // First pass: calculate stats and identify requests to display + const requestsToDisplay: Array<{ + req: OverseerrRequest + status: RequestItem["status"] + }> = [] + + for (const req of requests) { + // Map status (Overseerr uses: 1=pending, 2=approved, 3=declined, 4=available) + let status: RequestItem["status"] = "pending" + if (req.status === 1) { + status = "pending" + stats.pending++ + } else if (req.status === 2) { + status = "approved" + stats.approved++ + } else if (req.status === 3) { + status = "declined" + stats.declined++ + } else if (req.status === 4 || req.status === 5) { + status = "available" + stats.available++ + } + + // Only add recent items (first 10) to display list + if (requestsToDisplay.length < 10) { + requestsToDisplay.push({ req, status }) + } + } + + // Fetch media details for requests we'll display (to get titles) + const config = { + name: overseerr.name, + url: overseerr.url, + apiKey: overseerr.apiKey, + } + + const recentRequests: RequestItem[] = await Promise.all( + requestsToDisplay.map(async ({ req, status }) => { + let title = "Unknown" + const mediaType = req.type === "movie" ? "movie" : "tv" + + // Try to get title from cache first + if (req.media?.tmdbId) { + const cachedTitle = getCachedTitle(req.media.tmdbId, mediaType) + if (cachedTitle) { + title = cachedTitle + } else { + // Fetch media details to get the title + try { + const details = await getOverseerrMediaDetails( + config, + req.media.tmdbId, + mediaType + ) + // Movies use 'title', TV shows use 'name' + title = details.title || details.name || details.originalTitle || details.originalName || "Unknown" + // Cache the result + setCachedTitle(req.media.tmdbId, mediaType, title) + } catch { + // If fetching details fails, keep "Unknown" + } + } + } + + return { + id: req.id, + type: mediaType as "movie" | "tv", + title, + status, + requestedBy: req.requestedBy?.displayName || req.requestedBy?.email || "Unknown", + requestedAt: req.createdAt || new Date().toISOString(), + tmdbId: req.media?.tmdbId, + } + }) + ) + + return NextResponse.json({ + available: true, + configured: true, + recentRequests, + stats, + overseerrUrl: overseerr.publicUrl || overseerr.url, + } satisfies RequestsStatsResponse) + } catch (error) { + logError("OBSERVABILITY_OVERSEERR_REQUESTS", error) + return NextResponse.json({ + available: false, + configured: true, + recentRequests: [], + stats: { pending: 0, approved: 0, available: 0, declined: 0, total: 0 }, + error: "Failed to fetch Overseerr data", + } satisfies RequestsStatsResponse) + } + } catch (error) { + logError("OBSERVABILITY_REQUESTS", error) + return NextResponse.json( + { + available: false, + configured: false, + recentRequests: [], + stats: { pending: 0, approved: 0, available: 0, declined: 0, total: 0 }, + error: "Failed to fetch request metrics", + } satisfies RequestsStatsResponse, + { status: getStatusCode(ErrorCode.INTERNAL_ERROR) } + ) + } +} diff --git a/app/api/observability/sessions/route.ts b/app/api/observability/sessions/route.ts new file mode 100644 index 00000000..4c3f224f --- /dev/null +++ b/app/api/observability/sessions/route.ts @@ -0,0 +1,125 @@ +import { getTautulliActivity } from "@/lib/connections/tautulli" +import { prisma } from "@/lib/prisma" +import { requireAdminAPI } from "@/lib/security/api-helpers" +import { ErrorCode, getStatusCode, logError } from "@/lib/security/error-handler" +import { adminRateLimiter } from "@/lib/security/rate-limit" +import { tautulliActivityResponseSchema } from "@/lib/validations/tautulli" +import { NextRequest, NextResponse } from "next/server" + +export interface PlexSession { + sessionId: string + user: string + userThumb: string | null + title: string + grandparentTitle: string | null + mediaType: "movie" | "episode" | "track" + progress: number + state: "playing" | "paused" | "buffering" + player: string + quality: string + duration: number + viewOffset: number +} + +export interface SessionsResponse { + available: boolean + sessions: PlexSession[] + streamCount: number + tautulliUrl?: string + error?: string +} + +export const dynamic = "force-dynamic" + +export async function GET(request: NextRequest) { + try { + // Apply rate limiting + const rateLimitResponse = await adminRateLimiter(request) + if (rateLimitResponse) { + return rateLimitResponse + } + + // Require admin authentication + const authResult = await requireAdminAPI(request) + if (authResult.response) { + return authResult.response + } + + // Check if Tautulli is configured + const tautulli = await prisma.tautulli.findFirst({ where: { isActive: true } }) + + if (!tautulli) { + return NextResponse.json({ + available: false, + sessions: [], + streamCount: 0, + error: "Tautulli not configured", + } satisfies SessionsResponse) + } + + // Fetch activity from Tautulli + const result = await getTautulliActivity({ + name: tautulli.name, + url: tautulli.url, + apiKey: tautulli.apiKey, + }) + + if (!result.success) { + return NextResponse.json({ + available: false, + sessions: [], + streamCount: 0, + error: result.error || "Failed to fetch Tautulli activity", + } satisfies SessionsResponse) + } + + // Validate and parse the response + const validatedActivity = tautulliActivityResponseSchema.safeParse(result.data) + + if (!validatedActivity.success) { + logError("OBSERVABILITY_SESSIONS_VALIDATION", validatedActivity.error) + return NextResponse.json({ + available: false, + sessions: [], + streamCount: 0, + error: "Invalid response format from Tautulli", + } satisfies SessionsResponse) + } + + const activity = validatedActivity.data + const sessions: PlexSession[] = (activity?.response?.data?.sessions || []).map( + (session) => ({ + sessionId: session.session_id || session.session_key || "", + user: session.user || "Unknown", + userThumb: session.user_thumb || null, + title: session.title || "Unknown", + grandparentTitle: session.grandparent_title || null, + mediaType: session.media_type === "movie" ? "movie" : session.media_type === "episode" ? "episode" : "track", + progress: Number(session.progress_percent || 0), + state: session.state === "playing" ? "playing" : session.state === "paused" ? "paused" : "buffering", + player: session.player || "Unknown", + quality: session.quality_profile || session.stream_video_full_resolution || "Unknown", + duration: Number(session.duration || 0), + viewOffset: Number(session.view_offset || 0), + }) + ) + + return NextResponse.json({ + available: true, + sessions, + streamCount: activity?.response?.data?.stream_count || sessions.length, + tautulliUrl: tautulli.publicUrl || tautulli.url, + } satisfies SessionsResponse) + } catch (error) { + logError("OBSERVABILITY_SESSIONS", error) + return NextResponse.json( + { + available: false, + sessions: [], + streamCount: 0, + error: "Failed to fetch sessions", + } satisfies SessionsResponse, + { status: getStatusCode(ErrorCode.INTERNAL_ERROR) } + ) + } +} diff --git a/app/api/observability/storage/route.ts b/app/api/observability/storage/route.ts new file mode 100644 index 00000000..f89ca69a --- /dev/null +++ b/app/api/observability/storage/route.ts @@ -0,0 +1,225 @@ +import { getSonarrDiskSpace } from "@/lib/connections/sonarr" +import { getRadarrDiskSpace } from "@/lib/connections/radarr" +import { getTautulliLibraries } from "@/lib/connections/tautulli" +import { prisma } from "@/lib/prisma" +import { requireAdminAPI } from "@/lib/security/api-helpers" +import { ErrorCode, getStatusCode, logError } from "@/lib/security/error-handler" +import { adminRateLimiter } from "@/lib/security/rate-limit" +import { NextRequest, NextResponse } from "next/server" + +export interface DiskSpaceItem { + path: string + label: string + freeSpace: number + totalSpace: number + usedSpace: number + usedPercent: number + source: "sonarr" | "radarr" +} + +export interface LibraryInfo { + sectionId: string + sectionName: string + sectionType: string + count: number +} + +export interface StorageResponse { + available: boolean + diskSpace: DiskSpaceItem[] + libraries: LibraryInfo[] + sonarrConfigured: boolean + radarrConfigured: boolean + tautulliConfigured: boolean + error?: string +} + +export const dynamic = "force-dynamic" + +export async function GET(request: NextRequest) { + try { + // Apply rate limiting + const rateLimitResponse = await adminRateLimiter(request) + if (rateLimitResponse) { + return rateLimitResponse + } + + // Require admin authentication + const authResult = await requireAdminAPI(request) + if (authResult.response) { + return authResult.response + } + + // Check configurations + const [sonarr, radarr, tautulli] = await Promise.all([ + prisma.sonarr.findFirst({ where: { isActive: true } }), + prisma.radarr.findFirst({ where: { isActive: true } }), + prisma.tautulli.findFirst({ where: { isActive: true } }), + ]) + + const sonarrConfigured = !!sonarr + const radarrConfigured = !!radarr + const tautulliConfigured = !!tautulli + + if (!sonarrConfigured && !radarrConfigured && !tautulliConfigured) { + return NextResponse.json({ + available: false, + diskSpace: [], + libraries: [], + sonarrConfigured: false, + radarrConfigured: false, + tautulliConfigured: false, + error: "No services configured for storage metrics", + } satisfies StorageResponse) + } + + const diskSpace: DiskSpaceItem[] = [] + const libraries: LibraryInfo[] = [] + + // Disk space item type + interface DiskSpaceRecord { + path?: string + label?: string + freeSpace?: number + totalSpace?: number + } + + // Fetch Sonarr disk space + if (sonarr) { + try { + const result = await getSonarrDiskSpace({ + name: sonarr.name, + url: sonarr.url, + apiKey: sonarr.apiKey, + }) + + if (result.success && result.data) { + const space = result.data as DiskSpaceRecord[] + for (const disk of space || []) { + const usedSpace = (disk.totalSpace || 0) - (disk.freeSpace || 0) + const usedPercent = disk.totalSpace && disk.totalSpace > 0 + ? Math.round((usedSpace / disk.totalSpace) * 100) + : 0 + + diskSpace.push({ + path: disk.path || "Unknown", + label: disk.label || disk.path || "Sonarr Storage", + freeSpace: disk.freeSpace || 0, + totalSpace: disk.totalSpace || 0, + usedSpace, + usedPercent, + source: "sonarr", + }) + } + } else if (!result.success) { + logError("OBSERVABILITY_SONARR_DISKSPACE", new Error(result.error)) + } + } catch (error) { + logError("OBSERVABILITY_SONARR_DISKSPACE", error) + } + } + + // Fetch Radarr disk space + if (radarr) { + try { + const result = await getRadarrDiskSpace({ + name: radarr.name, + url: radarr.url, + apiKey: radarr.apiKey, + }) + + if (result.success && result.data) { + const space = result.data as DiskSpaceRecord[] + for (const disk of space || []) { + // Check if this path is already added from Sonarr + const existingPath = diskSpace.find(d => d.path === disk.path) + if (existingPath) { + // Update to show it's shared between both + existingPath.label = `${existingPath.label} (Shared)` + continue + } + + const usedSpace = (disk.totalSpace || 0) - (disk.freeSpace || 0) + const usedPercent = disk.totalSpace && disk.totalSpace > 0 + ? Math.round((usedSpace / disk.totalSpace) * 100) + : 0 + + diskSpace.push({ + path: disk.path || "Unknown", + label: disk.label || disk.path || "Radarr Storage", + freeSpace: disk.freeSpace || 0, + totalSpace: disk.totalSpace || 0, + usedSpace, + usedPercent, + source: "radarr", + }) + } + } else if (!result.success) { + logError("OBSERVABILITY_RADARR_DISKSPACE", new Error(result.error)) + } + } catch (error) { + logError("OBSERVABILITY_RADARR_DISKSPACE", error) + } + } + + // Fetch Tautulli library info (using get_libraries which includes item counts) + if (tautulli) { + try { + const result = await getTautulliLibraries({ + name: tautulli.name, + url: tautulli.url, + apiKey: tautulli.apiKey, + }) + + if (result.success && result.data) { + const libraryData = result.data as { response?: { data?: Array<{ + section_id?: string | number + section_name?: string + section_type?: string + count?: string | number + }> } } + const libList = libraryData.response?.data || [] + for (const lib of libList) { + libraries.push({ + sectionId: lib.section_id?.toString() || "", + sectionName: lib.section_name || "Unknown", + sectionType: lib.section_type || "unknown", + // get_libraries returns count as a string + count: typeof lib.count === 'string' ? parseInt(lib.count, 10) : (lib.count || 0), + }) + } + } else if (!result.success) { + logError("OBSERVABILITY_TAUTULLI_LIBRARIES", new Error(result.error)) + } + } catch (error) { + logError("OBSERVABILITY_TAUTULLI_LIBRARIES", error) + } + } + + // Sort disk space by used percent (highest first) + diskSpace.sort((a, b) => b.usedPercent - a.usedPercent) + + return NextResponse.json({ + available: true, + diskSpace, + libraries, + sonarrConfigured, + radarrConfigured, + tautulliConfigured, + } satisfies StorageResponse) + } catch (error) { + logError("OBSERVABILITY_STORAGE", error) + return NextResponse.json( + { + available: false, + diskSpace: [], + libraries: [], + sonarrConfigured: false, + radarrConfigured: false, + tautulliConfigured: false, + error: "Failed to fetch storage metrics", + } satisfies StorageResponse, + { status: getStatusCode(ErrorCode.INTERNAL_ERROR) } + ) + } +} diff --git a/components/admin/observability/__tests__/active-sessions-panel.test.tsx b/components/admin/observability/__tests__/active-sessions-panel.test.tsx new file mode 100644 index 00000000..008ec888 --- /dev/null +++ b/components/admin/observability/__tests__/active-sessions-panel.test.tsx @@ -0,0 +1,332 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { ActiveSessionsPanel } from '../active-sessions-panel' +import type { SessionsResponse } from '@/app/api/observability/sessions/route' + +// Mock fetch +global.fetch = jest.fn() + +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + +function renderWithQuery(ui: React.ReactElement) { + const queryClient = createQueryClient() + return render( + {ui} + ) +} + +describe('ActiveSessionsPanel', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + const mockSessions: SessionsResponse = { + available: true, + streamCount: 2, + sessions: [ + { + sessionId: 'session-1', + user: 'John Doe', + userThumb: 'https://example.com/john.jpg', + title: 'Episode 5', + grandparentTitle: 'Breaking Bad', + mediaType: 'episode', + progress: 45, + state: 'playing', + player: 'Chrome', + quality: '1080p', + duration: 3600000, + viewOffset: 1620000, + }, + { + sessionId: 'session-2', + user: 'Jane Smith', + userThumb: null, + title: 'Inception', + grandparentTitle: null, + mediaType: 'movie', + progress: 78, + state: 'paused', + player: 'Roku', + quality: '4K', + duration: 9000000, + viewOffset: 7020000, + }, + ], + } + + describe('Loading State', () => { + it('should show skeleton while loading', () => { + ;(fetch as jest.Mock).mockImplementation(() => new Promise(() => {})) + + const { container } = renderWithQuery() + + // Should show skeleton animation + expect(container.querySelector('.animate-pulse')).toBeInTheDocument() + }) + }) + + describe('Error State', () => { + it('should show error message when fetch fails', async () => { + ;(fetch as jest.Mock).mockRejectedValue(new Error('Network error')) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('Network error')).toBeInTheDocument() + }) + }) + + it('should show generic error message for non-Error exceptions', async () => { + ;(fetch as jest.Mock).mockRejectedValue('Unknown error') + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('Failed to load sessions')).toBeInTheDocument() + }) + }) + }) + + describe('Not Configured State', () => { + it('should show "Tautulli not configured" when not available', async () => { + const notConfigured: SessionsResponse = { + available: false, + sessions: [], + streamCount: 0, + error: 'Tautulli not configured', + } + + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(notConfigured), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('Tautulli not configured')).toBeInTheDocument() + }) + }) + + it('should show custom error message when provided', async () => { + const customError: SessionsResponse = { + available: false, + sessions: [], + streamCount: 0, + error: 'Custom error message', + } + + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(customError), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('Custom error message')).toBeInTheDocument() + }) + }) + }) + + describe('Empty Sessions State', () => { + it('should show "No active streams" when sessions array is empty', async () => { + const emptySessions: SessionsResponse = { + available: true, + sessions: [], + streamCount: 0, + } + + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(emptySessions), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('No active streams')).toBeInTheDocument() + }) + }) + }) + + describe('Sessions Display', () => { + it('should render active sessions panel', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockSessions), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByTestId('active-sessions-panel')).toBeInTheDocument() + }) + }) + + it('should display each session', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockSessions), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByTestId('session-session-1')).toBeInTheDocument() + expect(screen.getByTestId('session-session-2')).toBeInTheDocument() + }) + }) + + it('should display session titles correctly', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockSessions), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('Breaking Bad - Episode 5')).toBeInTheDocument() + expect(screen.getByText('Inception')).toBeInTheDocument() + }) + }) + + it('should display user names', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockSessions), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('John Doe')).toBeInTheDocument() + expect(screen.getByText('Jane Smith')).toBeInTheDocument() + }) + }) + + it('should display player and quality info', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockSessions), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('Chrome')).toBeInTheDocument() + expect(screen.getByText('1080p')).toBeInTheDocument() + expect(screen.getByText('Roku')).toBeInTheDocument() + expect(screen.getByText('4K')).toBeInTheDocument() + }) + }) + + it('should display progress percentages', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockSessions), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('45%')).toBeInTheDocument() + expect(screen.getByText('78%')).toBeInTheDocument() + }) + }) + + it('should display session states', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockSessions), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('playing')).toBeInTheDocument() + expect(screen.getByText('paused')).toBeInTheDocument() + }) + }) + + it('should display stream count in footer', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockSessions), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText(/2 active streams/)).toBeInTheDocument() + }) + }) + }) + + describe('User Avatar', () => { + it('should display user image when available', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockSessions), + }) + + renderWithQuery() + + await waitFor(() => { + const img = screen.getByAltText('John Doe') as HTMLImageElement + expect(img.src).toBe('https://example.com/john.jpg') + }) + }) + + it('should display initial fallback when no image', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockSessions), + }) + + renderWithQuery() + + await waitFor(() => { + // Jane Smith has no image, should show 'J' + expect(screen.getByText('J')).toBeInTheDocument() + }) + }) + }) + + describe('API Interaction', () => { + it('should fetch sessions from correct endpoint', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockSessions), + }) + + renderWithQuery() + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith('/api/observability/sessions') + }) + }) + + it('should handle non-ok response', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 500, + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('Failed to fetch sessions')).toBeInTheDocument() + }) + }) + }) +}) diff --git a/components/admin/observability/__tests__/activity-trend-chart.test.tsx b/components/admin/observability/__tests__/activity-trend-chart.test.tsx new file mode 100644 index 00000000..17df1743 --- /dev/null +++ b/components/admin/observability/__tests__/activity-trend-chart.test.tsx @@ -0,0 +1,205 @@ +import { render, screen } from '@testing-library/react' +import { ActivityTrendChart } from '../activity-trend-chart' +import type { ActivityTrendPoint } from '@/actions/admin' + +// Mock Chart.js and react-chartjs-2 +jest.mock('chart.js', () => ({ + Chart: { + register: jest.fn(), + }, + CategoryScale: jest.fn(), + LinearScale: jest.fn(), + PointElement: jest.fn(), + LineElement: jest.fn(), + Tooltip: jest.fn(), + Legend: jest.fn(), +})) + +jest.mock('react-chartjs-2', () => ({ + Line: ({ data, options }: { data: unknown; options: unknown }) => ( +
+
{JSON.stringify(data)}
+
{JSON.stringify(options)}
+
+ ), +})) + +describe('ActivityTrendChart', () => { + const mockData: ActivityTrendPoint[] = [ + { date: '2024-01-15', requests: 100, cost: 1.5, tokens: 5000 }, + { date: '2024-01-16', requests: 150, cost: 2.25, tokens: 7500 }, + { date: '2024-01-17', requests: 120, cost: 1.8, tokens: 6000 }, + ] + + describe('Rendering', () => { + it('should render chart with valid data', () => { + render() + + expect(screen.getByTestId('activity-trend-chart')).toBeInTheDocument() + expect(screen.getByTestId('line-chart')).toBeInTheDocument() + }) + + it('should show "No activity data available" message when data is empty', () => { + render() + + expect(screen.getByText('No activity data available')).toBeInTheDocument() + expect(screen.queryByTestId('line-chart')).not.toBeInTheDocument() + }) + + it('should render chart container with proper styling', () => { + const { container } = render() + + const chartContainer = container.querySelector('.w-full.h-full') + expect(chartContainer).toBeInTheDocument() + }) + }) + + describe('Data Processing', () => { + it('should include requests data in chart', () => { + render() + + const chartData = screen.getByTestId('chart-data') + const parsedData = JSON.parse(chartData.textContent || '{}') + + expect(parsedData.datasets[0].data).toEqual([100, 150, 120]) + expect(parsedData.datasets[0].label).toBe('Requests') + }) + + it('should include cost data in chart', () => { + render() + + const chartData = screen.getByTestId('chart-data') + const parsedData = JSON.parse(chartData.textContent || '{}') + + expect(parsedData.datasets[1].data).toEqual([1.5, 2.25, 1.8]) + expect(parsedData.datasets[1].label).toBe('Cost ($)') + }) + + it('should format dates as "Mon DD"', () => { + render() + + const chartData = screen.getByTestId('chart-data') + const parsedData = JSON.parse(chartData.textContent || '{}') + + expect(parsedData.labels).toHaveLength(3) + expect(parsedData.labels[0]).toMatch(/Jan 1\d/) + }) + + it('should handle single data point', () => { + const singlePoint = [mockData[0]] + + render() + + const chartData = screen.getByTestId('chart-data') + const parsedData = JSON.parse(chartData.textContent || '{}') + + expect(parsedData.datasets[0].data).toEqual([100]) + expect(parsedData.labels).toHaveLength(1) + }) + }) + + describe('Chart Configuration', () => { + it('should set responsive and maintainAspectRatio options', () => { + render() + + const chartOptions = screen.getByTestId('chart-options') + const parsedOptions = JSON.parse(chartOptions.textContent || '{}') + + expect(parsedOptions.responsive).toBe(true) + expect(parsedOptions.maintainAspectRatio).toBe(false) + }) + + it('should display legend at top', () => { + render() + + const chartOptions = screen.getByTestId('chart-options') + const parsedOptions = JSON.parse(chartOptions.textContent || '{}') + + expect(parsedOptions.plugins.legend.display).toBe(true) + expect(parsedOptions.plugins.legend.position).toBe('top') + }) + + it('should configure dual y-axes', () => { + render() + + const chartOptions = screen.getByTestId('chart-options') + const parsedOptions = JSON.parse(chartOptions.textContent || '{}') + + expect(parsedOptions.scales.y).toBeDefined() + expect(parsedOptions.scales.y1).toBeDefined() + expect(parsedOptions.scales.y.position).toBe('left') + expect(parsedOptions.scales.y1.position).toBe('right') + }) + + it('should configure tooltip with custom styling', () => { + render() + + const chartOptions = screen.getByTestId('chart-options') + const parsedOptions = JSON.parse(chartOptions.textContent || '{}') + + expect(parsedOptions.plugins.tooltip.backgroundColor).toBe('#1e293b') + expect(parsedOptions.plugins.tooltip.titleColor).toBe('#cbd5e1') + expect(parsedOptions.plugins.tooltip.bodyColor).toBe('#e2e8f0') + }) + }) + + describe('Chart Styling', () => { + it('should use purple color for requests line', () => { + render() + + const chartData = screen.getByTestId('chart-data') + const parsedData = JSON.parse(chartData.textContent || '{}') + + expect(parsedData.datasets[0].borderColor).toBe('#a855f7') + }) + + it('should use green color for cost line', () => { + render() + + const chartData = screen.getByTestId('chart-data') + const parsedData = JSON.parse(chartData.textContent || '{}') + + expect(parsedData.datasets[1].borderColor).toBe('#22c55e') + }) + + it('should configure smooth line tension', () => { + render() + + const chartData = screen.getByTestId('chart-data') + const parsedData = JSON.parse(chartData.textContent || '{}') + + expect(parsedData.datasets[0].tension).toBe(0.4) + expect(parsedData.datasets[1].tension).toBe(0.4) + }) + }) + + describe('Edge Cases', () => { + it('should handle zero values', () => { + const zeroData: ActivityTrendPoint[] = [ + { date: '2024-01-15', requests: 0, cost: 0, tokens: 0 }, + ] + + render() + + const chartData = screen.getByTestId('chart-data') + const parsedData = JSON.parse(chartData.textContent || '{}') + + expect(parsedData.datasets[0].data).toEqual([0]) + expect(parsedData.datasets[1].data).toEqual([0]) + }) + + it('should handle large numbers', () => { + const largeData: ActivityTrendPoint[] = [ + { date: '2024-01-15', requests: 1000000, cost: 9999.99, tokens: 50000000 }, + ] + + render() + + const chartData = screen.getByTestId('chart-data') + const parsedData = JSON.parse(chartData.textContent || '{}') + + expect(parsedData.datasets[0].data).toEqual([1000000]) + expect(parsedData.datasets[1].data).toEqual([9999.99]) + }) + }) +}) diff --git a/components/admin/observability/__tests__/download-queues-panel.test.tsx b/components/admin/observability/__tests__/download-queues-panel.test.tsx new file mode 100644 index 00000000..43f1a736 --- /dev/null +++ b/components/admin/observability/__tests__/download-queues-panel.test.tsx @@ -0,0 +1,335 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { DownloadQueuesPanel } from '../download-queues-panel' +import type { QueuesResponse } from '@/app/api/observability/queues/route' + +// Mock fetch +global.fetch = jest.fn() + +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + +function renderWithQuery(ui: React.ReactElement) { + const queryClient = createQueryClient() + return render( + {ui} + ) +} + +describe('DownloadQueuesPanel', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + const mockQueues: QueuesResponse = { + available: true, + sonarrConfigured: true, + radarrConfigured: true, + items: [ + { + id: 1, + source: 'sonarr', + title: 'Breaking Bad - S01E05', + status: 'downloading', + progress: 65, + size: 1073741824, // 1 GB + sizeLeft: 375809638, + estimatedCompletionTime: new Date(Date.now() + 3600000).toISOString(), + quality: '1080p', + }, + { + id: 2, + source: 'radarr', + title: 'Inception', + status: 'queued', + progress: 0, + size: 2147483648, // 2 GB + sizeLeft: 2147483648, + estimatedCompletionTime: null, + quality: '4K', + }, + { + id: 3, + source: 'sonarr', + title: 'The Office - S05E12', + status: 'paused', + progress: 30, + size: 536870912, // 512 MB + sizeLeft: 375809638, + estimatedCompletionTime: null, + quality: '720p', + }, + ], + } + + describe('Loading State', () => { + it('should show skeleton while loading', () => { + ;(fetch as jest.Mock).mockImplementation(() => new Promise(() => {})) + + const { container } = renderWithQuery() + + expect(container.querySelector('.animate-pulse')).toBeInTheDocument() + }) + }) + + describe('Error State', () => { + it('should show error message when fetch fails', async () => { + ;(fetch as jest.Mock).mockRejectedValue(new Error('Network error')) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('Network error')).toBeInTheDocument() + }) + }) + }) + + describe('Not Configured State', () => { + it('should show message when neither service is configured', async () => { + const notConfigured: QueuesResponse = { + available: false, + sonarrConfigured: false, + radarrConfigured: false, + items: [], + error: 'Neither Sonarr nor Radarr configured', + } + + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(notConfigured), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('Neither Sonarr nor Radarr configured')).toBeInTheDocument() + }) + }) + }) + + describe('Empty Queue State', () => { + it('should show "No items in queue" when queue is empty', async () => { + const emptyQueue: QueuesResponse = { + available: true, + sonarrConfigured: true, + radarrConfigured: true, + items: [], + } + + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(emptyQueue), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('No items in queue')).toBeInTheDocument() + }) + }) + }) + + describe('Queue Items Display', () => { + it('should render download queues panel', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockQueues), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByTestId('download-queues-panel')).toBeInTheDocument() + }) + }) + + it('should display each queue item', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockQueues), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByTestId('queue-item-sonarr-1')).toBeInTheDocument() + expect(screen.getByTestId('queue-item-radarr-2')).toBeInTheDocument() + expect(screen.getByTestId('queue-item-sonarr-3')).toBeInTheDocument() + }) + }) + + it('should display item titles', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockQueues), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('Breaking Bad - S01E05')).toBeInTheDocument() + expect(screen.getByText('Inception')).toBeInTheDocument() + expect(screen.getByText('The Office - S05E12')).toBeInTheDocument() + }) + }) + + it('should display status labels', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockQueues), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('Downloading')).toBeInTheDocument() + expect(screen.getByText('Queued')).toBeInTheDocument() + expect(screen.getByText('Paused')).toBeInTheDocument() + }) + }) + + it('should display quality info', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockQueues), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('1080p')).toBeInTheDocument() + expect(screen.getByText('4K')).toBeInTheDocument() + expect(screen.getByText('720p')).toBeInTheDocument() + }) + }) + + it('should display progress percentages', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockQueues), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('65%')).toBeInTheDocument() + expect(screen.getByText('0%')).toBeInTheDocument() + expect(screen.getByText('30%')).toBeInTheDocument() + }) + }) + + it('should display item count in footer', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockQueues), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText(/3 items in queue/)).toBeInTheDocument() + }) + }) + }) + + describe('Size Formatting', () => { + it('should format sizes correctly', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockQueues), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('1 GB')).toBeInTheDocument() + expect(screen.getByText('2 GB')).toBeInTheDocument() + }) + }) + }) + + describe('Limits Display', () => { + it('should only show first 5 items', async () => { + const manyItems: QueuesResponse = { + available: true, + sonarrConfigured: true, + radarrConfigured: true, + items: Array.from({ length: 10 }, (_, i) => ({ + id: i + 1, + source: 'sonarr' as const, + title: `Show ${i + 1}`, + status: 'queued' as const, + progress: 0, + size: 1000000000, + sizeLeft: 1000000000, + estimatedCompletionTime: null, + quality: '1080p', + })), + } + + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(manyItems), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('Show 1')).toBeInTheDocument() + expect(screen.getByText('Show 5')).toBeInTheDocument() + expect(screen.queryByText('Show 6')).not.toBeInTheDocument() + }) + }) + + it('should show count with "showing 5" when more than 5 items', async () => { + const manyItems: QueuesResponse = { + available: true, + sonarrConfigured: true, + radarrConfigured: true, + items: Array.from({ length: 10 }, (_, i) => ({ + id: i + 1, + source: 'sonarr' as const, + title: `Show ${i + 1}`, + status: 'queued' as const, + progress: 0, + size: 1000000000, + sizeLeft: 1000000000, + estimatedCompletionTime: null, + quality: '1080p', + })), + } + + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(manyItems), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText(/10 items in queue.*showing 5/)).toBeInTheDocument() + }) + }) + }) + + describe('API Interaction', () => { + it('should fetch from correct endpoint', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockQueues), + }) + + renderWithQuery() + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith('/api/observability/queues') + }) + }) + }) +}) diff --git a/components/admin/observability/__tests__/requests-panel.test.tsx b/components/admin/observability/__tests__/requests-panel.test.tsx new file mode 100644 index 00000000..32a56b1f --- /dev/null +++ b/components/admin/observability/__tests__/requests-panel.test.tsx @@ -0,0 +1,396 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { RequestsPanel } from '../requests-panel' +import type { RequestsStatsResponse } from '@/app/api/observability/requests/route' + +// Mock fetch +global.fetch = jest.fn() + +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + +function renderWithQuery(ui: React.ReactElement) { + const queryClient = createQueryClient() + return render( + {ui} + ) +} + +describe('RequestsPanel', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + const mockRequests: RequestsStatsResponse = { + available: true, + configured: true, + stats: { + pending: 5, + approved: 12, + available: 45, + declined: 3, + total: 65, + }, + recentRequests: [ + { + id: 1, + type: 'movie', + title: 'Inception', + status: 'available', + requestedBy: 'John Doe', + requestedAt: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago + }, + { + id: 2, + type: 'tv', + title: 'Breaking Bad', + status: 'pending', + requestedBy: 'Jane Smith', + requestedAt: new Date(Date.now() - 86400000).toISOString(), // 1 day ago + }, + { + id: 3, + type: 'movie', + title: 'The Dark Knight', + status: 'approved', + requestedBy: 'Bob Wilson', + requestedAt: new Date(Date.now() - 172800000).toISOString(), // 2 days ago + }, + ], + } + + describe('Loading State', () => { + it('should show skeleton while loading', () => { + ;(fetch as jest.Mock).mockImplementation(() => new Promise(() => {})) + + const { container } = renderWithQuery() + + expect(container.querySelector('.animate-pulse')).toBeInTheDocument() + }) + }) + + describe('Error State', () => { + it('should show error message when fetch fails', async () => { + ;(fetch as jest.Mock).mockRejectedValue(new Error('Network error')) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('Network error')).toBeInTheDocument() + }) + }) + }) + + describe('Not Configured State', () => { + it('should show message when Overseerr is not configured', async () => { + const notConfigured: RequestsStatsResponse = { + available: false, + configured: false, + stats: { pending: 0, approved: 0, available: 0, declined: 0, total: 0 }, + recentRequests: [], + error: 'Overseerr not configured', + } + + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(notConfigured), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('Overseerr not configured')).toBeInTheDocument() + }) + }) + + it('should show message when Overseerr is configured but unavailable', async () => { + const unavailable: RequestsStatsResponse = { + available: false, + configured: true, + stats: { pending: 0, approved: 0, available: 0, declined: 0, total: 0 }, + recentRequests: [], + error: 'Unable to fetch request data', + } + + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(unavailable), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('Unable to fetch request data')).toBeInTheDocument() + }) + }) + }) + + describe('Stats Display', () => { + it('should render requests panel', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRequests), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByTestId('requests-panel')).toBeInTheDocument() + }) + }) + + it('should display pending count', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRequests), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('5')).toBeInTheDocument() + expect(screen.getByText('Pending')).toBeInTheDocument() + }) + }) + + it('should display approved count', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRequests), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('12')).toBeInTheDocument() + expect(screen.getByText('Approved')).toBeInTheDocument() + }) + }) + + it('should display available count', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRequests), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('45')).toBeInTheDocument() + expect(screen.getByText('Available')).toBeInTheDocument() + }) + }) + + it('should display declined count', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRequests), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('3')).toBeInTheDocument() + expect(screen.getByText('Declined')).toBeInTheDocument() + }) + }) + + it('should display total in footer', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRequests), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText(/65 total requests/)).toBeInTheDocument() + }) + }) + }) + + describe('Recent Requests Display', () => { + it('should display "Recent Requests" heading', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRequests), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('Recent Requests')).toBeInTheDocument() + }) + }) + + it('should display request titles', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRequests), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('Inception')).toBeInTheDocument() + expect(screen.getByText('Breaking Bad')).toBeInTheDocument() + expect(screen.getByText('The Dark Knight')).toBeInTheDocument() + }) + }) + + it('should display media types', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRequests), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getAllByText('Movie')).toHaveLength(2) + expect(screen.getByText('TV')).toBeInTheDocument() + }) + }) + + it('should display requester names', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRequests), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText(/John Doe/)).toBeInTheDocument() + expect(screen.getByText(/Jane Smith/)).toBeInTheDocument() + expect(screen.getByText(/Bob Wilson/)).toBeInTheDocument() + }) + }) + + it('should display request test IDs', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRequests), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByTestId('request-1')).toBeInTheDocument() + expect(screen.getByTestId('request-2')).toBeInTheDocument() + expect(screen.getByTestId('request-3')).toBeInTheDocument() + }) + }) + + it('should display status badges', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRequests), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('available')).toBeInTheDocument() + expect(screen.getByText('pending')).toBeInTheDocument() + expect(screen.getByText('approved')).toBeInTheDocument() + }) + }) + }) + + describe('Time Formatting', () => { + it('should format recent time as "Xh ago"', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRequests), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText(/1h ago/)).toBeInTheDocument() + }) + }) + + it('should format days as "Xd ago"', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRequests), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText(/1d ago/)).toBeInTheDocument() + expect(screen.getByText(/2d ago/)).toBeInTheDocument() + }) + }) + }) + + describe('API Interaction', () => { + it('should fetch from correct endpoint', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRequests), + }) + + renderWithQuery() + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith('/api/observability/requests') + }) + }) + }) + + describe('Edge Cases', () => { + it('should handle zero stats', async () => { + const zeroStats: RequestsStatsResponse = { + available: true, + configured: true, + stats: { pending: 0, approved: 0, available: 0, declined: 0, total: 0 }, + recentRequests: [], + } + + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(zeroStats), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByTestId('requests-panel')).toBeInTheDocument() + // All stat boxes should show 0 + const zeros = screen.getAllByText('0') + expect(zeros.length).toBeGreaterThanOrEqual(4) + }) + }) + + it('should not show Recent Requests section when empty', async () => { + const noRecent: RequestsStatsResponse = { + available: true, + configured: true, + stats: { pending: 5, approved: 10, available: 20, declined: 2, total: 37 }, + recentRequests: [], + } + + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(noRecent), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByTestId('requests-panel')).toBeInTheDocument() + expect(screen.queryByText('Recent Requests')).not.toBeInTheDocument() + }) + }) + }) +}) diff --git a/components/admin/observability/__tests__/service-status-grid.test.tsx b/components/admin/observability/__tests__/service-status-grid.test.tsx new file mode 100644 index 00000000..f96a7c49 --- /dev/null +++ b/components/admin/observability/__tests__/service-status-grid.test.tsx @@ -0,0 +1,157 @@ +import { render, screen } from '@testing-library/react' +import { ServiceStatusGrid } from '../service-status-grid' +import type { ServiceStatus } from '@/actions/admin' + +// Mock next/link +jest.mock('next/link', () => { + return function MockLink({ children, href }: { children: React.ReactNode; href: string }) { + return {children} + } +}) + +describe('ServiceStatusGrid', () => { + const mockServices: { + plex: ServiceStatus + tautulli: ServiceStatus + overseerr: ServiceStatus + sonarr: ServiceStatus + radarr: ServiceStatus + discord: ServiceStatus + llm: ServiceStatus + } = { + plex: { configured: true, name: 'Plex', description: 'Media server' }, + tautulli: { configured: true, name: 'Tautulli', description: 'Plex monitoring' }, + overseerr: { configured: false, name: 'Overseerr', description: 'Request management' }, + sonarr: { configured: true, name: 'Sonarr', description: 'TV show management' }, + radarr: { configured: false, name: 'Radarr', description: 'Movie management' }, + discord: { configured: false, name: 'Discord', description: 'Bot integration' }, + llm: { configured: true, name: 'LLM Provider', description: 'AI generation' }, + } + + describe('Rendering', () => { + it('should render the service status grid', () => { + render() + + expect(screen.getByTestId('service-status-grid')).toBeInTheDocument() + }) + + it('should display the "Service Status" heading', () => { + render() + + expect(screen.getByText('Service Status')).toBeInTheDocument() + }) + + it('should display configured count correctly', () => { + render() + + // 4 configured out of 7 + expect(screen.getByText('4 of 7 configured')).toBeInTheDocument() + }) + + it('should render all service cards', () => { + render() + + expect(screen.getByTestId('service-status-plex')).toBeInTheDocument() + expect(screen.getByTestId('service-status-tautulli')).toBeInTheDocument() + expect(screen.getByTestId('service-status-overseerr')).toBeInTheDocument() + expect(screen.getByTestId('service-status-sonarr')).toBeInTheDocument() + expect(screen.getByTestId('service-status-radarr')).toBeInTheDocument() + expect(screen.getByTestId('service-status-discord')).toBeInTheDocument() + expect(screen.getByTestId('service-status-llm')).toBeInTheDocument() + }) + + it('should display service names', () => { + render() + + expect(screen.getByText('Plex')).toBeInTheDocument() + expect(screen.getByText('Tautulli')).toBeInTheDocument() + expect(screen.getByText('Overseerr')).toBeInTheDocument() + expect(screen.getByText('Sonarr')).toBeInTheDocument() + expect(screen.getByText('Radarr')).toBeInTheDocument() + expect(screen.getByText('Discord')).toBeInTheDocument() + expect(screen.getByText('LLM Provider')).toBeInTheDocument() + }) + + it('should display service descriptions', () => { + render() + + expect(screen.getByText('Media server')).toBeInTheDocument() + expect(screen.getByText('Plex monitoring')).toBeInTheDocument() + expect(screen.getByText('Request management')).toBeInTheDocument() + expect(screen.getByText('TV show management')).toBeInTheDocument() + expect(screen.getByText('Movie management')).toBeInTheDocument() + expect(screen.getByText('Bot integration')).toBeInTheDocument() + expect(screen.getByText('AI generation')).toBeInTheDocument() + }) + }) + + describe('Configuration Status', () => { + it('should show configure link for unconfigured services', () => { + render() + + // Unconfigured services should have Configure links + const configureLinks = screen.getAllByText('Configure') + expect(configureLinks).toHaveLength(3) // overseerr, radarr, discord + }) + + it('should not show configure link for configured services', () => { + const allConfigured = { + plex: { configured: true, name: 'Plex', description: 'Media server' }, + tautulli: { configured: true, name: 'Tautulli', description: 'Plex monitoring' }, + overseerr: { configured: true, name: 'Overseerr', description: 'Request management' }, + sonarr: { configured: true, name: 'Sonarr', description: 'TV show management' }, + radarr: { configured: true, name: 'Radarr', description: 'Movie management' }, + discord: { configured: true, name: 'Discord', description: 'Bot integration' }, + llm: { configured: true, name: 'LLM Provider', description: 'AI generation' }, + } + + render() + + expect(screen.queryByText('Configure')).not.toBeInTheDocument() + }) + + it('should show all services unconfigured', () => { + const noneConfigured = { + plex: { configured: false, name: 'Plex', description: 'Media server' }, + tautulli: { configured: false, name: 'Tautulli', description: 'Plex monitoring' }, + overseerr: { configured: false, name: 'Overseerr', description: 'Request management' }, + sonarr: { configured: false, name: 'Sonarr', description: 'TV show management' }, + radarr: { configured: false, name: 'Radarr', description: 'Movie management' }, + discord: { configured: false, name: 'Discord', description: 'Bot integration' }, + llm: { configured: false, name: 'LLM Provider', description: 'AI generation' }, + } + + render() + + expect(screen.getByText('0 of 7 configured')).toBeInTheDocument() + expect(screen.getAllByText('Configure')).toHaveLength(7) + }) + + it('should show 7 of 7 configured when all services are configured', () => { + const allConfigured = { + plex: { configured: true, name: 'Plex', description: 'Media server' }, + tautulli: { configured: true, name: 'Tautulli', description: 'Plex monitoring' }, + overseerr: { configured: true, name: 'Overseerr', description: 'Request management' }, + sonarr: { configured: true, name: 'Sonarr', description: 'TV show management' }, + radarr: { configured: true, name: 'Radarr', description: 'Movie management' }, + discord: { configured: true, name: 'Discord', description: 'Bot integration' }, + llm: { configured: true, name: 'LLM Provider', description: 'AI generation' }, + } + + render() + + expect(screen.getByText('7 of 7 configured')).toBeInTheDocument() + }) + }) + + describe('Links', () => { + it('should link configure buttons to /admin/settings', () => { + render() + + const configureLinks = screen.getAllByText('Configure') + configureLinks.forEach(link => { + expect(link.closest('a')).toHaveAttribute('href', '/admin/settings') + }) + }) + }) +}) diff --git a/components/admin/observability/__tests__/storage-panel.test.tsx b/components/admin/observability/__tests__/storage-panel.test.tsx new file mode 100644 index 00000000..751f46a8 --- /dev/null +++ b/components/admin/observability/__tests__/storage-panel.test.tsx @@ -0,0 +1,346 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { StoragePanel } from '../storage-panel' +import type { StorageResponse } from '@/app/api/observability/storage/route' + +// Mock fetch +global.fetch = jest.fn() + +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + +function renderWithQuery(ui: React.ReactElement) { + const queryClient = createQueryClient() + return render( + {ui} + ) +} + +describe('StoragePanel', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + const mockStorage: StorageResponse = { + available: true, + sonarrConfigured: true, + radarrConfigured: true, + tautulliConfigured: true, + diskSpace: [ + { + path: '/media/shows', + label: 'TV Shows', + freeSpace: 500000000000, + totalSpace: 2000000000000, + usedSpace: 1500000000000, + usedPercent: 75, + source: 'sonarr', + }, + { + path: '/media/movies', + label: 'Movies', + freeSpace: 200000000000, + totalSpace: 1000000000000, + usedSpace: 800000000000, + usedPercent: 80, + source: 'radarr', + }, + ], + libraries: [ + { + sectionId: '1', + sectionName: 'Movies', + sectionType: 'movie', + count: 1250, + }, + { + sectionId: '2', + sectionName: 'TV Shows', + sectionType: 'show', + count: 350, + }, + { + sectionId: '3', + sectionName: 'Music', + sectionType: 'artist', + count: 5000, + }, + ], + } + + describe('Loading State', () => { + it('should show skeleton while loading', () => { + ;(fetch as jest.Mock).mockImplementation(() => new Promise(() => {})) + + const { container } = renderWithQuery() + + expect(container.querySelector('.animate-pulse')).toBeInTheDocument() + }) + }) + + describe('Error State', () => { + it('should show error message when fetch fails', async () => { + ;(fetch as jest.Mock).mockRejectedValue(new Error('Network error')) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('Network error')).toBeInTheDocument() + }) + }) + }) + + describe('Not Configured State', () => { + it('should show message when no services configured', async () => { + const notConfigured: StorageResponse = { + available: false, + sonarrConfigured: false, + radarrConfigured: false, + tautulliConfigured: false, + diskSpace: [], + libraries: [], + error: 'No services configured for storage metrics', + } + + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(notConfigured), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('No services configured for storage metrics')).toBeInTheDocument() + }) + }) + }) + + describe('Storage Display', () => { + it('should render storage panel', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockStorage), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByTestId('storage-panel')).toBeInTheDocument() + }) + }) + + it('should display "Disk Usage" heading', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockStorage), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('Disk Usage')).toBeInTheDocument() + }) + }) + + it('should display disk labels', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockStorage), + }) + + renderWithQuery() + + await waitFor(() => { + // TV Shows and Movies appear in both disk space and libraries sections + expect(screen.getAllByText('TV Shows').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('Movies').length).toBeGreaterThanOrEqual(1) + }) + }) + + it('should display usage percentages', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockStorage), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('75%')).toBeInTheDocument() + expect(screen.getByText('80%')).toBeInTheDocument() + }) + }) + + it('should display disk test IDs', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockStorage), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByTestId('disk-sonarr')).toBeInTheDocument() + expect(screen.getByTestId('disk-radarr')).toBeInTheDocument() + }) + }) + }) + + describe('Libraries Display', () => { + it('should display "Libraries" heading', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockStorage), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('Libraries')).toBeInTheDocument() + }) + }) + + it('should display library names', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockStorage), + }) + + renderWithQuery() + + await waitFor(() => { + // Note: Movies and TV Shows also appear in disk labels, so we check libraries section + expect(screen.getByTestId('library-1')).toBeInTheDocument() + expect(screen.getByTestId('library-2')).toBeInTheDocument() + expect(screen.getByTestId('library-3')).toBeInTheDocument() + }) + }) + + it('should display library counts', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockStorage), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('1,250 items')).toBeInTheDocument() + expect(screen.getByText('350 items')).toBeInTheDocument() + expect(screen.getByText('5,000 items')).toBeInTheDocument() + }) + }) + }) + + describe('Storage Formatting', () => { + it('should format bytes correctly', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockStorage), + }) + + renderWithQuery() + + await waitFor(() => { + // Check for formatted sizes (used/free) + expect(screen.getByText('1.4 TB used')).toBeInTheDocument() + expect(screen.getByText('465.7 GB free')).toBeInTheDocument() + }) + }) + }) + + describe('Usage Color Coding', () => { + it('should use yellow for 75% usage', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockStorage), + }) + + renderWithQuery() + + await waitFor(() => { + const disk75 = screen.getByTestId('disk-sonarr') + expect(disk75).toBeInTheDocument() + // The 75% text should have yellow color + expect(screen.getByText('75%')).toHaveClass('text-yellow-400') + }) + }) + + it('should use red for 90%+ usage', async () => { + const highUsage: StorageResponse = { + ...mockStorage, + diskSpace: [ + { + path: '/media/full', + label: 'Full Drive', + freeSpace: 50000000000, + totalSpace: 1000000000000, + usedSpace: 950000000000, + usedPercent: 95, + source: 'sonarr', + }, + ], + } + + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(highUsage), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('95%')).toHaveClass('text-red-400') + }) + }) + + it('should use green for under 75% usage', async () => { + const lowUsage: StorageResponse = { + ...mockStorage, + diskSpace: [ + { + path: '/media/empty', + label: 'Empty Drive', + freeSpace: 800000000000, + totalSpace: 1000000000000, + usedSpace: 200000000000, + usedPercent: 20, + source: 'sonarr', + }, + ], + } + + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(lowUsage), + }) + + renderWithQuery() + + await waitFor(() => { + expect(screen.getByText('20%')).toHaveClass('text-green-400') + }) + }) + }) + + describe('API Interaction', () => { + it('should fetch from correct endpoint', async () => { + ;(fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockStorage), + }) + + renderWithQuery() + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith('/api/observability/storage') + }) + }) + }) +}) diff --git a/components/admin/observability/__tests__/top-users-widget.test.tsx b/components/admin/observability/__tests__/top-users-widget.test.tsx new file mode 100644 index 00000000..f7acae1c --- /dev/null +++ b/components/admin/observability/__tests__/top-users-widget.test.tsx @@ -0,0 +1,235 @@ +import { render, screen } from '@testing-library/react' +import { TopUsersWidget } from '../top-users-widget' +import type { TopUser } from '@/actions/admin' + +// Mock next/link +jest.mock('next/link', () => { + return function MockLink({ children, href }: { children: React.ReactNode; href: string }) { + return {children} + } +}) + +describe('TopUsersWidget', () => { + const mockUsers: TopUser[] = [ + { + userId: 'user-1', + name: 'John Doe', + email: 'john@example.com', + image: 'https://example.com/john.jpg', + requests: 150, + cost: 3.50, + tokens: 15000, + }, + { + userId: 'user-2', + name: 'Jane Smith', + email: 'jane@example.com', + image: null, + requests: 100, + cost: 2.25, + tokens: 10000, + }, + { + userId: 'user-3', + name: 'Bob Wilson', + email: 'bob@example.com', + image: 'https://example.com/bob.jpg', + requests: 75, + cost: 1.80, + tokens: 7500, + }, + ] + + describe('Rendering', () => { + it('should render the top users widget', () => { + render() + + expect(screen.getByTestId('top-users-widget')).toBeInTheDocument() + }) + + it('should display all user rows', () => { + render() + + expect(screen.getByTestId('top-user-row-user-1')).toBeInTheDocument() + expect(screen.getByTestId('top-user-row-user-2')).toBeInTheDocument() + expect(screen.getByTestId('top-user-row-user-3')).toBeInTheDocument() + }) + + it('should display user names', () => { + render() + + expect(screen.getByText('John Doe')).toBeInTheDocument() + expect(screen.getByText('Jane Smith')).toBeInTheDocument() + expect(screen.getByText('Bob Wilson')).toBeInTheDocument() + }) + + it('should display user emails', () => { + render() + + expect(screen.getByText('john@example.com')).toBeInTheDocument() + expect(screen.getByText('jane@example.com')).toBeInTheDocument() + expect(screen.getByText('bob@example.com')).toBeInTheDocument() + }) + + it('should display table headers', () => { + render() + + expect(screen.getByText('User')).toBeInTheDocument() + expect(screen.getByText('Requests')).toBeInTheDocument() + expect(screen.getByText('Cost')).toBeInTheDocument() + }) + + it('should display request counts', () => { + render() + + expect(screen.getByText('150')).toBeInTheDocument() + expect(screen.getByText('100')).toBeInTheDocument() + expect(screen.getByText('75')).toBeInTheDocument() + }) + + it('should display costs formatted with 4 decimal places', () => { + render() + + expect(screen.getByText('$3.5000')).toBeInTheDocument() + expect(screen.getByText('$2.2500')).toBeInTheDocument() + expect(screen.getByText('$1.8000')).toBeInTheDocument() + }) + + it('should display rank numbers', () => { + render() + + expect(screen.getByText('1')).toBeInTheDocument() + expect(screen.getByText('2')).toBeInTheDocument() + expect(screen.getByText('3')).toBeInTheDocument() + }) + }) + + describe('Empty State', () => { + it('should show "No user activity data available" when users array is empty', () => { + render() + + expect(screen.getByText('No user activity data available')).toBeInTheDocument() + expect(screen.queryByTestId('top-users-widget')).not.toBeInTheDocument() + }) + }) + + describe('User Images', () => { + it('should display user images when available', () => { + const { container } = render() + + // Two users have images (John and Bob) + const images = container.querySelectorAll('img') as NodeListOf + expect(images).toHaveLength(2) + + const imageSrcs = Array.from(images).map(img => img.src) + expect(imageSrcs).toContain('https://example.com/john.jpg') + expect(imageSrcs).toContain('https://example.com/bob.jpg') + }) + + it('should display initials fallback when image is null', () => { + const usersWithNoImage: TopUser[] = [{ + userId: 'user-1', + name: 'Jane Smith', + email: 'jane@example.com', + image: null, + requests: 100, + cost: 2.25, + tokens: 10000, + }] + + render() + + // Should show first letter of name as fallback + expect(screen.getByText('J')).toBeInTheDocument() + }) + + it('should use email initial when name is empty', () => { + const userWithNoName: TopUser[] = [{ + userId: 'user-1', + name: '', + email: 'test@example.com', + image: null, + requests: 100, + cost: 2.25, + tokens: 10000, + }] + + render() + + // Should show first letter of email as fallback + expect(screen.getByText('T')).toBeInTheDocument() + }) + }) + + describe('Links', () => { + it('should link user rows to user detail page', () => { + render() + + const userLinks = screen.getAllByRole('link') + // First 3 links are user links, last one is "View all LLM usage" + expect(userLinks[0]).toHaveAttribute('href', '/admin/users/user-1') + expect(userLinks[1]).toHaveAttribute('href', '/admin/users/user-2') + expect(userLinks[2]).toHaveAttribute('href', '/admin/users/user-3') + }) + + it('should display "View all LLM usage" link', () => { + render() + + const viewAllLink = screen.getByText('View all LLM usage →') + expect(viewAllLink).toBeInTheDocument() + expect(viewAllLink.closest('a')).toHaveAttribute('href', '/admin/llm-usage') + }) + }) + + describe('Edge Cases', () => { + it('should handle user with Unknown name', () => { + const userWithUnknownName: TopUser[] = [{ + userId: 'user-1', + name: 'Unknown', + email: '', + image: null, + requests: 50, + cost: 1.00, + tokens: 5000, + }] + + render() + + expect(screen.getByText('Unknown')).toBeInTheDocument() + }) + + it('should handle zero values', () => { + const userWithZeros: TopUser[] = [{ + userId: 'user-1', + name: 'Test User', + email: 'test@example.com', + image: null, + requests: 0, + cost: 0, + tokens: 0, + }] + + render() + + expect(screen.getByText('0')).toBeInTheDocument() + expect(screen.getByText('$0.0000')).toBeInTheDocument() + }) + + it('should handle large request counts', () => { + const userWithLargeNumbers: TopUser[] = [{ + userId: 'user-1', + name: 'Power User', + email: 'power@example.com', + image: null, + requests: 1000000, + cost: 9999.9999, + tokens: 50000000, + }] + + render() + + expect(screen.getByText('1,000,000')).toBeInTheDocument() + expect(screen.getByText('$9999.9999')).toBeInTheDocument() + }) + }) +}) diff --git a/components/admin/observability/active-sessions-panel.tsx b/components/admin/observability/active-sessions-panel.tsx new file mode 100644 index 00000000..418a2e24 --- /dev/null +++ b/components/admin/observability/active-sessions-panel.tsx @@ -0,0 +1,175 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import type { SessionsResponse } from "@/app/api/observability/sessions/route" +import { REFRESH_INTERVALS } from "@/lib/constants/observability" + +async function fetchSessions(): Promise { + const response = await fetch("/api/observability/sessions") + if (!response.ok) { + throw new Error("Failed to fetch sessions") + } + return response.json() +} + +export function ActiveSessionsPanel() { + const { + data, + isLoading, + isError, + error, + dataUpdatedAt, + } = useQuery({ + queryKey: ["observability", "sessions"], + queryFn: fetchSessions, + refetchInterval: REFRESH_INTERVALS.ACTIVE_SESSIONS, + staleTime: 5_000, + }) + + if (isLoading) { + return + } + + if (isError) { + return ( +
+ {error instanceof Error ? error.message : "Failed to load sessions"} +
+ ) + } + + if (!data?.available) { + return ( +
+ {data?.error || "Tautulli not configured"} +
+ ) + } + + if (data.sessions.length === 0) { + return ( +
+
No active streams
+
+ Last updated: {new Date(dataUpdatedAt).toLocaleTimeString()} +
+
+ ) + } + + const activityUrl = data.tautulliUrl ? `${data.tautulliUrl}/activity` : undefined + + return ( +
+
+ {data.sessions.map((session) => { + const Wrapper = activityUrl ? 'a' : 'div' + const wrapperProps = activityUrl ? { + href: activityUrl, + target: "_blank", + rel: "noopener noreferrer", + } : {} + + return ( + + {/* User avatar */} +
+ {session.userThumb ? ( + {session.user} + ) : ( +
+ {session.user[0].toUpperCase()} +
+ )} +
+ + {/* Content info */} +
+
+ + {session.grandparentTitle + ? `${session.grandparentTitle} - ${session.title}` + : session.title} + + +
+
+ {session.user} + + {session.player} + + {session.quality} +
+ {/* Progress bar */} +
+
+
+
+ + {/* Progress percentage */} +
+
+ {session.progress}% +
+
+ {session.state} +
+
+ + ) + })} +
+
+ {data.streamCount} active stream{data.streamCount !== 1 ? "s" : ""} • Updated{" "} + {new Date(dataUpdatedAt).toLocaleTimeString()} +
+
+ ) +} + +function SessionsSkeleton() { + return ( +
+ {[...Array(2)].map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ) +} diff --git a/components/admin/observability/activity-trend-chart.tsx b/components/admin/observability/activity-trend-chart.tsx new file mode 100644 index 00000000..764d86ca --- /dev/null +++ b/components/admin/observability/activity-trend-chart.tsx @@ -0,0 +1,185 @@ +"use client" + +import { + CategoryScale, + Chart as ChartJS, + ChartOptions, + Legend, + LineElement, + LinearScale, + PointElement, + Tooltip, +} from "chart.js" +import { Line } from "react-chartjs-2" +import type { ActivityTrendPoint } from "@/actions/admin" + +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + Tooltip, + Legend +) + +interface ActivityTrendChartProps { + data: ActivityTrendPoint[] +} + +export function ActivityTrendChart({ data }: ActivityTrendChartProps) { + if (data.length === 0) { + return ( +
+ No activity data available +
+ ) + } + + const labels = data.map((point) => + new Date(point.date).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }) + ) + + const chartData = { + labels, + datasets: [ + { + label: "Requests", + data: data.map((point) => point.requests), + borderColor: "#a855f7", + backgroundColor: "rgba(168, 85, 247, 0.1)", + borderWidth: 2, + fill: true, + tension: 0.4, + pointRadius: 4, + pointHoverRadius: 6, + pointBackgroundColor: "#a855f7", + pointBorderColor: "#9333ea", + yAxisID: "y", + }, + { + label: "Cost ($)", + data: data.map((point) => point.cost), + borderColor: "#22c55e", + backgroundColor: "rgba(34, 197, 94, 0.1)", + borderWidth: 2, + fill: false, + tension: 0.4, + pointRadius: 3, + pointHoverRadius: 5, + pointBackgroundColor: "#22c55e", + pointBorderColor: "#16a34a", + yAxisID: "y1", + }, + ], + } + + const options: ChartOptions<"line"> = { + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: "index", + intersect: false, + }, + plugins: { + legend: { + display: true, + position: "top" as const, + labels: { + color: "#94a3b8", + font: { size: 12 }, + usePointStyle: true, + padding: 15, + }, + }, + tooltip: { + backgroundColor: "#1e293b", + titleColor: "#cbd5e1", + bodyColor: "#e2e8f0", + borderColor: "#475569", + borderWidth: 1, + padding: 12, + cornerRadius: 6, + displayColors: true, + callbacks: { + label: (context) => { + const index = context.dataIndex + const point = data[index] + if (context.datasetIndex === 0) { + return `Requests: ${point.requests.toLocaleString()}` + } + return `Cost: $${point.cost.toFixed(4)}` + }, + afterBody: (items) => { + if (items.length > 0) { + const index = items[0].dataIndex + const point = data[index] + return [`Tokens: ${point.tokens.toLocaleString()}`] + } + return [] + }, + }, + }, + }, + scales: { + x: { + ticks: { + color: "#94a3b8", + font: { size: 11 }, + }, + grid: { + color: "rgba(71, 85, 105, 0.3)", + }, + border: { display: false }, + }, + y: { + type: "linear" as const, + display: true, + position: "left" as const, + beginAtZero: true, + ticks: { + color: "#a855f7", + font: { size: 11 }, + callback: (value) => Number(value).toLocaleString(), + }, + grid: { + color: "rgba(71, 85, 105, 0.3)", + }, + border: { display: false }, + title: { + display: true, + text: "Requests", + color: "#a855f7", + }, + }, + y1: { + type: "linear" as const, + display: true, + position: "right" as const, + beginAtZero: true, + ticks: { + color: "#22c55e", + font: { size: 11 }, + callback: (value) => `$${Number(value).toFixed(2)}`, + }, + grid: { + drawOnChartArea: false, + }, + border: { display: false }, + title: { + display: true, + text: "Cost ($)", + color: "#22c55e", + }, + }, + }, + } + + return ( +
+ +
+ ) +} diff --git a/components/admin/observability/download-queues-panel.tsx b/components/admin/observability/download-queues-panel.tsx new file mode 100644 index 00000000..f14473dd --- /dev/null +++ b/components/admin/observability/download-queues-panel.tsx @@ -0,0 +1,215 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import type { QueuesResponse, QueueItem } from "@/app/api/observability/queues/route" +import { SonarrIcon, RadarrIcon } from "@/components/ui/service-icons" +import { REFRESH_INTERVALS } from "@/lib/constants/observability" + +async function fetchQueues(): Promise { + const response = await fetch("/api/observability/queues") + if (!response.ok) { + throw new Error("Failed to fetch queues") + } + return response.json() +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B" + const k = 1024 + const sizes = ["B", "KB", "MB", "GB", "TB"] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}` +} + +function formatETA(isoString: string | null): string { + if (!isoString) return "--" + const eta = new Date(isoString) + const now = new Date() + const diffMs = eta.getTime() - now.getTime() + if (diffMs < 0) return "Soon" + const diffMins = Math.floor(diffMs / 60000) + if (diffMins < 60) return `${diffMins}m` + const diffHours = Math.floor(diffMins / 60) + if (diffHours < 24) return `${diffHours}h ${diffMins % 60}m` + return `${Math.floor(diffHours / 24)}d ${diffHours % 24}h` +} + +function getStatusColor(status: QueueItem["status"]): string { + switch (status) { + case "downloading": + return "text-green-400" + case "queued": + return "text-cyan-400" + case "paused": + return "text-yellow-400" + case "failed": + return "text-red-400" + case "completed": + return "text-green-400" + default: + return "text-slate-400" + } +} + +function getProgressBarColor(status: QueueItem["status"]): string { + switch (status) { + case "downloading": + return "bg-green-500" + case "queued": + return "bg-cyan-500" + case "paused": + return "bg-yellow-500" + case "failed": + return "bg-red-500" + case "completed": + return "bg-green-500" + default: + return "bg-slate-500" + } +} + +export function DownloadQueuesPanel() { + const { + data, + isLoading, + isError, + error, + dataUpdatedAt, + } = useQuery({ + queryKey: ["observability", "queues"], + queryFn: fetchQueues, + refetchInterval: REFRESH_INTERVALS.DOWNLOAD_QUEUES, + staleTime: 10_000, + }) + + if (isLoading) { + return + } + + if (isError) { + return ( +
+ {error instanceof Error ? error.message : "Failed to load queues"} +
+ ) + } + + if (!data?.available) { + return ( +
+ {data?.error || "Neither Sonarr nor Radarr configured"} +
+ ) + } + + if (data.items.length === 0) { + return ( +
+
No items in queue
+
+ Last updated: {new Date(dataUpdatedAt).toLocaleTimeString()} +
+
+ ) + } + + const getQueueUrl = (source: "sonarr" | "radarr") => { + const baseUrl = source === "sonarr" ? data.sonarrUrl : data.radarrUrl + return baseUrl ? `${baseUrl}/activity/queue` : undefined + } + + return ( +
+
+ {data.items.slice(0, 5).map((item) => { + const queueUrl = getQueueUrl(item.source) + const Wrapper = queueUrl ? 'a' : 'div' + const wrapperProps = queueUrl ? { + href: queueUrl, + target: "_blank", + rel: "noopener noreferrer", + } : {} + + return ( + + {/* Source icon */} +
+ {item.source === "sonarr" ? ( + + ) : ( + + )} +
+ + {/* Content info */} +
+
+ {item.title} +
+
+ + {item.status.charAt(0).toUpperCase() + item.status.slice(1)} + + + {item.quality} + + {formatBytes(item.size)} +
+ {/* Progress bar */} +
+
+
+
+ + {/* Progress and ETA */} +
+
+ {item.progress}% +
+ {item.status === "downloading" && ( +
+ ETA: {formatETA(item.estimatedCompletionTime)} +
+ )} +
+ + ) + })} +
+
+ {data.items.length} item{data.items.length !== 1 ? "s" : ""} in queue + {data.items.length > 5 && ` (showing 5)`} • Updated{" "} + {new Date(dataUpdatedAt).toLocaleTimeString()} +
+
+ ) +} + +function QueuesSkeleton() { + return ( +
+ {[...Array(3)].map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ) +} diff --git a/components/admin/observability/requests-panel.tsx b/components/admin/observability/requests-panel.tsx new file mode 100644 index 00000000..aecd7e43 --- /dev/null +++ b/components/admin/observability/requests-panel.tsx @@ -0,0 +1,223 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import type { RequestsStatsResponse, RequestItem } from "@/app/api/observability/requests/route" +import { REFRESH_INTERVALS } from "@/lib/constants/observability" + +async function fetchRequests(): Promise { + const response = await fetch("/api/observability/requests") + if (!response.ok) { + throw new Error("Failed to fetch requests") + } + return response.json() +} + +function getStatusColor(status: RequestItem["status"]): string { + switch (status) { + case "pending": + return "bg-yellow-500/20 text-yellow-400" + case "approved": + return "bg-blue-500/20 text-blue-400" + case "available": + return "bg-green-500/20 text-green-400" + case "declined": + return "bg-red-500/20 text-red-400" + default: + return "bg-slate-500/20 text-slate-400" + } +} + +function formatTimeAgo(dateString: string): string { + const date = new Date(dateString) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMins = Math.floor(diffMs / 60000) + + if (diffMins < 1) return "Just now" + if (diffMins < 60) return `${diffMins}m ago` + + const diffHours = Math.floor(diffMins / 60) + if (diffHours < 24) return `${diffHours}h ago` + + const diffDays = Math.floor(diffHours / 24) + if (diffDays < 7) return `${diffDays}d ago` + + return date.toLocaleDateString() +} + +export function RequestsPanel() { + const { + data, + isLoading, + isError, + error, + dataUpdatedAt, + } = useQuery({ + queryKey: ["observability", "requests"], + queryFn: fetchRequests, + refetchInterval: REFRESH_INTERVALS.REQUESTS, + staleTime: 30_000, + }) + + if (isLoading) { + return + } + + if (isError) { + return ( +
+ {error instanceof Error ? error.message : "Failed to load requests"} +
+ ) + } + + if (!data?.configured) { + return ( +
+ {data?.error || "Overseerr not configured"} +
+ ) + } + + if (!data?.available) { + return ( +
+ {data?.error || "Unable to fetch request data"} +
+ ) + } + + return ( +
+ {/* Stats Summary */} +
+
+ + + + +
+
+ + {/* Recent Requests */} + {data.recentRequests.length > 0 && ( +
+

+ Recent Requests +

+
+ {data.recentRequests.slice(0, 5).map((req) => ( + + ))} +
+
+ )} + +
+ {data.stats.total} total requests • Updated {new Date(dataUpdatedAt).toLocaleTimeString()} +
+
+ ) +} + +function StatBox({ label, value, color }: { label: string; value: number; color: string }) { + return ( +
+
{value}
+
{label}
+
+ ) +} + +function RequestRow({ request, overseerrUrl }: { request: RequestItem; overseerrUrl?: string }) { + const href = overseerrUrl && request.tmdbId + ? `${overseerrUrl}/${request.type}/${request.tmdbId}` + : undefined + + const baseClassName = "flex items-center gap-3 bg-slate-800/30 rounded-lg p-2" + const hoverClassName = href ? "hover:bg-slate-800/50 transition-colors cursor-pointer" : "" + + const content = ( + <> + + {request.type === "movie" ? ( + + + + ) : ( + + + + )} + +
+
+ {request.title} + + {request.type === "movie" ? "Movie" : "TV"} + +
+
+ by {request.requestedBy} • {formatTimeAgo(request.requestedAt)} +
+
+ + {request.status} + + + ) + + if (href) { + return ( + + {content} + + ) + } + + return ( +
+ {content} +
+ ) +} + +function RequestsSkeleton() { + return ( +
+
+
+ {[...Array(4)].map((_, i) => ( +
+
+
+
+ ))} +
+
+
+
+
+ {[...Array(3)].map((_, i) => ( +
+
+
+
+
+
+
+
+ ))} +
+
+
+ ) +} diff --git a/components/admin/observability/service-status-grid.tsx b/components/admin/observability/service-status-grid.tsx new file mode 100644 index 00000000..eb284d9c --- /dev/null +++ b/components/admin/observability/service-status-grid.tsx @@ -0,0 +1,120 @@ +import Link from "next/link" +import type { ServiceStatus } from "@/actions/admin" +import { PlexIcon, TautulliIcon, RadarrIcon, SonarrIcon } from "@/components/ui/service-icons" + +interface ServiceStatusGridProps { + services: { + plex: ServiceStatus + tautulli: ServiceStatus + overseerr: ServiceStatus + sonarr: ServiceStatus + radarr: ServiceStatus + discord: ServiceStatus + llm: ServiceStatus + } +} + +function ServiceIcon({ service }: { service: string }) { + switch (service) { + case "plex": + return + case "tautulli": + return + case "sonarr": + return + case "radarr": + return + case "overseerr": + return ( + + + + ) + case "discord": + return ( + + + + ) + case "llm": + return ( + + + + ) + default: + return ( + + + + ) + } +} + +function ServiceCard({ + serviceKey, + status +}: { + serviceKey: string + status: ServiceStatus +}) { + return ( +
+
+
+ +
+
+
+ + {status.name} + + +
+
+ {status.description} +
+
+
+ {!status.configured && ( + + Configure + + + + + )} +
+ ) +} + +export function ServiceStatusGrid({ services }: ServiceStatusGridProps) { + const serviceEntries = Object.entries(services) as [string, ServiceStatus][] + const configuredCount = serviceEntries.filter(([, s]) => s.configured).length + const totalCount = serviceEntries.length + + return ( +
+
+

Service Status

+ + {configuredCount} of {totalCount} configured + +
+
+ {serviceEntries.map(([key, status]) => ( + + ))} +
+
+ ) +} diff --git a/components/admin/observability/storage-panel.tsx b/components/admin/observability/storage-panel.tsx new file mode 100644 index 00000000..c329252e --- /dev/null +++ b/components/admin/observability/storage-panel.tsx @@ -0,0 +1,211 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import type { StorageResponse, DiskSpaceItem, LibraryInfo } from "@/app/api/observability/storage/route" +import { SonarrIcon, RadarrIcon } from "@/components/ui/service-icons" +import { REFRESH_INTERVALS } from "@/lib/constants/observability" + +async function fetchStorage(): Promise { + const response = await fetch("/api/observability/storage") + if (!response.ok) { + throw new Error("Failed to fetch storage") + } + return response.json() +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B" + const k = 1024 + const sizes = ["B", "KB", "MB", "GB", "TB", "PB"] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}` +} + +function getUsageColor(percent: number): string { + if (percent >= 90) return "bg-red-500" + if (percent >= 75) return "bg-yellow-500" + return "bg-green-500" +} + +function getUsageTextColor(percent: number): string { + if (percent >= 90) return "text-red-400" + if (percent >= 75) return "text-yellow-400" + return "text-green-400" +} + +function getLibraryIcon(type: string): string { + switch (type.toLowerCase()) { + case "movie": + return "🎬" + case "show": + return "📺" + case "artist": + case "music": + return "🎵" + case "photo": + return "📷" + default: + return "📁" + } +} + +export function StoragePanel() { + const { + data, + isLoading, + isError, + error, + dataUpdatedAt, + } = useQuery({ + queryKey: ["observability", "storage"], + queryFn: fetchStorage, + refetchInterval: REFRESH_INTERVALS.STORAGE, + staleTime: 30_000, + }) + + if (isLoading) { + return + } + + if (isError) { + return ( +
+ {error instanceof Error ? error.message : "Failed to load storage"} +
+ ) + } + + if (!data?.available) { + return ( +
+ {data?.error || "No services configured for storage metrics"} +
+ ) + } + + return ( +
+ {/* Disk Space Section */} + {data.diskSpace.length > 0 && ( +
+

+ Disk Usage +

+
+ {data.diskSpace.map((disk, idx) => ( + + ))} +
+
+ )} + + {/* Libraries Section */} + {data.libraries.length > 0 && ( +
+

+ Libraries +

+
+ {data.libraries.map((lib) => ( + + ))} +
+
+ )} + +
+ Updated {new Date(dataUpdatedAt).toLocaleTimeString()} +
+
+ ) +} + +function DiskSpaceRow({ disk }: { disk: DiskSpaceItem }) { + return ( +
+
+
+ {disk.source === "sonarr" ? ( + + ) : ( + + )} + + {disk.label} + +
+ + {disk.usedPercent}% + +
+
+
+
+
+ {formatBytes(disk.usedSpace)} used + {formatBytes(disk.freeSpace)} free +
+
+ ) +} + +function LibraryCard({ library }: { library: LibraryInfo }) { + return ( +
+ {getLibraryIcon(library.sectionType)} +
+
+ {library.sectionName} +
+
+ {library.count.toLocaleString()} items +
+
+
+ ) +} + +function StorageSkeleton() { + return ( +
+
+
+
+ {[...Array(2)].map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} +
+
+
+
+
+ {[...Array(4)].map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+
+
+ ) +} diff --git a/components/admin/observability/top-users-widget.tsx b/components/admin/observability/top-users-widget.tsx new file mode 100644 index 00000000..fb80a5b1 --- /dev/null +++ b/components/admin/observability/top-users-widget.tsx @@ -0,0 +1,97 @@ +import Link from "next/link" +import type { TopUser } from "@/actions/admin" + +interface TopUsersWidgetProps { + users: TopUser[] +} + +export function TopUsersWidget({ users }: TopUsersWidgetProps) { + if (users.length === 0) { + return ( +
+ No user activity data available +
+ ) + } + + return ( +
+ + + + + + + + + + {users.map((user, index) => ( + + + + + + ))} + +
+ User + + Requests + + Cost +
+ +
+ {index + 1} +
+
+ {user.image ? ( + + ) : ( +
+ {(user.name || user.email || "?")[0].toUpperCase()} +
+ )} +
+ + {user.name || "Unknown"} + + {user.email && ( + + {user.email} + + )} +
+
+ +
+ + {user.requests.toLocaleString()} + + + + ${user.cost.toFixed(4)} + +
+
+ + View all LLM usage → + +
+
+ ) +} diff --git a/components/admin/shared/admin-nav.tsx b/components/admin/shared/admin-nav.tsx index 4ef78acb..779d92df 100644 --- a/components/admin/shared/admin-nav.tsx +++ b/components/admin/shared/admin-nav.tsx @@ -9,6 +9,16 @@ import { MobileNavButton, MobileMoreMenu, type NavItem } from "./mobile-nav" // Navigation items organized by logical groups // Group 1: Core Management (most frequently accessed) const coreNavItems: NavItem[] = [ + { + href: "/admin/observability", + label: "Overview", + testId: "admin-nav-observability", + icon: ( + + + + ), + }, { href: "/admin/users", label: "Users", @@ -222,8 +232,11 @@ export function AdminNav() { return false } + if (href === "/admin/observability") { + return pathname === "/admin/observability" || pathname === "/admin" + } if (href === "/admin/users") { - return pathname === "/admin/users" || pathname === "/admin" + return pathname === "/admin/users" || pathname.startsWith("/admin/users/") } if (href === "/admin/prompts") { // Prompts is active for /admin/prompts and /admin/prompts/[id] but not /admin/playground @@ -271,7 +284,7 @@ export function AdminNav() { {/* Desktop Sidebar */}