Remove Rules and Health pages

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-20 21:20:02 +00:00
1 parent 84c2b30358
commit 6b503e114b
15 files changed
+37 -2428

No files matched your search

+5
View File
@@ -254,6 +254,11 @@ describe("Apps routes", () => {
expect(appSource).toContain('<Route path="apps/connect/:appKey/:stage" element={<Navigate to="/apps" replace />} />');
expect(appSource).toContain('<Route path="apps/advanced/gateways" element={<GatewaysList />} />');
});
it("redirects legacy Rules and Health links to the remaining developer surfaces", () => {
expect(appSource).toContain('if (tab === "runtime") return "/apps/connections";');
expect(appSource).toContain('if (tab === "policies") return "/apps/advanced/profiles";');
});
});
describe("Decisions routes", () => {
+2
View File
@@ -404,6 +404,8 @@ function LegacyToolsRedirect() {
function legacyToolsRedirectTarget(tab?: string) {
if (!tab) return "/apps/advanced/profiles";
if (tab === "applications" || tab === "connections" || tab === "overview" || tab === "examples") return "/apps/connections";
if (tab === "runtime") return "/apps/connections";
if (tab === "policies") return "/apps/advanced/profiles";
return `/apps/advanced/${tab}`;
}
+2 -10
View File
@@ -8,7 +8,6 @@ import { AppsSidebar } from "./AppsSidebar";
const sidebarNavItemMock = vi.hoisted(() => vi.fn());
const mockToolsApi = vi.hoisted(() => ({
listRuntimeSlots: vi.fn(),
listActionRequests: vi.fn(),
}));
@@ -89,12 +88,6 @@ describe("AppsSidebar", () => {
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockToolsApi.listRuntimeSlots.mockResolvedValue({
runtimeSlots: [
{ id: "slot-1", status: "running" },
{ id: "slot-2", status: "stopped" },
],
});
mockToolsApi.listActionRequests.mockResolvedValue({ actionRequests: [] });
});
@@ -154,9 +147,8 @@ describe("AppsSidebar", () => {
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({ to: "/apps/advanced/profiles", label: "Profiles", end: true }),
);
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({ to: "/apps/advanced/runtime", label: "Health", end: true, liveCount: 1 }),
);
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Rules" }));
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Health" }));
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({ to: "/apps/advanced/audit", label: "Activity", end: true }),
);
+2 -15
View File
@@ -1,10 +1,7 @@
import { ChevronLeft, AppWindow, Store, ShieldQuestion } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Link } from "@/lib/router";
import { useCompany } from "@/context/CompanyContext";
import { useSidebar } from "@/context/SidebarContext";
import { queryKeys } from "@/lib/queryKeys";
import { toolsApi } from "@/api/tools";
import { DEVELOPER_TABS, advancedTabHref, isExperimentalToolTab } from "@/pages/tools/tool-tabs";
import { useSmokeLabEnabled } from "@/hooks/useSmokeLabEnabled";
import { useReviewCount } from "@/pages/apps/useReviewCount";
@@ -15,7 +12,7 @@ import { SidebarNavItem } from "./SidebarNavItem";
* PAP-13254 / U3).
*
* ← Back · APPS: Browse / Review (n)
* DEVELOPER: Connections / Gateways / Profiles / Rules / Health / Activity
* DEVELOPER: Connections / Gateways / Profiles / Activity
*
* "Browse" is the store and "Review" holds decisions waiting on the user's
* OK. Connection management lives with the Developer tools.
@@ -28,7 +25,7 @@ import { SidebarNavItem } from "./SidebarNavItem";
* (PAP-10922).
*/
export function AppsSidebar() {
const { selectedCompany, selectedCompanyId } = useCompany();
const { selectedCompany } = useCompany();
const { isMobile, setSidebarOpen } = useSidebar();
const reviewCount = useReviewCount();
@@ -37,15 +34,6 @@ export function AppsSidebar() {
(tab) => !isExperimentalToolTab(tab.key) || smokeLabEnabled,
);
const runtimeSlots = useQuery({
queryKey: queryKeys.tools.runtimeSlots(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listRuntimeSlots(selectedCompanyId!),
enabled: !!selectedCompanyId,
refetchInterval: 15_000,
});
const runtimeActiveCount = (runtimeSlots.data?.runtimeSlots ?? [])
.filter((slot) => slot.status === "running").length;
return (
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
<div className="flex flex-col gap-1 px-3 py-3 shrink-0">
@@ -95,7 +83,6 @@ export function AppsSidebar() {
label={tab.label}
icon={tab.icon}
end
liveCount={tab.key === "runtime" && runtimeActiveCount > 0 ? runtimeActiveCount : undefined}
/>
))}
</div>
+3 -5
View File
@@ -422,7 +422,6 @@ export function AgentToolsTab({ agent, companyId }: { agent: AgentDetailRecord;
return <ToolsErrorState error={effective.error} onRetry={() => effective.refetch()} />;
}
const policiesHref = "/apps/advanced/policies";
const profilesHref = "/apps/advanced/profiles";
return (
@@ -574,13 +573,12 @@ export function AgentToolsTab({ agent, companyId }: { agent: AgentDetailRecord;
governingPolicies.map(({ policy, order }) => (
<div key={policy.id} className="rounded-md border border-border/70 px-2.5 py-2">
<div className="flex items-center justify-between gap-2">
<Link
to={policiesHref}
className="truncate text-xs font-medium text-primary hover:underline"
<span
className="truncate text-xs font-medium text-foreground"
title={`Policy #${order}: ${policy.name}`}
>
#{order} {policy.name}
</Link>
</span>
<span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-(length:--text-nano) uppercase text-muted-foreground">
{POLICY_EFFECT_LABEL[policy.policyType] ?? policy.policyType}
</span>
@@ -11,7 +11,6 @@ describe("Apps agent selector contract", () => {
const permissions = source("./app-detail/PermissionsPanel.tsx");
const tester = source("./app-detail/TestPanel.tsx");
const profiles = source("../tools/ProfilesTab.tsx");
const policies = source("../tools/PoliciesTab.tsx");
const audit = source("../tools/AuditTab.tsx");
expect(appConnect).toContain("<AgentMultiSelect");
@@ -22,10 +21,6 @@ describe("Apps agent selector contract", () => {
expect(profiles).not.toContain("<Select value={agentId}");
expect(profiles).not.toContain("<Select value={targetAgentId}");
expect(policies.match(/<AgentSelect/g)).toHaveLength(2);
expect(policies).not.toContain("<Select value={agentId}");
expect(policies).not.toContain("<Select value={form.agentId}");
expect(audit).toContain("<AgentSelect");
expect(audit).not.toContain("<Select value={agent}");
});
+4 -4
View File
@@ -138,17 +138,17 @@ describe("AuditTab", () => {
expect(container.textContent).not.toContain("server-authoritative");
});
it("expands a row to show the plain reason, the linked rule, and the Details collapse", async () => {
it("expands a row to show the plain reason, the matched rule, and the Details collapse", async () => {
await render();
await clickButton("used Send Email");
await flushReact();
expect(container.textContent).toContain("Blocked by a rule.");
const ruleLink = Array.from(container.querySelectorAll("a")).find((a) =>
expect(container.textContent).toContain("destructive actions");
expect(Array.from(container.querySelectorAll("a")).some((a) =>
a.textContent?.includes("destructive actions"),
);
expect(ruleLink?.getAttribute("href")).toBe("/apps/advanced/policies");
)).toBe(false);
// Raw tool name + reason code only appear once Details is opened.
expect(container.textContent).not.toContain("mail:send_email");
+1 -4
View File
@@ -24,7 +24,6 @@ import {
import { agentsApi } from "@/api/agents";
import { AgentSelect } from "@/components/AgentMultiSelect";
import { ToolsPageHeader, LoadingState, ErrorState, RelativeTime } from "./shared";
import { advancedTabHref } from "./tool-tabs";
const PAGE_SIZE = 50;
const ALL = "__all";
@@ -214,9 +213,7 @@ function ActivityRow({
{matchedRuleName ? (
<>
{" "}
<Link to={advancedTabHref("policies")} className="text-primary hover:underline">
{matchedRuleName}
</Link>
<span className="font-medium">{matchedRuleName}</span>
</>
) : null}
</p>
-282
View File
@@ -1,282 +0,0 @@
// @vitest-environment jsdom
import { createElement, type ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ToolCatalogEntry, ToolPolicy } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const toolsApiMock = vi.hoisted(() => ({
listPolicies: vi.fn(),
createPolicy: vi.fn(),
reorderPolicies: vi.fn(),
duplicatePolicy: vi.fn(),
updatePolicy: vi.fn(),
deletePolicy: vi.fn(),
listTrustRules: vi.fn(),
revokeTrustRule: vi.fn(),
testPolicy: vi.fn(),
listAudit: vi.fn(),
listApplications: vi.fn(),
listConnections: vi.fn(),
listCatalog: vi.fn(),
}));
const agentsApiMock = vi.hoisted(() => ({ list: vi.fn() }));
const projectsApiMock = vi.hoisted(() => ({ list: vi.fn() }));
const toastMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/tools", () => ({ toolsApi: toolsApiMock }));
vi.mock("@/api/agents", () => ({ agentsApi: agentsApiMock }));
vi.mock("@/api/projects", () => ({ projectsApi: projectsApiMock }));
vi.mock("@/context/ToastContext", () => ({ useToast: () => ({ pushToast: toastMock }) }));
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: ReactNode }) => createElement("div", null, children),
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => createElement("div", null, children),
DropdownMenuContent: ({ children }: { children: ReactNode }) => createElement("div", null, children),
DropdownMenuItem: ({
children,
onSelect,
className,
}: {
children: ReactNode;
onSelect?: () => void;
className?: string;
}) => createElement("button", { type: "button", className, onClick: onSelect }, children),
DropdownMenuSeparator: () => createElement("hr"),
}));
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ open, children }: { open?: boolean; children: ReactNode }) => (open ? createElement("div", null, children) : null),
DialogContent: ({ children }: { children: ReactNode }) => createElement("div", null, children),
DialogDescription: ({ children }: { children: ReactNode }) => createElement("p", null, children),
DialogFooter: ({ children }: { children: ReactNode }) => createElement("div", null, children),
DialogHeader: ({ children }: { children: ReactNode }) => createElement("div", null, children),
DialogTitle: ({ children }: { children: ReactNode }) => createElement("h2", null, children),
}));
vi.mock("@/components/ui/sheet", () => ({
Sheet: ({ open, children }: { open?: boolean; children: ReactNode }) => (open ? createElement("div", null, children) : null),
SheetContent: ({ children }: { children: ReactNode }) => createElement("div", null, children),
SheetDescription: ({ children }: { children: ReactNode }) => createElement("p", null, children),
SheetFooter: ({ children }: { children: ReactNode }) => createElement("div", null, children),
SheetHeader: ({ children }: { children: ReactNode }) => createElement("div", null, children),
SheetTitle: ({ children }: { children: ReactNode }) => createElement("h2", null, children),
}));
import { PoliciesTab } from "./PoliciesTab";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function policy(partial: Partial<ToolPolicy> & { id: string; policyType: ToolPolicy["policyType"] }): ToolPolicy {
return {
id: partial.id,
companyId: "company-1",
name: partial.name ?? partial.id,
description: partial.description ?? null,
policyType: partial.policyType,
priority: partial.priority ?? 100,
enabled: partial.enabled ?? true,
selectors: partial.selectors ?? {},
conditions: partial.conditions ?? null,
config: partial.config ?? null,
createdByAgentId: null,
createdByUserId: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-01T00:00:00Z"),
};
}
function catalog(partial: Partial<ToolCatalogEntry> & { id: string; toolName: string }): ToolCatalogEntry {
return {
id: partial.id,
companyId: "company-1",
applicationId: partial.applicationId ?? "app-gmail",
connectionId: partial.connectionId ?? "conn-gmail",
entryKind: "tool",
name: partial.name,
toolName: partial.toolName,
title: partial.title ?? partial.toolName,
description: partial.description ?? null,
inputSchema: null,
outputSchema: null,
annotations: null,
riskLevel: partial.riskLevel ?? "read",
isReadOnly: partial.isReadOnly ?? true,
isWrite: partial.isWrite ?? false,
isDestructive: partial.isDestructive ?? false,
status: partial.status ?? "active",
addedAt: new Date("2026-06-01T00:00:00Z"),
version: null,
versionHash: null,
schemaHash: null,
firstSeenAt: new Date("2026-06-01T00:00:00Z"),
lastSeenAt: new Date("2026-06-01T00:00:00Z"),
reviewedAt: null,
reviewedByAgentId: null,
reviewedByUserId: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-01T00:00:00Z"),
};
}
async function flushReact() {
for (let i = 0; i < 4; i += 1) {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
}
describe("PoliciesTab", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
agentsApiMock.list.mockResolvedValue([{ id: "agent-1", name: "Fable" }]);
projectsApiMock.list.mockResolvedValue([{ id: "project-1", name: "Launch" }]);
toolsApiMock.listApplications.mockResolvedValue({ applications: [{ id: "app-gmail", name: "Gmail" }] });
toolsApiMock.listConnections.mockResolvedValue({ connections: [{ id: "conn-gmail", name: "Gmail" }] });
toolsApiMock.listCatalog.mockResolvedValue({
catalog: [
catalog({ id: "send", toolName: "gmail.send", title: "Send email", riskLevel: "write", isReadOnly: false, isWrite: true }),
catalog({ id: "delete", toolName: "gmail.delete", title: "Delete email", riskLevel: "destructive", isReadOnly: false, isWrite: true, isDestructive: true }),
],
});
toolsApiMock.listTrustRules.mockResolvedValue({ trustRules: [] });
toolsApiMock.listAudit.mockResolvedValue([
{ createdAt: new Date().toISOString(), details: { matchedPolicyIds: ["rule-1"] } },
{ createdAt: new Date().toISOString(), details: { matchedPolicyIds: ["rule-1"] } },
{ createdAt: new Date().toISOString(), details: { matchedPolicyIds: ["rule-1"] } },
]);
toolsApiMock.reorderPolicies.mockResolvedValue({ policies: [] });
toolsApiMock.duplicatePolicy.mockResolvedValue(policy({ id: "copy", policyType: "block" }));
toolsApiMock.updatePolicy.mockResolvedValue(policy({ id: "rule-1", policyType: "block" }));
toolsApiMock.deletePolicy.mockResolvedValue(policy({ id: "rule-1", policyType: "block" }));
toolsApiMock.revokeTrustRule.mockResolvedValue(policy({ id: "trust-1", policyType: "trust_rule" }));
});
afterEach(() => {
flushSync(() => root.unmount());
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
async function render(policies: ToolPolicy[]) {
toolsApiMock.listPolicies.mockResolvedValue({ policies });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
flushSync(() => {
root.render(
<QueryClientProvider client={client}>
<PoliciesTab companyId="company-1" />
</QueryClientProvider>,
);
});
await flushReact();
}
it("renders ordered sentence rows without exposing priority numbers", async () => {
await render([
policy({
id: "rule-1",
policyType: "require_approval",
priority: 500,
selectors: { agentId: "agent-1", riskLevel: "destructive" },
}),
]);
expect(container.textContent).toContain("Rules are checked top to bottom");
expect(container.textContent).toContain("When Fable uses destructive actions → Ask first");
expect(container.textContent).toContain("3 times");
expect(container.textContent).not.toContain("priority 500");
});
it("does not advertise or seed wildcard action selectors", async () => {
await render([]);
expect(container.textContent).toContain("Ask first before selected actions");
expect(container.textContent).not.toContain("Wildcard action names");
expect(container.textContent).not.toContain("*send*");
expect(container.textContent).not.toContain("*delete*");
expect(container.textContent).not.toContain("Hide sensitive details");
expect(container.textContent).not.toContain("Custom check");
const starter = [...container.querySelectorAll("button")].find((button) =>
button.textContent?.includes("Ask first before selected actions")
);
flushSync(() => starter?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
await flushReact();
expect(container.querySelector("#tool-patterns")).toBeNull();
expect(container.querySelector("#conditions-json")).toBeNull();
expect(container.querySelector("#config-json")).toBeNull();
expect([...container.querySelectorAll("input")].map((input) => input.value).join(" ")).not.toContain("*");
});
it("wires duplicate, toggle, delete, and reorder actions to the Rules endpoints", async () => {
await render([
policy({ id: "rule-1", policyType: "block", selectors: { toolName: "gmail.delete" } }),
policy({ id: "rule-2", policyType: "allow", selectors: { applicationId: "app-gmail" }, priority: 200 }),
]);
const duplicateButton = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("Duplicate"));
flushSync(() => duplicateButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
await flushReact();
expect(toolsApiMock.duplicatePolicy).toHaveBeenCalledWith("company-1", "rule-1");
const firstSwitch = container.querySelector<HTMLButtonElement>('[role="switch"]');
flushSync(() => firstSwitch?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
await flushReact();
expect(toolsApiMock.updatePolicy).toHaveBeenCalledWith("company-1", "rule-1", { enabled: false });
const rows = container.querySelectorAll("tbody tr");
flushSync(() => {
rows[0]?.dispatchEvent(new Event("dragstart", { bubbles: true }));
rows[1]?.dispatchEvent(new Event("drop", { bubbles: true }));
});
await flushReact();
expect(toolsApiMock.reorderPolicies).toHaveBeenCalledWith("company-1", { policyIds: ["rule-2", "rule-1"] });
const deleteButton = [...container.querySelectorAll("button")].find((button) =>
button.textContent?.includes("Delete") && button.className.includes("text-destructive")
);
flushSync(() => deleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
await flushReact();
expect(container.textContent).toContain("matched 3 times in the last 24 hours");
const confirmDelete = [...container.querySelectorAll("button")].filter((button) => button.textContent?.includes("Delete")).at(-1);
flushSync(() => confirmDelete?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
await flushReact();
expect(toolsApiMock.deletePolicy).toHaveBeenCalledWith("company-1", "rule-1");
});
it("shows remembered approvals with Forget confirmation", async () => {
toolsApiMock.listTrustRules.mockResolvedValue({
trustRules: [
policy({
id: "trust-1",
policyType: "trust_rule",
selectors: { agentId: "agent-1", toolName: "gmail.send" },
}),
],
});
await render([]);
expect(container.textContent).toContain("Remembered approvals");
expect(container.textContent).toContain("When Fable uses Send email → Allow");
const forget = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("Forget"));
flushSync(() => forget?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
await flushReact();
expect(container.textContent).toContain("Paperclip will ask again");
const confirmForget = [...container.querySelectorAll("button")].filter((button) => button.textContent?.includes("Forget")).at(-1);
flushSync(() => confirmForget?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
await flushReact();
expect(toolsApiMock.revokeTrustRule).toHaveBeenCalledWith("company-1", "trust-1");
});
});
File diff suppressed because it is too large. Load diff
-244
View File
@@ -1,244 +0,0 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import { RuntimeTab } from "./RuntimeTab";
const listRuntimeSlotsMock = vi.hoisted(() => vi.fn());
const getRuntimeHealthMock = vi.hoisted(() => vi.fn());
const listConnectionsMock = vi.hoisted(() => vi.fn());
const stopRuntimeSlotMock = vi.hoisted(() => vi.fn());
const restartRuntimeSlotMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/tools", () => ({
toolsApi: {
listRuntimeSlots: (companyId: string) => listRuntimeSlotsMock(companyId),
getRuntimeHealth: (companyId: string) => getRuntimeHealthMock(companyId),
listConnections: (companyId: string) => listConnectionsMock(companyId),
stopRuntimeSlot: (companyId: string, slotId: string) => stopRuntimeSlotMock(companyId, slotId),
restartRuntimeSlot: (companyId: string, slotId: string) => restartRuntimeSlotMock(companyId, slotId),
},
}));
vi.mock("@/lib/router", () => ({
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => (
<a href={to} {...props}>
{children}
</a>
),
}));
vi.mock("@/context/ToastContext", () => ({
useToast: () => ({ pushToast: vi.fn() }),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
async function flushReact() {
for (let i = 0; i < 3; i += 1) {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
}
function slot(overrides: Record<string, unknown> = {}) {
return {
id: "slot-1",
companyId: "company-1",
applicationId: "app-1",
connectionId: "conn-1",
projectWorkspaceId: null,
executionWorkspaceId: null,
issueId: null,
ownerScopeType: "company",
ownerScopeId: null,
runtimeKind: "local_stdio",
slotKey: "gmail-stdio-local",
status: "running",
reuseKey: null,
workspaceScope: null,
credentialScopeHash: null,
provider: null,
providerRef: null,
processId: 41832,
commandTemplateKey: "gmail",
healthStatus: "healthy",
lastHealthCheckAt: null,
idleExpiresAt: null,
startedAt: new Date("2026-06-13T10:00:00Z"),
stoppedAt: null,
lastUsedAt: new Date("2026-06-13T12:55:00Z"),
lastError: null,
metadata: null,
createdAt: new Date("2026-06-13T10:00:00Z"),
updatedAt: new Date("2026-06-13T10:00:00Z"),
...overrides,
};
}
function connection(overrides: Record<string, unknown> = {}) {
return {
id: "conn-1",
companyId: "company-1",
applicationId: "app-1",
name: "Gmail",
connectionKind: "managed",
transport: "local_stdio",
status: "active",
transportConfig: {},
credentialSecretRefs: [],
healthStatus: "healthy",
healthCheckedAt: null,
lastError: null,
enabled: true,
createdByAgentId: null,
createdByUserId: null,
createdAt: new Date("2026-06-13T10:00:00Z"),
updatedAt: new Date("2026-06-13T10:00:00Z"),
...overrides,
};
}
function alert(overrides: Record<string, unknown> = {}) {
return {
name: "mcp_runtime_connection_health_degraded",
severity: "critical",
status: "ok",
threshold: "Any degraded connection.",
observed: "1 degraded connection(s), 0 disabled connection(s).",
description: "A configured MCP connection is not healthy or has been disabled.",
firstResponderAction: "Run a connection health check.",
runbookSection: "runbook#health",
...overrides,
};
}
function health(overrides: Record<string, unknown> = {}) {
return {
status: "ok",
generatedAt: new Date("2026-06-13T13:00:00Z"),
runbookPath: "docs/runbook.md",
metrics: {
averageToolLatencyMsLastHour: 1200,
p95ToolLatencyMsLastHour: 2400,
timeoutRateLastHour: 0,
toolFailuresLastHour: 0,
toolTimeoutsLastHour: 0,
capacityDeferralsLastHour: 0,
activeSlots: 1,
runningSlots: 1,
},
supportMatrix: {},
alerts: [],
recommendations: [],
...overrides,
};
}
describe("RuntimeTab", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
listRuntimeSlotsMock.mockResolvedValue({ runtimeSlots: [slot()] });
getRuntimeHealthMock.mockResolvedValue(health());
listConnectionsMock.mockResolvedValue({ connections: [connection()] });
stopRuntimeSlotMock.mockResolvedValue(slot({ status: "stopped" }));
restartRuntimeSlotMock.mockResolvedValue(slot());
});
afterEach(() => {
flushSync(() => root?.unmount());
container.remove();
vi.clearAllMocks();
});
async function render() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={client}>
<TooltipProvider>
<RuntimeTab companyId="company-1" />
</TooltipProvider>
</QueryClientProvider>,
);
});
await flushReact();
}
it("shows the plain-words summary strip and a Working row linked to the app page", async () => {
await render();
expect(container.textContent).toContain("Apps running");
expect(container.textContent).toContain("1 of 1");
expect(container.textContent).toContain("about 1.2s");
expect(container.textContent).toContain("Working");
const appLink = container.querySelector<HTMLAnchorElement>('a[href="/apps/conn-1"]');
expect(appLink?.textContent).toContain("Gmail");
// No ops vocabulary on the primary surface.
expect(container.textContent).not.toContain("P95 latency");
expect(container.textContent).not.toContain("local_stdio");
});
it("renders a plain needs-attention card for a firing alert and marks the row", async () => {
getRuntimeHealthMock.mockResolvedValue(
health({ status: "degraded", alerts: [alert({ status: "firing" })] }),
);
listConnectionsMock.mockResolvedValue({ connections: [connection({ healthStatus: "degraded" })] });
await render();
// Plain title, not the raw alert name / runbook on the surface.
expect(container.textContent).toContain("An app needs reconnecting");
expect(container.textContent).toContain("Needs attention");
expect(container.textContent).not.toContain("mcp_runtime_connection_health_degraded");
});
it("opens a confirm dialog before restarting and only mutates after confirm", async () => {
await render();
const restartButton = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.trim() === "Restart",
);
expect(restartButton).toBeTruthy();
await act(async () => {
restartButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
// Confirm modal copy is present; nothing has been restarted yet.
expect(document.body.textContent).toContain("Restart Gmail?");
expect(restartRuntimeSlotMock).not.toHaveBeenCalled();
const confirmButton = Array.from(document.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "Restart" && b.closest('[data-slot="dialog-content"]'),
);
await act(async () => {
confirmButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(restartRuntimeSlotMock).toHaveBeenCalledWith("company-1", "slot-1");
});
});
-660
View File
@@ -1,660 +0,0 @@
import { useMemo, useState, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronRight, Loader2, RotateCw, Server, Square } from "lucide-react";
import type {
ToolConnection,
ToolRuntimeAlertRecommendation,
ToolRuntimeMetricSnapshot,
ToolRuntimeSlot,
} from "@paperclipai/shared";
import { humanizeConnectionDisplayName, isToolConnectionAttentionHealth } from "@paperclipai/shared";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Link } from "@/lib/router";
import { queryKeys } from "@/lib/queryKeys";
import { toolsApi } from "@/api/tools";
import { ApiError } from "@/api/client";
import { useToast } from "@/context/ToastContext";
import { EmptyState } from "@/components/EmptyState";
import { ToolsPageHeader, LoadingState, ErrorState, RelativeTime } from "./shared";
/** Working / Needs attention / Off — the only status vocabulary on this surface. */
type RowStatus = "working" | "attention" | "off";
/**
* A running-app row: a runtime slot joined to the connection it powers so we can
* humanize its name and link to its `/apps/:connectionId` page. Status is derived
* from the connection's health via `isToolConnectionAttentionHealth()` (with a
* slot-health fallback) so the Apps index, app detail, and Health never disagree.
*/
interface RuntimeRow {
slot: ToolRuntimeSlot;
connection: ToolConnection | null;
name: string;
isLocal: boolean;
status: RowStatus;
}
/** A health value that means the runtime slot itself is unhealthy. */
function slotHealthNeedsAttention(health: string | null | undefined): boolean {
return health === "error" || health === "unhealthy" || health === "failed" || health === "degraded";
}
function rowStatusFor(slot: ToolRuntimeSlot, connection: ToolConnection | null): RowStatus {
if (slot.status === "stopped" || slot.status === "disabled") return "off";
if (connection && isToolConnectionAttentionHealth(connection.healthStatus)) return "attention";
if (slot.status === "failed" || slot.status === "error") return "attention";
if (slotHealthNeedsAttention(slot.healthStatus)) return "attention";
return "working";
}
const STATUS_WORD: Record<RowStatus, string> = {
working: "Working",
attention: "Needs attention",
off: "Off",
};
/** Filled dot (working) / triangle (needs attention) / hollow dot (off). */
function StatusMarker({ status }: { status: RowStatus }) {
if (status === "attention") {
return <span className="text-amber-600 dark:text-amber-400">▲</span>;
}
if (status === "off") {
return <span className="inline-block h-2.5 w-2.5 rounded-full border border-muted-foreground/50" />;
}
return <span className="inline-block h-2.5 w-2.5 rounded-full bg-emerald-500" />;
}
function humanizeRowName(slot: ToolRuntimeSlot, connection: ToolConnection | null): string {
if (connection) return humanizeConnectionDisplayName(connection);
return humanizeConnectionDisplayName(slot.commandTemplateKey ?? slot.providerRef ?? slot.id.slice(0, 8));
}
/** Plain-words latency: "about 1.2s" / "about 240ms" / "—". */
function formatTypicalLatency(ms: number | null | undefined): string {
if (typeof ms !== "number" || Number.isNaN(ms)) return "—";
if (ms >= 950) return `about ${(ms / 1000).toFixed(1)}s`;
return `about ${Math.round(ms)}ms`;
}
/** How the slot runs, in plain words. */
function howItRuns(slot: ToolRuntimeSlot): string {
return slot.runtimeKind === "local_stdio" ? "Runs on this machine" : "Connects over the internet";
}
/** Humanize the owner scope into a plain phrase. */
function scopeLabel(scope: string | null | undefined): string {
switch (scope) {
case "company":
return "Whole company";
case "project":
case "project_workspace":
return "This project";
case "execution_workspace":
case "issue":
return "This task";
case "agent":
return "A single agent";
default:
return scope ? scope.replace(/[_-]+/g, " ") : "—";
}
}
/** Plain-words trust tier — quarantined local code reads as such; remote is provider-side. */
function trustTierLabel(slot: ToolRuntimeSlot): string {
if (slot.runtimeKind !== "local_stdio") return "Provider-verified";
const quarantined =
slot.status === "failed" ||
slot.status === "error" ||
slot.healthStatus === "error" ||
slot.healthStatus === "unhealthy";
return quarantined ? "Quarantined" : "Trusted (runs locally)";
}
/**
* Plain-language translation for each supervisor alert. The runbook/severity
* vocabulary stays out of these — it lives in the card's "Technical details".
* `action` picks the one suggested button: restart the failing app, or a link to
* the surface where the admin resolves it.
*/
type AlertAction = "restart" | "reviewApps" | "reviewActivity";
const ALERT_COPY: Record<string, { title: string; body: (a: ToolRuntimeAlertRecommendation) => string; action: AlertAction }> = {
mcp_runtime_stuck_starting_slot: {
title: "An app is stuck starting up",
body: () => "It began starting but never came online. Restarting usually clears this.",
action: "restart",
},
mcp_runtime_stuck_running_slot: {
title: "An app stopped responding",
body: () => "The process is still running but isn't answering. Restarting usually clears this.",
action: "restart",
},
mcp_runtime_high_timeout_rate: {
title: "Apps are responding slowly",
body: (a) => `Some actions are timing out (${a.observed.toLowerCase()}). Check the apps involved or try again shortly.`,
action: "reviewActivity",
},
mcp_runtime_high_error_rate: {
title: "Apps are failing more than usual",
body: (a) => `Recent actions failed after they were allowed (${a.observed.toLowerCase()}).`,
action: "reviewActivity",
},
mcp_runtime_capacity_deferrals_repeated: {
title: "Too many apps running at once",
body: (a) => `Some actions had to wait for a free slot (${a.observed.toLowerCase()}).`,
action: "reviewActivity",
},
mcp_runtime_restart_storm: {
title: "An app keeps restarting",
body: (a) => `It has restarted repeatedly (${a.observed.toLowerCase()}). It may be misconfigured or offline.`,
action: "restart",
},
mcp_runtime_connection_health_degraded: {
title: "An app needs reconnecting",
body: () => "A connected app isn't healthy. Open it to check the key or reconnect.",
action: "reviewApps",
},
mcp_runtime_missing_secret_failures: {
title: "An app is missing a key",
body: () => "A saved key couldn't be found, so some actions failed. Reconnect the app to fix it.",
action: "reviewApps",
},
mcp_runtime_audit_write_failures: {
title: "Activity logging hit a problem",
body: () => "Some actions may not have been recorded. This needs an administrator to look into it.",
action: "reviewActivity",
},
};
function plainAlertTitle(alert: ToolRuntimeAlertRecommendation): string {
return ALERT_COPY[alert.name]?.title ?? alert.description;
}
function plainAlertBody(alert: ToolRuntimeAlertRecommendation): string {
return ALERT_COPY[alert.name]?.body(alert) ?? alert.observed;
}
function alertAction(alert: ToolRuntimeAlertRecommendation): AlertAction {
return ALERT_COPY[alert.name]?.action ?? "reviewActivity";
}
interface ConfirmTarget {
kind: "stop" | "restart";
slotId: string;
name: string;
}
/** One plain-number summary card with an optional ops-vocabulary tooltip. */
function SummaryCard({
label,
value,
note,
detail,
}: {
label: string;
value: string;
note?: string;
detail?: string;
}) {
const labelEl = detail ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-help border-b border-dotted border-muted-foreground/40 text-xs font-semibold text-muted-foreground">
{label}
</span>
</TooltipTrigger>
<TooltipContent className="max-w-xs">{detail}</TooltipContent>
</Tooltip>
) : (
<span className="text-xs font-semibold text-muted-foreground">{label}</span>
);
return (
<Card className="py-0">
<CardContent className="space-y-1.5 px-5 py-4">
<div>{labelEl}</div>
<div className="text-2xl font-bold tracking-tight text-foreground tabular-nums">{value}</div>
<div className="text-xs text-muted-foreground">{note ?? " "}</div>
</CardContent>
</Card>
);
}
function LivePill() {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex cursor-help items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs font-medium text-foreground">
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
Live
</span>
</TooltipTrigger>
<TooltipContent>Updates automatically every 15 seconds.</TooltipContent>
</Tooltip>
);
}
/** Card-level "Technical details" / row-level expander toggle. */
function Disclosure({ open, label }: { open: boolean; label: string }) {
return (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${open ? "rotate-90" : ""}`} />
{label}
</span>
);
}
export function RuntimeTab({ companyId }: { companyId: string }) {
const qc = useQueryClient();
const { pushToast } = useToast();
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [openAlertDetails, setOpenAlertDetails] = useState<Record<string, boolean>>({});
const [confirm, setConfirm] = useState<ConfirmTarget | null>(null);
const slots = useQuery({
queryKey: queryKeys.tools.runtimeSlots(companyId),
queryFn: () => toolsApi.listRuntimeSlots(companyId),
refetchInterval: 15_000,
});
const health = useQuery({
queryKey: queryKeys.tools.runtimeHealth(companyId),
queryFn: () => toolsApi.getRuntimeHealth(companyId),
refetchInterval: 15_000,
});
const connections = useQuery({
queryKey: queryKeys.tools.connections(companyId),
queryFn: () => toolsApi.listConnections(companyId),
refetchInterval: 15_000,
});
const invalidateRuntime = () => {
qc.invalidateQueries({ queryKey: queryKeys.tools.runtimeSlots(companyId) });
qc.invalidateQueries({ queryKey: queryKeys.tools.runtimeHealth(companyId) });
qc.invalidateQueries({ queryKey: queryKeys.tools.connections(companyId) });
};
const stopSlot = useMutation({
mutationFn: (slotId: string) => toolsApi.stopRuntimeSlot(companyId, slotId),
onSuccess: () => {
invalidateRuntime();
pushToast({ title: "App stopped", tone: "success" });
},
onError: (err) =>
pushToast({ title: "Stop failed", body: err instanceof ApiError ? err.message : String(err), tone: "error" }),
onSettled: () => setConfirm(null),
});
const restartSlot = useMutation({
mutationFn: (slotId: string) => toolsApi.restartRuntimeSlot(companyId, slotId),
onSuccess: () => {
invalidateRuntime();
pushToast({ title: "App restarted", tone: "success" });
},
onError: (err) =>
pushToast({ title: "Restart failed", body: err instanceof ApiError ? err.message : String(err), tone: "error" }),
onSettled: () => setConfirm(null),
});
const rows = useMemo<RuntimeRow[]>(() => {
const list = slots.data?.runtimeSlots ?? [];
const byId = new Map((connections.data?.connections ?? []).map((c) => [c.id, c] as const));
return list.map((slot) => {
const connection = slot.connectionId ? byId.get(slot.connectionId) ?? null : null;
return {
slot,
connection,
name: humanizeRowName(slot, connection),
isLocal: slot.runtimeKind === "local_stdio",
status: rowStatusFor(slot, connection),
};
});
}, [slots.data, connections.data]);
if (slots.isLoading || health.isLoading || connections.isLoading) return <LoadingState />;
if (slots.error || health.error) {
return (
<ErrorState
error={slots.error ?? health.error}
onRetry={() => {
slots.refetch();
health.refetch();
connections.refetch();
}}
/>
);
}
const metrics = health.data?.metrics as ToolRuntimeMetricSnapshot | undefined;
const firingAlerts = (health.data?.alerts ?? []).filter((a) => a.status === "firing");
const workingCount = rows.filter((r) => r.status === "working").length;
const attentionCount = rows.filter((r) => r.status === "attention").length;
const totalCount = rows.length;
const localAttentionRow = rows.find((r) => r.status === "attention" && r.isLocal) ?? null;
const errors = (metrics?.toolFailuresLastHour ?? 0) + (metrics?.toolTimeoutsLastHour ?? 0);
const beginRestart = (row: RuntimeRow) =>
setConfirm({ kind: "restart", slotId: row.slot.id, name: row.name });
const beginStop = (row: RuntimeRow) => setConfirm({ kind: "stop", slotId: row.slot.id, name: row.name });
return (
<div className="space-y-5">
<div className="flex items-start justify-between gap-3">
<ToolsPageHeader title="Health" description="How your apps are doing right now." />
<LivePill />
</div>
{/* Summary strip — plain words; ops vocabulary lives in tooltips. */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<SummaryCard
label="Apps running"
value={totalCount === 0 ? "None" : `${workingCount} of ${totalCount}`}
note={
totalCount === 0
? "Apps start when an agent first needs them"
: attentionCount > 0
? `${attentionCount} need${attentionCount === 1 ? "s" : ""} attention`
: "All working"
}
/>
<SummaryCard
label="Typical response time"
value={formatTypicalLatency(metrics?.averageToolLatencyMsLastHour)}
note={
metrics?.averageToolLatencyMsLastHour == null
? "No calls in the last hour"
: (metrics?.timeoutRateLastHour ?? 0) >= 10
? "slower than usual"
: "across all apps"
}
detail={`Slowest 5% (P95): ${formatTypicalLatency(metrics?.p95ToolLatencyMsLastHour)} · timeout rate ${metrics?.timeoutRateLastHour ?? 0}%`}
/>
<SummaryCard
label="Errors in the last hour"
value={String(errors)}
note={errors === 0 ? "None" : "across your apps"}
detail={`${metrics?.toolFailuresLastHour ?? 0} failed · ${metrics?.toolTimeoutsLastHour ?? 0} timed out · ${metrics?.capacityDeferralsLastHour ?? 0} waited for capacity`}
/>
</div>
{/* Needs-attention cards — one per firing supervisor alert, in plain words. */}
{firingAlerts.map((alert) => {
const action = alertAction(alert);
const detailsOpen = openAlertDetails[alert.name] ?? false;
return (
<Card key={alert.name} className="overflow-hidden border-foreground/30 py-0">
<CardContent className="relative space-y-3 py-4 pl-6">
<span className="absolute inset-y-0 left-0 w-1.5 bg-foreground" />
<div>
<p className="text-base font-bold text-foreground">▲ {plainAlertTitle(alert)}</p>
<p className="mt-1 max-w-2xl text-sm text-foreground/80">{plainAlertBody(alert)}</p>
</div>
<div className="flex flex-wrap items-center gap-4">
{action === "restart" && localAttentionRow ? (
<Button size="sm" onClick={() => beginRestart(localAttentionRow)}>
<RotateCw className="mr-1.5 h-3.5 w-3.5" />
Restart {localAttentionRow.name}
</Button>
) : action === "reviewApps" ? (
<Button size="sm" asChild>
<Link to="/apps/attention">Review apps</Link>
</Button>
) : (
<Button size="sm" asChild>
<Link to="/apps/advanced/audit">Review activity</Link>
</Button>
)}
<button
type="button"
className="text-left"
onClick={() => setOpenAlertDetails((s) => ({ ...s, [alert.name]: !detailsOpen }))}
>
<Disclosure open={detailsOpen} label="Technical details" />
</button>
</div>
{detailsOpen ? (
<dl className="grid grid-cols-1 gap-x-8 gap-y-2 rounded-md bg-muted/40 p-3 text-xs sm:grid-cols-2">
<Fact label="Alert" value={<span className="font-mono">{alert.name}</span>} />
<Fact label="Severity" value={alert.severity} />
<Fact label="Threshold" value={alert.threshold} />
<Fact label="Observed" value={alert.observed} />
<Fact label="First responder" value={alert.firstResponderAction} />
<Fact label="Runbook" value={<span className="font-mono">{alert.runbookSection || health.data?.runbookPath}</span>} />
</dl>
) : null}
</CardContent>
</Card>
);
})}
{/* Status table — one row per running app. */}
{totalCount === 0 ? (
<EmptyState
icon={Server}
message="No apps running right now"
description="Apps that run on this machine start automatically the first time an agent needs them. Apps that connect over the internet don't use a local process."
/>
) : (
<Card className="py-0">
<CardContent className="px-0 py-0">
<div className="px-5 pb-1 pt-4">
<h3 className="text-base font-bold text-foreground">Running apps</h3>
<p className="text-xs text-muted-foreground">Click a row to see how the connection is wired up.</p>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs font-medium text-muted-foreground">
<th className="px-5 py-2.5">App</th>
<th className="px-3 py-2.5">Status</th>
<th className="px-3 py-2.5">Last used</th>
<th className="px-5 py-2.5 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{rows.map((row) => {
const open = expanded[row.slot.id] ?? false;
const busy =
(stopSlot.isPending && stopSlot.variables === row.slot.id) ||
(restartSlot.isPending && restartSlot.variables === row.slot.id);
return (
<RuntimeRowView
key={row.slot.id}
row={row}
open={open}
busy={busy}
onToggle={() => setExpanded((s) => ({ ...s, [row.slot.id]: !open }))}
onRestart={() => beginRestart(row)}
onStop={() => beginStop(row)}
/>
);
})}
</tbody>
</table>
</CardContent>
</Card>
)}
<p className="text-xs text-muted-foreground">
Apps that "connect over the internet" hide Stop and Restart — those run on the provider's side, so there's
no local process to control here.
</p>
<ConfirmDialog
target={confirm}
pending={stopSlot.isPending || restartSlot.isPending}
onCancel={() => setConfirm(null)}
onConfirm={() => {
if (!confirm) return;
if (confirm.kind === "restart") restartSlot.mutate(confirm.slotId);
else stopSlot.mutate(confirm.slotId);
}}
/>
</div>
);
}
function Fact({ label, value }: { label: string; value: ReactNode }) {
return (
<div>
<dt className="font-semibold text-muted-foreground">{label}</dt>
<dd className="mt-0.5 text-foreground">{value}</dd>
</div>
);
}
function RuntimeRowView({
row,
open,
busy,
onToggle,
onRestart,
onStop,
}: {
row: RuntimeRow;
open: boolean;
busy: boolean;
onToggle: () => void;
onRestart: () => void;
onStop: () => void;
}) {
const { slot, connection, name, isLocal, status } = row;
const canControl = isLocal && status !== "off";
return (
<>
<tr className="cursor-pointer align-middle hover:bg-accent/40" onClick={onToggle}>
<td className="px-5 py-2.5">
<div className="flex items-center gap-2.5">
<ChevronRight className={`h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform ${open ? "rotate-90" : ""}`} />
<StatusMarker status={status} />
{connection ? (
<Link
to={`/apps/${connection.id}`}
className="font-semibold text-foreground hover:underline"
onClick={(e) => e.stopPropagation()}
>
{name}
</Link>
) : (
<span className="font-semibold text-foreground">{name}</span>
)}
</div>
</td>
<td className="px-3 py-2.5">
<span className={status === "attention" ? "font-semibold text-foreground" : "text-foreground"}>
{STATUS_WORD[status]}
</span>
</td>
<td className="px-3 py-2.5">
<RelativeTime value={slot.lastUsedAt} />
</td>
<td className="px-5 py-2.5 text-right" onClick={(e) => e.stopPropagation()}>
{isLocal ? (
<Button size="sm" variant="outline" disabled={busy || status === "off"} onClick={onRestart}>
{busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : <RotateCw className="mr-1.5 h-3.5 w-3.5" />}
Restart
</Button>
) : (
<span className="text-xs text-muted-foreground">Runs on the provider's side</span>
)}
</td>
</tr>
{open ? (
<tr className="bg-muted/40">
<td colSpan={4} className="px-5 py-4">
<dl className="grid grid-cols-1 gap-x-8 gap-y-3 sm:grid-cols-3">
<Fact label="Slot key" value={<span className="font-mono text-xs">{slot.slotKey ?? slot.commandTemplateKey ?? slot.id}</span>} />
<Fact label="How it runs" value={howItRuns(slot)} />
<Fact label="Process ID" value={slot.processId ?? "—"} />
<Fact label="Scope" value={scopeLabel(slot.ownerScopeType)} />
<Fact label="Trust tier" value={trustTierLabel(slot)} />
<Fact label="Started" value={<RelativeTime value={slot.lastStartedAt ?? slot.startedAt} />} />
</dl>
{slot.lastError ? (
<p className="mt-3 text-xs text-destructive">Last error: {slot.lastError}</p>
) : null}
<div className="mt-4 flex items-center gap-2">
{canControl ? (
<>
<Button size="sm" variant="outline" disabled={busy} onClick={onStop}>
{busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : <Square className="mr-1.5 h-3.5 w-3.5" fill="currentColor" />}
Stop
</Button>
<Button size="sm" variant="outline" disabled={busy} onClick={onRestart}>
{busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : <RotateCw className="mr-1.5 h-3.5 w-3.5" />}
Restart
</Button>
</>
) : !isLocal ? (
<p className="text-xs text-muted-foreground">
This app runs on the provider's side — there's nothing to stop or restart here.
</p>
) : (
<p className="text-xs text-muted-foreground">This app is off. It will start again when an agent needs it.</p>
)}
</div>
</td>
</tr>
) : null}
</>
);
}
function ConfirmDialog({
target,
pending,
onCancel,
onConfirm,
}: {
target: ConfirmTarget | null;
pending: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
const isRestart = target?.kind === "restart";
return (
<Dialog open={!!target} onOpenChange={(o) => (!o ? onCancel() : undefined)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{isRestart ? "Restart" : "Stop"} {target?.name}?
</DialogTitle>
</DialogHeader>
<div className="space-y-2 text-sm text-foreground">
{isRestart ? (
<>
<p>
Anything in progress will stop. Agents using {target?.name} right now will see a Failed result on
their action.
</p>
<p className="text-xs text-muted-foreground">Restart usually takes 2–3 seconds.</p>
</>
) : (
<>
<p>
{target?.name} will stop running. Agents won't be able to use it until it starts again.
</p>
<p className="text-xs text-muted-foreground">
It starts again automatically the next time an agent needs it.
</p>
</>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={onCancel} disabled={pending}>
Cancel
</Button>
<Button onClick={onConfirm} disabled={pending}>
{pending ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
{isRestart ? "Restart" : "Stop"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+10 -8
View File
@@ -29,14 +29,6 @@ vi.mock("./profiles/ProfilesIndex", () => ({
ProfilesIndex: () => <section>Tool profiles</section>,
}));
vi.mock("./PoliciesTab", () => ({
PoliciesTab: () => <section>Policies tab</section>,
}));
vi.mock("./RuntimeTab", () => ({
RuntimeTab: () => <section>Runtime tab</section>,
}));
vi.mock("./AuditTab", () => ({
AuditTab: () => <section>Audit tab</section>,
}));
@@ -100,6 +92,16 @@ describe("ToolsAccess", () => {
},
);
it.each([
["runtime", "/apps/connections"],
["policies", "/apps/advanced/profiles"],
])("redirects the retired %s page to %s", async (tab, target) => {
mockParams.tab = tab;
await render();
expect(navigateMock).toHaveBeenCalledWith(expect.objectContaining({ to: target, replace: true }));
});
it("uses Profiles as the developer surface entry point", async () => {
await render();
+8 -6
View File
@@ -5,8 +5,6 @@ import { cn } from "@/lib/utils";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useCompany } from "@/context/CompanyContext";
import { ProfilesIndex } from "./profiles/ProfilesIndex";
import { PoliciesTab } from "./PoliciesTab";
import { RuntimeTab } from "./RuntimeTab";
import { AuditTab } from "./AuditTab";
import { GatewaysTab } from "./GatewaysTab";
import { PasteConfigTab } from "./PasteConfigTab";
@@ -24,10 +22,6 @@ function renderTab(tab: ToolTabKey, companyId: string) {
switch (tab) {
case "profiles":
return <ProfilesIndex companyId={companyId} />;
case "policies":
return <PoliciesTab companyId={companyId} />;
case "runtime":
return <RuntimeTab companyId={companyId} />;
case "audit":
return <AuditTab companyId={companyId} />;
case "gateways":
@@ -78,6 +72,14 @@ export function ToolsAccess() {
return <Navigate to="/apps/connections" replace />;
}
if (params.tab === "runtime") {
return <Navigate to="/apps/connections" replace />;
}
if (params.tab === "policies") {
return <Navigate to="/apps/advanced/profiles" replace />;
}
if (advanced) {
// M8a/M8b chrome (PAP-10839 wires): Advanced badge, plain-words subtitle,
// and a two-tab switcher. The developer surface stays behind a quiet link.
-4
View File
@@ -4,8 +4,6 @@ import {
Layers,
Network,
ScrollText,
Server,
Shield,
TerminalSquare,
} from "lucide-react";
@@ -35,8 +33,6 @@ export const ADVANCED_TABS = [
export const DEVELOPER_TABS = [
{ key: "gateways", label: "Gateways", icon: Network },
{ key: "profiles", label: "Profiles", icon: Layers },
{ key: "policies", label: "Rules", icon: Shield },
{ key: "runtime", label: "Health", icon: Server },
{ key: "audit", label: "Activity", icon: ScrollText },
{ key: "smoke-lab", label: "Smoke Lab", icon: FlaskConical },
] as const;