Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
363 changes: 272 additions & 91 deletions ui/observe/app/(observe)/agents/[id]/page.tsx

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions ui/observe/app/(observe)/agents/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ export default function AgentsPage() {
<th className="text-right px-3.5 py-2.5 text-[var(--color-text-muted)] font-medium text-xs uppercase tracking-wider">
Last Active
</th>
<th className="text-right px-3.5 py-2.5 text-[var(--color-text-muted)] font-medium text-xs uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody>
Expand Down Expand Up @@ -201,6 +203,18 @@ export default function AgentsPage() {
<td className="px-3.5 py-2.5 text-right font-mono text-[var(--color-text-muted)] text-[11px]">
{lastActive}
</td>
<td className="px-3.5 py-2.5 text-right">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
router.push(`/agents/${encodeURIComponent(agent.agent_id)}?tab=policies`);
}}
className="text-[10px] px-2 py-1 bg-[var(--color-accent-teal-dim)] text-[var(--color-accent-teal)] rounded-sm hover:bg-[var(--color-accent-teal-dim)] transition-colors"
>
Permissions
</button>
</td>
</tr>
);
})}
Expand Down
151 changes: 138 additions & 13 deletions ui/observe/app/(observe)/decisions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ import type {
import ErrorDisplay from "@/components/ErrorDisplay";
import StatCard from "@/components/StatCard";
import PolicyBuilder from "@/components/PolicyBuilder";
import DecisionGroup from "@/components/DecisionGroup";
import BatchApproveBar from "@/components/BatchApproveBar";
import {
redactSensitiveFields,
groupByDate,
} from "@/lib/utils";
import { groupDecisions, type GroupingStrategy } from "@/lib/decision-grouping";


const ALL_TENANTS = "__all__";
Expand Down Expand Up @@ -240,6 +243,9 @@ export default function DecisionsPage() {
const [actingIds, setActingIds] = useState<Set<string>>(new Set());
const [actionError, setActionError] = useState<string | null>(null);
const [liveDecisions, setLiveDecisions] = useState<PendingDecision[]>([]);
const [batchMode, setBatchMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [groupingStrategy, setGroupingStrategy] = useState<GroupingStrategy>("action_resource");

const loadInitial = useCallback(async () => {
setInitialLoading(true);
Expand Down Expand Up @@ -349,6 +355,71 @@ export default function DecisionsPage() {
return data.decisions.filter((d) => d.status !== "pending");
}, [data]);

const pendingGroups = useMemo(
() => groupDecisions(pendingDecisions, groupingStrategy),
[pendingDecisions, groupingStrategy],
);

const selectedDecisions = useMemo(
() => pendingDecisions.filter((d) => selectedIds.has(d.id)),
[pendingDecisions, selectedIds],
);

const handleToggleSelect = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);

const handleToggleGroup = useCallback((ids: string[]) => {
setSelectedIds((prev) => {
const next = new Set(prev);
const allSelected = ids.every((id) => next.has(id));
if (allSelected) {
for (const id of ids) next.delete(id);
} else {
for (const id of ids) next.add(id);
}
return next;
});
}, []);

const handleBatchApprove = useCallback(
async (ids: string[], matrix: PolicyScopeMatrix) => {
setActionError(null);
for (const id of ids) {
setActingIds((prev) => new Set(prev).add(id));
}
const allDecisions = data?.decisions || [];
const results = await Promise.allSettled(
ids.map((id) => {
const decision = allDecisions.find((d) => d.id === id);
return approveDecision(decision?.tenant || "", id, matrix);
}),
);
const succeeded = results.filter((r) => r.status === "fulfilled").length;
const failed = results.filter((r) => r.status === "rejected").length;
if (failed > 0) {
const firstError = results.find((r) => r.status === "rejected") as PromiseRejectedResult;
setActionError(`${failed} approval(s) failed: ${firstError.reason}`);
}
setSelectedIds(new Set());
for (const id of ids) {
setActingIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
}
await decisionsPoll.refresh();
return { succeeded, failed };
},
[decisionsPoll, data],
);

const groupedHistory = useMemo(
() => groupByDate(resolvedDecisions, (d) => d.decided_at),
[resolvedDecisions],
Expand Down Expand Up @@ -425,6 +496,34 @@ export default function DecisionsPage() {
<option value="denied">Denied</option>
<option value="expired">Expired</option>
</select>
{pendingDecisions.length > 1 && (
<>
<button
onClick={() => {
setBatchMode(!batchMode);
setSelectedIds(new Set());
}}
className={`px-2.5 py-1.5 text-xs rounded-sm transition-colors ${
batchMode
? "bg-[var(--color-accent-teal-dim)] text-[var(--color-accent-teal)] ring-1 ring-[var(--color-accent-teal)]"
: "bg-[var(--color-bg-elevated)] text-[var(--color-text-secondary)] hover:bg-[var(--color-border-hover)]"
}`}
>
Batch
</button>
{batchMode && (
<select
value={groupingStrategy}
onChange={(e) => setGroupingStrategy(e.target.value as GroupingStrategy)}
className="bg-[var(--color-bg-surface)] text-[var(--color-text-secondary)] text-xs rounded-sm px-2 py-1.5 focus:outline-none"
>
<option value="action_resource">By action + type</option>
<option value="agent_action">By agent + action</option>
<option value="agent_type_action">By agent type + action</option>
</select>
)}
</>
)}
{resolvedDecisions.length > 0 && (
<button
onClick={() => exportDecisions(data?.decisions ?? [])}
Expand Down Expand Up @@ -481,7 +580,7 @@ export default function DecisionsPage() {

{/* Pending Decisions */}
{pendingDecisions.length > 0 && (
<div className="mb-6">
<div className={`mb-6 ${batchMode && selectedDecisions.length > 0 ? "pb-24" : ""}`}>
<div className="flex items-center gap-2 mb-3">
<div className="w-1.5 h-1.5 bg-[var(--color-accent-pink)] rounded-full animate-pulse" />
<h2 className="text-base font-semibold text-[var(--color-text-primary)] tracking-tight">
Expand All @@ -491,18 +590,35 @@ export default function DecisionsPage() {
{pendingDecisions.length}
</span>
</div>
<div className="grid gap-3">
{pendingDecisions.map((d) => (
<DecisionCard
key={d.id}
decision={d}
onApprove={handleApprove}
onDeny={handleDeny}
acting={actingIds.has(d.id)}
showTenant={showTenantBadge}
/>
))}
</div>

{batchMode ? (
<div className="grid gap-3">
{Array.from(pendingGroups.entries()).map(([key, decisions]) => (
<DecisionGroup
key={key}
groupKey={key}
strategy={groupingStrategy}
decisions={decisions}
selectedIds={selectedIds}
onToggleSelect={handleToggleSelect}
onToggleGroup={handleToggleGroup}
/>
))}
</div>
) : (
<div className="grid gap-3">
{pendingDecisions.map((d) => (
<DecisionCard
key={d.id}
decision={d}
onApprove={handleApprove}
onDeny={handleDeny}
acting={actingIds.has(d.id)}
showTenant={showTenantBadge}
/>
))}
</div>
)}
</div>
)}

Expand All @@ -514,6 +630,15 @@ export default function DecisionsPage() {
</div>
)}

{/* Batch approve bar */}
{batchMode && (
<BatchApproveBar
selectedDecisions={selectedDecisions}
onApprove={handleBatchApprove}
onClear={() => setSelectedIds(new Set())}
/>
)}

{/* History Table — grouped by date */}
{resolvedDecisions.length > 0 && (
<div>
Expand Down
107 changes: 19 additions & 88 deletions ui/observe/app/(observe)/policies/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ import type {
AllPoliciesResponse,
PolicySource,
} from "@/lib/types";
import type { SpecSummary } from "@/lib/types";
import ErrorDisplay from "@/components/ErrorDisplay";
import StatCard from "@/components/StatCard";
import PolicyCard from "@/components/PolicyCard";
import VisualPolicyCreator from "@/components/VisualPolicyCreator";

const ALL_TENANTS = "__all__";
const SOURCE_FILTERS: { value: PolicySource | "all"; label: string }[] = [
Expand All @@ -41,16 +43,16 @@ export default function PoliciesPage() {
const [sourceFilter, setSourceFilter] = useState<PolicySource | "all">("all");
const [statusFilter, setStatusFilter] = useState<"all" | "enabled" | "disabled">("all");
const [showCreate, setShowCreate] = useState(false);
const [newPolicyId, setNewPolicyId] = useState("");
const [newCedarText, setNewCedarText] = useState("");
const [specs, setSpecs] = useState<SpecSummary[]>([]);

const loadInitial = useCallback(async () => {
setInitialLoading(true);
setInitialError(null);
try {
const specs = await fetchSpecs();
const loadedSpecs = await fetchSpecs();
setSpecs(loadedSpecs);
const tenantSet = new Set<string>();
for (const s of specs) {
for (const s of loadedSpecs) {
if (s.tenant && s.tenant !== "temper-system") tenantSet.add(s.tenant);
}
setTenants(Array.from(tenantSet).sort());
Expand Down Expand Up @@ -204,24 +206,14 @@ export default function PoliciesPage() {
[allPolicies, policiesPoll],
);

const handleCreate = useCallback(
async () => {
if (!newPolicyId.trim() || !newCedarText.trim()) return;
const targetTenant = tenant === ALL_TENANTS ? (tenants[0] || "default") : tenant;
const handleVisualCreate = useCallback(
async (targetTenant: string, policyId: string, cedarText: string) => {
setActionError(null);
try {
await createPolicy(targetTenant, newPolicyId.trim(), newCedarText.trim());
setNewPolicyId("");
setNewCedarText("");
setShowCreate(false);
await policiesPoll.refresh();
} catch (err) {
setActionError(
err instanceof Error ? err.message : "Failed to create policy",
);
}
await createPolicy(targetTenant, policyId, cedarText);
setShowCreate(false);
await policiesPoll.refresh();
},
[tenant, tenants, newPolicyId, newCedarText, policiesPoll],
[policiesPoll],
);

if (initialLoading) {
Expand Down Expand Up @@ -356,75 +348,14 @@ export default function PoliciesPage() {
</div>
)}

{/* Create new policy form */}
{/* Visual policy creator */}
{showCreate && (
<div className="glass rounded p-4 mb-6 animate-fade-in">
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3">
Create New Policy
</h3>
<div className="space-y-3">
<div className="flex gap-3">
<div className="flex-1">
<label className="text-[10px] text-[var(--color-text-muted)] uppercase tracking-wider block mb-1">
Policy ID
</label>
<input
type="text"
value={newPolicyId}
onChange={(e) => setNewPolicyId(e.target.value)}
placeholder="e.g., custom:my-agent-rules"
className="w-full bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] text-xs rounded-sm px-2.5 py-1.5 font-mono focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-teal)] placeholder:text-[var(--color-text-muted)]"
/>
</div>
{tenant === ALL_TENANTS && (
<div>
<label className="text-[10px] text-[var(--color-text-muted)] uppercase tracking-wider block mb-1">
Tenant
</label>
<select
className="bg-[var(--color-bg-surface)] text-[var(--color-text-secondary)] text-xs rounded-sm px-2 py-1.5 focus:outline-none"
defaultValue={tenants[0]}
>
{tenants.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
)}
</div>
<div>
<label className="text-[10px] text-[var(--color-text-muted)] uppercase tracking-wider block mb-1">
Cedar Policy Text
</label>
<textarea
value={newCedarText}
onChange={(e) => setNewCedarText(e.target.value)}
placeholder={'permit(\n principal is Agent,\n action == Action::"myAction",\n resource is MyType\n);'}
className="w-full min-h-[120px] p-2.5 bg-black/30 rounded text-[11px] font-mono text-[var(--color-text-primary)] border border-[var(--color-border)] focus:outline-none focus:border-[var(--color-accent-teal)] resize-y placeholder:text-[var(--color-text-muted)]"
spellCheck={false}
/>
</div>
<div className="flex gap-2">
<button
type="button"
disabled={!newPolicyId.trim() || !newCedarText.trim()}
onClick={handleCreate}
className="px-3 py-1.5 text-xs bg-[var(--color-accent-teal-dim)] text-[var(--color-accent-teal)] rounded hover:bg-[var(--color-accent-teal-dim)] disabled:opacity-50 transition-colors"
>
Create Policy
</button>
<button
type="button"
onClick={() => setShowCreate(false)}
className="px-3 py-1.5 text-xs bg-[var(--color-bg-elevated)] text-[var(--color-text-secondary)] rounded hover:bg-[var(--color-border)] transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
<VisualPolicyCreator
specs={specs}
tenants={tenants}
onCreated={handleVisualCreate}
onCancel={() => setShowCreate(false)}
/>
)}

{/* Policy list */}
Expand Down
Loading
Loading