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"} - - - - - - - -
- ); - })} -
-
-
- ))} -
- )} -
- ); -}