mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
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:
1 parent
6215f46536
commit
ccf1b84c65
2 files changed
+170
-55
No files matched your search
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in new issue
Block a user