diff --git a/ui/src/pages/InstanceSettings.tsx b/ui/src/pages/InstanceSettings.tsx new file mode 100644 index 00000000..7e83f476 --- /dev/null +++ b/ui/src/pages/InstanceSettings.tsx @@ -0,0 +1,213 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Clock3, ExternalLink, Settings } from "lucide-react"; +import type { InstanceSchedulerHeartbeatAgent } from "@paperclipai/shared"; +import { Link } from "@/lib/router"; +import { heartbeatsApi } from "../api/heartbeats"; +import { agentsApi } from "../api/agents"; +import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { EmptyState } from "../components/EmptyState"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { queryKeys } from "../lib/queryKeys"; +import { formatDateTime, relativeTime } from "../lib/utils"; + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function humanize(value: string) { + return value.replaceAll("_", " "); +} + +function buildAgentHref(agent: InstanceSchedulerHeartbeatAgent) { + return `/${agent.companyIssuePrefix}/agents/${encodeURIComponent(agent.agentUrlKey)}`; +} + +export function InstanceSettings() { + const { setBreadcrumbs } = useBreadcrumbs(); + const queryClient = useQueryClient(); + const [actionError, setActionError] = useState(null); + + useEffect(() => { + setBreadcrumbs([ + { label: "Instance Settings" }, + { label: "Heartbeats" }, + ]); + }, [setBreadcrumbs]); + + const heartbeatsQuery = useQuery({ + queryKey: queryKeys.instance.schedulerHeartbeats, + queryFn: () => heartbeatsApi.listInstanceSchedulerAgents(), + refetchInterval: 15_000, + }); + + const toggleMutation = useMutation({ + mutationFn: async (agentRow: InstanceSchedulerHeartbeatAgent) => { + const agent = await agentsApi.get(agentRow.id, agentRow.companyId); + const runtimeConfig = asRecord(agent.runtimeConfig) ?? {}; + const heartbeat = asRecord(runtimeConfig.heartbeat) ?? {}; + + return agentsApi.update( + agentRow.id, + { + runtimeConfig: { + ...runtimeConfig, + heartbeat: { + ...heartbeat, + enabled: !agentRow.heartbeatEnabled, + }, + }, + }, + agentRow.companyId, + ); + }, + onSuccess: async (_, agentRow) => { + setActionError(null); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.instance.schedulerHeartbeats }), + queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(agentRow.companyId) }), + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentRow.id) }), + ]); + }, + onError: (error) => { + setActionError(error instanceof Error ? error.message : "Failed to update heartbeat."); + }, + }); + + if (heartbeatsQuery.isLoading) { + return
Loading scheduler heartbeats...
; + } + + if (heartbeatsQuery.error) { + return ( +
+ {heartbeatsQuery.error instanceof Error + ? heartbeatsQuery.error.message + : "Failed to load scheduler heartbeats."} +
+ ); + } + + const agents = heartbeatsQuery.data ?? []; + const activeCount = agents.filter((agent) => agent.schedulerActive).length; + const disabledCount = agents.length - activeCount; + + const grouped = useMemo(() => { + const map = new Map(); + for (const agent of agents) { + let group = map.get(agent.companyId); + if (!group) { + group = { companyName: agent.companyName, agents: [] }; + map.set(agent.companyId, group); + } + group.agents.push(agent); + } + return [...map.values()]; + }, [agents]); + + return ( +
+
+
+ +

Scheduler Heartbeats

+
+

+ Shows timer-based heartbeats where intervalSec > 0 and agent status is not + paused, terminated, or pending approval. Toggling a row only changes{" "} + runtimeConfig.heartbeat.enabled. +

+
+ +
+ {activeCount} active + {disabledCount} disabled + {grouped.length} {grouped.length === 1 ? "company" : "companies"} +
+ + {actionError && ( +
+ {actionError} +
+ )} + + {agents.length === 0 ? ( + + ) : ( +
+ {grouped.map((group) => ( + + +
+ {group.companyName} +
+
+ {group.agents.map((agent) => { + const saving = toggleMutation.isPending && toggleMutation.variables?.id === agent.id; + return ( +
+ + {agent.schedulerActive ? "On" : "Off"} + + + {agent.agentName} + + + {humanize(agent.title ?? agent.role)} + + + {agent.intervalSec}s + + + {agent.lastHeartbeatAt + ? relativeTime(agent.lastHeartbeatAt) + : "never"} + + + + + + + +
+ ); + })} +
+
+
+ ))} +
+ )} +
+ ); +}