diff --git a/apps/client/src/components/AdminRoute.tsx b/apps/client/src/components/AdminRoute.tsx new file mode 100644 index 0000000..032949b --- /dev/null +++ b/apps/client/src/components/AdminRoute.tsx @@ -0,0 +1,8 @@ +import { Navigate, Outlet } from "react-router-dom"; +import { useToken } from "@/contexts/TokenProvider"; + +export const AdminRoute = () => { + const { isAdmin } = useToken(); + + return isAdmin ? : ; +}; diff --git a/apps/client/src/features/admin/components/AdminLookupTable.tsx b/apps/client/src/features/admin/components/AdminLookupTable.tsx new file mode 100644 index 0000000..3be6091 --- /dev/null +++ b/apps/client/src/features/admin/components/AdminLookupTable.tsx @@ -0,0 +1,263 @@ +import { Button, Card, TextInput } from "@cv/ui"; +import { keepPreviousData, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { + useAdminCreateLookupEntityMutation, + useAdminDeleteLookupEntityMutation, + useAdminLookupEntitiesQuery, + useAdminUpdateLookupEntityMutation, +} from "@/generated/graphql"; + +interface AdminLookupTableProps { + entityType: string; + title: string; +} + +type LookupEntity = { + id: string; + name: string; + description: string | null; + createdAt: string; + updatedAt: string; +}; + +const formatDate = (iso: string) => + new Date(iso).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); + +export const AdminLookupTable = ({ + entityType, + title, +}: AdminLookupTableProps) => { + const queryClient = useQueryClient(); + const [searchTerm, setSearchTerm] = useState(""); + const [newName, setNewName] = useState(""); + const [newDescription, setNewDescription] = useState(""); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(""); + const [editDescription, setEditDescription] = useState(""); + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + + const queryKey = ["AdminLookupEntities", { entityType, searchTerm }]; + + const { data, isLoading } = useAdminLookupEntitiesQuery( + { + entityType: entityType as never, + searchTerm: searchTerm || undefined, + }, + { placeholderData: keepPreviousData }, + ); + + const invalidate = () => + queryClient.invalidateQueries({ queryKey: ["AdminLookupEntities"] }); + + const createMutation = useAdminCreateLookupEntityMutation({ + onSuccess: () => { + invalidate(); + setNewName(""); + setNewDescription(""); + }, + }); + + const updateMutation = useAdminUpdateLookupEntityMutation({ + onSuccess: () => { + invalidate(); + setEditingId(null); + }, + }); + + const deleteMutation = useAdminDeleteLookupEntityMutation({ + onSuccess: () => { + invalidate(); + setDeleteConfirmId(null); + }, + }); + + const handleCreate = (e: React.FormEvent) => { + e.preventDefault(); + if (!newName.trim()) return; + createMutation.mutate({ + entityType: entityType as never, + name: newName.trim(), + description: newDescription.trim() || undefined, + }); + }; + + const handleUpdate = (id: string) => { + updateMutation.mutate({ + entityType: entityType as never, + id, + name: editName.trim() || undefined, + description: editDescription, + }); + }; + + const handleDelete = (id: string) => { + deleteMutation.mutate({ entityType: entityType as never, id }); + }; + + const startEditing = (entity: LookupEntity) => { + setEditingId(entity.id); + setEditName(entity.name); + setEditDescription(entity.description ?? ""); + }; + + const entities = (data?.adminLookupEntities ?? []) as LookupEntity[]; + + return ( + +
+

{title}

+ + {entities.length} {entities.length === 1 ? "item" : "items"} + +
+ +
+ +
+ +
+ + + + + + {isLoading ? ( +

Loading...

+ ) : entities.length === 0 ? ( +

No entities found

+ ) : ( +
+ + + + + + + + + + + {entities.map((entity) => ( + + {editingId === entity.id ? ( + <> + + + + + + ) : ( + <> + + + + + + )} + + ))} + +
NameDescriptionCreatedActions
+ + + + + {formatDate(entity.createdAt)} + +
+ + +
+
+ {entity.name} + + {entity.description ?? "-"} + + {formatDate(entity.createdAt)} + + {deleteConfirmId === entity.id ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+
+ )} +
+ ); +}; diff --git a/apps/client/src/features/admin/components/AiCallLogTable.tsx b/apps/client/src/features/admin/components/AiCallLogTable.tsx new file mode 100644 index 0000000..ee991da --- /dev/null +++ b/apps/client/src/features/admin/components/AiCallLogTable.tsx @@ -0,0 +1,282 @@ +import { useState } from "react"; +import { Badge, Card, StatusBadge, StatusDot } from "@cv/ui"; +import type { AiCallLogQuery } from "@/generated/graphql"; + +type CallLogEntry = AiCallLogQuery["aiCallLog"][number]; + +type StatusFilter = "all" | "success" | "error"; + +const formatRelativeTime = (isoString: string): string => { + const diff = Date.now() - new Date(isoString).getTime(); + const seconds = Math.floor(diff / 1000); + + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +}; + +const formatAbsoluteTime = (isoString: string): string => + new Date(isoString).toLocaleString(); + +const formatDuration = (ms: number): string => + ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`; + +const formatTokens = ( + prompt?: number | null, + completion?: number | null, +): string => + prompt != null || completion != null + ? `${prompt ?? "?"} / ${completion ?? "?"}` + : "-"; + +const uniqueProviders = (entries: CallLogEntry[]): string[] => [ + ...new Set(entries.map((e) => e.providerName)), +]; + +const ExpandedDetail = ({ + label, + value, +}: { + label: string; + value: React.ReactNode; +}) => + value ? ( +
+ {label}: + {value} +
+ ) : null; + +const CallLogRow = ({ + entry, + isExpanded, + onToggle, +}: { + entry: CallLogEntry; + isExpanded: boolean; + onToggle: () => void; +}) => ( + <> + + + {formatRelativeTime(entry.timestamp)} + + {entry.providerName} + + {entry.model ?? "-"} + + + {formatDuration(entry.durationMs)} + + + {formatTokens(entry.promptTokens, entry.completionTokens)} + + + + + {entry.status === "error" && entry.error ? ( + + {entry.error} + + ) : ( + entry.finishReason ?? entry.status + )} + + + + {isExpanded && ( + + +
+ + + + + + + {entry.error && ( +
+ Error: +
+                  {entry.error}
+                
+
+ )} +
+ + + )} + +); + +const FilterBar = ({ + statusFilter, + onStatusChange, + providerFilter, + onProviderChange, + providers, +}: { + statusFilter: StatusFilter; + onStatusChange: (f: StatusFilter) => void; + providerFilter: string; + onProviderChange: (f: string) => void; + providers: string[]; +}) => { + const statusOptions: StatusFilter[] = ["all", "success", "error"]; + const statusColors: Record = { + all: "ctp-blue", + success: "ctp-green", + error: "ctp-red", + } as const; + + return ( +
+
+ {statusOptions.map((s) => ( + + ))} +
+ {providers.length > 1 && ( + + )} +
+ ); +}; + +interface AiCallLogTableProps { + entries: CallLogEntry[]; +} + +export const AiCallLogTable = ({ entries }: AiCallLogTableProps) => { + const [statusFilter, setStatusFilter] = useState("all"); + const [providerFilter, setProviderFilter] = useState("all"); + const [expandedId, setExpandedId] = useState(null); + + const providers = uniqueProviders(entries); + + const filtered = entries.filter( + (e) => + (statusFilter === "all" || e.status === statusFilter) && + (providerFilter === "all" || e.providerName === providerFilter), + ); + + return ( + +
+
+

AI Call Log

+ {entries.length > 0 && ( +

+ Showing last {entries.length} calls from current session +

+ )} +
+ {entries.length > 0 && ( +
+ + {entries.filter((e) => e.status === "success").length} ok + + + {entries.filter((e) => e.status === "error").length} errors + + +
+ )} +
+ + {entries.length === 0 ? ( +

No AI calls recorded yet

+ ) : ( + <> +
+ +
+
+ + + + + + + + + + + + + {filtered.map((entry) => ( + + setExpandedId((prev) => + prev === entry.id ? null : entry.id, + ) + } + /> + ))} + +
TimeProviderModel + Duration + TokensStatus
+ {filtered.length === 0 && ( +

+ No entries match the current filters +

+ )} +
+ + )} +
+ ); +}; diff --git a/apps/client/src/features/admin/components/QueueMessagesTable.tsx b/apps/client/src/features/admin/components/QueueMessagesTable.tsx new file mode 100644 index 0000000..c09a585 --- /dev/null +++ b/apps/client/src/features/admin/components/QueueMessagesTable.tsx @@ -0,0 +1,142 @@ +import { useState } from "react"; +import { Badge, Card, StatusDot } from "@cv/ui"; +import type { QueueMessagesQuery } from "@/generated/graphql"; + +type MessageConnection = QueueMessagesQuery["queueMessages"]; +type Message = MessageConnection["edges"][number]["node"]; + +type StatusFilter = "all" | "pending" | "scheduled" | "processing"; + +type DotColor = "green" | "yellow" | "red" | "gray"; + +const statusColor: Record = { + pending: "yellow", + scheduled: "gray", + processing: "green", +}; + +const statusBadgeColor: Record = { + all: "ctp-blue", + pending: "ctp-yellow", + scheduled: "ctp-gray", + processing: "ctp-green", +}; + +const formatRelativeTime = (isoString: string): string => { + const diff = Date.now() - new Date(isoString).getTime(); + const seconds = Math.floor(diff / 1000); + + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +}; + +interface QueueMessagesTableProps { + connection: MessageConnection; +} + +export const QueueMessagesTable = ({ connection }: QueueMessagesTableProps) => { + const [statusFilter, setStatusFilter] = useState("all"); + + const messages = connection.edges.map((e) => e.node); + + const filtered = + statusFilter === "all" + ? messages + : messages.filter((m) => m.status === statusFilter); + + const statusOptions: StatusFilter[] = [ + "all", + "pending", + "scheduled", + "processing", + ]; + + return ( + +
+
+

Queue Messages

+ {connection.totalCount > 0 && ( +

+ {connection.totalCount} messages +

+ )} +
+
+ + {messages.length === 0 ? ( +

No messages in queue

+ ) : ( + <> +
+ {statusOptions.map((s) => ( + + ))} +
+
+ + + + + + + + + + + {filtered.map((msg) => ( + + + + + + + ))} + +
TimeMessageQueueStatus
+ {formatRelativeTime(msg.createdAt)} + + {msg.messageName ?? "-"} + + {msg.queueName} + + + + {msg.status} + +
+ {filtered.length === 0 && ( +

+ No messages match the current filter +

+ )} +
+ + )} +
+ ); +}; diff --git a/apps/client/src/features/admin/components/QueueStatsCard.tsx b/apps/client/src/features/admin/components/QueueStatsCard.tsx new file mode 100644 index 0000000..a973f74 --- /dev/null +++ b/apps/client/src/features/admin/components/QueueStatsCard.tsx @@ -0,0 +1,56 @@ +import { Card, StatusDot } from "@cv/ui"; +import type { QueueStatsQuery } from "@/generated/graphql"; + +type Stats = NonNullable; + +const formatDuration = (seconds: number): string => { + if (seconds < 60) return `${Math.round(seconds)}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ${Math.round(seconds % 60)}s`; + const hours = Math.floor(minutes / 60); + return `${hours}h ${minutes % 60}m`; +}; + +const DetailRow = ({ + label, + value, +}: { + label: string; + value: React.ReactNode; +}) => ( +
+ {label} + {value} +
+); + +interface QueueStatsCardProps { + stats: Stats; +} + +export const QueueStatsCard = ({ stats }: QueueStatsCardProps) => ( + +

Queue

+
+ + + {stats.pending} + + } + /> + + + +
+
+); diff --git a/apps/client/src/features/admin/components/WorkerHealthCard.tsx b/apps/client/src/features/admin/components/WorkerHealthCard.tsx new file mode 100644 index 0000000..9fba3af --- /dev/null +++ b/apps/client/src/features/admin/components/WorkerHealthCard.tsx @@ -0,0 +1,79 @@ +import { Card, StatusDot } from "@cv/ui"; +import type { WorkerHealthQuery } from "@/generated/graphql"; + +type Worker = WorkerHealthQuery["workerHealth"][number]; + +type DotColor = "green" | "yellow" | "red"; + +const statusColor: Record = { + healthy: "green", + stale: "yellow", + dead: "red", +}; + +const formatRelativeTime = (isoString: string): string => { + const diff = Date.now() - new Date(isoString).getTime(); + const seconds = Math.floor(diff / 1000); + + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +}; + +const truncateUuid = (uuid: string): string => uuid.slice(0, 8); + +interface WorkerHealthCardProps { + workers: Worker[]; +} + +export const WorkerHealthCard = ({ workers }: WorkerHealthCardProps) => ( + +

Workers

+ {workers.length === 0 ? ( +

No workers registered

+ ) : ( +
+ + + + + + + + + + + {workers.map((w) => ( + + + + + + + ))} + +
Worker IDStatusStartedLast Seen
+ {truncateUuid(w.workerId)} + + + + {w.status} + + + {formatRelativeTime(w.startedAt)} + + {formatRelativeTime(w.lastSeenAt)} +
+
+ )} +
+); diff --git a/apps/client/src/features/admin/queries/admin-lookup.graphql b/apps/client/src/features/admin/queries/admin-lookup.graphql new file mode 100644 index 0000000..723a37b --- /dev/null +++ b/apps/client/src/features/admin/queries/admin-lookup.graphql @@ -0,0 +1,51 @@ +query AdminLookupEntities($entityType: AdminEntityType!, $searchTerm: String) { + adminLookupEntities(entityType: $entityType, searchTerm: $searchTerm) { + id + name + description + createdAt + updatedAt + } +} + +mutation AdminCreateLookupEntity( + $entityType: AdminEntityType! + $name: String! + $description: String +) { + adminCreateLookupEntity( + entityType: $entityType + name: $name + description: $description + ) { + id + name + description + createdAt + updatedAt + } +} + +mutation AdminUpdateLookupEntity( + $entityType: AdminEntityType! + $id: String! + $name: String + $description: String +) { + adminUpdateLookupEntity( + entityType: $entityType + id: $id + name: $name + description: $description + ) { + id + name + description + createdAt + updatedAt + } +} + +mutation AdminDeleteLookupEntity($entityType: AdminEntityType!, $id: String!) { + adminDeleteLookupEntity(entityType: $entityType, id: $id) +} diff --git a/apps/client/src/features/admin/queries/queue-status.graphql b/apps/client/src/features/admin/queries/queue-status.graphql new file mode 100644 index 0000000..af20d41 --- /dev/null +++ b/apps/client/src/features/admin/queries/queue-status.graphql @@ -0,0 +1,32 @@ +query QueueStats { + queueStats { + pending + scheduled + processing + oldestPendingSeconds + } +} + +query QueueMessages($limit: Int) { + queueMessages(limit: $limit) { + totalCount + edges { + node { + id + queueName + messageName + status + createdAt + } + } + } +} + +query WorkerHealth { + workerHealth { + workerId + status + startedAt + lastSeenAt + } +} diff --git a/apps/client/src/features/admin/queries/system-status.graphql b/apps/client/src/features/admin/queries/system-status.graphql new file mode 100644 index 0000000..d3c6852 --- /dev/null +++ b/apps/client/src/features/admin/queries/system-status.graphql @@ -0,0 +1,28 @@ +query SystemStatus { + systemStatus { + platformProvider { + healthy + providerName + detailsJson + } + registeredProviderTypes + serverUptime + } +} + +query AiCallLog($limit: Int, $status: String, $providerName: String) { + aiCallLog(limit: $limit, status: $status, providerName: $providerName) { + id + timestamp + providerName + durationMs + promptTokens + completionTokens + model + finishReason + status + error + userId + source + } +} diff --git a/apps/client/src/pages/AdminPage.tsx b/apps/client/src/pages/AdminPage.tsx new file mode 100644 index 0000000..c01802f --- /dev/null +++ b/apps/client/src/pages/AdminPage.tsx @@ -0,0 +1,52 @@ +import { PageHeader } from "@cv/ui"; +import { useState } from "react"; +import { AdminLookupTable } from "@/features/admin/components/AdminLookupTable"; + +const ENTITY_TABS = [ + { type: "SKILL", label: "Skills" }, + { type: "COMPANY", label: "Companies" }, + { type: "ROLE", label: "Roles" }, + { type: "LEVEL", label: "Levels" }, + { type: "INSTITUTION", label: "Institutions" }, + { type: "JOB_TYPE", label: "Job Types" }, + { type: "APPLICATION_STATUS", label: "Application Statuses" }, + { type: "ORGANIZATION", label: "Organizations" }, + { type: "ORGANIZATION_ROLE", label: "Organization Roles" }, +] as const; + +export default function AdminPage() { + const [activeTab, setActiveTab] = useState<(typeof ENTITY_TABS)[number]["type"]>(ENTITY_TABS[0].type); + + const activeEntry = ENTITY_TABS.find((t) => t.type === activeTab); + + return ( +
+ + +
+ {ENTITY_TABS.map((tab) => ( + + ))} +
+ + {activeEntry && ( + + )} +
+ ); +} diff --git a/apps/client/src/pages/SystemStatusPage.tsx b/apps/client/src/pages/SystemStatusPage.tsx new file mode 100644 index 0000000..39c7e18 --- /dev/null +++ b/apps/client/src/pages/SystemStatusPage.tsx @@ -0,0 +1,321 @@ +import { Badge, Card, PageHeader, Placeholder, StatusDot } from "@cv/ui"; +import { + useAiCallLogQuery, + useQueueMessagesQuery, + useQueueStatsQuery, + useSystemStatusQuery, + useWorkerHealthQuery, +} from "@/generated/graphql"; +import { AiCallLogTable } from "@/features/admin/components/AiCallLogTable"; +import { QueueMessagesTable } from "@/features/admin/components/QueueMessagesTable"; +import { QueueStatsCard } from "@/features/admin/components/QueueStatsCard"; +import { WorkerHealthCard } from "@/features/admin/components/WorkerHealthCard"; + +interface LlamaCppDetails { + kind: "llama-cpp"; + health: Record | null; + model: Record | null; + slots: Array> | null; + baseUrl: string; +} + +interface ApiDetails { + kind: "api"; + model: string; + baseUrl: string; +} + +type ProviderDetails = LlamaCppDetails | ApiDetails; + +const formatUptime = (seconds: number): string => { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + const parts: string[] = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + parts.push(`${secs}s`); + + return parts.join(" "); +}; + +const parseDetails = (detailsJson: string): ProviderDetails | null => { + try { + const parsed = JSON.parse(detailsJson) as Record | null; + if (!parsed || typeof parsed !== "object") return null; + const kind = parsed["kind"]; + if (kind === "llama-cpp") return parsed as unknown as LlamaCppDetails; + if (kind === "api") return parsed as unknown as ApiDetails; + return null; + } catch { + return null; + } +}; + +const HealthDot = ({ healthy }: { healthy: boolean }) => ( + +); + +const DetailRow = ({ + label, + value, +}: { + label: string; + value: React.ReactNode; +}) => ( +
+ {label} + {value} +
+); + +const ServerCard = ({ + uptime, + providerTypes, +}: { + uptime: number; + providerTypes: string[]; +}) => ( + +

Server

+
+ + + {providerTypes.map((t) => ( + + {t} + + ))} +
+ } + /> + +
+); + +const ProviderCard = ({ + healthy, + providerName, + details, +}: { + healthy: boolean; + providerName: string; + details: ProviderDetails | null; +}) => ( + +

AI Provider

+
+ + + {healthy ? "Healthy" : "Unavailable"} + + } + /> + + {details?.kind === "api" && ( + + )} + {details?.baseUrl && } +
+
+); + +const ModelCard = ({ model }: { model: Record }) => { + const defaultGenSettings = model["default_generation_settings"] as + | Record + | undefined; + + const modelName = + defaultGenSettings?.["model"] ?? model["model"] ?? "Unknown"; + const totalSlots = model["total_slots"] ?? "N/A"; + + return ( + +

Model Info

+
+ + + {defaultGenSettings?.["n_ctx"] != null && ( + + )} +
+
+ ); +}; + +const SlotsCard = ({ slots }: { slots: Array> }) => { + const activeCount = slots.filter( + (s) => s["is_processing"] === true || s["state"] !== 0, + ).length; + + return ( + +

+ Inference Slots +

+
+ +
+
+ + + + + + + + + + {slots.map((slot, i) => { + const isActive = + slot["is_processing"] === true || slot["state"] !== 0; + return ( + + + + + + ); + })} + +
SlotStateTokens
+ #{slot["id"] != null ? String(slot["id"]) : i} + + + + {isActive ? "Active" : "Idle"} + + + {slot["n_predict"] != null + ? String(slot["n_predict"]) + : "-"} +
+
+
+ ); +}; + +export default function SystemStatusPage() { + const POLL_INTERVAL = 5_000; + + const { data, isLoading, error } = useSystemStatusQuery(undefined, { + refetchInterval: POLL_INTERVAL, + }); + const { data: callLogData } = useAiCallLogQuery(undefined, { + refetchInterval: POLL_INTERVAL, + }); + const { data: queueStatsData } = useQueueStatsQuery(undefined, { + refetchInterval: POLL_INTERVAL, + }); + const { data: queueMessagesData } = useQueueMessagesQuery(undefined, { + refetchInterval: POLL_INTERVAL, + }); + const { data: workerHealthData } = useWorkerHealthQuery(undefined, { + refetchInterval: POLL_INTERVAL, + }); + + if (isLoading) { + return ( +
+ + +
+ ); + } + + if (error) { + return ( +
+ + +
+ ); + } + + const status = data?.systemStatus; + + if (!status) { + return ( +
+ + +
+ ); + } + + const details = status.platformProvider + ? parseDetails(status.platformProvider.detailsJson) + : null; + + return ( +
+ + +
+ + + {status.platformProvider ? ( + + ) : ( + +

+ AI Provider +

+

+ No platform AI provider configured +

+
+ )} + + {details?.kind === "llama-cpp" && details.model && ( + + )} + + {details?.kind === "llama-cpp" && + details.slots && + Array.isArray(details.slots) && ( + + )} + + {queueStatsData?.queueStats && ( + + )} + + {workerHealthData && ( + + )} +
+ + {queueMessagesData && ( + + )} + + +
+ ); +}