diff --git a/package.json b/package.json index 1d46bfb..6698c10 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pgt-web-app", - "version": "0.1.20", + "version": "0.1.22", "private": true, "type": "module", "scripts": { @@ -29,13 +29,14 @@ "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", + "@radix-ui/react-hover-card": "^1.1.4", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-scroll-area": "^1.2.1", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-separator": "^1.1.0", - "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.1.4", "bree": "^9.2.4", diff --git a/src/app/admin/worker-heartbeats/page.tsx b/src/app/admin/worker-heartbeats/page.tsx new file mode 100644 index 0000000..467d071 --- /dev/null +++ b/src/app/admin/worker-heartbeats/page.tsx @@ -0,0 +1,23 @@ +import { WorkerHeartbeatsTable } from "@/components/admin/worker-heartbeats/WorkerHeartbeatsTable"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Worker Heartbeats | MEF Admin", + description: "Monitor background job statuses and heartbeats", +}; + +export default function WorkerHeartbeatsPage() { + return ( +
+ + + Worker Heartbeats + + + + + +
+ ); +} \ No newline at end of file diff --git a/src/app/api/admin/worker-heartbeats/route.ts b/src/app/api/admin/worker-heartbeats/route.ts new file mode 100644 index 0000000..83dc7e5 --- /dev/null +++ b/src/app/api/admin/worker-heartbeats/route.ts @@ -0,0 +1,74 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { getOrCreateUserFromRequest } from "@/lib/auth"; +import { AdminService } from "@/services/AdminService"; +import { ApiResponse } from "@/lib/api-response"; +import { AppError } from "@/lib/errors"; +import { AuthErrors } from "@/constants/errors"; +import logger from "@/logging"; +import { WorkerStatus } from "@prisma/client"; + +const adminService = new AdminService(prisma); +const DEFAULT_PAGE_SIZE = 25; + +type SortField = 'createdAt' | 'lastHeartbeat' | 'status' | 'name'; +type SortOrder = 'asc' | 'desc'; + +export async function GET(request: Request) { + try { + const user = await getOrCreateUserFromRequest(request); + if (!user) { + throw AppError.unauthorized(AuthErrors.UNAUTHORIZED); + } + + const isAdmin = await adminService.checkAdminStatus(user.id, user.linkId); + if (!isAdmin) { + throw AppError.forbidden(AuthErrors.FORBIDDEN); + } + + // Parse query parameters + const { searchParams } = new URL(request.url); + const page = Math.max(1, Number(searchParams.get('page')) || 1); + const pageSize = Math.max(1, Number(searchParams.get('pageSize')) || DEFAULT_PAGE_SIZE); + const sortField = (searchParams.get('sortField') as SortField) || 'createdAt'; + const sortOrder = (searchParams.get('sortOrder') as SortOrder) || 'desc'; + + // Validate sort field + const validSortFields: SortField[] = ['createdAt', 'lastHeartbeat', 'status', 'name']; + if (!validSortFields.includes(sortField)) { + throw AppError.badRequest('Invalid sort field'); + } + + // Get total count for pagination + const totalCount = await prisma.workerHeartbeat.count(); + const totalPages = Math.ceil(totalCount / pageSize); + + // Fetch paginated and sorted data + const heartbeats = await prisma.workerHeartbeat.findMany({ + take: pageSize, + skip: (page - 1) * pageSize, + orderBy: { + [sortField]: sortOrder, + }, + }); + + logger.info(`Fetched ${heartbeats.length} worker heartbeats (page ${page}/${totalPages})`); + + // Return paginated response + return ApiResponse.success({ + data: heartbeats, + pagination: { + currentPage: page, + totalPages, + pageSize, + totalCount, + }, + sort: { + field: sortField, + order: sortOrder, + }, + }); + } catch (error) { + return ApiResponse.error(error); + } +} \ No newline at end of file diff --git a/src/components/AdminDashboard.tsx b/src/components/AdminDashboard.tsx index 51247dc..7fb7d4d 100644 --- a/src/components/AdminDashboard.tsx +++ b/src/components/AdminDashboard.tsx @@ -2,7 +2,7 @@ import { Button } from "@/components/ui/button" import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { Users, MessageSquare, Coins, FileCheck, Vote } from 'lucide-react' +import { Users, MessageSquare, Coins, FileCheck, Vote, Activity } from 'lucide-react' import Link from "next/link" export function AdminDashboardComponent() { @@ -36,6 +36,12 @@ export function AdminDashboardComponent() { description: "Count Votes for a Funding Round..", icon: , href: "/admin/votes" + }, + { + title: "Worker Heartbeats", + description: "Monitor background job statuses", + icon: , + href: "/admin/worker-heartbeats" } ] diff --git a/src/components/admin/worker-heartbeats/WorkerHeartbeatsTable.tsx b/src/components/admin/worker-heartbeats/WorkerHeartbeatsTable.tsx new file mode 100644 index 0000000..6f74f84 --- /dev/null +++ b/src/components/admin/worker-heartbeats/WorkerHeartbeatsTable.tsx @@ -0,0 +1,315 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { CopyIcon, InfoIcon, ArrowUpDown, AlertCircle } from "lucide-react"; +import { formatDistanceToNow } from "date-fns"; +import { WorkerStatus } from "@prisma/client"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; +import { cn } from "@/lib/utils"; + +// Match the Prisma schema exactly +interface WorkerHeartbeat { + id: string; + name: string; + lastHeartbeat: string; // ISO date string + status: WorkerStatus; + metadata: Record | null; + createdAt: string; // ISO date string +} + +const statusColors = { + RUNNING: "bg-blue-500", + COMPLETED: "bg-green-500", + FAILED: "bg-red-500", + NOT_STARTED: "bg-gray-500", +} as const; + +type SortField = keyof Pick; +type SortOrder = 'asc' | 'desc'; + +interface SortConfig { + field: SortField; + order: SortOrder; +} + +interface PaginatedResponse { + data: WorkerHeartbeat[]; + pagination: { + currentPage: number; + totalPages: number; + pageSize: number; + totalCount: number; + }; + sort: { + field: SortField; + order: SortOrder; + }; +} + +export const WorkerHeartbeatsTable: React.FC = () => { + const [heartbeats, setHeartbeats] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [sortConfig, setSortConfig] = useState({ + field: 'createdAt', + order: 'desc', + }); + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + }; + + useEffect(() => { + const fetchHeartbeats = async () => { + try { + setIsLoading(true); + const params = new URLSearchParams({ + page: currentPage.toString(), + pageSize: '25', + sortField: sortConfig.field, + sortOrder: sortConfig.order, + }); + + const response = await fetch(`/api/admin/worker-heartbeats?${params}`); + const json = await response.json(); + + if (!response.ok) { + throw new Error(json.message || 'Failed to fetch heartbeats'); + } + + const { data, pagination } = json as PaginatedResponse; + setHeartbeats(data); + setTotalPages(pagination.totalPages); + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred'); + } finally { + setIsLoading(false); + } + }; + + fetchHeartbeats(); + }, [currentPage, sortConfig]); + + const handleSort = (field: SortField) => { + setSortConfig(current => ({ + field, + order: current.field === field && current.order === 'asc' ? 'desc' : 'asc' + })); + }; + + const getSortIcon = (field: SortField) => { + if (sortConfig.field !== field) { + return ; + } + return ( + + ); + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+
+ + {error} +
+
+ ); + } + + if (heartbeats.length === 0) { + return ( +
+ No worker heartbeats found +
+ ); + } + + return ( +
+
+ + + + handleSort('name')} + className="cursor-pointer hover:bg-muted/50 transition-colors" + > +
+ Job Name {getSortIcon('name')} +
+
+ handleSort('status')} + className="cursor-pointer hover:bg-muted/50 transition-colors" + > +
+ Status {getSortIcon('status')} +
+
+ handleSort('lastHeartbeat')} + className="cursor-pointer hover:bg-muted/50 transition-colors" + > +
+ Last Heartbeat {getSortIcon('lastHeartbeat')} +
+
+ handleSort('createdAt')} + className="cursor-pointer hover:bg-muted/50 transition-colors" + > +
+ Created At {getSortIcon('createdAt')} +
+
+ Metadata +
+
+ + {heartbeats.map((heartbeat) => ( + + {heartbeat.name} + + + {heartbeat.status} + + + + {formatDistanceToNow(new Date(heartbeat.lastHeartbeat), { + addSuffix: true, + })} + + + {formatDistanceToNow(new Date(heartbeat.createdAt), { + addSuffix: true, + })} + + + {heartbeat.metadata ? ( + + + + + +
+
+                            {JSON.stringify(heartbeat.metadata, null, 2)}
+                          
+ +
+
+
+ ) : ( + No metadata + )} +
+
+ ))} +
+
+
+ + {totalPages > 1 && ( +
+ + + + setCurrentPage(p => Math.max(1, p - 1))} + className={cn( + "cursor-pointer", + currentPage === 1 && "pointer-events-none opacity-50" + )} + /> + + {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( + + setCurrentPage(page)} + isActive={currentPage === page} + className="cursor-pointer" + > + {page} + + + ))} + + setCurrentPage(p => Math.min(totalPages, p + 1))} + className={cn( + "cursor-pointer", + currentPage === totalPages && "pointer-events-none opacity-50" + )} + /> + + + +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/src/components/ui/hover-card.tsx b/src/components/ui/hover-card.tsx new file mode 100644 index 0000000..e54d91c --- /dev/null +++ b/src/components/ui/hover-card.tsx @@ -0,0 +1,29 @@ +"use client" + +import * as React from "react" +import * as HoverCardPrimitive from "@radix-ui/react-hover-card" + +import { cn } from "@/lib/utils" + +const HoverCard = HoverCardPrimitive.Root + +const HoverCardTrigger = HoverCardPrimitive.Trigger + +const HoverCardContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + +)) +HoverCardContent.displayName = HoverCardPrimitive.Content.displayName + +export { HoverCard, HoverCardTrigger, HoverCardContent } diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx new file mode 100644 index 0000000..484e5ce --- /dev/null +++ b/src/components/ui/pagination.tsx @@ -0,0 +1,116 @@ +import * as React from "react" +import { cn } from "@/lib/utils" +import { ButtonProps, buttonVariants } from "@/components/ui/button" +import { ChevronLeftIcon, ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons" + +const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( +