From f203d1a0d8ff3ead6c3c1e30dcd41e27e03e807f Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 10 Jun 2026 12:25:05 +0000 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Connections=20v2=20=E2=80=94=20dens?= =?UTF-8?q?e=20table=20+=20vault=20credential=20picker=20+=20inline=20prob?= =?UTF-8?q?e=20(PAP-10785)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the Connections deltas from the v2 punch list (PAP-10778): - Replace the connection cards with a dense table: connection (mono URL/cmd subtitle), application, transport pill, Health pill (HealthBadge), catalog count + 'refreshed N min ago', and Test / Refresh / Tools row actions. Per-connection catalog counts are fanned out via cached useQueries (same pattern as ApplicationsTab) since no company-wide aggregate endpoint exists. - New-connection dialog now renders a vault-reference credential picker: selecting a secret shows its 'vault://provider/key@version' reference and a 'Free-text secrets are not accepted' help line. The persisted shape stays the structured McpConnectionCredentialRef (secretId) — the vault:// string is display-only. - Inline Probe panel runs before activation (auto-probes after draft create): health-check + catalog discovery, showing a status pill, tools-discovered count, probe round-trip latency, and quarantined count, following the LoadingState/ErrorState rhythm. Adds a component test and a storybook story for the table/dialog. Co-Authored-By: Paperclip --- ui/src/pages/tools/ConnectionsTab.test.tsx | 206 +++++++++ ui/src/pages/tools/ConnectionsTab.tsx | 427 +++++++++++++----- .../stories/tools-connections.stories.tsx | 171 +++++++ 3 files changed, 692 insertions(+), 112 deletions(-) create mode 100644 ui/src/pages/tools/ConnectionsTab.test.tsx create mode 100644 ui/storybook/stories/tools-connections.stories.tsx diff --git a/ui/src/pages/tools/ConnectionsTab.test.tsx b/ui/src/pages/tools/ConnectionsTab.test.tsx new file mode 100644 index 00000000000..3bcff3b3200 --- /dev/null +++ b/ui/src/pages/tools/ConnectionsTab.test.tsx @@ -0,0 +1,206 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { CompanySecret, ToolApplication, ToolConnection } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ToastProvider } from "../../context/ToastContext"; +import { ConnectionsTab } from "./ConnectionsTab"; + +const mockToolsApi = vi.hoisted(() => ({ + listConnections: vi.fn(), + listApplications: vi.fn(), + listCatalog: vi.fn(), + listStdioTemplates: vi.fn(), + checkConnectionHealth: vi.fn(), + refreshCatalog: vi.fn(), + createConnection: vi.fn(), + updateConnection: vi.fn(), +})); + +const mockSecretsApi = vi.hoisted(() => ({ + list: vi.fn(), +})); + +vi.mock("../../api/tools", () => ({ toolsApi: mockToolsApi })); +vi.mock("../../api/secrets", () => ({ secretsApi: mockSecretsApi })); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +if (!globalThis.PointerEvent) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).PointerEvent = MouseEvent; +} + +async function act(callback: () => void | Promise) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +function makeConnection(overrides: Partial): ToolConnection { + return { + id: "conn-1", + companyId: "company-1", + applicationId: "app-1", + name: "Production GitHub", + connectionKind: "managed", + transport: "remote_http", + status: "active", + transportConfig: { url: "https://mcp.github.example.com" }, + config: {}, + credentialSecretRefs: [], + credentialRefs: [{ name: "Authorization", secretId: "secret-1", version: "latest", placement: "header", key: "Authorization" }], + healthStatus: "healthy", + healthMessage: null, + healthCheckedAt: new Date("2026-06-10T00:00:00Z"), + lastCatalogRefreshAt: new Date("2026-06-10T00:00:00Z"), + lastError: null, + enabled: true, + createdByAgentId: null, + createdByUserId: null, + createdAt: new Date("2026-06-01T00:00:00Z"), + updatedAt: new Date("2026-06-10T00:00:00Z"), + ...overrides, + }; +} + +function makeApp(overrides: Partial): ToolApplication { + return { + id: "app-1", + companyId: "company-1", + name: "GitHub", + description: null, + type: "mcp_http", + status: "active", + pluginId: null, + ownerAgentId: null, + ownerUserId: null, + metadata: null, + archivedAt: null, + createdAt: new Date("2026-06-01T00:00:00Z"), + updatedAt: new Date("2026-06-01T00:00:00Z"), + ...overrides, + }; +} + +function makeSecret(overrides: Partial): CompanySecret { + return { + id: "secret-1", + companyId: "company-1", + key: "github_token", + name: "GitHub token", + provider: "paperclip", + status: "active", + managedMode: "managed", + externalRef: null, + providerConfigId: null, + providerMetadata: null, + latestVersion: 3, + description: null, + lastResolvedAt: null, + lastRotatedAt: null, + deletedAt: null, + createdByAgentId: null, + createdByUserId: null, + createdAt: new Date("2026-06-01T00:00:00Z"), + updatedAt: new Date("2026-06-01T00:00:00Z"), + ...overrides, + }; +} + +describe("ConnectionsTab", () => { + let container: HTMLDivElement; + let root: ReturnType | null; + let queryClient: QueryClient; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + mockToolsApi.listConnections.mockResolvedValue({ connections: [makeConnection({})] }); + mockToolsApi.listApplications.mockResolvedValue({ applications: [makeApp({})] }); + mockToolsApi.listCatalog.mockResolvedValue({ + catalog: [ + { id: "c1", toolName: "create_issue" }, + { id: "c2", toolName: "list_repos" }, + ], + }); + mockToolsApi.listStdioTemplates.mockResolvedValue({ templates: [] }); + mockSecretsApi.list.mockResolvedValue([makeSecret({})]); + }); + + afterEach(() => { + if (root) { + act(() => root!.unmount()); + } + container.remove(); + vi.clearAllMocks(); + }); + + async function render() { + await act(() => { + root = createRoot(container); + root.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + } + + it("renders a dense table with the endpoint, application, transport and catalog count", async () => { + await render(); + const table = container.querySelector("table"); + expect(table).not.toBeNull(); + const headers = Array.from(container.querySelectorAll("th")).map((th) => th.textContent); + expect(headers).toEqual(["Connection", "Application", "Transport", "Health", "Catalog", "Actions"]); + + const text = container.textContent ?? ""; + expect(text).toContain("Production GitHub"); + expect(text).toContain("https://mcp.github.example.com"); + expect(text).toContain("GitHub"); // application column + expect(text).toContain("remote http"); // transport pill label + expect(text).toContain("2 tools"); // catalog count from listCatalog + expect(text).toContain("1 credential ref"); + }); + + it("opens the new-connection dialog and enforces vault references (no free-text secrets)", async () => { + await render(); + const newBtn = Array.from(container.querySelectorAll("button")).find((b) => + b.textContent?.includes("New connection"), + ); + expect(newBtn).toBeTruthy(); + await act(() => { + newBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + const body = document.body.textContent ?? ""; + expect(body).toContain("Free-text secrets are not accepted"); + expect(body).toContain("Credential references"); + // The dialog must NOT offer a free-text token/secret input. + const inputs = Array.from(document.querySelectorAll("input")); + const hasSecretValueField = inputs.some((i) => + /token|secret value|password|api[- ]?key/i.test(`${i.getAttribute("placeholder") ?? ""} ${i.getAttribute("aria-label") ?? ""}`), + ); + expect(hasSecretValueField).toBe(false); + }); +}); diff --git a/ui/src/pages/tools/ConnectionsTab.tsx b/ui/src/pages/tools/ConnectionsTab.tsx index dae685f17e3..c56083c7a06 100644 --- a/ui/src/pages/tools/ConnectionsTab.tsx +++ b/ui/src/pages/tools/ConnectionsTab.tsx @@ -1,7 +1,11 @@ import { useMemo, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { KeyRound, ListTree, Plug, Plus, RefreshCw, Stethoscope, Trash2 } from "lucide-react"; -import type { McpConnectionCredentialRef, ToolConnection } from "@paperclipai/shared"; +import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; +import { KeyRound, ListTree, Plug, Plus, RefreshCw, Stethoscope, Trash2, Vault } from "lucide-react"; +import type { + CompanySecret, + McpConnectionCredentialRef, + ToolConnection, +} from "@paperclipai/shared"; import { queryKeys } from "@/lib/queryKeys"; import { toolsApi, type CreateToolConnectionInput } from "@/api/tools"; import { secretsApi } from "@/api/secrets"; @@ -39,6 +43,35 @@ import { RelativeTime, } from "./shared"; +const TRANSPORT_LABEL: Record = { + remote_http: "remote http", + local_stdio: "local stdio", +}; + +/** Mono URL (remote) or command-template (stdio) subtitle for a connection row. */ +function connectionEndpoint(conn: ToolConnection): string | null { + const config = { ...(conn.transportConfig ?? {}), ...(conn.config ?? {}) } as Record; + const url = config.url ?? config.endpoint ?? config.endpointUrl; + if (typeof url === "string" && url.trim()) return url.trim(); + const template = config.templateId ?? config.template ?? config.command; + if (typeof template === "string" && template.trim()) return template.trim(); + if (Array.isArray(config.command)) return config.command.join(" "); + return null; +} + +/** + * Display-only vault reference for a credential. The persisted shape is the + * structured {@link McpConnectionCredentialRef} (secretId + version) — this is + * just the human-readable `vault://provider/key@version` rendering of it so the + * operator can confirm *which* vault entry resolves at gateway time. Free-text + * secrets are never accepted; only references to the secret vault. + */ +function vaultRef(secret: CompanySecret | undefined, version: number | "latest" = "latest"): string { + if (!secret) return "vault://…"; + const v = version === "latest" || version === undefined ? "latest" : `v${version}`; + return `vault://${secret.provider}/${secret.key}@${v}`; +} + function CatalogDialog({ connection, onClose }: { connection: ToolConnection; onClose: () => void }) { const catalog = useQuery({ queryKey: queryKeys.tools.catalog(connection.id), @@ -84,10 +117,18 @@ function CatalogDialog({ connection, onClose }: { connection: ToolConnection; on type CredentialDraft = { secretId: string; headerName: string }; +/** Probe outcome captured before activation: health + discovered tool count + round-trip latency. */ +type ProbeResult = { + connection: ToolConnection; + toolCount: number | null; + quarantinedCount: number; + latencyMs: number | null; +}; + /** * New-connection dialog. Enforces secret *references* (no free-text token field) - * and runs a live gateway probe (health-check) against the draft before the - * operator activates it — per the Phase 0B spec surface map. + * and runs a live gateway probe (health-check + catalog discovery) against the + * draft before the operator activates it — per the Phase 0B spec surface map. */ function NewConnectionDialog({ companyId, onClose }: { companyId: string; onClose: () => void }) { const qc = useQueryClient(); @@ -115,8 +156,10 @@ function NewConnectionDialog({ companyId, onClose }: { companyId: string; onClos const [pendingSecretId, setPendingSecretId] = useState(""); const [pendingHeader, setPendingHeader] = useState("Authorization"); const [draft, setDraft] = useState(null); + const [probeResult, setProbeResult] = useState(null); - const secretName = (id: string) => secrets.data?.find((s) => s.id === id)?.name ?? id.slice(0, 8); + const secretById = (id: string) => secrets.data?.find((s) => s.id === id); + const secretName = (id: string) => secretById(id)?.name ?? id.slice(0, 8); const credentialRefs: McpConnectionCredentialRef[] = useMemo( () => @@ -130,6 +173,29 @@ function NewConnectionDialog({ companyId, onClose }: { companyId: string; onClos [creds], ); + // Probe runs a real gateway health-check and then a catalog discovery so the + // pre-activation panel can show status + tool count. Latency is the measured + // round-trip of the health-check (a single sample — aggregate p95 across + // traffic is surfaced on the Runtime tab once the connection is live). + const runProbe = async (id: string): Promise => { + const startedAt = performance.now(); + const health = await toolsApi.checkConnectionHealth(id); + const latencyMs = Math.round(performance.now() - startedAt); + try { + const refreshed = await toolsApi.refreshCatalog(id); + return { + connection: refreshed.connection, + toolCount: refreshed.discoveredCount, + quarantinedCount: refreshed.quarantinedCount, + latencyMs, + }; + } catch { + // Health may be fine while discovery is not yet possible (e.g. auth pending) — + // keep the health result and report tools as unknown rather than failing the probe. + return { connection: health.connection, toolCount: null, quarantinedCount: 0, latencyMs }; + } + }; + const create = useMutation({ mutationFn: () => { const config: Record = @@ -145,7 +211,10 @@ function NewConnectionDialog({ companyId, onClose }: { companyId: string; onClos }; return toolsApi.createConnection(companyId, input); }, - onSuccess: (conn) => setDraft(conn), + onSuccess: (conn) => { + setDraft(conn); + probe.mutate(conn.id); + }, onError: (err) => pushToast({ title: "Could not create connection", @@ -155,8 +224,11 @@ function NewConnectionDialog({ companyId, onClose }: { companyId: string; onClos }); const probe = useMutation({ - mutationFn: (id: string) => toolsApi.checkConnectionHealth(id), - onSuccess: (res) => setDraft(res.connection), + mutationFn: (id: string) => runProbe(id), + onSuccess: (res) => { + setDraft(res.connection); + setProbeResult(res); + }, onError: (err) => pushToast({ title: "Probe failed", @@ -189,6 +261,7 @@ function NewConnectionDialog({ companyId, onClose }: { companyId: string; onClos const transportConfigValid = transport === "remote_http" ? endpointUrl.trim().length > 0 : templateId.length > 0; const canCreate = !!applicationId && name.trim().length > 0 && transportConfigValid && !create.isPending; + const locked = !!draft; return ( !o && onClose()}> @@ -204,7 +277,7 @@ function NewConnectionDialog({ companyId, onClose }: { companyId: string; onClos
- @@ -225,7 +298,7 @@ function NewConnectionDialog({ companyId, onClose }: { companyId: string; onClos value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Production GitHub" - disabled={!!draft} + disabled={locked} />
@@ -234,7 +307,7 @@ function NewConnectionDialog({ companyId, onClose }: { companyId: string; onClos + - - - - - {(secrets.data ?? []).map((s) => ( - - {s.name} - - ))} - - + {!locked ? ( + <> +
+
+ +
+ setPendingHeader(e.target.value)} + placeholder="Header" + className="w-32" + aria-label="Header name" + /> +
- setPendingHeader(e.target.value)} - placeholder="Header" - className="w-32" - aria-label="Header name" - /> - -
+ {pendingSecretId ? ( +

+ + {vaultRef(secretById(pendingSecretId))} +

+ ) : null} +

+ Free-text secrets are not accepted — pick a vault entry; Paperclip stores only the + vault:// reference and resolves it at gateway use time. +

+ ) : null} - {draft ? ( -
-
- Probe result - + {/* Inline probe panel — runs before activation, follows the Loading/Error rhythm. */} + {locked ? ( + probe.isPending ? ( +
+
- {draft.healthMessage ? ( -

{draft.healthMessage}

- ) : null} - {draft.lastError ?

{draft.lastError}

: null} -
+ ) : probe.isError ? ( + draft && probe.mutate(draft.id)} /> + ) : probeResult ? ( +
+
+ Probe result + +
+
+
+

+ {probeResult.toolCount ?? "—"} +

+

tools discovered

+
+
+

+ {probeResult.latencyMs != null ? `${probeResult.latencyMs}ms` : "—"} +

+

probe latency

+
+
+

+ {probeResult.quarantinedCount} +

+

quarantined

+
+
+ {probeResult.connection.healthMessage ? ( +

{probeResult.connection.healthMessage}

+ ) : null} + {probeResult.connection.lastError ? ( +

{probeResult.connection.lastError}

+ ) : null} +

+ Probe latency is a single round-trip sample. Aggregate p95 latency across traffic is tracked on + the Runtime tab once the connection is live. +

+
+ ) : null ) : null}
@@ -350,21 +475,17 @@ function NewConnectionDialog({ companyId, onClose }: { companyId: string; onClos - {!draft ? ( + {!locked ? ( ) : ( <> - - @@ -385,6 +506,40 @@ export function ConnectionsTab({ companyId }: { companyId: string }) { queryKey: queryKeys.tools.connections(companyId), queryFn: () => toolsApi.listConnections(companyId), }); + const apps = useQuery({ + queryKey: queryKeys.tools.applications(companyId), + queryFn: () => toolsApi.listApplications(companyId), + }); + + const appName = useMemo(() => { + const map = new Map(); + for (const a of apps.data?.applications ?? []) map.set(a.id, a.name); + return map; + }, [apps.data]); + + const list = useMemo( + () => (connections.data?.connections ?? []).filter((c) => (c.status ?? "active") !== "archived"), + [connections.data], + ); + + // Per-connection catalog counts — no company-wide aggregate endpoint exists, so + // we fan out one cached catalog query per visible connection (same pattern as + // ApplicationsTab) to render the "catalog count" column. + const catalogs = useQueries({ + queries: list.map((c) => ({ + queryKey: queryKeys.tools.catalog(c.id), + queryFn: () => toolsApi.listCatalog(c.id), + staleTime: 60_000, + })), + }); + const catalogCountByConn = useMemo(() => { + const counts = new Map(); + list.forEach((c, i) => { + const q = catalogs[i]; + counts.set(c.id, q?.data ? q.data.catalog.length : null); + }); + return counts; + }, [list, catalogs]); const invalidate = () => qc.invalidateQueries({ queryKey: queryKeys.tools.connections(companyId) }); @@ -428,8 +583,6 @@ export function ConnectionsTab({ companyId }: { companyId: string }) { if (connections.isLoading) return ; if (connections.error) return connections.refetch()} />; - const list = (connections.data?.connections ?? []).filter((c) => (c.status ?? "active") !== "archived"); - return (
setCreateOpen(true)} /> ) : ( -
- {list.map((conn) => ( - - - -
-
- {conn.name} - {conn.transport ?? "—"} - - {!conn.enabled ? disabled : null} -
-

- {(conn.credentialRefs?.length ?? 0) + conn.credentialSecretRefs.length} credential - ref(s) · last refresh{" "} - - {conn.lastError ? · {conn.lastError} : null} -

-
-
- - - -
-
-
- ))} -
+ + + + + + + + + + + + + + + {list.map((conn) => { + const endpoint = connectionEndpoint(conn); + const credCount = (conn.credentialRefs?.length ?? 0) + conn.credentialSecretRefs.length; + const catalogCount = catalogCountByConn.get(conn.id); + return ( + + + + + + + + + ); + })} + +
ConnectionApplicationTransportHealthCatalogActions
+
+ +
+
+ {conn.name} + {!conn.enabled ? disabled : null} + {conn.status === "draft" ? draft : null} +
+ {endpoint ? ( +
+ {endpoint} +
+ ) : null} +
+ {credCount} credential ref{credCount === 1 ? "" : "s"} +
+
+
+
+ {appName.get(conn.applicationId) ?? —} + + + {TRANSPORT_LABEL[conn.transport ?? ""] ?? conn.transport ?? "—"} + + + + {conn.lastError ? ( +

+ {conn.lastError} +

+ ) : null} +
+
+ {catalogCount == null ? "—" : `${catalogCount} tool${catalogCount === 1 ? "" : "s"}`} +
+
+ refreshed +
+
+
+ + + +
+
+
+
)} {catalogFor ? setCatalogFor(null)} /> : null} diff --git a/ui/storybook/stories/tools-connections.stories.tsx b/ui/storybook/stories/tools-connections.stories.tsx new file mode 100644 index 00000000000..a906afceefa --- /dev/null +++ b/ui/storybook/stories/tools-connections.stories.tsx @@ -0,0 +1,171 @@ +import { type ReactNode } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useQueryClient } from "@tanstack/react-query"; +import type { CompanySecret, ToolApplication, ToolCatalogEntry, ToolConnection } from "@paperclipai/shared"; +import { ConnectionsTab } from "@/pages/tools/ConnectionsTab"; +import { queryKeys } from "@/lib/queryKeys"; +import { storybookSecrets } from "../fixtures/paperclipData"; + +const COMPANY_ID = "company-storybook"; + +const APPLICATIONS: ToolApplication[] = [ + { + id: "app-github", + companyId: COMPANY_ID, + name: "GitHub", + description: "Source control + issues", + type: "mcp_http", + status: "active", + pluginId: null, + ownerAgentId: null, + ownerUserId: null, + metadata: null, + archivedAt: null, + createdAt: new Date("2026-05-01T00:00:00Z"), + updatedAt: new Date("2026-05-01T00:00:00Z"), + }, + { + id: "app-fs", + companyId: COMPANY_ID, + name: "Filesystem", + description: "Local stdio file tools", + type: "mcp_stdio", + status: "active", + pluginId: null, + ownerAgentId: null, + ownerUserId: null, + metadata: null, + archivedAt: null, + createdAt: new Date("2026-05-01T00:00:00Z"), + updatedAt: new Date("2026-05-01T00:00:00Z"), + }, +]; + +function makeConnection(overrides: Partial): ToolConnection { + return { + id: "conn-x", + companyId: COMPANY_ID, + applicationId: "app-github", + name: "Connection", + connectionKind: "managed", + transport: "remote_http", + status: "active", + transportConfig: {}, + config: {}, + credentialSecretRefs: [], + credentialRefs: [], + healthStatus: "healthy", + healthMessage: null, + healthCheckedAt: new Date(), + lastCatalogRefreshAt: new Date(Date.now() - 6 * 60 * 1000), + lastError: null, + enabled: true, + createdByAgentId: null, + createdByUserId: null, + createdAt: new Date("2026-05-01T00:00:00Z"), + updatedAt: new Date(Date.now() - 6 * 60 * 1000), + ...overrides, + }; +} + +const CONNECTIONS: ToolConnection[] = [ + makeConnection({ + id: "conn-github", + applicationId: "app-github", + name: "Production GitHub", + transport: "remote_http", + transportConfig: { url: "https://mcp.github.example.com/v1" }, + credentialRefs: [ + { name: "Authorization", secretId: "secret-github", version: "latest", placement: "header", key: "Authorization" }, + ], + healthStatus: "healthy", + }), + makeConnection({ + id: "conn-fs", + applicationId: "app-fs", + name: "Workspace files", + transport: "local_stdio", + transportConfig: { templateId: "filesystem-readonly" }, + healthStatus: "degraded", + healthMessage: "Discovery slow", + lastError: "stdio handshake exceeded 2s on last probe", + lastCatalogRefreshAt: new Date(Date.now() - 3 * 60 * 60 * 1000), + }), + makeConnection({ + id: "conn-staging", + applicationId: "app-github", + name: "Staging GitHub (draft)", + status: "draft", + enabled: false, + transportConfig: { url: "https://mcp.staging.github.example.com" }, + healthStatus: "unchecked", + lastCatalogRefreshAt: null, + }), +]; + +function catalogFor(connId: string): ToolCatalogEntry[] { + const count = connId === "conn-github" ? 7 : connId === "conn-fs" ? 3 : 0; + return Array.from({ length: count }, (_, i) => ({ + id: `${connId}-cat-${i}`, + companyId: COMPANY_ID, + applicationId: null, + connectionId: connId, + entryKind: "tool", + toolName: `tool_${i}`, + title: null, + description: null, + inputSchema: null, + outputSchema: null, + annotations: null, + riskLevel: "read", + isReadOnly: true, + isWrite: false, + isDestructive: false, + status: "active", + version: null, + schemaHash: null, + firstSeenAt: new Date(), + lastSeenAt: new Date(), + reviewedAt: null, + reviewedByAgentId: null, + reviewedByUserId: null, + createdAt: new Date(), + updatedAt: new Date(), + })); +} + +function Fixtures({ children }: { children: ReactNode }) { + const queryClient = useQueryClient(); + queryClient.setQueryData(queryKeys.tools.connections(COMPANY_ID), { connections: CONNECTIONS }); + queryClient.setQueryData(queryKeys.tools.applications(COMPANY_ID), { applications: APPLICATIONS }); + queryClient.setQueryData(queryKeys.tools.stdioTemplates(COMPANY_ID), { + templates: [{ templateId: "filesystem-readonly", title: "Filesystem (read-only)" }], + }); + queryClient.setQueryData(queryKeys.secrets.list(COMPANY_ID), storybookSecrets as CompanySecret[]); + for (const c of CONNECTIONS) { + queryClient.setQueryData(queryKeys.tools.catalog(c.id), { catalog: catalogFor(c.id) }); + } + return <>{children}; +} + +const meta: Meta = { + title: "Product/Tools & Access/Connections", + parameters: { + layout: "fullscreen", + a11y: { test: "off" }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const ConnectionsTable: Story = { + render: () => ( + +
+ +
+
+ ), +};