feat(ui): Connections v2 — dense table + vault credential picker + inline probe (PAP-10785)

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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-10 12:49:31 +00:00
1 parent 4041289c7f
commit f203d1a0d8
3 files changed
+692 -112

No files matched your search

+206
View File
@@ -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<void>) {
let result: void | Promise<void> = 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>): 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>): 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>): 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<typeof createRoot> | 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(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<ConnectionsTab companyId="company-1" />
</ToastProvider>
</QueryClientProvider>,
);
});
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);
});
});
+315 -112
View File
@@ -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<string, string> = {
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<string, unknown>;
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<ToolConnection | null>(null);
const [probeResult, setProbeResult] = useState<ProbeResult | null>(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<ProbeResult> => {
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<string, unknown> =
@@ -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 (
<Dialog open onOpenChange={(o) => !o && onClose()}>
@@ -204,7 +277,7 @@ function NewConnectionDialog({ companyId, onClose }: { companyId: string; onClos
<div className="space-y-3">
<div className="space-y-1.5">
<Label>Application</Label>
<Select value={applicationId} onValueChange={setApplicationId} disabled={!!draft}>
<Select value={applicationId} onValueChange={setApplicationId} disabled={locked}>
<SelectTrigger>
<SelectValue placeholder="Select an application" />
</SelectTrigger>
@@ -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}
/>
</div>
@@ -234,7 +307,7 @@ function NewConnectionDialog({ companyId, onClose }: { companyId: string; onClos
<Select
value={transport}
onValueChange={(v) => setTransport(v as "remote_http" | "local_stdio")}
disabled={!!draft}
disabled={locked}
>
<SelectTrigger>
<SelectValue />
@@ -254,13 +327,13 @@ function NewConnectionDialog({ companyId, onClose }: { companyId: string; onClos
value={endpointUrl}
onChange={(e) => setEndpointUrl(e.target.value)}
placeholder="https://mcp.example.com"
disabled={!!draft}
disabled={locked}
/>
</div>
) : (
<div className="space-y-1.5">
<Label>Command template</Label>
<Select value={templateId} onValueChange={setTemplateId} disabled={!!draft}>
<Select value={templateId} onValueChange={setTemplateId} disabled={locked}>
<SelectTrigger>
<SelectValue placeholder="Select an approved template" />
</SelectTrigger>
@@ -278,22 +351,27 @@ function NewConnectionDialog({ companyId, onClose }: { companyId: string; onClos
</div>
)}
{/* Secret-reference picker — no free-text token field. */}
{/* Vault-reference credential picker — no free-text token field. */}
<div className="space-y-1.5">
<Label>Credential references</Label>
{creds.length > 0 ? (
<ul className="space-y-1">
{creds.map((c, i) => (
<li key={`${c.secretId}-${i}`} className="flex items-center gap-2 text-sm">
<KeyRound className="h-3.5 w-3.5 text-muted-foreground" />
<li
key={`${c.secretId}-${i}`}
className="flex items-center gap-2 rounded-md border border-border bg-muted/30 px-2 py-1.5 text-sm"
>
<KeyRound className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="font-mono text-xs">{c.headerName}</span>
<span className="text-muted-foreground">→ secret {secretName(c.secretId)}</span>
{!draft ? (
<span className="truncate font-mono text-xs text-primary" title={vaultRef(secretById(c.secretId))}>
→ {vaultRef(secretById(c.secretId))}
</span>
{!locked ? (
<button
type="button"
className="ml-auto text-muted-foreground hover:text-destructive"
onClick={() => setCreds((cs) => cs.filter((_, idx) => idx !== i))}
aria-label="Remove credential reference"
aria-label={`Remove credential reference for ${secretName(c.secretId)}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
@@ -302,47 +380,94 @@ function NewConnectionDialog({ companyId, onClose }: { companyId: string; onClos
))}
</ul>
) : null}
{!draft ? (
<div className="flex items-end gap-2">
<div className="flex-1 space-y-1">
<Select value={pendingSecretId} onValueChange={setPendingSecretId}>
<SelectTrigger>
<SelectValue placeholder="Secret" />
</SelectTrigger>
<SelectContent>
{(secrets.data ?? []).map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
{!locked ? (
<>
<div className="flex items-end gap-2">
<div className="flex-1 space-y-1">
<Select value={pendingSecretId} onValueChange={setPendingSecretId}>
<SelectTrigger>
<SelectValue placeholder="Select a vault secret" />
</SelectTrigger>
<SelectContent>
{(secrets.data ?? []).map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Input
value={pendingHeader}
onChange={(e) => setPendingHeader(e.target.value)}
placeholder="Header"
className="w-32"
aria-label="Header name"
/>
<Button type="button" size="sm" variant="outline" onClick={addCred} disabled={!pendingSecretId}>
Add
</Button>
</div>
<Input
value={pendingHeader}
onChange={(e) => setPendingHeader(e.target.value)}
placeholder="Header"
className="w-32"
aria-label="Header name"
/>
<Button type="button" size="sm" variant="outline" onClick={addCred} disabled={!pendingSecretId}>
Add
</Button>
</div>
{pendingSecretId ? (
<p className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground">
<Vault className="h-3 w-3" />
{vaultRef(secretById(pendingSecretId))}
</p>
) : null}
<p className="text-xs text-muted-foreground">
Free-text secrets are not accepted — pick a vault entry; Paperclip stores only the
<span className="font-mono"> vault://</span> reference and resolves it at gateway use time.
</p>
</>
) : null}
</div>
{draft ? (
<div className="rounded-md border border-border bg-muted/40 p-3 text-sm">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">Probe result</span>
<HealthBadge status={draft.healthStatus} />
{/* Inline probe panel — runs before activation, follows the Loading/Error rhythm. */}
{locked ? (
probe.isPending ? (
<div className="rounded-md border border-border bg-muted/40 p-3">
<LoadingState label="Probing connection…" />
</div>
{draft.healthMessage ? (
<p className="mt-1 text-xs text-muted-foreground">{draft.healthMessage}</p>
) : null}
{draft.lastError ? <p className="mt-1 text-xs text-destructive">{draft.lastError}</p> : null}
</div>
) : probe.isError ? (
<ErrorState error={probe.error} onRetry={() => draft && probe.mutate(draft.id)} />
) : probeResult ? (
<div className="rounded-md border border-border bg-muted/40 p-3">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-foreground">Probe result</span>
<HealthBadge status={probeResult.connection.healthStatus} />
</div>
<div className="mt-2 grid grid-cols-3 gap-2">
<div>
<p className="text-lg font-semibold tabular-nums text-foreground">
{probeResult.toolCount ?? "—"}
</p>
<p className="text-xs text-muted-foreground">tools discovered</p>
</div>
<div>
<p className="text-lg font-semibold tabular-nums text-foreground">
{probeResult.latencyMs != null ? `${probeResult.latencyMs}ms` : "—"}
</p>
<p className="text-xs text-muted-foreground">probe latency</p>
</div>
<div>
<p className="text-lg font-semibold tabular-nums text-foreground">
{probeResult.quarantinedCount}
</p>
<p className="text-xs text-muted-foreground">quarantined</p>
</div>
</div>
{probeResult.connection.healthMessage ? (
<p className="mt-2 text-xs text-muted-foreground">{probeResult.connection.healthMessage}</p>
) : null}
{probeResult.connection.lastError ? (
<p className="mt-1 text-xs text-destructive">{probeResult.connection.lastError}</p>
) : null}
<p className="mt-2 text-[11px] text-muted-foreground">
Probe latency is a single round-trip sample. Aggregate p95 latency across traffic is tracked on
the Runtime tab once the connection is live.
</p>
</div>
) : null
) : null}
</div>
@@ -350,21 +475,17 @@ function NewConnectionDialog({ companyId, onClose }: { companyId: string; onClos
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
{!draft ? (
{!locked ? (
<Button disabled={!canCreate} onClick={() => create.mutate()}>
{create.isPending ? "Creating draft…" : "Create & probe"}
</Button>
) : (
<>
<Button
variant="outline"
disabled={probe.isPending}
onClick={() => probe.mutate(draft.id)}
>
<Button variant="outline" disabled={probe.isPending} onClick={() => draft && probe.mutate(draft.id)}>
<Stethoscope className="mr-1 h-3.5 w-3.5" />
{probe.isPending ? "Probing…" : "Re-probe"}
</Button>
<Button disabled={activate.isPending} onClick={() => activate.mutate(draft.id)}>
<Button disabled={activate.isPending || probe.isPending} onClick={() => draft && activate.mutate(draft.id)}>
{activate.isPending ? "Activating…" : "Activate"}
</Button>
</>
@@ -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<string, string>();
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<string, number | null>();
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 <LoadingState />;
if (connections.error) return <ErrorState error={connections.error} onRetry={() => connections.refetch()} />;
const list = (connections.data?.connections ?? []).filter((c) => (c.status ?? "active") !== "archived");
return (
<div className="space-y-4">
<ToolsPageHeader
@@ -452,53 +605,103 @@ export function ConnectionsTab({ companyId }: { companyId: string }) {
onAction={() => setCreateOpen(true)}
/>
) : (
<div className="grid gap-3">
{list.map((conn) => (
<Card key={conn.id}>
<CardContent className="flex flex-wrap items-center gap-3 py-4">
<Plug className="h-5 w-5 text-muted-foreground" />
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-foreground">{conn.name}</span>
<Badge variant="outline">{conn.transport ?? "—"}</Badge>
<HealthBadge status={conn.healthStatus} />
{!conn.enabled ? <Badge variant="outline">disabled</Badge> : null}
</div>
<p className="text-xs text-muted-foreground">
{(conn.credentialRefs?.length ?? 0) + conn.credentialSecretRefs.length} credential
ref(s) · last refresh{" "}
<RelativeTime value={conn.lastCatalogRefreshAt ?? conn.updatedAt} />
{conn.lastError ? <span className="text-destructive"> · {conn.lastError}</span> : null}
</p>
</div>
<div className="ml-auto flex shrink-0 gap-1.5">
<Button
size="sm"
variant="outline"
disabled={healthCheck.isPending}
onClick={() => healthCheck.mutate(conn.id)}
>
<Stethoscope className="mr-1 h-3.5 w-3.5" />
Test
</Button>
<Button
size="sm"
variant="outline"
disabled={refresh.isPending}
onClick={() => refresh.mutate(conn.id)}
>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
Refresh
</Button>
<Button size="sm" variant="outline" onClick={() => setCatalogFor(conn)}>
<ListTree className="mr-1 h-3.5 w-3.5" />
Tools
</Button>
</div>
</CardContent>
</Card>
))}
</div>
<Card>
<CardContent className="px-0 py-0">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-muted-foreground">
<th className="px-3 py-2.5 font-medium">Connection</th>
<th className="px-3 py-2.5 font-medium">Application</th>
<th className="px-3 py-2.5 font-medium">Transport</th>
<th className="px-3 py-2.5 font-medium">Health</th>
<th className="px-3 py-2.5 font-medium">Catalog</th>
<th className="px-4 py-2.5 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{list.map((conn) => {
const endpoint = connectionEndpoint(conn);
const credCount = (conn.credentialRefs?.length ?? 0) + conn.credentialSecretRefs.length;
const catalogCount = catalogCountByConn.get(conn.id);
return (
<tr key={conn.id} className="align-top">
<td className="px-3 py-3">
<div className="flex items-start gap-2">
<Plug className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">{conn.name}</span>
{!conn.enabled ? <Badge variant="outline">disabled</Badge> : null}
{conn.status === "draft" ? <Badge variant="outline">draft</Badge> : null}
</div>
{endpoint ? (
<div className="truncate font-mono text-xs text-muted-foreground" title={endpoint}>
{endpoint}
</div>
) : null}
<div className="text-[11px] text-muted-foreground">
{credCount} credential ref{credCount === 1 ? "" : "s"}
</div>
</div>
</div>
</td>
<td className="px-3 py-3 text-foreground">
{appName.get(conn.applicationId) ?? <span className="text-muted-foreground">—</span>}
</td>
<td className="px-3 py-3">
<Badge variant="outline">
{TRANSPORT_LABEL[conn.transport ?? ""] ?? conn.transport ?? "—"}
</Badge>
</td>
<td className="px-3 py-3">
<HealthBadge status={conn.healthStatus} />
{conn.lastError ? (
<p className="mt-1 max-w-[14rem] truncate text-[11px] text-destructive" title={conn.lastError}>
{conn.lastError}
</p>
) : null}
</td>
<td className="px-3 py-3">
<div className="font-medium tabular-nums text-foreground">
{catalogCount == null ? "—" : `${catalogCount} tool${catalogCount === 1 ? "" : "s"}`}
</div>
<div className="text-[11px] text-muted-foreground">
refreshed <RelativeTime value={conn.lastCatalogRefreshAt ?? conn.updatedAt} />
</div>
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-1.5">
<Button
size="sm"
variant="outline"
disabled={healthCheck.isPending}
onClick={() => healthCheck.mutate(conn.id)}
>
<Stethoscope className="mr-1 h-3.5 w-3.5" />
Test
</Button>
<Button
size="sm"
variant="outline"
disabled={refresh.isPending}
onClick={() => refresh.mutate(conn.id)}
>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
Refresh
</Button>
<Button size="sm" variant="outline" onClick={() => setCatalogFor(conn)}>
<ListTree className="mr-1 h-3.5 w-3.5" />
Tools
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</CardContent>
</Card>
)}
{catalogFor ? <CatalogDialog connection={catalogFor} onClose={() => setCatalogFor(null)} /> : null}
@@ -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>): 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: () => (
<Fixtures>
<div className="min-h-screen w-full bg-background p-6">
<ConnectionsTab companyId={COMPANY_ID} />
</div>
</Fixtures>
),
};