Compact grouped heartbeat list on instance settings page
Group agents by company with a single card per company and dense inline rows instead of one card per agent. Replaces the three stat cards with a slim inline summary. Each row shows status badge, linked agent name, role, interval, last heartbeat time, a config link icon, and an enable/disable button. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
213
ui/src/pages/InstanceSettings.tsx
Normal file
213
ui/src/pages/InstanceSettings.tsx
Normal file
@@ -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<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string | null>(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 <div className="text-sm text-muted-foreground">Loading scheduler heartbeats...</div>;
|
||||
}
|
||||
|
||||
if (heartbeatsQuery.error) {
|
||||
return (
|
||||
<div className="text-sm text-destructive">
|
||||
{heartbeatsQuery.error instanceof Error
|
||||
? heartbeatsQuery.error.message
|
||||
: "Failed to load scheduler heartbeats."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const agents = heartbeatsQuery.data ?? [];
|
||||
const activeCount = agents.filter((agent) => agent.schedulerActive).length;
|
||||
const disabledCount = agents.length - activeCount;
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, { companyName: string; agents: InstanceSchedulerHeartbeatAgent[] }>();
|
||||
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 (
|
||||
<div className="max-w-5xl space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Scheduler Heartbeats</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Shows timer-based heartbeats where <code>intervalSec > 0</code> and agent status is not
|
||||
paused, terminated, or pending approval. Toggling a row only changes{" "}
|
||||
<code>runtimeConfig.heartbeat.enabled</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 text-sm text-muted-foreground">
|
||||
<span><span className="font-semibold text-foreground">{activeCount}</span> active</span>
|
||||
<span><span className="font-semibold text-foreground">{disabledCount}</span> disabled</span>
|
||||
<span><span className="font-semibold text-foreground">{grouped.length}</span> {grouped.length === 1 ? "company" : "companies"}</span>
|
||||
</div>
|
||||
|
||||
{actionError && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{agents.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Clock3}
|
||||
message="No scheduler heartbeats match the current criteria."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{grouped.map((group) => (
|
||||
<Card key={group.companyName}>
|
||||
<CardContent className="p-0">
|
||||
<div className="border-b px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{group.companyName}
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{group.agents.map((agent) => {
|
||||
const saving = toggleMutation.isPending && toggleMutation.variables?.id === agent.id;
|
||||
return (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="flex items-center gap-3 px-3 py-2 text-sm"
|
||||
>
|
||||
<Badge
|
||||
variant={agent.schedulerActive ? "default" : "outline"}
|
||||
className="shrink-0 text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{agent.schedulerActive ? "On" : "Off"}
|
||||
</Badge>
|
||||
<Link
|
||||
to={buildAgentHref(agent)}
|
||||
className="font-medium truncate hover:underline"
|
||||
>
|
||||
{agent.agentName}
|
||||
</Link>
|
||||
<span className="hidden sm:inline text-muted-foreground truncate">
|
||||
{humanize(agent.title ?? agent.role)}
|
||||
</span>
|
||||
<span className="text-muted-foreground tabular-nums shrink-0">
|
||||
{agent.intervalSec}s
|
||||
</span>
|
||||
<span
|
||||
className="hidden md:inline text-muted-foreground truncate"
|
||||
title={agent.lastHeartbeatAt ? formatDateTime(agent.lastHeartbeatAt) : undefined}
|
||||
>
|
||||
{agent.lastHeartbeatAt
|
||||
? relativeTime(agent.lastHeartbeatAt)
|
||||
: "never"}
|
||||
</span>
|
||||
<span className="ml-auto flex items-center gap-1.5 shrink-0">
|
||||
<Link
|
||||
to={buildAgentHref(agent)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="Full agent config"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
disabled={saving}
|
||||
onClick={() => toggleMutation.mutate(agent)}
|
||||
>
|
||||
{saving ? "..." : agent.heartbeatEnabled ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user