From ccf1b84c653dadf45a839d55463ea9e354ce81dd Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 10 Jun 2026 12:06:26 +0000 Subject: [PATCH] =?UTF-8?q?feat(ui):=20v2=20Tools=20&=20Access=20deltas=20?= =?UTF-8?q?=E2=80=94=20Policies=20+=20Examples=20(PAP-10778)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ui/src/pages/tools/ExamplesTab.tsx | 58 ++++++++-- ui/src/pages/tools/PoliciesTab.tsx | 167 ++++++++++++++++++++--------- 2 files changed, 170 insertions(+), 55 deletions(-) diff --git a/ui/src/pages/tools/ExamplesTab.tsx b/ui/src/pages/tools/ExamplesTab.tsx index c35b94238a8..9b5181f94d1 100644 --- a/ui/src/pages/tools/ExamplesTab.tsx +++ b/ui/src/pages/tools/ExamplesTab.tsx @@ -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 (
@@ -60,6 +67,7 @@ export function ExamplesTab({ companyId }: { companyId: string }) { const qc = useQueryClient(); const { pushToast } = useToast(); const [smokeResults, setSmokeResults] = useState>({}); + const [smokeRanAt, setSmokeRanAt] = useState>({}); 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." /> ) : ( -
+
{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 ( - +
@@ -147,18 +158,23 @@ export function ExamplesTab({ companyId }: { companyId: string }) { {installed ? "installed" : "available"} {example.fixture.transport} + {result ? ( + + {result.ok ? "smoke pass" : "smoke fail"} + + ) : null}

{example.description}

- {example.install.reason ? ( + {hasRuntimeError ? ( +
+ + + A smoke check hit a runtime-error 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. + +
+ ) : example.install.reason ? (
{example.install.reason} @@ -207,6 +232,27 @@ export function ExamplesTab({ companyId }: { companyId: string }) {
{result ? : null} + +
+ + {ranAt ? ( + <> + Last smoke + + ) : ( + "Not smoked this session" + )} + + +
); diff --git a/ui/src/pages/tools/PoliciesTab.tsx b/ui/src/pages/tools/PoliciesTab.tsx index 5d446cdd299..a6b0b8724ad 100644 --- a/ui/src/pages/tools/PoliciesTab.tsx +++ b/ui/src/pages/tools/PoliciesTab.tsx @@ -146,8 +146,9 @@ function parseJsonObject(value: string, label: string): Record 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(); + 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) => { 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())} /> ) : ( -
- {policyList.map((policy) => ( - - - {policyIcon(policy.policyType)} -
-
- {policy.name} - {policyTypeLabel(policy.policyType)} - priority {policy.priority} - {!policy.enabled ? disabled : null} -
-

{selectorSummary(policy)}

- {policy.description ? ( -

{policy.description}

- ) : null} -
- - - -
- - -
-
-
- ))} -
+ + + + + + + + + + + + + + + + {policyList.map((policy, index) => { + const chips = selectorChips(policy); + return ( + + + + + + + + + + ); + })} + +
#PolicyEffectMatchStatus24h hitsActions
{index + 1} +
+ {policyIcon(policy.policyType)} +
+
{policy.name}
+ {policy.description ? ( +
{policy.description}
+ ) : null} +
priority {policy.priority}
+
+
+
+ + + {chips.length === 0 ? ( + company-wide + ) : ( +
+ {chips.map((chip) => ( + + {chip} + + ))} +
+ )} +
+ + {policy.enabled ? "enabled" : "disabled"} + + + {hitsByPolicy.get(policy.id) ?? 0} + +
+ + +
+
+
+
)} +

+ 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. +