diff --git a/ui/observe/app/(observe)/agents/[id]/page.tsx b/ui/observe/app/(observe)/agents/[id]/page.tsx index 40e28155..98056dbf 100644 --- a/ui/observe/app/(observe)/agents/[id]/page.tsx +++ b/ui/observe/app/(observe)/agents/[id]/page.tsx @@ -1,18 +1,41 @@ "use client"; -import { useState, useMemo } from "react"; -import { useParams } from "next/navigation"; +import { useState, useMemo, useCallback, useEffect } from "react"; +import { useParams, useSearchParams } from "next/navigation"; import Link from "next/link"; -import { fetchAgentHistory } from "@/lib/api"; +import { fetchAgentHistory, fetchAllPolicies, fetchSpecs, createPolicy, togglePolicy, deletePolicy } from "@/lib/api"; import { useSSERefresh, useRelativeTime } from "@/lib/hooks"; -import type { AgentHistoryResponse } from "@/lib/types"; +import type { AgentHistoryResponse, PolicyEntry, SpecSummary } from "@/lib/types"; import ErrorDisplay from "@/components/ErrorDisplay"; import StatCard from "@/components/StatCard"; +import VisualPolicyCreator from "@/components/VisualPolicyCreator"; + +type Tab = "history" | "policies"; export default function AgentDetailPage() { const params = useParams(); + const searchParams = useSearchParams(); const agentId = decodeURIComponent(params.id as string); + const initialTab = (searchParams.get("tab") as Tab) || "history"; + + const [activeTab, setActiveTab] = useState(initialTab); const [entityTypeFilter, setEntityTypeFilter] = useState("all"); + const [showPolicyCreator, setShowPolicyCreator] = useState(false); + const [specs, setSpecs] = useState([]); + const [actionError, setActionError] = useState(null); + + // Fetch specs for policy creator + useEffect(() => { + fetchSpecs().then(setSpecs).catch(() => {}); + }, []); + + const tenants = useMemo(() => { + const set = new Set(); + for (const s of specs) { + if (s.tenant && s.tenant !== "temper-system") set.add(s.tenant); + } + return Array.from(set).sort(); + }, [specs]); const historyPoll = useSSERefresh({ fetcher: () => @@ -27,6 +50,22 @@ export default function AgentDetailPage() { const data = historyPoll.data; const lastUpdated = useRelativeTime(historyPoll.lastUpdated); + // Fetch policies relevant to this agent + const policiesPoll = useSSERefresh<{ policies: PolicyEntry[] }>({ + fetcher: async () => { + const res = await fetchAllPolicies(); + // Filter to policies that mention this agent ID in cedar text + const relevant = res.policies.filter( + (p) => p.cedar_text.includes(`"${agentId}"`) || p.cedar_text.includes("principal is Agent"), + ); + return { policies: relevant }; + }, + sseKinds: ["Policies"], + enabled: activeTab === "policies", + }); + + const agentPolicies = policiesPoll.data?.policies || []; + const entityTypes = useMemo(() => { if (!data) return []; const set = new Set(); @@ -49,6 +88,41 @@ export default function AgentDetailPage() { return { total: data.total, success, errors, denials }; }, [data]); + const handleTogglePolicy = useCallback( + async (policy: PolicyEntry) => { + setActionError(null); + try { + await togglePolicy(policy.tenant, policy.policy_id, !policy.enabled); + await policiesPoll.refresh(); + } catch (err) { + setActionError(err instanceof Error ? err.message : "Failed to toggle policy"); + } + }, + [policiesPoll], + ); + + const handleDeletePolicy = useCallback( + async (policy: PolicyEntry) => { + setActionError(null); + try { + await deletePolicy(policy.tenant, policy.policy_id); + await policiesPoll.refresh(); + } catch (err) { + setActionError(err instanceof Error ? err.message : "Failed to delete policy"); + } + }, + [policiesPoll], + ); + + const handlePolicyCreated = useCallback( + async (tenant: string, policyId: string, cedarText: string) => { + await createPolicy(tenant, policyId, cedarText); + setShowPolicyCreator(false); + await policiesPoll.refresh(); + }, + [policiesPoll], + ); + if (historyPoll.error && !data) { return (

- Action history and authorization events + Action history and authorization policies

- {entityTypes.length > 1 && ( + {activeTab === "history" && entityTypes.length > 1 && ( + {pendingDecisions.length > 1 && ( + <> + + {batchMode && ( + + )} + + )} {resolvedDecisions.length > 0 && (