feat(ui): v2 Tools & Access deltas — Policies + Examples (PAP-10778)

- Policies: replace the policy cards with a real table — visible `#`
  evaluation-order column, match predicates as outline `label:value` pills
  (no more comma-soup), `DecisionBadge` effect column, and a right-aligned,
  mono `24h hits` column derived from the recent audit sample's
  `matchedPolicyIds` (footnoted as best-effort until a server counter ships).
- Examples: side-by-side two-up grid; header `smoke pass`/`smoke fail` status
  pill; inline destructive `runtime-error` banner with an intentionally
  friction-y "Install anyway" button when a smoke check trips the fails-closed
  path; footer row with "Last smoke N ago" + Install.

UI polish only. Typecheck (tsc -b) passes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-10 12:49:31 +00:00
1 parent 6215f46536
commit ccf1b84c65
2 files changed
+170 -55

No files matched your search

+52 -6
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, CircleSlash, PackagePlus, PlayCircle, Sparkles, XCircle } from "lucide-react";
import { AlertTriangle, CheckCircle2, CircleSlash, PackagePlus, PlayCircle, Sparkles, XCircle } from "lucide-react";
import type { ToolExampleSmokeResult } from "@paperclipai/shared";
import { queryKeys } from "@/lib/queryKeys";
import { toolsApi } from "@/api/tools";
@@ -15,6 +15,7 @@ import {
DecisionBadge,
ErrorState,
LoadingState,
RelativeTime,
RiskBadge,
ToolsPageHeader,
} from "./shared";
@@ -23,6 +24,12 @@ function errorMessage(error: unknown) {
return error instanceof ApiError ? error.message : error instanceof Error ? error.message : String(error);
}
/** A smoke check that exercised (and tripped) the runtime-error / fails-closed path. */
function isRuntimeErrorCheck(check: ToolExampleSmokeResult["checks"][number]): boolean {
const haystacks = [check.name, check.decision ?? "", check.reasonCode ?? ""];
return haystacks.some((h) => /runtime[_-]?error/i.test(String(h)));
}
function SmokeResult({ result }: { result: ToolExampleSmokeResult }) {
return (
<div className="rounded-md border border-border bg-muted/30 p-3">
@@ -60,6 +67,7 @@ export function ExamplesTab({ companyId }: { companyId: string }) {
const qc = useQueryClient();
const { pushToast } = useToast();
const [smokeResults, setSmokeResults] = useState<Record<string, ToolExampleSmokeResult>>({});
const [smokeRanAt, setSmokeRanAt] = useState<Record<string, number>>({});
const examples = useQuery({
queryKey: queryKeys.tools.examples(companyId),
@@ -97,6 +105,7 @@ export function ExamplesTab({ companyId }: { companyId: string }) {
mutationFn: (exampleId: string) => toolsApi.smokeExample(companyId, exampleId),
onSuccess: (result) => {
setSmokeResults((current) => ({ ...current, [result.exampleId]: result }));
setSmokeRanAt((current) => ({ ...current, [result.exampleId]: Date.now() }));
invalidateToolState();
pushToast({
title: result.ok ? "Smoke passed" : "Smoke failed",
@@ -131,12 +140,14 @@ export function ExamplesTab({ companyId }: { companyId: string }) {
description="Example fixtures are bundled with Paperclip and should appear here when the server exposes them."
/>
) : (
<div className="grid gap-3">
<div className="grid gap-3 lg:grid-cols-2">
{list.map((example) => {
const installed = example.install.installed;
const result = smokeResults[example.id];
const ranAt = smokeRanAt[example.id];
const hasRuntimeError = result ? result.checks.some(isRuntimeErrorCheck) : false;
return (
<Card key={example.id}>
<Card key={example.id} className={hasRuntimeError ? "border-destructive/40" : undefined}>
<CardContent className="space-y-4 py-4">
<div className="flex flex-wrap items-start gap-3">
<Sparkles className="mt-0.5 h-5 w-5 text-muted-foreground" />
@@ -147,18 +158,23 @@ export function ExamplesTab({ companyId }: { companyId: string }) {
{installed ? "installed" : "available"}
</Badge>
<Badge variant="outline">{example.fixture.transport}</Badge>
{result ? (
<Badge variant={result.ok ? "default" : "destructive"}>
{result.ok ? "smoke pass" : "smoke fail"}
</Badge>
) : null}
</div>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">{example.description}</p>
</div>
<div className="flex shrink-0 gap-1.5">
<Button
size="sm"
variant={installed ? "outline" : "default"}
variant={hasRuntimeError ? "outline" : installed ? "outline" : "default"}
disabled={!example.install.canInstall || install.isPending}
onClick={() => install.mutate(example.id)}
>
<PackagePlus className="mr-1 h-3.5 w-3.5" />
{installed ? "Repair" : "Install"}
{hasRuntimeError ? "Install anyway" : installed ? "Repair" : "Install"}
</Button>
<Button
size="sm"
@@ -172,7 +188,16 @@ export function ExamplesTab({ companyId }: { companyId: string }) {
</div>
</div>
{example.install.reason ? (
{hasRuntimeError ? (
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>
A smoke check hit a <span className="font-medium">runtime-error</span> path — the stdio
slot died and the gateway failed closed (the agent saw no partial output). The supervisor
restarts automatically; installing is intentionally friction-y until the slot is healthy.
</span>
</div>
) : example.install.reason ? (
<div className="flex items-start gap-2 rounded-md border border-border bg-muted/30 p-3 text-sm text-muted-foreground">
<CircleSlash className="mt-0.5 h-4 w-4 shrink-0" />
<span>{example.install.reason}</span>
@@ -207,6 +232,27 @@ export function ExamplesTab({ companyId }: { companyId: string }) {
</div>
{result ? <SmokeResult result={result} /> : null}
<div className="flex items-center justify-between border-t border-border pt-3 text-xs text-muted-foreground">
<span>
{ranAt ? (
<>
Last smoke <RelativeTime value={new Date(ranAt)} />
</>
) : (
"Not smoked this session"
)}
</span>
<Button
size="sm"
variant={hasRuntimeError ? "outline" : installed ? "outline" : "default"}
disabled={!example.install.canInstall || install.isPending}
onClick={() => install.mutate(example.id)}
>
<PackagePlus className="mr-1 h-3.5 w-3.5" />
{hasRuntimeError ? "Install anyway" : installed ? "Repair" : "Install"}
</Button>
</div>
</CardContent>
</Card>
);
+118 -49
View File
@@ -146,8 +146,9 @@ function parseJsonObject(value: string, label: string): Record<string, unknown>
return parsed;
}
function policyTypeLabel(type: ToolPolicyType | string) {
return POLICY_TYPES.find((item) => item.value === type)?.label ?? type.replaceAll("_", " ");
/** Map a policy's effect type onto the canonical DecisionBadge palette. */
function effectDecision(type: ToolPolicyType | string): string {
return type === "rate_limit" ? "rate_limited" : String(type);
}
function policyIcon(type: ToolPolicyType) {
@@ -362,6 +363,30 @@ export function PoliciesTab({ companyId }: { companyId: string }) {
queryKey: queryKeys.tools.trustRules(companyId),
queryFn: () => toolsApi.listTrustRules(companyId),
});
const audit = useQuery({
queryKey: queryKeys.tools.audit(companyId, 250),
queryFn: () => toolsApi.listAudit(companyId, 250),
});
// 24h hit count per policy, derived from the recent audit sample's
// `matchedPolicyIds`. This is a best-effort signal until the gateway exposes
// a server-side per-policy counter; it under-counts if traffic exceeds the
// 250-event sample window.
const hitsByPolicy = useMemo(() => {
const counts = new Map<string, number>();
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
for (const row of audit.data ?? []) {
const ts = new Date(row.createdAt).getTime();
if (Number.isFinite(ts) && ts < cutoff) continue;
const matched = row.details?.matchedPolicyIds;
if (Array.isArray(matched)) {
for (const id of matched) {
if (typeof id === "string") counts.set(id, (counts.get(id) ?? 0) + 1);
}
}
}
return counts;
}, [audit.data]);
const nameMaps = useMemo(() => ({
agent: new Map((agents.data ?? []).map((item) => [item.id, item.name])),
@@ -431,23 +456,22 @@ export function PoliciesTab({ companyId }: { companyId: string }) {
}),
});
function selectorSummary(policy: ToolPolicy) {
/** Match predicates as discrete `label:value` chips (vs the comma-soup summary). */
function selectorChips(policy: ToolPolicy): string[] {
const selectors = policy.selectors ?? {};
const parts: string[] = [];
const chips: string[] = [];
const add = (label: string, key: string, names?: Map<string, string>) => {
const values = [...stringList(selectors[key]), ...stringList(selectors[`${key}s`])];
if (values.length > 0) {
parts.push(`${label}: ${values.map((value) => names?.get(value) ?? value).join(", ")}`);
}
for (const value of values) chips.push(`${label}:${names?.get(value) ?? value}`);
};
add("actor", "actorType");
add("agent", "agentId", nameMaps.agent);
add("project", "projectId", nameMaps.project);
add("application", "applicationId", nameMaps.application);
add("connection", "connectionId", nameMaps.connection);
add("app", "applicationId", nameMaps.application);
add("conn", "connectionId", nameMaps.connection);
add("risk", "riskLevel");
add("tool", "toolName");
return parts.length > 0 ? parts.join(" · ") : "company-wide";
return chips;
}
function submitPolicy() {
@@ -503,46 +527,91 @@ export function PoliciesTab({ companyId }: { companyId: string }) {
onAction={() => setForm(emptyPolicyForm())}
/>
) : (
<div className="grid gap-2">
{policyList.map((policy) => (
<Card key={policy.id}>
<CardContent className="flex flex-wrap items-center gap-3 py-3">
{policyIcon(policy.policyType)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-foreground">{policy.name}</span>
<Badge variant="outline">{policyTypeLabel(policy.policyType)}</Badge>
<Badge variant="outline">priority {policy.priority}</Badge>
{!policy.enabled ? <Badge variant="outline">disabled</Badge> : null}
</div>
<p className="truncate text-xs text-muted-foreground">{selectorSummary(policy)}</p>
{policy.description ? (
<p className="truncate text-xs text-muted-foreground">{policy.description}</p>
) : null}
</div>
<span className="text-xs">
<RelativeTime value={policy.updatedAt} />
</span>
<div className="flex shrink-0 gap-1.5">
<Button size="sm" variant="outline" onClick={() => setForm(policyToForm(policy))}>
<Pencil className="mr-1 h-3.5 w-3.5" />
Edit
</Button>
<Button
size="sm"
variant="outline"
disabled={deletePolicy.isPending}
onClick={() => deletePolicy.mutate(policy.id)}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
Delete
</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 text-right font-medium">#</th>
<th className="px-3 py-2.5 font-medium">Policy</th>
<th className="px-3 py-2.5 font-medium">Effect</th>
<th className="px-3 py-2.5 font-medium">Match</th>
<th className="px-3 py-2.5 font-medium">Status</th>
<th className="px-3 py-2.5 text-right font-medium">24h hits</th>
<th className="px-4 py-2.5 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{policyList.map((policy, index) => {
const chips = selectorChips(policy);
return (
<tr key={policy.id} className="align-top">
<td className="px-3 py-3 text-right font-mono text-xs text-muted-foreground">{index + 1}</td>
<td className="px-3 py-3">
<div className="flex items-start gap-2">
{policyIcon(policy.policyType)}
<div className="min-w-0">
<div className="font-medium text-foreground">{policy.name}</div>
{policy.description ? (
<div className="truncate text-xs text-muted-foreground">{policy.description}</div>
) : null}
<div className="text-[11px] text-muted-foreground">priority {policy.priority}</div>
</div>
</div>
</td>
<td className="px-3 py-3">
<DecisionBadge decision={effectDecision(policy.policyType)} />
</td>
<td className="px-3 py-3">
{chips.length === 0 ? (
<span className="text-xs text-muted-foreground">company-wide</span>
) : (
<div className="flex max-w-xs flex-wrap gap-1">
{chips.map((chip) => (
<Badge key={chip} variant="outline" className="font-mono text-[11px]">
{chip}
</Badge>
))}
</div>
)}
</td>
<td className="px-3 py-3">
<Badge variant={policy.enabled ? "secondary" : "outline"}>
{policy.enabled ? "enabled" : "disabled"}
</Badge>
</td>
<td className="px-3 py-3 text-right font-mono text-xs text-foreground">
{hitsByPolicy.get(policy.id) ?? 0}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-1.5">
<Button size="sm" variant="outline" onClick={() => setForm(policyToForm(policy))}>
<Pencil className="mr-1 h-3.5 w-3.5" />
Edit
</Button>
<Button
size="sm"
variant="outline"
disabled={deletePolicy.isPending}
onClick={() => deletePolicy.mutate(policy.id)}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
Delete
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</CardContent>
</Card>
)}
<p className="text-xs text-muted-foreground">
Rows are shown in evaluation order (#). 24h hits are derived from the recent audit sample's matched
policies and may under-count high-traffic rules until a server-side counter ships.
</p>
</div>
<PolicySimulator companyId={companyId} />