From 7551b63ef225d0cf24c31a82c185528428e71dd9 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 27 Aug 2026 11:31:43 -0700 Subject: [PATCH] Remove the instance Heartbeats settings page (#12282) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- docs/deploy/environment-variables.md | 6 +- packages/shared/src/settings-visibility.ts | 1 - ui/src/App.test.tsx | 10 + ui/src/App.tsx | 5 +- ui/src/api/heartbeats.ts | 3 - .../CompanySettingsSidebar.test.tsx | 12 +- ui/src/components/CompanySettingsSidebar.tsx | 9 - .../access/CompanySettingsNav.test.tsx | 5 +- .../components/access/CompanySettingsNav.tsx | 6 - ui/src/lib/instance-settings.test.ts | 11 +- ui/src/lib/instance-settings.ts | 1 - ui/src/lib/queryKeys.ts | 1 - ui/src/pages/InstanceSettings.tsx | 283 ------------------ 13 files changed, 23 insertions(+), 330 deletions(-) delete mode 100644 ui/src/pages/InstanceSettings.tsx diff --git a/docs/deploy/environment-variables.md b/docs/deploy/environment-variables.md index 6ee12bfa59..20bbdd332d 100644 --- a/docs/deploy/environment-variables.md +++ b/docs/deploy/environment-variables.md @@ -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)). diff --git a/packages/shared/src/settings-visibility.ts b/packages/shared/src/settings-visibility.ts index 40588531e9..6729d4d062 100644 --- a/packages/shared/src/settings-visibility.ts +++ b/packages/shared/src/settings-visibility.ts @@ -34,7 +34,6 @@ export const HIDEABLE_INSTANCE_PAGES = [ "instance.profile", "instance.environments", "instance.access", - "instance.heartbeats", "instance.experimental", "instance.plugins", "instance.adapters", diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index 020c2c0eb2..221c07ec5f 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -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( + '} />', + ); + expect(appSource).not.toContain(" { it("does not register decision-training views", () => { expect(appSource).not.toContain('path="decisions/training"'); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 5e6b326c48..c7f59babf0 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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() { } /> } /> + } /> }> } /> } /> @@ -184,9 +184,6 @@ function boardRoutes() { }> } /> - }> - } /> - }> } /> diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index c3a9f5ac6f..c9f4312c5c 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -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(`/companies/${companyId}/live-runs${qs ? `?${qs}` : ""}`); }, - listInstanceSchedulerAgents: () => - api.get("/instance/scheduler-heartbeats"), }; diff --git a/ui/src/components/CompanySettingsSidebar.test.tsx b/ui/src/components/CompanySettingsSidebar.test.tsx index a0671cb844..6e722a23ec 100644 --- a/ui/src/components/CompanySettingsSidebar.test.tsx +++ b/ui/src/components/CompanySettingsSidebar.test.tsx @@ -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(); diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx index 368715fcb4..ecb1c8fca3 100644 --- a/ui/src/components/CompanySettingsSidebar.tsx +++ b/ui/src/components/CompanySettingsSidebar.tsx @@ -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") && ( - - )} {showPage("company.export") && ( )} diff --git a/ui/src/components/access/CompanySettingsNav.test.tsx b/ui/src/components/access/CompanySettingsNav.test.tsx index a01844aed1..683e081393 100644 --- a/ui/src/components/access/CompanySettingsNav.test.tsx +++ b/ui/src/components/access/CompanySettingsNav.test.tsx @@ -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( diff --git a/ui/src/components/access/CompanySettingsNav.tsx b/ui/src/components/access/CompanySettingsNav.tsx index 96ff1c1b97..a05b7acd55 100644 --- a/ui/src/components/access/CompanySettingsNav.tsx +++ b/ui/src/components/access/CompanySettingsNav.tsx @@ -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> = { "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"; } diff --git a/ui/src/lib/instance-settings.test.ts b/ui/src/lib/instance-settings.test.ts index 730b9ecb21..84f7ba0724 100644 --- a/ui/src/lib/instance-settings.test.ts +++ b/ui/src/lib/instance-settings.test.ts @@ -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); }); diff --git a/ui/src/lib/instance-settings.ts b/ui/src/lib/instance-settings.ts index 6f9dfcb077..25ff0aac01 100644 --- a/ui/src/lib/instance-settings.ts +++ b/ui/src/lib/instance-settings.ts @@ -65,7 +65,6 @@ export function normalizeRememberedInstanceSettingsPath(rawPath: string | null): suffix === "/general" || suffix === "/environments" || suffix === "/access" || - suffix === "/heartbeats" || suffix === "/plugins" || suffix === "/experimental" || suffix === "/adapters" diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index b4680ed189..4087c795d9 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -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, diff --git a/ui/src/pages/InstanceSettings.tsx b/ui/src/pages/InstanceSettings.tsx deleted file mode 100644 index c6ef6c4013..0000000000 --- a/ui/src/pages/InstanceSettings.tsx +++ /dev/null @@ -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 | 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: "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(); - 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
Loading scheduler heartbeats...
; - } - - if (heartbeatsQuery.error) { - return ( -
- {heartbeatsQuery.error instanceof Error - ? heartbeatsQuery.error.message - : "Failed to load scheduler heartbeats."} -
- ); - } - - return ( -
-
-
- -

Scheduler Heartbeats

-
-

- Agents with a timer heartbeat enabled across all of your organizations. -

-
- -
- {activeCount} active - {disabledCount} disabled - {grouped.length} {grouped.length === 1 ? "organization" : "organizations"} - {anyEnabled && ( - - )} -
- - {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"} - - - - - - - -
- ); - })} -
-
-
- ))} -
- )} -
- ); -}