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}
install.mutate(example.id)}
>
- {installed ? "Repair" : "Install"}
+ {hasRuntimeError ? "Install anyway" : installed ? "Repair" : "Install"}
- {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"
+ )}
+
+
install.mutate(example.id)}
+ >
+
+ {hasRuntimeError ? "Install anyway" : installed ? "Repair" : "Install"}
+
+
);
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}
-
-
-
-
-
-
setForm(policyToForm(policy))}>
-
- Edit
-
-
deletePolicy.mutate(policy.id)}
- >
-
- Delete
-
-
-
-
- ))}
-
+
+
+
+
+
+ #
+ Policy
+ Effect
+ Match
+ Status
+ 24h hits
+ Actions
+
+
+
+ {policyList.map((policy, index) => {
+ const chips = selectorChips(policy);
+ return (
+
+ {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}
+
+
+
+
setForm(policyToForm(policy))}>
+
+ Edit
+
+
deletePolicy.mutate(policy.id)}
+ >
+
+ Delete
+
+
+
+
+ );
+ })}
+
+
+
+
)}
+
+ 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.
+