Remove the instance Heartbeats settings page (#12282)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Instance settings collect deployment-wide controls; one of them was
the Heartbeats page, an instance-wide list of scheduler heartbeat agents
with enable/disable toggles
> - The same controls live on each agent's own configuration surface, so
the standalone list duplicates them, and its framing no longer matches
how heartbeat agents are managed
> - Keeping a settings view that no longer makes sense costs every
deployment navigation noise and maintenance
> - This pull request removes the page, its route, its navigation
entries, and its hidden-settings key for all deployments
> - The benefit is a smaller, coherent settings surface, with operator
hidden-settings lists that still mention the retired key continuing to
work unchanged

## Linked Issues or Issue Description

No public issue exists; describing the issue inline per the enhancement
template:

**What existing behavior does this improve?**
The instance settings surface — specifically the Settings → Heartbeats
page, which listed scheduler heartbeat agents instance-wide with
enable/disable toggles. The view no longer makes sense as a standalone
settings page: the same controls are available on each agent's
configuration surface, and the instance-wide list framing does not match
how heartbeat agents are managed.

**Subsystem affected**
Cross-cutting: `ui/` (page, route, navigation), `packages/shared`
(settings-visibility registry), docs.

**Current behavior**
The page renders at `/company/settings/instance/heartbeats`, appears in
the settings sidebar and tab bar, and is hideable by hosting operators
via the `instance.heartbeats` key of `PAPERCLIP_HIDDEN_SETTINGS`.

**Proposed behavior**
The page, route, and navigation entries are removed for every
deployment. The `instance.heartbeats` registry key is retired; operator
lists that still send it are logged and ignored, so mixed-version fleets
keep working. Remembered settings paths pointing at the old page remap
to the settings root. Heartbeat APIs are unchanged.

**Reason and benefit**
A smaller, coherent settings surface with no duplicated controls; less
navigation noise and maintenance for every deployment.

**Breaking changes**
None functional. Bookmarks and remembered paths to the removed page land
on the settings root; `PAPERCLIP_HIDDEN_SETTINGS` lists that still
include `instance.heartbeats` log a warning and are otherwise honored
unchanged.

## What Changed

- Deleted `ui/src/pages/InstanceSettings.tsx` (the Heartbeats view) and
its route in `ui/src/App.tsx`.
- Removed the sidebar entry (`CompanySettingsSidebar`) and tab-bar item
(`CompanySettingsNav`).
- Removed `"/heartbeats"` from the remembered-settings-path allowlist;
remembered heartbeats paths now remap to the settings root.
- Retired the `instance.heartbeats` key from the shared
settings-visibility registry and the environment-variables doc;
documented that retired keys are ignored with a warning.
- Dropped the now-unused UI client wrapper for the instance
scheduler-agent list (`heartbeatsApi.listInstanceSchedulerAgents`); the
server endpoint stays.
- Removed the unused `schedulerHeartbeats` query key.

## Verification

- `npx vitest run packages/shared/src/settings-visibility.test.ts
ui/src/lib/instance-settings.test.ts
ui/src/components/CompanySettingsSidebar.test.tsx
ui/src/components/access/CompanySettingsNav.test.tsx` — 24 tests
passing.
- Full `ui` vitest suite: 4426 tests, 4 failures — all in files this PR
does not touch; 3 were load-induced timeouts that pass on rerun, and
`OnboardingWizard.test.tsx` "renders instead of throwing when the
browser denies storage access" fails identically on a clean master
checkout (pre-existing).
- `pnpm --filter @paperclipai/ui typecheck` and `pnpm --filter
@paperclipai/shared typecheck` — clean.
- Merged `master` to clear a conflict (see below) and re-ran the four
focused suites (24 passing), `ui/src/App.test.tsx` and
`ui/src/plugins/bridge.test.ts` (22 passing), and both typechecks — all
clean. Full CI is green on the merge commit.

## Merge With master

`master` gained the `company` → `organization` copy pass (#12243), which
reworded strings inside `ui/src/pages/InstanceSettings.tsx` — the page
this branch deletes — producing a modify/delete conflict. Resolved by
keeping the deletion: the page is going away, so the rewording of its
copy has nothing to apply to. Every other file merged cleanly, and
`master`'s rewording in `App.tsx`, `App.test.tsx`, and
`CompanySettingsSidebar.tsx` sits away from this branch's structural
removals, so both changes survive. The net diff against `master` is
unchanged from the pre-merge review: the same 13 files, 23 insertions,
330 deletions.

## Risks

- Low. Pure removal of a UI surface; heartbeat data and APIs are
untouched. Operators still listing `instance.heartbeats` in
`PAPERCLIP_HIDDEN_SETTINGS` get a warning log and otherwise unchanged
behavior (covered by the registry's unknown-key handling). Bookmarks and
remembered paths to the old page land on the settings root.

## Model Used

Claude (Anthropic), model id `claude-fable-5`, extended thinking,
agentic tool use via Claude Code.

## 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 not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [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
This commit is contained in:
Devin Foley 2026-08-27 11:31:43 -07:00 committed by GitHub
parent 8f195c2f49
commit 7551b63ef2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 23 additions and 330 deletions

View File

@ -28,7 +28,7 @@ All environment variables that Paperclip uses for server configuration.
`packages/shared/src/settings-visibility.ts`:
- Any instance settings page: `instance.profile`, `instance.environments`,
`instance.access`, `instance.heartbeats`, `instance.experimental`,
`instance.access`, `instance.experimental`,
`instance.plugins`, `instance.adapters` — removed from navigation and
routing (the General page is the settings root and stays visible). Hiding
`instance.access`, `instance.plugins`, or `instance.adapters` also floors
@ -57,7 +57,9 @@ All environment variables that Paperclip uses for server configuration.
provider-config and proposal APIs stay live for agents and integrations.
Unknown keys are logged and ignored, so one list can be rolled across a fleet
of mixed app versions. With the variable unset nothing is hidden and behavior
of mixed app versions, and retired keys (like `instance.heartbeats`, whose
page was removed) can stay in an operator list without breaking older or
newer releases. With the variable unset nothing is hidden and behavior
is identical to earlier releases. Hiding a toggle does not change its value;
pair hiding with the desired default where it matters (for general settings,
see [Operator setting defaults](#operator-setting-defaults)).

View File

@ -34,7 +34,6 @@ export const HIDEABLE_INSTANCE_PAGES = [
"instance.profile",
"instance.environments",
"instance.access",
"instance.heartbeats",
"instance.experimental",
"instance.plugins",
"instance.adapters",

View File

@ -254,6 +254,16 @@ describe("Apps routes", () => {
});
});
describe("Retired settings routes", () => {
it("redirects the removed heartbeats page to the settings root instead of dropping the route", () => {
expect(appSource).toContain(
'<Route path="company/settings/instance/heartbeats" element={<Navigate to="/company/settings" replace />} />',
);
expect(appSource).not.toContain("<InstanceSettings");
expect(appSource).not.toContain('"./pages/InstanceSettings"');
});
});
describe("Decisions routes", () => {
it("does not register decision-training views", () => {
expect(appSource).not.toContain('path="decisions/training"');

View File

@ -76,7 +76,6 @@ import { CompanyImport } from "./pages/CompanyImport";
import { DesignGuide } from "./pages/DesignGuide";
import { InstanceExperimentalSettings } from "./pages/InstanceExperimentalSettings";
import { InstanceAccess } from "./pages/InstanceAccess";
import { InstanceSettings } from "./pages/InstanceSettings";
import { ProfileSettings } from "./pages/ProfileSettings";
import { PluginManager } from "./pages/PluginManager";
import { PluginSettings } from "./pages/PluginSettings";
@ -176,6 +175,7 @@ function boardRoutes() {
<Route path="company/settings/instance/profile" element={<ProfileSettings />} />
</Route>
<Route path="company/settings/instance/general" element={<Navigate to="/company/settings" replace />} />
<Route path="company/settings/instance/heartbeats" element={<Navigate to="/company/settings" replace />} />
<Route element={<HiddenSettingsPageGate pageKey="instance.environments" />}>
<Route path="company/settings/instance/environments" element={<CompanyEnvironments />} />
<Route path="company/settings/instance/environments/new" element={<CompanyEnvironments mode="create" />} />
@ -184,9 +184,6 @@ function boardRoutes() {
<Route element={<HiddenSettingsPageGate pageKey="instance.access" />}>
<Route path="company/settings/instance/access" element={<InstanceAccess />} />
</Route>
<Route element={<HiddenSettingsPageGate pageKey="instance.heartbeats" />}>
<Route path="company/settings/instance/heartbeats" element={<InstanceSettings />} />
</Route>
<Route element={<HiddenSettingsPageGate pageKey="instance.experimental" />}>
<Route path="company/settings/instance/experimental" element={<InstanceExperimentalSettings />} />
</Route>

View File

@ -1,7 +1,6 @@
import type {
HeartbeatRun,
HeartbeatRunEvent,
InstanceSchedulerHeartbeatAgent,
WorkspaceOperation,
} from "@paperclipai/shared";
import { api } from "./client";
@ -134,6 +133,4 @@ export const heartbeatsApi = {
const qs = searchParams.toString();
return api.get<LiveRunForIssue[]>(`/companies/${companyId}/live-runs${qs ? `?${qs}` : ""}`);
},
listInstanceSchedulerAgents: () =>
api.get<InstanceSchedulerHeartbeatAgent[]>("/instance/scheduler-heartbeats"),
};

View File

@ -148,7 +148,6 @@ describe("CompanySettingsSidebar", () => {
expect(container.textContent).toContain("Invites");
expect(container.textContent).toContain("Secrets");
expect(container.textContent).toContain("Access");
expect(container.textContent).toContain("Heartbeats");
expect(container.textContent).not.toContain("Tools & Access");
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({
@ -184,13 +183,6 @@ describe("CompanySettingsSidebar", () => {
end: true,
}),
);
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "/company/settings/instance/heartbeats",
label: "Heartbeats",
end: true,
}),
);
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "/company/settings/members",
@ -396,10 +388,9 @@ describe("CompanySettingsSidebar operator-hidden entries", () => {
}
it("skips operator-hidden pages and their queries", async () => {
await renderSidebar(["instance.plugins", "instance.heartbeats"]);
await renderSidebar(["instance.plugins"]);
expect(container.textContent).not.toContain("Plugins");
expect(container.textContent).not.toContain("Heartbeats");
expect(container.textContent).toContain("General");
expect(container.textContent).toContain("Adapters");
expect(container.textContent).toContain("Access");
@ -411,7 +402,6 @@ describe("CompanySettingsSidebar operator-hidden entries", () => {
expect(container.textContent).toContain("Access");
expect(container.textContent).toContain("Plugins");
expect(container.textContent).toContain("Heartbeats");
expect(container.textContent).toContain("Adapters");
expect(container.textContent).toContain("Import");
expect(mockPluginsApi.list).toHaveBeenCalled();

View File

@ -1,7 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import {
ChevronLeft,
Clock3,
Cpu,
Download,
FlaskConical,
@ -151,14 +150,6 @@ export function CompanySettingsSidebar() {
end
/>
)}
{showPage("instance.heartbeats") && (
<SidebarNavItem
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/heartbeats`}
label="Heartbeats"
icon={Clock3}
end
/>
)}
{showPage("company.export") && (
<SidebarNavItem to="/company/export" label="Export" icon={Download} />
)}

View File

@ -84,8 +84,6 @@ describe("CompanySettingsNav", () => {
expect(getCompanySettingsTab("/company/settings/instance/environments")).toBe("instance-environments");
expect(getCompanySettingsTab("/company/settings/instance/access")).toBe("instance-access");
expect(getCompanySettingsTab("/PAP/company/settings/instance/access")).toBe("instance-access");
expect(getCompanySettingsTab("/company/settings/instance/heartbeats")).toBe("instance-heartbeats");
expect(getCompanySettingsTab("/PAP/company/settings/instance/heartbeats")).toBe("instance-heartbeats");
expect(getCompanySettingsTab("/company/settings/instance/experimental")).toBe("instance-experimental");
expect(getCompanySettingsTab("/PAP/company/settings/instance/plugins/example")).toBe("instance-plugins");
expect(getCompanySettingsTab("/company/settings/instance/adapters")).toBe("instance-adapters");
@ -131,7 +129,6 @@ describe("CompanySettingsNav", () => {
{ value: "instance-profile", label: "Profile" },
{ value: "instance-environments", label: "Environments" },
{ value: "instance-access", label: "Access" },
{ value: "instance-heartbeats", label: "Heartbeats" },
{ value: "instance-experimental", label: "Experimental" },
{ value: "instance-plugins", label: "Plugins" },
{ value: "instance-adapters", label: "Adapters" },
@ -158,7 +155,7 @@ describe("CompanySettingsNav", () => {
const root = createRoot(container);
await act(async () => {
renderNav(root, ["instance.plugins", "instance.heartbeats"]);
renderNav(root, ["instance.plugins"]);
});
const renderedValues = pageTabBarMock.mock.calls.at(-1)?.[0]?.items?.map(

View File

@ -15,7 +15,6 @@ const items = [
{ value: "instance-profile", label: "Profile", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/profile` },
{ value: "instance-environments", label: "Environments", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/environments` },
{ value: "instance-access", label: "Access", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/access` },
{ value: "instance-heartbeats", label: "Heartbeats", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/heartbeats` },
{ value: "instance-experimental", label: "Experimental", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/experimental` },
{ value: "instance-plugins", label: "Plugins", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/plugins` },
{ value: "instance-adapters", label: "Adapters", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/adapters` },
@ -33,7 +32,6 @@ const hiddenSettingKeyByTab: Partial<Record<CompanySettingsTab, string>> = {
"instance-profile": "instance.profile",
"instance-environments": "instance.environments",
"instance-access": "instance.access",
"instance-heartbeats": "instance.heartbeats",
"instance-experimental": "instance.experimental",
"instance-plugins": "instance.plugins",
"instance-adapters": "instance.adapters",
@ -52,10 +50,6 @@ export function getCompanySettingsTab(pathname: string): CompanySettingsTab {
return "instance-access";
}
if (pathname.includes(`${INSTANCE_SETTINGS_PATH_PREFIX}/heartbeats`)) {
return "instance-heartbeats";
}
if (pathname.includes(`${INSTANCE_SETTINGS_PATH_PREFIX}/experimental`)) {
return "instance-experimental";
}

View File

@ -19,9 +19,6 @@ describe("normalizeRememberedInstanceSettingsPath", () => {
expect(normalizeRememberedInstanceSettingsPath("/settings/access?tab=users#admins")).toBe(
"/company/settings/instance/access?tab=users#admins",
);
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/heartbeats")).toBe(
"/company/settings/instance/heartbeats",
);
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/plugins/example?tab=config#logs")).toBe(
"/company/settings/instance/plugins/example?tab=config#logs",
);
@ -46,12 +43,16 @@ describe("normalizeRememberedInstanceSettingsPath", () => {
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/nope")).toBe(
DEFAULT_INSTANCE_SETTINGS_PATH,
);
// The Heartbeats page was removed; remembered paths remap to the default.
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/heartbeats")).toBe(
DEFAULT_INSTANCE_SETTINGS_PATH,
);
expect(normalizeRememberedInstanceSettingsPath(null)).toBe(DEFAULT_INSTANCE_SETTINGS_PATH);
});
});
describe("filterHiddenInstanceSettingsPath", () => {
const hidden = new Set(["instance.plugins", "instance.heartbeats"]);
const hidden = new Set(["instance.plugins", "instance.environments"]);
it("remaps hidden pages (including sub-paths) to the default settings path", () => {
expect(
@ -61,7 +62,7 @@ describe("filterHiddenInstanceSettingsPath", () => {
filterHiddenInstanceSettingsPath("/company/settings/instance/plugins/plugin-1", hidden),
).toBe(DEFAULT_INSTANCE_SETTINGS_PATH);
expect(
filterHiddenInstanceSettingsPath("/company/settings/instance/heartbeats", hidden),
filterHiddenInstanceSettingsPath("/company/settings/instance/environments", hidden),
).toBe(DEFAULT_INSTANCE_SETTINGS_PATH);
});

View File

@ -65,7 +65,6 @@ export function normalizeRememberedInstanceSettingsPath(rawPath: string | null):
suffix === "/general" ||
suffix === "/environments" ||
suffix === "/access" ||
suffix === "/heartbeats" ||
suffix === "/plugins" ||
suffix === "/experimental" ||
suffix === "/adapters"

View File

@ -386,7 +386,6 @@ export const queryKeys = {
instance: {
settings: ["instance", "settings"] as const,
generalSettings: ["instance", "general-settings"] as const,
schedulerHeartbeats: ["instance", "scheduler-heartbeats"] as const,
experimentalSettings: ["instance", "experimental-settings"] as const,
},
health: ["health"] as const,

View File

@ -1,283 +0,0 @@
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 organizations.
</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 ? "organization" : "organizations"}</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-(length:--text-nano) 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>
);
}