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:
parent
8f195c2f49
commit
7551b63ef2
|
|
@ -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)).
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ export const HIDEABLE_INSTANCE_PAGES = [
|
|||
"instance.profile",
|
||||
"instance.environments",
|
||||
"instance.access",
|
||||
"instance.heartbeats",
|
||||
"instance.experimental",
|
||||
"instance.plugins",
|
||||
"instance.adapters",
|
||||
|
|
|
|||
|
|
@ -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"');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -65,7 +65,6 @@ export function normalizeRememberedInstanceSettingsPath(rawPath: string | null):
|
|||
suffix === "/general" ||
|
||||
suffix === "/environments" ||
|
||||
suffix === "/access" ||
|
||||
suffix === "/heartbeats" ||
|
||||
suffix === "/plugins" ||
|
||||
suffix === "/experimental" ||
|
||||
suffix === "/adapters"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue