mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
Remove Rules and Health pages
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
84c2b30358
commit
6b503e114b
15 files changed
+37
-2428
No files matched your search
@@ -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", () => {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}");
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
Reference in new issue
Block a user