because I got bored of customising my CV for every job
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(client): add admin dashboard and system status pages

+1314
+8
apps/client/src/components/AdminRoute.tsx
··· 1 + import { Navigate, Outlet } from "react-router-dom"; 2 + import { useToken } from "@/contexts/TokenProvider"; 3 + 4 + export const AdminRoute = () => { 5 + const { isAdmin } = useToken(); 6 + 7 + return isAdmin ? <Outlet /> : <Navigate to="/" replace />; 8 + };
+263
apps/client/src/features/admin/components/AdminLookupTable.tsx
··· 1 + import { Button, Card, TextInput } from "@cv/ui"; 2 + import { keepPreviousData, useQueryClient } from "@tanstack/react-query"; 3 + import { useState } from "react"; 4 + import { 5 + useAdminCreateLookupEntityMutation, 6 + useAdminDeleteLookupEntityMutation, 7 + useAdminLookupEntitiesQuery, 8 + useAdminUpdateLookupEntityMutation, 9 + } from "@/generated/graphql"; 10 + 11 + interface AdminLookupTableProps { 12 + entityType: string; 13 + title: string; 14 + } 15 + 16 + type LookupEntity = { 17 + id: string; 18 + name: string; 19 + description: string | null; 20 + createdAt: string; 21 + updatedAt: string; 22 + }; 23 + 24 + const formatDate = (iso: string) => 25 + new Date(iso).toLocaleDateString(undefined, { 26 + year: "numeric", 27 + month: "short", 28 + day: "numeric", 29 + }); 30 + 31 + export const AdminLookupTable = ({ 32 + entityType, 33 + title, 34 + }: AdminLookupTableProps) => { 35 + const queryClient = useQueryClient(); 36 + const [searchTerm, setSearchTerm] = useState(""); 37 + const [newName, setNewName] = useState(""); 38 + const [newDescription, setNewDescription] = useState(""); 39 + const [editingId, setEditingId] = useState<string | null>(null); 40 + const [editName, setEditName] = useState(""); 41 + const [editDescription, setEditDescription] = useState(""); 42 + const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null); 43 + 44 + const queryKey = ["AdminLookupEntities", { entityType, searchTerm }]; 45 + 46 + const { data, isLoading } = useAdminLookupEntitiesQuery( 47 + { 48 + entityType: entityType as never, 49 + searchTerm: searchTerm || undefined, 50 + }, 51 + { placeholderData: keepPreviousData }, 52 + ); 53 + 54 + const invalidate = () => 55 + queryClient.invalidateQueries({ queryKey: ["AdminLookupEntities"] }); 56 + 57 + const createMutation = useAdminCreateLookupEntityMutation({ 58 + onSuccess: () => { 59 + invalidate(); 60 + setNewName(""); 61 + setNewDescription(""); 62 + }, 63 + }); 64 + 65 + const updateMutation = useAdminUpdateLookupEntityMutation({ 66 + onSuccess: () => { 67 + invalidate(); 68 + setEditingId(null); 69 + }, 70 + }); 71 + 72 + const deleteMutation = useAdminDeleteLookupEntityMutation({ 73 + onSuccess: () => { 74 + invalidate(); 75 + setDeleteConfirmId(null); 76 + }, 77 + }); 78 + 79 + const handleCreate = (e: React.FormEvent) => { 80 + e.preventDefault(); 81 + if (!newName.trim()) return; 82 + createMutation.mutate({ 83 + entityType: entityType as never, 84 + name: newName.trim(), 85 + description: newDescription.trim() || undefined, 86 + }); 87 + }; 88 + 89 + const handleUpdate = (id: string) => { 90 + updateMutation.mutate({ 91 + entityType: entityType as never, 92 + id, 93 + name: editName.trim() || undefined, 94 + description: editDescription, 95 + }); 96 + }; 97 + 98 + const handleDelete = (id: string) => { 99 + deleteMutation.mutate({ entityType: entityType as never, id }); 100 + }; 101 + 102 + const startEditing = (entity: LookupEntity) => { 103 + setEditingId(entity.id); 104 + setEditName(entity.name); 105 + setEditDescription(entity.description ?? ""); 106 + }; 107 + 108 + const entities = (data?.adminLookupEntities ?? []) as LookupEntity[]; 109 + 110 + return ( 111 + <Card> 112 + <div className="mb-4 flex items-center justify-between"> 113 + <h3 className="text-lg font-medium text-ctp-text">{title}</h3> 114 + <span className="text-xs text-ctp-subtext0"> 115 + {entities.length} {entities.length === 1 ? "item" : "items"} 116 + </span> 117 + </div> 118 + 119 + <div className="mb-4"> 120 + <TextInput 121 + label="" 122 + placeholder="Search..." 123 + value={searchTerm} 124 + onChange={setSearchTerm} 125 + /> 126 + </div> 127 + 128 + <form onSubmit={handleCreate} className="mb-4 flex gap-2"> 129 + <TextInput 130 + label="" 131 + placeholder="Name" 132 + value={newName} 133 + onChange={setNewName} 134 + /> 135 + <TextInput 136 + label="" 137 + placeholder="Description (optional)" 138 + value={newDescription} 139 + onChange={setNewDescription} 140 + /> 141 + <Button type="submit" disabled={!newName.trim()}> 142 + Add 143 + </Button> 144 + </form> 145 + 146 + {isLoading ? ( 147 + <p className="text-sm text-ctp-subtext0">Loading...</p> 148 + ) : entities.length === 0 ? ( 149 + <p className="text-sm text-ctp-subtext0">No entities found</p> 150 + ) : ( 151 + <div className="overflow-x-auto"> 152 + <table className="w-full text-sm"> 153 + <thead> 154 + <tr className="border-b border-ctp-surface1 text-left text-ctp-subtext0"> 155 + <th className="pb-2 pr-4 font-medium">Name</th> 156 + <th className="pb-2 pr-4 font-medium">Description</th> 157 + <th className="pb-2 pr-4 font-medium">Created</th> 158 + <th className="pb-2 pr-4 font-medium text-right">Actions</th> 159 + </tr> 160 + </thead> 161 + <tbody> 162 + {entities.map((entity) => ( 163 + <tr 164 + key={entity.id} 165 + className="border-b border-ctp-surface1 last:border-b-0" 166 + > 167 + {editingId === entity.id ? ( 168 + <> 169 + <td className="py-2 pr-4"> 170 + <TextInput 171 + label="" 172 + value={editName} 173 + onChange={setEditName} 174 + /> 175 + </td> 176 + <td className="py-2 pr-4"> 177 + <TextInput 178 + label="" 179 + value={editDescription} 180 + onChange={setEditDescription} 181 + /> 182 + </td> 183 + <td className="py-2 pr-4 text-ctp-subtext0"> 184 + {formatDate(entity.createdAt)} 185 + </td> 186 + <td className="py-2 pr-4 text-right"> 187 + <div className="flex justify-end gap-1"> 188 + <Button 189 + variant="ghost" 190 + size="sm" 191 + onClick={() => handleUpdate(entity.id)} 192 + > 193 + Save 194 + </Button> 195 + <Button 196 + variant="ghost" 197 + size="sm" 198 + onClick={() => setEditingId(null)} 199 + > 200 + Cancel 201 + </Button> 202 + </div> 203 + </td> 204 + </> 205 + ) : ( 206 + <> 207 + <td className="py-2 pr-4 text-ctp-text"> 208 + {entity.name} 209 + </td> 210 + <td className="py-2 pr-4 text-ctp-subtext0"> 211 + {entity.description ?? "-"} 212 + </td> 213 + <td className="py-2 pr-4 text-ctp-subtext0"> 214 + {formatDate(entity.createdAt)} 215 + </td> 216 + <td className="py-2 pr-4 text-right"> 217 + {deleteConfirmId === entity.id ? ( 218 + <div className="flex justify-end gap-1"> 219 + <Button 220 + variant="ghost" 221 + size="sm" 222 + onClick={() => handleDelete(entity.id)} 223 + > 224 + Confirm 225 + </Button> 226 + <Button 227 + variant="ghost" 228 + size="sm" 229 + onClick={() => setDeleteConfirmId(null)} 230 + > 231 + Cancel 232 + </Button> 233 + </div> 234 + ) : ( 235 + <div className="flex justify-end gap-1"> 236 + <Button 237 + variant="ghost" 238 + size="sm" 239 + onClick={() => startEditing(entity)} 240 + > 241 + Edit 242 + </Button> 243 + <Button 244 + variant="ghost" 245 + size="sm" 246 + onClick={() => setDeleteConfirmId(entity.id)} 247 + > 248 + Delete 249 + </Button> 250 + </div> 251 + )} 252 + </td> 253 + </> 254 + )} 255 + </tr> 256 + ))} 257 + </tbody> 258 + </table> 259 + </div> 260 + )} 261 + </Card> 262 + ); 263 + };
+282
apps/client/src/features/admin/components/AiCallLogTable.tsx
··· 1 + import { useState } from "react"; 2 + import { Badge, Card, StatusBadge, StatusDot } from "@cv/ui"; 3 + import type { AiCallLogQuery } from "@/generated/graphql"; 4 + 5 + type CallLogEntry = AiCallLogQuery["aiCallLog"][number]; 6 + 7 + type StatusFilter = "all" | "success" | "error"; 8 + 9 + const formatRelativeTime = (isoString: string): string => { 10 + const diff = Date.now() - new Date(isoString).getTime(); 11 + const seconds = Math.floor(diff / 1000); 12 + 13 + if (seconds < 60) return `${seconds}s ago`; 14 + const minutes = Math.floor(seconds / 60); 15 + if (minutes < 60) return `${minutes}m ago`; 16 + const hours = Math.floor(minutes / 60); 17 + if (hours < 24) return `${hours}h ago`; 18 + return `${Math.floor(hours / 24)}d ago`; 19 + }; 20 + 21 + const formatAbsoluteTime = (isoString: string): string => 22 + new Date(isoString).toLocaleString(); 23 + 24 + const formatDuration = (ms: number): string => 25 + ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`; 26 + 27 + const formatTokens = ( 28 + prompt?: number | null, 29 + completion?: number | null, 30 + ): string => 31 + prompt != null || completion != null 32 + ? `${prompt ?? "?"} / ${completion ?? "?"}` 33 + : "-"; 34 + 35 + const uniqueProviders = (entries: CallLogEntry[]): string[] => [ 36 + ...new Set(entries.map((e) => e.providerName)), 37 + ]; 38 + 39 + const ExpandedDetail = ({ 40 + label, 41 + value, 42 + }: { 43 + label: string; 44 + value: React.ReactNode; 45 + }) => 46 + value ? ( 47 + <div className="flex gap-2"> 48 + <span className="text-ctp-subtext0 shrink-0">{label}:</span> 49 + <span className="text-ctp-text">{value}</span> 50 + </div> 51 + ) : null; 52 + 53 + const CallLogRow = ({ 54 + entry, 55 + isExpanded, 56 + onToggle, 57 + }: { 58 + entry: CallLogEntry; 59 + isExpanded: boolean; 60 + onToggle: () => void; 61 + }) => ( 62 + <> 63 + <tr 64 + className="border-b border-ctp-surface1 last:border-b-0 hover:bg-ctp-surface0/50 cursor-pointer" 65 + onClick={onToggle} 66 + > 67 + <td className="py-2 pr-4 text-ctp-subtext0 whitespace-nowrap"> 68 + {formatRelativeTime(entry.timestamp)} 69 + </td> 70 + <td className="py-2 pr-4 text-ctp-text">{entry.providerName}</td> 71 + <td className="py-2 pr-4 text-ctp-text font-mono text-xs"> 72 + {entry.model ?? "-"} 73 + </td> 74 + <td className="py-2 pr-4 text-ctp-text whitespace-nowrap text-right tabular-nums"> 75 + {formatDuration(entry.durationMs)} 76 + </td> 77 + <td className="py-2 pr-4 text-ctp-text whitespace-nowrap text-right tabular-nums"> 78 + {formatTokens(entry.promptTokens, entry.completionTokens)} 79 + </td> 80 + <td className="py-2 pr-4"> 81 + <span className="flex items-center gap-2"> 82 + <StatusDot color={entry.status === "success" ? "green" : "red"} /> 83 + {entry.status === "error" && entry.error ? ( 84 + <span 85 + className="text-ctp-red truncate max-w-md" 86 + title={entry.error} 87 + > 88 + {entry.error} 89 + </span> 90 + ) : ( 91 + entry.finishReason ?? entry.status 92 + )} 93 + </span> 94 + </td> 95 + </tr> 96 + {isExpanded && ( 97 + <tr className="bg-ctp-surface0/30"> 98 + <td colSpan={6} className="px-4 py-3"> 99 + <div className="grid grid-cols-2 gap-x-8 gap-y-1 text-xs"> 100 + <ExpandedDetail 101 + label="Timestamp" 102 + value={formatAbsoluteTime(entry.timestamp)} 103 + /> 104 + <ExpandedDetail label="Source" value={entry.source} /> 105 + <ExpandedDetail label="User ID" value={entry.userId} /> 106 + <ExpandedDetail label="Model" value={entry.model} /> 107 + <ExpandedDetail label="Finish Reason" value={entry.finishReason} /> 108 + <ExpandedDetail 109 + label="Duration" 110 + value={formatDuration(entry.durationMs)} 111 + /> 112 + {entry.error && ( 113 + <div className="col-span-2 mt-1"> 114 + <span className="text-ctp-subtext0">Error:</span> 115 + <pre className="mt-1 whitespace-pre-wrap break-all text-ctp-red bg-ctp-surface0 rounded p-2"> 116 + {entry.error} 117 + </pre> 118 + </div> 119 + )} 120 + </div> 121 + </td> 122 + </tr> 123 + )} 124 + </> 125 + ); 126 + 127 + const FilterBar = ({ 128 + statusFilter, 129 + onStatusChange, 130 + providerFilter, 131 + onProviderChange, 132 + providers, 133 + }: { 134 + statusFilter: StatusFilter; 135 + onStatusChange: (f: StatusFilter) => void; 136 + providerFilter: string; 137 + onProviderChange: (f: string) => void; 138 + providers: string[]; 139 + }) => { 140 + const statusOptions: StatusFilter[] = ["all", "success", "error"]; 141 + const statusColors: Record<StatusFilter, string> = { 142 + all: "ctp-blue", 143 + success: "ctp-green", 144 + error: "ctp-red", 145 + } as const; 146 + 147 + return ( 148 + <div className="flex items-center gap-3 flex-wrap"> 149 + <div className="flex gap-1"> 150 + {statusOptions.map((s) => ( 151 + <button 152 + key={s} 153 + type="button" 154 + onClick={() => onStatusChange(s)} 155 + className="focus:outline-none" 156 + > 157 + <Badge 158 + color={ 159 + statusFilter === s 160 + ? (statusColors[s] as "ctp-blue") 161 + : "ctp-gray" 162 + } 163 + > 164 + {s} 165 + </Badge> 166 + </button> 167 + ))} 168 + </div> 169 + {providers.length > 1 && ( 170 + <select 171 + value={providerFilter} 172 + onChange={(e) => onProviderChange(e.target.value)} 173 + className="bg-ctp-surface0 text-ctp-text text-xs rounded px-2 py-1 border border-ctp-surface1" 174 + > 175 + <option value="all">All providers</option> 176 + {providers.map((p) => ( 177 + <option key={p} value={p}> 178 + {p} 179 + </option> 180 + ))} 181 + </select> 182 + )} 183 + </div> 184 + ); 185 + }; 186 + 187 + interface AiCallLogTableProps { 188 + entries: CallLogEntry[]; 189 + } 190 + 191 + export const AiCallLogTable = ({ entries }: AiCallLogTableProps) => { 192 + const [statusFilter, setStatusFilter] = useState<StatusFilter>("all"); 193 + const [providerFilter, setProviderFilter] = useState("all"); 194 + const [expandedId, setExpandedId] = useState<string | null>(null); 195 + 196 + const providers = uniqueProviders(entries); 197 + 198 + const filtered = entries.filter( 199 + (e) => 200 + (statusFilter === "all" || e.status === statusFilter) && 201 + (providerFilter === "all" || e.providerName === providerFilter), 202 + ); 203 + 204 + return ( 205 + <Card> 206 + <div className="flex items-center justify-between mb-3"> 207 + <div> 208 + <h3 className="text-lg font-medium text-ctp-text">AI Call Log</h3> 209 + {entries.length > 0 && ( 210 + <p className="text-xs text-ctp-subtext0 mt-0.5"> 211 + Showing last {entries.length} calls from current session 212 + </p> 213 + )} 214 + </div> 215 + {entries.length > 0 && ( 216 + <div className="flex items-center gap-3 text-xs text-ctp-subtext0"> 217 + <span> 218 + {entries.filter((e) => e.status === "success").length} ok 219 + </span> 220 + <span> 221 + {entries.filter((e) => e.status === "error").length} errors 222 + </span> 223 + <StatusBadge 224 + status={entries[0]?.status === "error" ? "error" : "success"} 225 + /> 226 + </div> 227 + )} 228 + </div> 229 + 230 + {entries.length === 0 ? ( 231 + <p className="text-sm text-ctp-subtext0">No AI calls recorded yet</p> 232 + ) : ( 233 + <> 234 + <div className="mb-3"> 235 + <FilterBar 236 + statusFilter={statusFilter} 237 + onStatusChange={setStatusFilter} 238 + providerFilter={providerFilter} 239 + onProviderChange={setProviderFilter} 240 + providers={providers} 241 + /> 242 + </div> 243 + <div className="overflow-x-auto"> 244 + <table className="w-full text-sm"> 245 + <thead> 246 + <tr className="border-b border-ctp-surface1 text-left text-ctp-subtext0"> 247 + <th className="pb-2 pr-4 font-medium">Time</th> 248 + <th className="pb-2 pr-4 font-medium">Provider</th> 249 + <th className="pb-2 pr-4 font-medium">Model</th> 250 + <th className="pb-2 pr-4 font-medium text-right"> 251 + Duration 252 + </th> 253 + <th className="pb-2 pr-4 font-medium text-right">Tokens</th> 254 + <th className="pb-2 pr-4 font-medium">Status</th> 255 + </tr> 256 + </thead> 257 + <tbody> 258 + {filtered.map((entry) => ( 259 + <CallLogRow 260 + key={entry.id} 261 + entry={entry} 262 + isExpanded={expandedId === entry.id} 263 + onToggle={() => 264 + setExpandedId((prev) => 265 + prev === entry.id ? null : entry.id, 266 + ) 267 + } 268 + /> 269 + ))} 270 + </tbody> 271 + </table> 272 + {filtered.length === 0 && ( 273 + <p className="text-sm text-ctp-subtext0 text-center py-4"> 274 + No entries match the current filters 275 + </p> 276 + )} 277 + </div> 278 + </> 279 + )} 280 + </Card> 281 + ); 282 + };
+142
apps/client/src/features/admin/components/QueueMessagesTable.tsx
··· 1 + import { useState } from "react"; 2 + import { Badge, Card, StatusDot } from "@cv/ui"; 3 + import type { QueueMessagesQuery } from "@/generated/graphql"; 4 + 5 + type MessageConnection = QueueMessagesQuery["queueMessages"]; 6 + type Message = MessageConnection["edges"][number]["node"]; 7 + 8 + type StatusFilter = "all" | "pending" | "scheduled" | "processing"; 9 + 10 + type DotColor = "green" | "yellow" | "red" | "gray"; 11 + 12 + const statusColor: Record<string, DotColor> = { 13 + pending: "yellow", 14 + scheduled: "gray", 15 + processing: "green", 16 + }; 17 + 18 + const statusBadgeColor: Record<StatusFilter, string> = { 19 + all: "ctp-blue", 20 + pending: "ctp-yellow", 21 + scheduled: "ctp-gray", 22 + processing: "ctp-green", 23 + }; 24 + 25 + const formatRelativeTime = (isoString: string): string => { 26 + const diff = Date.now() - new Date(isoString).getTime(); 27 + const seconds = Math.floor(diff / 1000); 28 + 29 + if (seconds < 60) return `${seconds}s ago`; 30 + const minutes = Math.floor(seconds / 60); 31 + if (minutes < 60) return `${minutes}m ago`; 32 + const hours = Math.floor(minutes / 60); 33 + if (hours < 24) return `${hours}h ago`; 34 + return `${Math.floor(hours / 24)}d ago`; 35 + }; 36 + 37 + interface QueueMessagesTableProps { 38 + connection: MessageConnection; 39 + } 40 + 41 + export const QueueMessagesTable = ({ connection }: QueueMessagesTableProps) => { 42 + const [statusFilter, setStatusFilter] = useState<StatusFilter>("all"); 43 + 44 + const messages = connection.edges.map((e) => e.node); 45 + 46 + const filtered = 47 + statusFilter === "all" 48 + ? messages 49 + : messages.filter((m) => m.status === statusFilter); 50 + 51 + const statusOptions: StatusFilter[] = [ 52 + "all", 53 + "pending", 54 + "scheduled", 55 + "processing", 56 + ]; 57 + 58 + return ( 59 + <Card> 60 + <div className="flex items-center justify-between mb-3"> 61 + <div> 62 + <h3 className="text-lg font-medium text-ctp-text">Queue Messages</h3> 63 + {connection.totalCount > 0 && ( 64 + <p className="text-xs text-ctp-subtext0 mt-0.5"> 65 + {connection.totalCount} messages 66 + </p> 67 + )} 68 + </div> 69 + </div> 70 + 71 + {messages.length === 0 ? ( 72 + <p className="text-sm text-ctp-subtext0">No messages in queue</p> 73 + ) : ( 74 + <> 75 + <div className="mb-3 flex gap-1"> 76 + {statusOptions.map((s) => ( 77 + <button 78 + key={s} 79 + type="button" 80 + onClick={() => setStatusFilter(s)} 81 + className="focus:outline-none" 82 + > 83 + <Badge 84 + color={ 85 + statusFilter === s 86 + ? (statusBadgeColor[s] as "ctp-blue") 87 + : "ctp-gray" 88 + } 89 + > 90 + {s} 91 + </Badge> 92 + </button> 93 + ))} 94 + </div> 95 + <div className="overflow-x-auto"> 96 + <table className="w-full text-sm"> 97 + <thead> 98 + <tr className="border-b border-ctp-surface1 text-left text-ctp-subtext0"> 99 + <th className="pb-2 pr-4 font-medium">Time</th> 100 + <th className="pb-2 pr-4 font-medium">Message</th> 101 + <th className="pb-2 pr-4 font-medium">Queue</th> 102 + <th className="pb-2 pr-4 font-medium">Status</th> 103 + </tr> 104 + </thead> 105 + <tbody> 106 + {filtered.map((msg) => ( 107 + <tr 108 + key={msg.id} 109 + className="border-b border-ctp-surface1 last:border-b-0" 110 + > 111 + <td className="py-2 pr-4 text-ctp-subtext0 whitespace-nowrap"> 112 + {formatRelativeTime(msg.createdAt)} 113 + </td> 114 + <td className="py-2 pr-4 text-ctp-text font-mono text-xs"> 115 + {msg.messageName ?? "-"} 116 + </td> 117 + <td className="py-2 pr-4 text-ctp-text"> 118 + {msg.queueName} 119 + </td> 120 + <td className="py-2 pr-4"> 121 + <span className="flex items-center gap-2"> 122 + <StatusDot 123 + color={statusColor[msg.status] ?? "gray"} 124 + /> 125 + {msg.status} 126 + </span> 127 + </td> 128 + </tr> 129 + ))} 130 + </tbody> 131 + </table> 132 + {filtered.length === 0 && ( 133 + <p className="text-sm text-ctp-subtext0 text-center py-4"> 134 + No messages match the current filter 135 + </p> 136 + )} 137 + </div> 138 + </> 139 + )} 140 + </Card> 141 + ); 142 + };
+56
apps/client/src/features/admin/components/QueueStatsCard.tsx
··· 1 + import { Card, StatusDot } from "@cv/ui"; 2 + import type { QueueStatsQuery } from "@/generated/graphql"; 3 + 4 + type Stats = NonNullable<QueueStatsQuery["queueStats"]>; 5 + 6 + const formatDuration = (seconds: number): string => { 7 + if (seconds < 60) return `${Math.round(seconds)}s`; 8 + const minutes = Math.floor(seconds / 60); 9 + if (minutes < 60) return `${minutes}m ${Math.round(seconds % 60)}s`; 10 + const hours = Math.floor(minutes / 60); 11 + return `${hours}h ${minutes % 60}m`; 12 + }; 13 + 14 + const DetailRow = ({ 15 + label, 16 + value, 17 + }: { 18 + label: string; 19 + value: React.ReactNode; 20 + }) => ( 21 + <div className="flex justify-between py-1.5 border-b border-ctp-surface1 last:border-b-0"> 22 + <span className="text-sm text-ctp-subtext0">{label}</span> 23 + <span className="text-sm font-medium text-ctp-text">{value}</span> 24 + </div> 25 + ); 26 + 27 + interface QueueStatsCardProps { 28 + stats: Stats; 29 + } 30 + 31 + export const QueueStatsCard = ({ stats }: QueueStatsCardProps) => ( 32 + <Card> 33 + <h3 className="mb-3 text-lg font-medium text-ctp-text">Queue</h3> 34 + <div className="space-y-0"> 35 + <DetailRow 36 + label="Pending" 37 + value={ 38 + <span className="flex items-center gap-2"> 39 + <StatusDot color={stats.pending === 0 ? "green" : "yellow"} /> 40 + {stats.pending} 41 + </span> 42 + } 43 + /> 44 + <DetailRow label="Scheduled" value={stats.scheduled} /> 45 + <DetailRow label="Processing" value={stats.processing} /> 46 + <DetailRow 47 + label="Oldest Pending" 48 + value={ 49 + stats.oldestPendingSeconds != null 50 + ? formatDuration(stats.oldestPendingSeconds) 51 + : "none" 52 + } 53 + /> 54 + </div> 55 + </Card> 56 + );
+79
apps/client/src/features/admin/components/WorkerHealthCard.tsx
··· 1 + import { Card, StatusDot } from "@cv/ui"; 2 + import type { WorkerHealthQuery } from "@/generated/graphql"; 3 + 4 + type Worker = WorkerHealthQuery["workerHealth"][number]; 5 + 6 + type DotColor = "green" | "yellow" | "red"; 7 + 8 + const statusColor: Record<string, DotColor> = { 9 + healthy: "green", 10 + stale: "yellow", 11 + dead: "red", 12 + }; 13 + 14 + const formatRelativeTime = (isoString: string): string => { 15 + const diff = Date.now() - new Date(isoString).getTime(); 16 + const seconds = Math.floor(diff / 1000); 17 + 18 + if (seconds < 60) return `${seconds}s ago`; 19 + const minutes = Math.floor(seconds / 60); 20 + if (minutes < 60) return `${minutes}m ago`; 21 + const hours = Math.floor(minutes / 60); 22 + if (hours < 24) return `${hours}h ago`; 23 + return `${Math.floor(hours / 24)}d ago`; 24 + }; 25 + 26 + const truncateUuid = (uuid: string): string => uuid.slice(0, 8); 27 + 28 + interface WorkerHealthCardProps { 29 + workers: Worker[]; 30 + } 31 + 32 + export const WorkerHealthCard = ({ workers }: WorkerHealthCardProps) => ( 33 + <Card> 34 + <h3 className="mb-3 text-lg font-medium text-ctp-text">Workers</h3> 35 + {workers.length === 0 ? ( 36 + <p className="text-sm text-ctp-subtext0">No workers registered</p> 37 + ) : ( 38 + <div className="overflow-x-auto"> 39 + <table className="w-full text-sm"> 40 + <thead> 41 + <tr className="border-b border-ctp-surface1 text-left text-ctp-subtext0"> 42 + <th className="pb-2 pr-4">Worker ID</th> 43 + <th className="pb-2 pr-4">Status</th> 44 + <th className="pb-2 pr-4">Started</th> 45 + <th className="pb-2 pr-4">Last Seen</th> 46 + </tr> 47 + </thead> 48 + <tbody> 49 + {workers.map((w) => ( 50 + <tr 51 + key={w.workerId} 52 + className="border-b border-ctp-surface1 last:border-b-0" 53 + > 54 + <td 55 + className="py-1.5 pr-4 font-mono text-xs text-ctp-text" 56 + title={w.workerId} 57 + > 58 + {truncateUuid(w.workerId)} 59 + </td> 60 + <td className="py-1.5 pr-4"> 61 + <span className="flex items-center gap-2"> 62 + <StatusDot color={statusColor[w.status] ?? "gray"} /> 63 + {w.status} 64 + </span> 65 + </td> 66 + <td className="py-1.5 pr-4 text-ctp-subtext0"> 67 + {formatRelativeTime(w.startedAt)} 68 + </td> 69 + <td className="py-1.5 pr-4 text-ctp-subtext0"> 70 + {formatRelativeTime(w.lastSeenAt)} 71 + </td> 72 + </tr> 73 + ))} 74 + </tbody> 75 + </table> 76 + </div> 77 + )} 78 + </Card> 79 + );
+51
apps/client/src/features/admin/queries/admin-lookup.graphql
··· 1 + query AdminLookupEntities($entityType: AdminEntityType!, $searchTerm: String) { 2 + adminLookupEntities(entityType: $entityType, searchTerm: $searchTerm) { 3 + id 4 + name 5 + description 6 + createdAt 7 + updatedAt 8 + } 9 + } 10 + 11 + mutation AdminCreateLookupEntity( 12 + $entityType: AdminEntityType! 13 + $name: String! 14 + $description: String 15 + ) { 16 + adminCreateLookupEntity( 17 + entityType: $entityType 18 + name: $name 19 + description: $description 20 + ) { 21 + id 22 + name 23 + description 24 + createdAt 25 + updatedAt 26 + } 27 + } 28 + 29 + mutation AdminUpdateLookupEntity( 30 + $entityType: AdminEntityType! 31 + $id: String! 32 + $name: String 33 + $description: String 34 + ) { 35 + adminUpdateLookupEntity( 36 + entityType: $entityType 37 + id: $id 38 + name: $name 39 + description: $description 40 + ) { 41 + id 42 + name 43 + description 44 + createdAt 45 + updatedAt 46 + } 47 + } 48 + 49 + mutation AdminDeleteLookupEntity($entityType: AdminEntityType!, $id: String!) { 50 + adminDeleteLookupEntity(entityType: $entityType, id: $id) 51 + }
+32
apps/client/src/features/admin/queries/queue-status.graphql
··· 1 + query QueueStats { 2 + queueStats { 3 + pending 4 + scheduled 5 + processing 6 + oldestPendingSeconds 7 + } 8 + } 9 + 10 + query QueueMessages($limit: Int) { 11 + queueMessages(limit: $limit) { 12 + totalCount 13 + edges { 14 + node { 15 + id 16 + queueName 17 + messageName 18 + status 19 + createdAt 20 + } 21 + } 22 + } 23 + } 24 + 25 + query WorkerHealth { 26 + workerHealth { 27 + workerId 28 + status 29 + startedAt 30 + lastSeenAt 31 + } 32 + }
+28
apps/client/src/features/admin/queries/system-status.graphql
··· 1 + query SystemStatus { 2 + systemStatus { 3 + platformProvider { 4 + healthy 5 + providerName 6 + detailsJson 7 + } 8 + registeredProviderTypes 9 + serverUptime 10 + } 11 + } 12 + 13 + query AiCallLog($limit: Int, $status: String, $providerName: String) { 14 + aiCallLog(limit: $limit, status: $status, providerName: $providerName) { 15 + id 16 + timestamp 17 + providerName 18 + durationMs 19 + promptTokens 20 + completionTokens 21 + model 22 + finishReason 23 + status 24 + error 25 + userId 26 + source 27 + } 28 + }
+52
apps/client/src/pages/AdminPage.tsx
··· 1 + import { PageHeader } from "@cv/ui"; 2 + import { useState } from "react"; 3 + import { AdminLookupTable } from "@/features/admin/components/AdminLookupTable"; 4 + 5 + const ENTITY_TABS = [ 6 + { type: "SKILL", label: "Skills" }, 7 + { type: "COMPANY", label: "Companies" }, 8 + { type: "ROLE", label: "Roles" }, 9 + { type: "LEVEL", label: "Levels" }, 10 + { type: "INSTITUTION", label: "Institutions" }, 11 + { type: "JOB_TYPE", label: "Job Types" }, 12 + { type: "APPLICATION_STATUS", label: "Application Statuses" }, 13 + { type: "ORGANIZATION", label: "Organizations" }, 14 + { type: "ORGANIZATION_ROLE", label: "Organization Roles" }, 15 + ] as const; 16 + 17 + export default function AdminPage() { 18 + const [activeTab, setActiveTab] = useState<(typeof ENTITY_TABS)[number]["type"]>(ENTITY_TABS[0].type); 19 + 20 + const activeEntry = ENTITY_TABS.find((t) => t.type === activeTab); 21 + 22 + return ( 23 + <div className="space-y-6"> 24 + <PageHeader title="Admin - Lookup Entities" /> 25 + 26 + <div className="flex flex-wrap gap-1 border-b border-ctp-surface1 pb-2"> 27 + {ENTITY_TABS.map((tab) => ( 28 + <button 29 + key={tab.type} 30 + type="button" 31 + onClick={() => setActiveTab(tab.type)} 32 + className={`rounded-md px-3 py-1.5 text-sm transition-colors ${ 33 + activeTab === tab.type 34 + ? "bg-ctp-blue text-ctp-base font-medium" 35 + : "text-ctp-subtext0 hover:bg-ctp-surface0 hover:text-ctp-text" 36 + }`} 37 + > 38 + {tab.label} 39 + </button> 40 + ))} 41 + </div> 42 + 43 + {activeEntry && ( 44 + <AdminLookupTable 45 + key={activeEntry.type} 46 + entityType={activeEntry.type} 47 + title={activeEntry.label} 48 + /> 49 + )} 50 + </div> 51 + ); 52 + }
+321
apps/client/src/pages/SystemStatusPage.tsx
··· 1 + import { Badge, Card, PageHeader, Placeholder, StatusDot } from "@cv/ui"; 2 + import { 3 + useAiCallLogQuery, 4 + useQueueMessagesQuery, 5 + useQueueStatsQuery, 6 + useSystemStatusQuery, 7 + useWorkerHealthQuery, 8 + } from "@/generated/graphql"; 9 + import { AiCallLogTable } from "@/features/admin/components/AiCallLogTable"; 10 + import { QueueMessagesTable } from "@/features/admin/components/QueueMessagesTable"; 11 + import { QueueStatsCard } from "@/features/admin/components/QueueStatsCard"; 12 + import { WorkerHealthCard } from "@/features/admin/components/WorkerHealthCard"; 13 + 14 + interface LlamaCppDetails { 15 + kind: "llama-cpp"; 16 + health: Record<string, unknown> | null; 17 + model: Record<string, unknown> | null; 18 + slots: Array<Record<string, unknown>> | null; 19 + baseUrl: string; 20 + } 21 + 22 + interface ApiDetails { 23 + kind: "api"; 24 + model: string; 25 + baseUrl: string; 26 + } 27 + 28 + type ProviderDetails = LlamaCppDetails | ApiDetails; 29 + 30 + const formatUptime = (seconds: number): string => { 31 + const days = Math.floor(seconds / 86400); 32 + const hours = Math.floor((seconds % 86400) / 3600); 33 + const minutes = Math.floor((seconds % 3600) / 60); 34 + const secs = Math.floor(seconds % 60); 35 + 36 + const parts: string[] = []; 37 + if (days > 0) parts.push(`${days}d`); 38 + if (hours > 0) parts.push(`${hours}h`); 39 + if (minutes > 0) parts.push(`${minutes}m`); 40 + parts.push(`${secs}s`); 41 + 42 + return parts.join(" "); 43 + }; 44 + 45 + const parseDetails = (detailsJson: string): ProviderDetails | null => { 46 + try { 47 + const parsed = JSON.parse(detailsJson) as Record<string, unknown> | null; 48 + if (!parsed || typeof parsed !== "object") return null; 49 + const kind = parsed["kind"]; 50 + if (kind === "llama-cpp") return parsed as unknown as LlamaCppDetails; 51 + if (kind === "api") return parsed as unknown as ApiDetails; 52 + return null; 53 + } catch { 54 + return null; 55 + } 56 + }; 57 + 58 + const HealthDot = ({ healthy }: { healthy: boolean }) => ( 59 + <StatusDot color={healthy ? "green" : "red"} size="lg" /> 60 + ); 61 + 62 + const DetailRow = ({ 63 + label, 64 + value, 65 + }: { 66 + label: string; 67 + value: React.ReactNode; 68 + }) => ( 69 + <div className="flex justify-between py-1.5 border-b border-ctp-surface1 last:border-b-0"> 70 + <span className="text-sm text-ctp-subtext0">{label}</span> 71 + <span className="text-sm font-medium text-ctp-text">{value}</span> 72 + </div> 73 + ); 74 + 75 + const ServerCard = ({ 76 + uptime, 77 + providerTypes, 78 + }: { 79 + uptime: number; 80 + providerTypes: string[]; 81 + }) => ( 82 + <Card> 83 + <h3 className="mb-3 text-lg font-medium text-ctp-text">Server</h3> 84 + <div className="space-y-0"> 85 + <DetailRow label="Uptime" value={formatUptime(uptime)} /> 86 + <DetailRow 87 + label="Registered Providers" 88 + value={ 89 + <div className="flex flex-wrap gap-1"> 90 + {providerTypes.map((t) => ( 91 + <Badge key={t} color="ctp-blue"> 92 + {t} 93 + </Badge> 94 + ))} 95 + </div> 96 + } 97 + /> 98 + </div> 99 + </Card> 100 + ); 101 + 102 + const ProviderCard = ({ 103 + healthy, 104 + providerName, 105 + details, 106 + }: { 107 + healthy: boolean; 108 + providerName: string; 109 + details: ProviderDetails | null; 110 + }) => ( 111 + <Card> 112 + <h3 className="mb-3 text-lg font-medium text-ctp-text">AI Provider</h3> 113 + <div className="space-y-0"> 114 + <DetailRow 115 + label="Status" 116 + value={ 117 + <span className="flex items-center gap-2"> 118 + <HealthDot healthy={healthy} /> 119 + {healthy ? "Healthy" : "Unavailable"} 120 + </span> 121 + } 122 + /> 123 + <DetailRow label="Provider" value={providerName} /> 124 + {details?.kind === "api" && ( 125 + <DetailRow label="Model" value={details.model} /> 126 + )} 127 + {details?.baseUrl && <DetailRow label="Base URL" value={details.baseUrl} />} 128 + </div> 129 + </Card> 130 + ); 131 + 132 + const ModelCard = ({ model }: { model: Record<string, unknown> }) => { 133 + const defaultGenSettings = model["default_generation_settings"] as 134 + | Record<string, unknown> 135 + | undefined; 136 + 137 + const modelName = 138 + defaultGenSettings?.["model"] ?? model["model"] ?? "Unknown"; 139 + const totalSlots = model["total_slots"] ?? "N/A"; 140 + 141 + return ( 142 + <Card> 143 + <h3 className="mb-3 text-lg font-medium text-ctp-text">Model Info</h3> 144 + <div className="space-y-0"> 145 + <DetailRow label="Model" value={String(modelName)} /> 146 + <DetailRow label="Total Slots" value={String(totalSlots)} /> 147 + {defaultGenSettings?.["n_ctx"] != null && ( 148 + <DetailRow 149 + label="Context Size" 150 + value={String(defaultGenSettings["n_ctx"])} 151 + /> 152 + )} 153 + </div> 154 + </Card> 155 + ); 156 + }; 157 + 158 + const SlotsCard = ({ slots }: { slots: Array<Record<string, unknown>> }) => { 159 + const activeCount = slots.filter( 160 + (s) => s["is_processing"] === true || s["state"] !== 0, 161 + ).length; 162 + 163 + return ( 164 + <Card> 165 + <h3 className="mb-3 text-lg font-medium text-ctp-text"> 166 + Inference Slots 167 + </h3> 168 + <div className="mb-3"> 169 + <DetailRow 170 + label="Active / Total" 171 + value={`${activeCount} / ${slots.length}`} 172 + /> 173 + </div> 174 + <div className="overflow-x-auto"> 175 + <table className="w-full text-sm"> 176 + <thead> 177 + <tr className="border-b border-ctp-surface1 text-left text-ctp-subtext0"> 178 + <th className="pb-2 pr-4">Slot</th> 179 + <th className="pb-2 pr-4">State</th> 180 + <th className="pb-2 pr-4">Tokens</th> 181 + </tr> 182 + </thead> 183 + <tbody> 184 + {slots.map((slot, i) => { 185 + const isActive = 186 + slot["is_processing"] === true || slot["state"] !== 0; 187 + return ( 188 + <tr 189 + key={slot["id"] != null ? String(slot["id"]) : i} 190 + className="border-b border-ctp-surface1 last:border-b-0" 191 + > 192 + <td className="py-1.5 pr-4 text-ctp-text"> 193 + #{slot["id"] != null ? String(slot["id"]) : i} 194 + </td> 195 + <td className="py-1.5 pr-4"> 196 + <span className="flex items-center gap-2"> 197 + <HealthDot healthy={isActive} /> 198 + {isActive ? "Active" : "Idle"} 199 + </span> 200 + </td> 201 + <td className="py-1.5 pr-4 text-ctp-text"> 202 + {slot["n_predict"] != null 203 + ? String(slot["n_predict"]) 204 + : "-"} 205 + </td> 206 + </tr> 207 + ); 208 + })} 209 + </tbody> 210 + </table> 211 + </div> 212 + </Card> 213 + ); 214 + }; 215 + 216 + export default function SystemStatusPage() { 217 + const POLL_INTERVAL = 5_000; 218 + 219 + const { data, isLoading, error } = useSystemStatusQuery(undefined, { 220 + refetchInterval: POLL_INTERVAL, 221 + }); 222 + const { data: callLogData } = useAiCallLogQuery(undefined, { 223 + refetchInterval: POLL_INTERVAL, 224 + }); 225 + const { data: queueStatsData } = useQueueStatsQuery(undefined, { 226 + refetchInterval: POLL_INTERVAL, 227 + }); 228 + const { data: queueMessagesData } = useQueueMessagesQuery(undefined, { 229 + refetchInterval: POLL_INTERVAL, 230 + }); 231 + const { data: workerHealthData } = useWorkerHealthQuery(undefined, { 232 + refetchInterval: POLL_INTERVAL, 233 + }); 234 + 235 + if (isLoading) { 236 + return ( 237 + <div className="space-y-6"> 238 + <PageHeader title="System Status" /> 239 + <Placeholder variant="loading" message="Loading system status..." /> 240 + </div> 241 + ); 242 + } 243 + 244 + if (error) { 245 + return ( 246 + <div className="space-y-6"> 247 + <PageHeader title="System Status" /> 248 + <Placeholder variant="error" message="Failed to load system status" /> 249 + </div> 250 + ); 251 + } 252 + 253 + const status = data?.systemStatus; 254 + 255 + if (!status) { 256 + return ( 257 + <div className="space-y-6"> 258 + <PageHeader title="System Status" /> 259 + <Placeholder variant="empty" message="No status data available" /> 260 + </div> 261 + ); 262 + } 263 + 264 + const details = status.platformProvider 265 + ? parseDetails(status.platformProvider.detailsJson) 266 + : null; 267 + 268 + return ( 269 + <div className="space-y-6"> 270 + <PageHeader title="System Status" /> 271 + 272 + <div className="grid grid-cols-1 gap-6 lg:grid-cols-2"> 273 + <ServerCard 274 + uptime={status.serverUptime} 275 + providerTypes={status.registeredProviderTypes} 276 + /> 277 + 278 + {status.platformProvider ? ( 279 + <ProviderCard 280 + healthy={status.platformProvider.healthy} 281 + providerName={status.platformProvider.providerName} 282 + details={details} 283 + /> 284 + ) : ( 285 + <Card> 286 + <h3 className="mb-3 text-lg font-medium text-ctp-text"> 287 + AI Provider 288 + </h3> 289 + <p className="text-sm text-ctp-subtext0"> 290 + No platform AI provider configured 291 + </p> 292 + </Card> 293 + )} 294 + 295 + {details?.kind === "llama-cpp" && details.model && ( 296 + <ModelCard model={details.model} /> 297 + )} 298 + 299 + {details?.kind === "llama-cpp" && 300 + details.slots && 301 + Array.isArray(details.slots) && ( 302 + <SlotsCard slots={details.slots} /> 303 + )} 304 + 305 + {queueStatsData?.queueStats && ( 306 + <QueueStatsCard stats={queueStatsData.queueStats} /> 307 + )} 308 + 309 + {workerHealthData && ( 310 + <WorkerHealthCard workers={workerHealthData.workerHealth} /> 311 + )} 312 + </div> 313 + 314 + {queueMessagesData && ( 315 + <QueueMessagesTable connection={queueMessagesData.queueMessages} /> 316 + )} 317 + 318 + <AiCallLogTable entries={callLogData?.aiCallLog ?? []} /> 319 + </div> 320 + ); 321 + }