76c88e5855
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Operators manage both company-scoped configuration and instance-level runtime/admin settings from the board UI > - Instance settings previously lived as their own top-level sidebar area, separate from the company settings context operators already use > - That split made settings navigation feel heavier and made instance configuration less discoverable from the settings tab > - This pull request moves instance settings under company settings while preserving the existing instance settings routes and plugin/admin surfaces > - The benefit is a smaller primary sidebar and a more coherent settings hierarchy for operators ## Linked Issues or Issue Description - Refs #338 - Internal: PAP-10491, PAP-10538 ## What Changed - Moved instance settings navigation under the company settings area. - Added route helpers and sidebar entries for nested instance settings paths. - Updated plugin/admin settings routes to use the company settings instance scope. - Preserved legacy instance-settings bookmarks through compatibility redirects that keep the active company prefix. - Updated focused UI and plugin tests for the new navigation shape. - Stabilized the process-loss retry test that was failing the serialized server shard in CI. - Rebased the branch onto current `paperclipai/paperclip` `master` and pushed the current head. ## Verification - `pnpm exec vitest run ui/src/components/CompanySettingsSidebar.test.tsx ui/src/components/access/CompanySettingsNav.test.tsx ui/src/lib/instance-settings.test.ts ui/src/components/InstanceSidebar.test.tsx ui/src/components/Layout.test.tsx ui/src/components/SidebarAccountMenu.test.tsx ui/src/pages/PluginPage.test.tsx ui/src/plugins/bridge.test.ts packages/shared/src/validators/plugin.test.ts` - `pnpm exec vitest run ui/src/lib/instance-settings.test.ts ui/src/components/CompanySettingsSidebar.test.tsx ui/src/components/access/CompanySettingsNav.test.tsx ui/src/components/Layout.test.tsx ui/src/plugins/bridge.test.ts` - `pnpm exec vitest run server/src/__tests__/heartbeat-process-recovery.test.ts -t "queues exactly one retry when the recorded local pid is dead"` - `pnpm test:run:serialized -- --shard-index 0 --shard-count 4` - GitHub PR checks are green on head `fe7b0955169dcae55cbe10889c1876a70ab0b80c`, including `verify`, `General tests (server)`, all serialized server shards, build, e2e, policy, security checks, and Greptile. - Confirmed the PR diff does not include `pnpm-lock.yaml` or `.github/workflows` changes. ## Risks - Medium UI/navigation risk: instance settings links are intentionally moving under company settings, so stale external bookmarks to legacy paths rely on the compatibility routing in this branch. - Low test-only risk from the CI stabilization commit: it makes the recovery assertion select the actual retry run by `retryOfRunId` instead of whichever non-original run appears first. - No database migrations. - No dependency lockfile or workflow changes. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex coding agent based on GPT-5, with shell/tool execution in a local repository worktree. Exact context window was not exposed by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
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: "Settings", href: "/company/settings" },
|
|
{ label: "Instance settings", href: "/company/settings/instance/general" },
|
|
{ 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.");
|
|
},
|
|
});
|
|
|
|
const disableAllMutation = useMutation({
|
|
mutationFn: async (agentRows: InstanceSchedulerHeartbeatAgent[]) => {
|
|
const enabled = agentRows.filter((a) => a.heartbeatEnabled);
|
|
if (enabled.length === 0) return enabled;
|
|
|
|
const results = await Promise.allSettled(
|
|
enabled.map(async (agentRow) => {
|
|
const agent = await agentsApi.get(agentRow.id, agentRow.companyId);
|
|
const runtimeConfig = asRecord(agent.runtimeConfig) ?? {};
|
|
const heartbeat = asRecord(runtimeConfig.heartbeat) ?? {};
|
|
await agentsApi.update(
|
|
agentRow.id,
|
|
{
|
|
runtimeConfig: {
|
|
...runtimeConfig,
|
|
heartbeat: { ...heartbeat, enabled: false },
|
|
},
|
|
},
|
|
agentRow.companyId,
|
|
);
|
|
}),
|
|
);
|
|
|
|
const failures = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
|
|
if (failures.length > 0) {
|
|
const firstError = failures[0]?.reason;
|
|
const detail = firstError instanceof Error ? firstError.message : "Unknown error";
|
|
throw new Error(
|
|
failures.length === 1
|
|
? `Failed to disable 1 timer heartbeat: ${detail}`
|
|
: `Failed to disable ${failures.length} of ${enabled.length} timer heartbeats. First error: ${detail}`,
|
|
);
|
|
}
|
|
return enabled;
|
|
},
|
|
onSuccess: async (updatedRows) => {
|
|
setActionError(null);
|
|
const companies = new Set(updatedRows.map((row) => row.companyId));
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.instance.schedulerHeartbeats }),
|
|
...Array.from(companies, (companyId) =>
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) }),
|
|
),
|
|
...updatedRows.map((row) =>
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(row.id) }),
|
|
),
|
|
]);
|
|
},
|
|
onError: (error) => {
|
|
setActionError(error instanceof Error ? error.message : "Failed to disable all heartbeats.");
|
|
},
|
|
});
|
|
|
|
const agents = heartbeatsQuery.data ?? [];
|
|
const activeCount = agents.filter((agent) => agent.schedulerActive).length;
|
|
const disabledCount = agents.length - activeCount;
|
|
const enabledCount = agents.filter((agent) => agent.heartbeatEnabled).length;
|
|
const anyEnabled = enabledCount > 0;
|
|
|
|
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]);
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
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">
|
|
Agents with a timer heartbeat enabled across all of your companies.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center 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>
|
|
{anyEnabled && (
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
className="ml-auto h-7 text-xs"
|
|
disabled={disableAllMutation.isPending}
|
|
onClick={() => {
|
|
const noun = enabledCount === 1 ? "agent" : "agents";
|
|
if (!window.confirm(`Disable timer heartbeats for all ${enabledCount} enabled ${noun}?`)) {
|
|
return;
|
|
}
|
|
disableAllMutation.mutate(agents);
|
|
}}
|
|
>
|
|
{disableAllMutation.isPending ? "Disabling..." : "Disable All"}
|
|
</Button>
|
|
)}
|
|
</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 Timer Heartbeat" : "Enable Timer Heartbeat"}
|
|
</Button>
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|