diff --git a/dashboard/__tests__/api-client.test.ts b/dashboard/__tests__/api-client.test.ts index 3348fe7..7d8a376 100644 --- a/dashboard/__tests__/api-client.test.ts +++ b/dashboard/__tests__/api-client.test.ts @@ -3,23 +3,34 @@ import { ApiClientError, apiMode, buildApiRequestInit, + fetchFleetSummary, fetchIncident, fetchIncidents, fetchPolicies, fetchPolicy, + fetchSpendTrackers, fetchTransactions, getErrorMessage, isUnauthorizedError, } from "@/lib/api/client"; -import { INCIDENTS, POLICIES } from "@/lib/mock"; +import { MOCK_DEMO_OWNER_WALLET, INCIDENTS, POLICIES } from "@/lib/mock"; describe("api client mock data", () => { it("runs in mock mode during tests", () => { expect(apiMode).toBe("mock"); }); + it("scopes mock fleet data to the viewer wallet pubkey", async () => { + const stranger = "Stranger11111111111111111111111111111111"; + expect(await fetchPolicies(stranger)).toHaveLength(0); + await expect(fetchPolicy(POLICIES[0].pubkey, stranger)).rejects.toThrow("Policy not found"); + const summary = await fetchFleetSummary(stranger); + expect(summary.activeAgents).toBe(0); + expect((await fetchIncidents(undefined, undefined, 50, stranger)).items).toHaveLength(0); + }); + it("returns policies sorted by most recently updated", async () => { - const policies = await fetchPolicies(); + const policies = await fetchPolicies(MOCK_DEMO_OWNER_WALLET); expect(policies).toHaveLength(POLICIES.length); for (let index = 1; index < policies.length; index += 1) { @@ -52,12 +63,12 @@ describe("api client mock data", () => { const txns = await fetchTransactions(undefined, undefined, 0); expect(txns.items.length).toBeGreaterThan(0); - const incidents = await fetchIncidents(undefined, undefined, -10); + const incidents = await fetchIncidents(undefined, undefined, -10, MOCK_DEMO_OWNER_WALLET); expect(incidents.items.length).toBeGreaterThan(0); }); it("returns incident detail and rejects unknown incident ids", async () => { - const incident = await fetchIncident(INCIDENTS[0].id); + const incident = await fetchIncident(INCIDENTS[0].id, MOCK_DEMO_OWNER_WALLET); expect(incident.id).toBe(INCIDENTS[0].id); expect(incident.policy.pubkey).toBe(INCIDENTS[0].policyPubkey); expect(Array.isArray(incident.judgeVerdict?.signals ?? [])).toBe(true); @@ -65,11 +76,27 @@ describe("api client mock data", () => { await expect(fetchIncident("does-not-exist")).rejects.toThrow("Incident not found"); }); + it("returns fleet summary and spend trackers in mock mode", async () => { + const summary = await fetchFleetSummary(MOCK_DEMO_OWNER_WALLET); + expect(summary.totalLamportsSpent24h).toMatch(/^\d+$/); + expect(summary.incidentsLast24h).toBeGreaterThanOrEqual(0); + + const trackers = await fetchSpendTrackers(MOCK_DEMO_OWNER_WALLET); + expect(trackers.length).toBe(POLICIES.length); + expect(trackers[0]).toMatchObject({ + policyPubkey: expect.any(String), + lamportsSpent24h: expect.any(String), + policy: { + dailyBudgetLamports: expect.any(String), + }, + }); + }); + it("supports incident pagination", async () => { - const firstPage = await fetchIncidents(undefined, undefined, 1); + const firstPage = await fetchIncidents(undefined, undefined, 1, MOCK_DEMO_OWNER_WALLET); expect(firstPage.items).toHaveLength(1); if (firstPage.nextCursor) { - const secondPage = await fetchIncidents(undefined, firstPage.nextCursor, 1); + const secondPage = await fetchIncidents(undefined, firstPage.nextCursor, 1, MOCK_DEMO_OWNER_WALLET); expect(secondPage.items[0]?.id).not.toEqual(firstPage.items[0].id); } }); diff --git a/dashboard/__tests__/dashboard-ui.test.tsx b/dashboard/__tests__/dashboard-ui.test.tsx index 589ba21..779f47d 100644 --- a/dashboard/__tests__/dashboard-ui.test.tsx +++ b/dashboard/__tests__/dashboard-ui.test.tsx @@ -14,8 +14,16 @@ vi.mock("next/navigation", () => ({ usePathname: () => "/agents", })); -vi.mock("@/components/wallet-controls", () => ({ - WalletControls: () => createElement("div", undefined, "Wallet controls"), +vi.mock("@solana/wallet-adapter-react", () => ({ + useWallet: () => ({ + connected: true, + publicKey: { toBase58: () => "WalletPub1111111111111111111111111111111" }, + disconnect: vi.fn(), + }), +})); + +vi.mock("@/components/shell-navbar-actions", () => ({ + ShellNavbarActions: () => createElement("div", undefined, "Navbar actions"), })); describe("dashboard-ui", () => { @@ -31,7 +39,7 @@ describe("dashboard-ui", () => { expect(screen.getByText("Guardrails")).toBeInTheDocument(); expect(screen.getAllByText("Guardrails overview").length).toBeGreaterThan(0); expect(screen.getByText("Body content")).toBeInTheDocument(); - expect(screen.getByText("Wallet controls")).toBeInTheDocument(); + expect(screen.getByText("Navbar actions")).toBeInTheDocument(); }); it("renders policy card details", () => { diff --git a/dashboard/__tests__/providers-auth.test.ts b/dashboard/__tests__/providers-auth.test.ts index 1946a94..8ef2f15 100644 --- a/dashboard/__tests__/providers-auth.test.ts +++ b/dashboard/__tests__/providers-auth.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { clearSiwsAndRedirectToSignin } from "@/lib/auth/siws-session"; +import { clearSiwsAndRedirectHome } from "@/lib/auth/siws-session"; import { useSiwsAuthStore } from "@/lib/stores/siws-auth"; describe("providers auth redirect helper", () => { @@ -12,10 +12,10 @@ describe("providers auth redirect helper", () => { useSiwsAuthStore.getState().markSignedIn("Wallet11111111111111111111111111111111"); const redirectMock = vi.fn(); - clearSiwsAndRedirectToSignin(redirectMock); + clearSiwsAndRedirectHome(redirectMock); expect(useSiwsAuthStore.getState().siwsWallet).toBeNull(); expect(useSiwsAuthStore.getState().siwsSignedInAt).toBeNull(); - expect(redirectMock).toHaveBeenCalledWith("/signin"); + expect(redirectMock).toHaveBeenCalledWith("/"); }); }); diff --git a/dashboard/__tests__/query-keys.test.ts b/dashboard/__tests__/query-keys.test.ts index c54052e..20c5193 100644 --- a/dashboard/__tests__/query-keys.test.ts +++ b/dashboard/__tests__/query-keys.test.ts @@ -3,15 +3,24 @@ import { queryKeys } from "@/lib/api/query-keys"; describe("query keys", () => { it("builds canonical dashboard query keys", () => { - expect(queryKeys.policies()).toEqual(["policies"]); + expect(queryKeys.policies("W1")).toEqual(["policies", "W1"]); + expect(queryKeys.policies(undefined)).toEqual(["policies", ""]); expect(queryKeys.policy("abc")).toEqual(["policy", "abc"]); expect(queryKeys.transactions()).toEqual(["transactions"]); expect(queryKeys.transactionsByPolicy("policy-1")).toEqual(["transactions", "policy-1"]); expect(queryKeys.transactionsInfinite(undefined, 50)).toEqual(["transactions", "infinite", "all", 50]); expect(queryKeys.transactionsInfinite("abc", 25)).toEqual(["transactions", "infinite", "abc", 25]); - expect(queryKeys.incidents()).toEqual(["incidents"]); + expect(queryKeys.incidents("W1")).toEqual(["incidents", "W1"]); expect(queryKeys.incidentsByPolicy("policy-1")).toEqual(["incidents", "policy-1"]); expect(queryKeys.incident("incident-1")).toEqual(["incident", "incident-1"]); + expect(queryKeys.fleetSummary("W1")).toEqual(["fleet", "summary", "W1"]); + expect(queryKeys.spendTrackers("W1")).toEqual(["spend-trackers", "W1"]); + expect(queryKeys.transactionBySig("sig1")).toEqual(["transactions", "detail", "sig1"]); + expect(queryKeys.webhookStatus()).toEqual(["settings", "webhook-status"]); + expect(queryKeys.operatorSession()).toEqual(["session"]); + expect(queryKeys.llmSettings()).toEqual(["settings", "llm"]); + expect(queryKeys.auditLog({})).toEqual(["audit", "all", "", "", ""]); + expect(queryKeys.escalation("e1")).toEqual(["escalation", "e1"]); }); it("keeps backward compatible alias for policy key", () => { diff --git a/dashboard/__tests__/routes-smoke.test.tsx b/dashboard/__tests__/routes-smoke.test.tsx index c3750ad..f4249a7 100644 --- a/dashboard/__tests__/routes-smoke.test.tsx +++ b/dashboard/__tests__/routes-smoke.test.tsx @@ -28,7 +28,7 @@ vi.mock("next/link", () => ({ })); vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: pushMock }), + useRouter: () => ({ push: pushMock, replace: pushMock }), })); vi.mock("@solana/wallet-adapter-react", () => ({ @@ -37,6 +37,11 @@ vi.mock("@solana/wallet-adapter-react", () => ({ signMessage: vi.fn(async () => new Uint8Array([1, 2, 3])), signTransaction: vi.fn(), signAllTransactions: vi.fn(), + connected: true, + connecting: false, + disconnect: vi.fn(), + connect: vi.fn().mockResolvedValue(undefined), + wallet: { adapter: {} }, }), })); @@ -109,7 +114,7 @@ describe("phase 1 route smoke tests", () => { it("renders landing and agents routes", async () => { const Home = (await import("@/app/page")).default; render(createElement(Home)); - expect(screen.getByText("Sign in with Solana")).toBeTruthy(); + expect(screen.getByText("Guardrails")).toBeTruthy(); cleanup(); const AgentsPage = (await import("@/app/agents/page")).default; @@ -155,7 +160,7 @@ describe("phase 1 route smoke tests", () => { it("renders signin, agent detail, policy edit, and incident detail routes", async () => { const SignInPage = (await import("@/app/(auth)/signin/page")).default; render(createElement(SignInPage)); - expect(screen.getByRole("heading", { name: "Sign In" })).toBeTruthy(); + expect(screen.getByText("Redirecting…")).toBeTruthy(); cleanup(); const AgentDetailPage = (await import("@/app/agents/[pubkey]/page")).default; diff --git a/dashboard/__tests__/sse-cache-updaters.test.ts b/dashboard/__tests__/sse-cache-updaters.test.ts index 66aef23..0833979 100644 --- a/dashboard/__tests__/sse-cache-updaters.test.ts +++ b/dashboard/__tests__/sse-cache-updaters.test.ts @@ -194,7 +194,8 @@ describe("applyVerdictEvent", () => { }); describe("applyAgentPausedEvent", () => { - const incidentsListKey = [...queryKeys.incidents(), 50] as const; + const viewer = "demo-viewer"; + const incidentsListKey = [...queryKeys.incidents(viewer), 50] as const; const incidentsPolicyKey = [...queryKeys.incidentsByPolicy("P1"), 50] as const; it("prepends incident and marks policy inactive in caches", () => { @@ -217,7 +218,7 @@ describe("applyAgentPausedEvent", () => { createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }; - qc.setQueryData(queryKeys.policies(), [policy]); + qc.setQueryData(queryKeys.policies(viewer), [policy]); qc.setQueryData(queryKeys.policy("P1"), policy); const detail: IncidentDetail = { @@ -251,7 +252,7 @@ describe("applyAgentPausedEvent", () => { createdAt: "2026-04-02T00:00:01.000Z", }); - const policies = qc.getQueryData(queryKeys.policies()); + const policies = qc.getQueryData(queryKeys.policies(viewer)); expect(policies?.[0]?.isActive).toBe(false); const one = qc.getQueryData(queryKeys.policy("P1")); expect(one?.isActive).toBe(false); @@ -261,7 +262,8 @@ describe("applyAgentPausedEvent", () => { }); describe("applyReportReadyEvent", () => { - const incidentsListKey = [...queryKeys.incidents(), 50] as const; + const viewer = "demo-viewer"; + const incidentsListKey = [...queryKeys.incidents(viewer), 50] as const; it("patches fullReport on list and detail caches", () => { const qc = new QueryClient(); diff --git a/dashboard/app/(auth)/signin/page.tsx b/dashboard/app/(auth)/signin/page.tsx index 335d0c2..e13265c 100644 --- a/dashboard/app/(auth)/signin/page.tsx +++ b/dashboard/app/(auth)/signin/page.tsx @@ -1,13 +1,18 @@ -import { AppShell } from "@/components/dashboard-ui"; -import { SiwsSignIn } from "@/components/auth/siws-sign-in"; +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; export default function SignInPage() { + const router = useRouter(); + + useEffect(() => { + router.replace("/"); + }, [router]); + return ( - - - +
+

Redirecting…

+
); } diff --git a/dashboard/app/activity/activity-view.tsx b/dashboard/app/activity/activity-view.tsx index f59485f..ed18d1e 100644 --- a/dashboard/app/activity/activity-view.tsx +++ b/dashboard/app/activity/activity-view.tsx @@ -1,7 +1,11 @@ "use client"; import { AppShell, TransactionRow } from "@/components/dashboard-ui"; -import { QueryEmpty, QueryError, QueryLoading } from "@/components/query-states"; +import { EmptyState } from "@/components/EmptyState"; +import { QueryError } from "@/components/query-states"; +import { ActivityViewSkeleton } from "@/components/skeletons"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Activity } from "lucide-react"; import { getErrorMessage } from "@/lib/api/client"; import { useInfiniteTransactionsQuery } from "@/lib/api/use-infinite-transactions-query"; import { usePoliciesQuery } from "@/lib/api/use-policies-query"; @@ -16,7 +20,7 @@ export function ActivityView() { if (transactionsQuery.isLoading) { return ( - + ); } @@ -48,31 +52,40 @@ export function ActivityView() { ) : null} -
- - +
+
+
+

Policy

+ +
+ +
+

Verdict

+ +
+

@@ -87,10 +100,13 @@ export function ActivityView() { ))}

) : ( - +
+ +
)} {transactionsQuery.hasNextPage ? ( diff --git a/dashboard/app/agents/[pubkey]/agent-detail-view.tsx b/dashboard/app/agents/[pubkey]/agent-detail-view.tsx index 7ee2dd2..c4ed7e6 100644 --- a/dashboard/app/agents/[pubkey]/agent-detail-view.tsx +++ b/dashboard/app/agents/[pubkey]/agent-detail-view.tsx @@ -1,34 +1,72 @@ "use client"; import Link from "next/link"; -import { AppShell, IncidentTable, Metric, SpendGauge, TransactionRow } from "@/components/dashboard-ui"; +import { ChevronLeft, Play } from "lucide-react"; +import { useWallet } from "@solana/wallet-adapter-react"; +import { + AnomalyRiskLabel, + anomalyBarClass, + AppShell, + IncidentTable, + Metric, + SpendGauge, + TransactionRow, +} from "@/components/dashboard-ui"; import { ClosePolicyButton } from "@/components/close-policy-button"; import { FundAgentButton } from "@/components/fund-agent-button"; import { KillSwitchButton } from "@/components/kill-switch-button"; import { RotateAgentKeyButton } from "@/components/rotate-agent-key-button"; -import { QueryEmpty, QueryError, QueryLoading } from "@/components/query-states"; +import { SimulatePanel } from "@/components/simulate-panel"; +import { QueryEmpty, QueryError } from "@/components/query-states"; +import { AgentDetailSkeleton, IncidentsViewSkeleton } from "@/components/skeletons"; import { useInfiniteTransactionsQuery } from "@/lib/api/use-infinite-transactions-query"; import { useIncidentsQuery } from "@/lib/api/use-incidents-query"; import { useEscalationsQuery } from "@/lib/api/use-escalations-query"; import { usePolicyQuery } from "@/lib/api/use-policy-query"; +import { useAllSpendTrackersQuery } from "@/lib/api/use-spend-trackers-query"; +import { useSimulationStore } from "@/lib/stores/simulation"; +import { formatSol } from "@/lib/utils"; + +function BackToAgentsLink() { + return ( + + + Back to agents + + ); +} export function AgentDetailView({ pubkey }: { pubkey: string }) { + const { publicKey } = useWallet(); + const simulationStore = useSimulationStore(); const policyQuery = usePolicyQuery(pubkey); const transactionsQuery = useInfiniteTransactionsQuery(pubkey, 10); const incidentsQuery = useIncidentsQuery(pubkey, 10); const escalationsQuery = useEscalationsQuery(pubkey); + const spendTrackersQuery = useAllSpendTrackersQuery(); if (policyQuery.isLoading) { return ( - - + } + > + ); } if (policyQuery.isError || !policyQuery.data) { return ( - + } + > row.policyPubkey === policy.pubkey); const transactions = transactionsQuery.data?.items ?? []; const incidents = incidentsQuery.data?.items ?? []; const shortenedPolicyPubkey = @@ -48,18 +87,46 @@ export function AgentDetailView({ pubkey }: { pubkey: string }) { } >
+
+
+ Anomaly +
+ {policy.anomalyScore}/100 + +
+
+
+
+
+
+ {publicKey && publicKey.toBase58() === policy.owner && ( +
+ +
+ )}
{policy.squadsMultisig ? (() => { @@ -87,18 +154,46 @@ export function AgentDetailView({ pubkey }: { pubkey: string }) { ); })() : null} -
+
Daily spend
- +
+
+ +
+
+
+

24h Transactions

+

{spendTracker?.txnCount24h ?? 0}

+
+
+

1h Spend

+

{formatSol(spendTracker?.lamportsSpent1h ?? "0")}

+
+
+

Budget remaining

+

+ {formatSol( + BigInt(policy.dailyBudgetLamports ?? "0") - + BigInt(spendTracker?.lamportsSpent24h ?? policy.dailySpentLamports ?? "0"), + )} +

+
+
+
Recent transactions
{transactionsQuery.isLoading ? ( - +
+ {Array.from({ length: 3 }).map((_, idx) => ( +
+ ))} +
) : transactionsQuery.isError ? (
Related incidents
{incidentsQuery.isLoading ? ( - + ) : incidentsQuery.isError ? ( void incidentsQuery.refetch()} /> ) : ( )}
+ + {simulationStore.panelOpen && } ); } diff --git a/dashboard/app/agents/[pubkey]/proposals/proposals-view.tsx b/dashboard/app/agents/[pubkey]/proposals/proposals-view.tsx index eb73872..9b628fa 100644 --- a/dashboard/app/agents/[pubkey]/proposals/proposals-view.tsx +++ b/dashboard/app/agents/[pubkey]/proposals/proposals-view.tsx @@ -2,7 +2,8 @@ import { useQueryClient } from "@tanstack/react-query"; import { AppShell } from "@/components/dashboard-ui"; -import { QueryLoading, QueryError, QueryEmpty } from "@/components/query-states"; +import { QueryError, QueryEmpty } from "@/components/query-states"; +import { ProposalsViewSkeleton } from "@/components/skeletons"; import { ProposalCard } from "@/components/proposal-card"; import { usePolicyQuery } from "@/lib/api/use-policy-query"; import { useEscalationsQuery } from "@/lib/api/use-escalations-query"; @@ -33,7 +34,7 @@ export function ProposalsView({ pubkey }: { pubkey: string }) { subtitle={`Squads multisig escalation proposals for ${shortAddress(pubkey)}`} > {escalationsQuery.isLoading ? ( - + ) : escalationsQuery.isError ? ( ) : escalations.length === 0 ? ( diff --git a/dashboard/app/agents/agents-overview.tsx b/dashboard/app/agents/agents-overview.tsx index 5867cf8..f7b872a 100644 --- a/dashboard/app/agents/agents-overview.tsx +++ b/dashboard/app/agents/agents-overview.tsx @@ -1,20 +1,27 @@ "use client"; -import Link from "next/link"; +import { Bot, Plus } from "lucide-react"; +import { EmptyState } from "@/components/EmptyState"; import { PolicyCard } from "@/components/dashboard-ui"; -import { QueryEmpty, QueryError, QueryLoading } from "@/components/query-states"; -import { getErrorMessage } from "@/lib/api/client"; +import { QueryError } from "@/components/query-states"; +import { SkeletonCard } from "@/components/skeletons"; import { usePoliciesQuery } from "@/lib/api/use-policies-query"; import { usePendingLabels } from "@/lib/hooks/use-pending-labels"; -export function AgentsOverview() { +export function AgentsOverview({ onNewAgent }: { onNewAgent?: () => void }) { const { data, isLoading, isError, error, refetch } = usePoliciesQuery(); // Process pending labels from the create-policy wizard usePendingLabels(data); if (isLoading) { - return ; + return ( +
+ {Array.from({ length: 3 }).map((_, idx) => ( + + ))} +
+ ); } if (isError) { @@ -29,18 +36,26 @@ export function AgentsOverview() { if (!data?.length) { return ( - - New policy - - } - /> +
+ + {onNewAgent ? ( +
+ +
+ ) : null} +
); } diff --git a/dashboard/app/agents/agents-page-client.tsx b/dashboard/app/agents/agents-page-client.tsx new file mode 100644 index 0000000..6f055cc --- /dev/null +++ b/dashboard/app/agents/agents-page-client.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { X } from "lucide-react"; +import { AppShell } from "@/components/dashboard-ui"; +import { CreatePolicyWizard } from "@/components/create-policy-wizard/CreatePolicyWizard"; +import { AgentsOverview } from "@/app/agents/agents-overview"; + +export function AgentsPageClient({ startWithNewAgentOpen }: { startWithNewAgentOpen: boolean }) { + const [isNewAgentOpen, setIsNewAgentOpen] = useState(startWithNewAgentOpen); + + useEffect(() => { + if (startWithNewAgentOpen) { + setIsNewAgentOpen(true); + } + }, [startWithNewAgentOpen]); + + return ( + <> + setIsNewAgentOpen(true)} + className="button button-primary px-3.5 py-2" + > + New Agent + + } + > + setIsNewAgentOpen(true)} /> + + + {isNewAgentOpen ? ( +
+
+ + +
+

Create Policy

+

+ Define program allow-lists, spend limits, and escalation controls. +

+
+ +
+ setIsNewAgentOpen(false)} /> +
+
+
+ ) : null} + + ); +} + diff --git a/dashboard/app/agents/page.tsx b/dashboard/app/agents/page.tsx index 5807a08..f308d54 100644 --- a/dashboard/app/agents/page.tsx +++ b/dashboard/app/agents/page.tsx @@ -1,22 +1,10 @@ -import { AppShell } from "@/components/dashboard-ui"; -import { AgentsOverview } from "@/app/agents/agents-overview"; -import Link from "next/link"; +import { AgentsPageClient } from "@/app/agents/agents-page-client"; -export default function AgentsPage() { - return ( - - New Agent - - )} - > - - - ); +export default function AgentsPage({ + searchParams, +}: { + searchParams?: { new?: string }; +}) { + const startWithNewAgentOpen = searchParams?.new === "1"; + return ; } diff --git a/dashboard/app/audit/audit-view.tsx b/dashboard/app/audit/audit-view.tsx new file mode 100644 index 0000000..3b9f431 --- /dev/null +++ b/dashboard/app/audit/audit-view.tsx @@ -0,0 +1,262 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useMemo, useState } from "react"; +import type { ReactNode } from "react"; +import { ArrowUpCircle, ClipboardList, RefreshCw, RotateCcw, ShieldCheck, ShieldOff, XCircle } from "lucide-react"; +import { EmptyState } from "@/components/EmptyState"; +import { AppShell } from "@/components/dashboard-ui"; +import { QueryError } from "@/components/query-states"; +import { SkeletonRow } from "@/components/skeletons"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { useAuditLogQuery } from "@/lib/api/use-audit-log-query"; +import { usePoliciesQuery } from "@/lib/api/use-policies-query"; +import type { AuditActionType, AuditLogFilters } from "@/lib/types/dashboard"; +import { formatRelativeTime, formatRelativeTooltip, shortAddress } from "@/lib/utils"; + +const ACTION_OPTIONS: Array<{ value: string; label: string }> = [ + { value: "all", label: "All actions" }, + { value: "pause", label: "Pause" }, + { value: "resume", label: "Resume" }, + { value: "rotate_key", label: "Rotate key" }, + { value: "close_policy", label: "Close policy" }, + { value: "escalation_created", label: "Escalation created" }, + { value: "escalation_updated", label: "Escalation updated" }, +]; + +const ACTION_ICONS: Record = { + agent_paused: , + pause: , + resume: , + rotate_key: , + policy_closed: , + close_policy: , + escalation_created: , + escalation_updated: , +}; + +export function AuditView() { + const router = useRouter(); + const policiesQuery = usePoliciesQuery(); + const [type, setType] = useState("all"); + const [policyPubkey, setPolicyPubkey] = useState(""); + const [range, setRange] = useState<"24h" | "7d" | "30d" | "all">("24h"); + + const filters: AuditLogFilters = useMemo(() => { + const f: AuditLogFilters = {}; + if (type !== "all") f.type = type; + if (policyPubkey) f.policyPubkey = policyPubkey; + if (range !== "all") { + const now = Date.now(); + const ms = + range === "24h" ? 86_400_000 : range === "7d" ? 7 * 86_400_000 : 30 * 86_400_000; + f.from = new Date(now - ms).toISOString(); + f.to = new Date(now).toISOString(); + } + return f; + }, [type, policyPubkey, range]); + + const auditQ = useAuditLogQuery(filters); + + if (policiesQuery.isLoading) { + return ( + +
+ + + {Array.from({ length: 5 }).map((_, idx) => ( + + ))} + +
+
+
+ ); + } + + if (policiesQuery.isError) { + return ( + + void policiesQuery.refetch()} /> + + ); + } + + return ( + +
+
+
+

Action type

+ +
+ +
+

Policy

+ +
+ +
+

Date range

+ +
+
+
+ + {auditQ.isLoading ? ( +
+ + + {Array.from({ length: 5 }).map((_, idx) => ( + + ))} + +
+
+ ) : auditQ.isError ? ( + void auditQ.refetch()} /> + ) : auditQ.data?.items.length === 0 ? ( +
+ +
+ ) : ( +
+
+ + + + + + + + + + + + + {auditQ.data!.items.map((row) => ( + { + const linkHref = row.relatedIncidentId + ? `/incidents/${row.relatedIncidentId}` + : row.relatedTxnSig + ? `/transactions/${row.relatedTxnSig}` + : row.relatedProposalId + ? `/agents/${row.policyPubkey}/proposals` + : `/agents/${row.policyPubkey}`; + router.push(linkHref); + }} + className="cursor-pointer border-b border-zinc-800/60 transition-colors duration-100 hover:bg-zinc-800/40 last:border-0" + > + + + + + + + + ))} + +
+ Time + + Action + + Policy + + Actor + + Details + + Link +
+ + {formatRelativeTime(row.timestamp)} + + +
+ {ACTION_ICONS[row.actionType] ?? } + {row.actionType.replace(/_/g, " ")} +
+
+ + {row.policyLabel ?? shortAddress(row.policyPubkey, 5)} + + + {shortAddress(row.actor, 6)} + {row.details} + {(() => { + const linkHref = row.relatedIncidentId + ? `/incidents/${row.relatedIncidentId}` + : row.relatedTxnSig + ? `/transactions/${row.relatedTxnSig}` + : row.relatedProposalId + ? `/agents/${row.policyPubkey}/proposals` + : null; + const linkLabel = row.relatedIncidentId + ? "Incident" + : row.relatedTxnSig + ? "Txn" + : row.relatedProposalId + ? "Proposals" + : null; + return linkHref ? ( + + {linkLabel ?? "View"} + + ) : ( + + ); + })()} +
+
+
+ )} +
+ ); +} diff --git a/dashboard/app/audit/page.tsx b/dashboard/app/audit/page.tsx new file mode 100644 index 0000000..4345876 --- /dev/null +++ b/dashboard/app/audit/page.tsx @@ -0,0 +1,5 @@ +import { AuditView } from "@/app/audit/audit-view"; + +export default function AuditPage() { + return ; +} diff --git a/dashboard/app/escalations/[id]/escalation-detail-view.tsx b/dashboard/app/escalations/[id]/escalation-detail-view.tsx new file mode 100644 index 0000000..10c77d1 --- /dev/null +++ b/dashboard/app/escalations/[id]/escalation-detail-view.tsx @@ -0,0 +1,182 @@ +"use client"; + +import Link from "next/link"; +import { AppShell, StatusChip } from "@/components/dashboard-ui"; +import { QueryEmpty, QueryError } from "@/components/query-states"; +import { useEscalationQuery } from "@/lib/api/use-escalation-query"; +import { escalationLabel, escalationTone } from "@/lib/utils/escalation-display"; +import { formatRelativeTime, formatRelativeTooltip, formatSol, programLabel, shortAddress } from "@/lib/utils"; + +export function EscalationDetailView({ id }: { id: string }) { + const query = useEscalationQuery(id); + + if (query.isLoading) { + return ( + +
+
+
+ + ); + } + + if (query.isError) { + return ( + + void query.refetch()} /> + + ); + } + + const esc = query.data; + if (!esc) { + return ( + + + + ); + } + + const txn = esc.txn; + + return ( + + All proposals + + } + > +
+
+
+ {shortAddress(id, 12, 10)} + + Policy{" "} + + {shortAddress(esc.policyPubkey, 8, 6)} + + +
+ {escalationLabel(esc.status)} +
+ +
+

Guarded transaction

+
+
+ Signature +
+ + {txn.txnSig} + +
+
+
+ Recorded +
+ {formatRelativeTime(txn.blockTime)} +
+
+
+ Amount +
+ {txn.amountLamports ? formatSol(txn.amountLamports) : "—"} +
+
+
+ Status +
{txn.status}
+
+
+ {txn.verdict ? ( +
+ AI verdict: {txn.verdict.verdict.toUpperCase()} ( + {txn.verdict.confidence}%) — {txn.verdict.reasoning} +
+ ) : null} +
+ +
+

Squads multisig

+

{esc.squadsMultisig}

+
+
+ Proposal PDA:{" "} + {esc.proposalPda ?? "—"} +
+
+ Transaction index:{" "} + {esc.transactionIndex ?? "—"} +
+
+
+ + {(esc.approvals?.length ?? 0) > 0 || (esc.rejections?.length ?? 0) > 0 ? ( +
+

Votes

+
+ {(esc.approvals ?? []).map((a) => ( + + ✓ {shortAddress(a.member)} + + ))} + {(esc.rejections ?? []).map((r) => ( + + ✗ {shortAddress(r.member)} + + ))} +
+
+ ) : null} + + {esc.executedTxnSig ? ( +
+ Executed on-chain:{" "} + + {esc.executedTxnSig} + +
+ ) : null} + +
+

Reconstructed instruction

+

+ CPI payload used when creating the Squads proposal (decoded server-side). +

+ {esc.instruction ? ( +
+              {JSON.stringify(esc.instruction, null, 2)}
+            
+ ) : ( +

No instruction payload available.

+ )} +
+
+
+ ); +} diff --git a/dashboard/app/escalations/[id]/page.tsx b/dashboard/app/escalations/[id]/page.tsx new file mode 100644 index 0000000..cbc13cf --- /dev/null +++ b/dashboard/app/escalations/[id]/page.tsx @@ -0,0 +1,5 @@ +import { EscalationDetailView } from "@/app/escalations/[id]/escalation-detail-view"; + +export default function EscalationDetailPage({ params }: { params: { id: string } }) { + return ; +} diff --git a/dashboard/app/globals.css b/dashboard/app/globals.css index f016cfe..17a50a8 100644 --- a/dashboard/app/globals.css +++ b/dashboard/app/globals.css @@ -1,18 +1,106 @@ +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; @tailwind base; @tailwind components; @tailwind utilities; @layer base { + :root, + .dark { + --background: 214 30% 5%; + --foreground: 210 40% 98%; + --card: 215 18% 13%; + --card-foreground: 210 40% 98%; + --card-border: 218 21% 21%; + --popover: 215 18% 13%; + --popover-foreground: 210 40% 98%; + --popover-border: 218 21% 21%; + --primary: 169 100% 50%; + --primary-foreground: 214 30% 5%; + --secondary: 215 18% 13%; + --secondary-foreground: 210 40% 98%; + --muted: 215 18% 13%; + --muted-foreground: 215 14% 65%; + --accent: 215 18% 20%; + --accent-foreground: 210 40% 98%; + --destructive: 350 100% 62%; + --destructive-foreground: 210 40% 98%; + --border: 218 21% 21%; + --input: 218 21% 21%; + --ring: 169 100% 50%; + --teal: 169 100% 50%; + --amber: 39 100% 59%; + --crimson: 350 100% 62%; + } + + * { + @apply border-border outline-ring/50; + scrollbar-width: thin; + scrollbar-color: hsl(var(--teal) / 0.42) hsl(var(--background)); + } + + html { + @apply font-sans; + } + html, body { @apply min-h-full overflow-x-hidden; } + *::-webkit-scrollbar { + width: 9px; + height: 9px; + } + + *::-webkit-scrollbar-corner { + background: hsl(var(--background)); + } + + *::-webkit-scrollbar-track { + background: hsl(var(--background)); + } + + *::-webkit-scrollbar-thumb { + background-color: hsl(var(--teal) / 0.38); + border-radius: 9999px; + border: 2px solid hsl(var(--background)); + } + + *::-webkit-scrollbar-thumb:hover { + background-color: hsl(var(--teal) / 0.58); + } + body { - @apply bg-zinc-950 text-zinc-100 antialiased; + @apply bg-background text-foreground antialiased; + font-family: + Inter, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + system-ui, + sans-serif; + font-weight: 400; + line-height: 1.6; background-image: - radial-gradient(circle at 10% -10%, rgba(59, 130, 246, 0.18), transparent 45%), - radial-gradient(circle at 100% 0%, rgba(14, 165, 233, 0.12), transparent 35%); + radial-gradient( + circle at 10% -10%, + hsl(var(--teal) / 0.16), + transparent 45% + ), + radial-gradient( + circle at 100% 0%, + hsl(var(--teal) / 0.08), + transparent 35% + ); + } + .theme { + --font-heading: var(--font-sans); + --font-sans: var(--font-sans); + } + + .font-brand { + font-family: var(--font-brand), var(--font-sans), sans-serif; } } @@ -22,19 +110,19 @@ } .button-primary { - @apply border border-blue-500 bg-gradient-to-r from-blue-600 to-blue-500 text-white shadow-[0_0_0_1px_rgba(59,130,246,0.85),0_0_24px_rgba(59,130,246,0.32)] hover:from-blue-500 hover:to-blue-400 hover:shadow-[0_0_0_1px_rgba(59,130,246,0.95),0_0_30px_rgba(59,130,246,0.42)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950; + @apply border border-primary bg-primary text-primary-foreground shadow-[0_0_0_1px_hsl(var(--primary)/0.75),0_0_24px_hsl(var(--primary)/0.25)] hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background; } .button-secondary { - @apply border border-[#2a3142] bg-[#10131c] text-zinc-100 hover:border-[#384056] hover:bg-[#151925] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950; + @apply border border-input bg-secondary text-secondary-foreground hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/70 focus-visible:ring-offset-2 focus-visible:ring-offset-background; } .panel-glow { - @apply rounded-xl border border-[#1e2433] bg-[#0b0d14] shadow-[0_12px_36px_-22px_rgba(59,130,246,0.35)] transition-all duration-300; + @apply rounded-xl border border-card-border bg-card shadow-[0_12px_36px_-22px_hsl(var(--primary)/0.2)] transition-all duration-300; } .panel-glow-hover { - @apply hover:border-[#2a3142] hover:shadow-[0_0_0_1px_rgba(59,130,246,0.25),0_0_24px_rgba(59,130,246,0.08)]; + @apply hover:border-primary/35 hover:shadow-[0_0_0_1px_hsl(var(--primary)/0.2),0_0_24px_hsl(var(--primary)/0.08)]; } } diff --git a/dashboard/app/home/fleet-dashboard.tsx b/dashboard/app/home/fleet-dashboard.tsx new file mode 100644 index 0000000..6994f19 --- /dev/null +++ b/dashboard/app/home/fleet-dashboard.tsx @@ -0,0 +1,456 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { ShieldCheck } from "lucide-react"; +import { Bar, BarChart, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; +import { AnomalyRiskLabel, anomalyBarClass, AppShell, StatusChip } from "@/components/dashboard-ui"; +import { EmptyState } from "@/components/EmptyState"; +import { QueryError } from "@/components/query-states"; +import { IncidentsViewSkeleton, SkeletonStatCard } from "@/components/skeletons"; +import { useFleetSummaryQuery } from "@/lib/api/use-fleet-summary-query"; +import { useAllSpendTrackersQuery } from "@/lib/api/use-spend-trackers-query"; +import { usePoliciesQuery } from "@/lib/api/use-policies-query"; +import { useRecentIncidentsQuery } from "@/lib/api/use-recent-incidents-query"; +import type { IncidentSummary, PolicySummary, SpendTrackerRow } from "@/lib/types/dashboard"; +import { useSSEEventLogStore } from "@/lib/stores/sse-event-log"; +import { + formatRelativeTime, + formatRelativeTooltip, + formatSol, + lamportsToSol, + policyLabel, + shortAddress, +} from "@/lib/utils"; + +function truncateText(text: string, max = 72): string { + if (text.length <= max) return text; + return `${text.slice(0, max - 1)}…`; +} + +function budgetBurnPct(spentLamports: string, budgetLamports: string): number { + const spent = lamportsToSol(spentLamports); + const budget = lamportsToSol(budgetLamports); + if (budget <= 0) return 0; + return (spent / budget) * 100; +} + +function burnBarFill(pct: number): string { + if (pct >= 90) return "hsl(var(--crimson))"; + if (pct >= 70) return "hsl(var(--amber))"; + return "hsl(var(--teal))"; +} + +function agentHealthTone(policy: PolicySummary): "green" | "amber" | "red" { + if (!policy.isActive) return "amber"; + if (policy.anomalyScore >= 61) return "red"; + if (policy.anomalyScore >= 31) return "amber"; + return "green"; +} + +function healthDotClass(tone: "green" | "amber" | "red"): string { + if (tone === "red") return "bg-[hsl(var(--crimson))] shadow-[0_0_10px_hsl(var(--crimson)/0.45)]"; + if (tone === "amber") return "bg-[hsl(var(--amber))] shadow-[0_0_10px_hsl(var(--amber)/0.35)]"; + return "bg-[hsl(var(--teal))] shadow-[0_0_10px_hsl(var(--teal)/0.35)]"; +} + +function sseRowAccent(type: string, payload: unknown): string { + if (type === "agent_paused") return "border-l-amber-500 text-amber-200/95"; + if (type === "new_transaction") return "border-l-teal-500/80 text-teal-100/80"; + if (type === "escalation_created" || type === "escalation_updated") { + return "border-l-purple-500 text-purple-200/95"; + } + if (type === "verdict") { + const o = payload && typeof payload === "object" ? (payload as Record) : null; + const v = o?.verdict != null ? String(o.verdict) : ""; + if (v === "pause") return "border-l-[hsl(var(--crimson))] text-red-200/95"; + } + return "border-l-zinc-600 text-zinc-300/95"; +} + +function FleetStatCard({ + label, + value, + delta, + deltaInverted, +}: { + label: string; + value: string | number; + /** When set (including 0), shows comparison vs prior 24h where applicable */ + delta?: number; + deltaInverted?: boolean; +}) { + const hasComparison = delta !== undefined; + + let deltaTone = "text-teal-400/90"; + if (hasComparison && delta !== 0) { + const worse = deltaInverted ? delta > 0 : delta < 0; + const better = deltaInverted ? delta < 0 : delta > 0; + if (worse) deltaTone = "text-amber-400/95"; + else if (better) deltaTone = "text-teal-400/90"; + } + + const deltaStr = + !hasComparison || delta === 0 + ? null + : `${delta > 0 ? "+" : ""}${delta} vs prior 24h`; + + return ( +
+
{label}
+
{value}
+ {hasComparison ? ( + deltaStr ? ( +
{deltaStr}
+ ) : ( +
Flat vs prior 24h
+ ) + ) : null} +
+ ); +} + +export default function FleetDashboard() { + const fleetQuery = useFleetSummaryQuery(); + const policiesQuery = usePoliciesQuery(); + const spendQuery = useAllSpendTrackersQuery(); + const incidentsQuery = useRecentIncidentsQuery(10); + + const sseEntries = useSSEEventLogStore((s) => s.entries); + const logViewportRef = useRef(null); + const [logPaused, setLogPaused] = useState(false); + + const logLines = useMemo(() => sseEntries.slice(-15), [sseEntries]); + + useEffect(() => { + if (logPaused || !logViewportRef.current) return; + logViewportRef.current.scrollTop = logViewportRef.current.scrollHeight; + }, [logLines, logPaused]); + + const policies = policiesQuery.data ?? []; + const spendRows = spendQuery.data ?? []; + + const trackerByPubkey = useMemo(() => { + const m = new Map(); + for (const row of spendRows) { + m.set(row.policyPubkey, row); + } + return m; + }, [spendRows]); + + const healthRows = useMemo(() => { + return [...policies] + .sort((a, b) => b.anomalyScore - a.anomalyScore) + .map((policy) => ({ + policy, + tracker: trackerByPubkey.get(policy.pubkey) ?? null, + })); + }, [policies, trackerByPubkey]); + + const chartRows = useMemo(() => { + return policies.map((policy) => { + const tracker = trackerByPubkey.get(policy.pubkey); + const spent = tracker?.lamportsSpent24h ?? "0"; + const pct = budgetBurnPct(spent, policy.dailyBudgetLamports); + const labelShort = policy.label ?? shortAddress(policy.pubkey, 4); + return { + name: labelShort.length > 14 ? `${labelShort.slice(0, 12)}…` : labelShort, + pubkey: policy.pubkey, + pctDisplay: Math.min(pct, 100), + pctFull: pct, + spent, + budget: policy.dailyBudgetLamports, + fill: burnBarFill(pct), + }; + }); + }, [policies, trackerByPubkey]); + const chartHeight = Math.max(160, policies.length * 48); + + const incidents = incidentsQuery.data?.items ?? []; + + if (fleetQuery.isLoading || policiesQuery.isLoading) { + return ( + +
+ {Array.from({ length: 4 }).map((_, idx) => ( + + ))} +
+
+ ); + } + + if (fleetQuery.isError || policiesQuery.isError) { + const err = fleetQuery.error ?? policiesQuery.error; + return ( + + { + void fleetQuery.refetch(); + void policiesQuery.refetch(); + void spendQuery.refetch(); + void incidentsQuery.refetch(); + }} + /> + + ); + } + + const summary = fleetQuery.data; + if (!summary) { + return null; + } + + const incidentDelta = summary.incidentsLast24h - summary.incidentsPrev24h; + + return ( + +
+
+ + + + +
+ +
+
+
+
+

+ Recent incidents +

+ + View all + +
+ {incidentsQuery.isLoading ? ( + + ) : incidentsQuery.isError ? ( + void incidentsQuery.refetch()} /> + ) : incidents.length === 0 ? ( +
+ +
+ ) : ( +
+ + + + + + + + + + + {incidents.map((inc: IncidentSummary) => ( + + + + + + + ))} + +
+ Agent + + When + + Reason + + Status +
+ + {inc.policyPubkey.slice(0, 4)}…{inc.policyPubkey.slice(-4)} + + {policyLabel(inc.policyPubkey)} + + + + + {formatRelativeTime(inc.pausedAt)} + + + {truncateText(inc.reason)} + + + {inc.resolvedAt ? "Resolved" : "Open"} + +
+
+ )} +
+ +
+
+

Live events

+ + Hover to pause scroll + +
+
setLogPaused(true)} + onMouseLeave={() => setLogPaused(false)} + > + {logLines.length === 0 ? ( +
+

— waiting for events —

+
+ ) : ( + logLines.map((line) => ( +
+ {new Date(line.receivedAt).toLocaleTimeString()} + {line.type} +
+                        {truncateText(JSON.stringify(line.payload), 280)}
+                      
+
+ )) + )} +
+
+
+ + +
+
+
+ ); +} diff --git a/dashboard/app/home/page.tsx b/dashboard/app/home/page.tsx new file mode 100644 index 0000000..f700b9c --- /dev/null +++ b/dashboard/app/home/page.tsx @@ -0,0 +1,5 @@ +import FleetDashboard from "@/app/home/fleet-dashboard"; + +export default function HomePage() { + return ; +} diff --git a/dashboard/app/incidents/[id]/incident-detail-view.tsx b/dashboard/app/incidents/[id]/incident-detail-view.tsx index c581294..06b0590 100644 --- a/dashboard/app/incidents/[id]/incident-detail-view.tsx +++ b/dashboard/app/incidents/[id]/incident-detail-view.tsx @@ -3,7 +3,8 @@ import Link from "next/link"; import { AppShell, IncidentTimeline, Metric, StatusChip } from "@/components/dashboard-ui"; import { ReportMarkdown } from "@/components/report-markdown"; -import { QueryError, QueryLoading } from "@/components/query-states"; +import { QueryError } from "@/components/query-states"; +import { IncidentDetailSkeleton } from "@/components/skeletons"; import { useIncidentQuery } from "@/lib/api/use-incident-query"; import { shortAddress } from "@/lib/utils"; @@ -13,7 +14,7 @@ export function IncidentDetailView({ id }: { id: string }) { if (incidentQuery.isLoading) { return ( - + ); } @@ -68,48 +69,48 @@ export function IncidentDetailView({ id }: { id: string }) { return ( -
+
-

+

{incident.policy.label ?? shortAddress(incident.policy.pubkey)}

{isResolved ? ( - + RESOLVED ) : ( - + ACTIVE )} - + Incident #{incident.id.slice(-4)}
-
{incident.policy.pubkey}
-
+
{incident.policy.pubkey}
+
- Paused at + Paused at {new Date(incident.pausedAt).toLocaleString()}
- Paused by + Paused by {pausedByType} · {shortAddress(incident.pausedBy, 4, 4)}
{incident.resolvedAt ? (
- Resolved + Resolved {new Date(incident.resolvedAt).toLocaleString()}
) : null}
- + View agent
@@ -123,16 +124,16 @@ export function IncidentDetailView({ id }: { id: string }) {
-
-
Timeline
+
+
Timeline
-
-
+
+
Guardian postmortem {incident.fullReport ? ( - + guardian ) : null} @@ -140,12 +141,12 @@ export function IncidentDetailView({ id }: { id: string }) { {incident.fullReport ? ( ) : ( -
+
No AI postmortem generated for this incident. {incident.resolution ? ( -
-
Resolution
-
{incident.resolution}
+
+
Resolution
+
{incident.resolution}
) : null}
diff --git a/dashboard/app/incidents/incidents-view.tsx b/dashboard/app/incidents/incidents-view.tsx index 4d4ac87..62639b8 100644 --- a/dashboard/app/incidents/incidents-view.tsx +++ b/dashboard/app/incidents/incidents-view.tsx @@ -1,8 +1,11 @@ "use client"; import { AppShell, IncidentTable } from "@/components/dashboard-ui"; -import { QueryEmpty, QueryError, QueryLoading } from "@/components/query-states"; +import { EmptyState } from "@/components/EmptyState"; +import { QueryError } from "@/components/query-states"; +import { IncidentsViewSkeleton } from "@/components/skeletons"; import { useIncidentsQuery } from "@/lib/api/use-incidents-query"; +import { ShieldCheck } from "lucide-react"; export function IncidentsView() { const incidentsQuery = useIncidentsQuery(undefined, 50); @@ -10,7 +13,7 @@ export function IncidentsView() { if (incidentsQuery.isLoading) { return ( - + ); } @@ -30,10 +33,13 @@ export function IncidentsView() { {incidents.length ? ( ) : ( - +
+ +
)} ); diff --git a/dashboard/app/layout.tsx b/dashboard/app/layout.tsx index 0389a6c..a1c42d3 100644 --- a/dashboard/app/layout.tsx +++ b/dashboard/app/layout.tsx @@ -1,11 +1,35 @@ import "@solana/wallet-adapter-react-ui/styles.css"; import "./globals.css"; import type { ReactNode } from "react"; +import type { Metadata } from "next"; import { AppProviders } from "@/components/providers"; +import { Inter, Michroma } from "next/font/google"; +import { cn } from "@/lib/utils"; + +const inter = Inter({ subsets: ['latin'], variable: '--font-sans' }); +const michroma = Michroma({ subsets: ["latin"], weight: "400", variable: "--font-brand" }); + +export const metadata: Metadata = { + title: "Guardrails Dashboard", + icons: { + icon: [ + { url: "/favicon.ico", sizes: "any" }, + { url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" }, + { url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" }, + ], + apple: [{ url: "/apple-touch-icon.png", sizes: "180x180" }], + other: [ + { rel: "android-chrome", url: "/android-chrome-192x192.png", sizes: "192x192" }, + { rel: "android-chrome", url: "/android-chrome-512x512.png", sizes: "512x512" }, + ], + }, + manifest: "/site.webmanifest", +}; + export default function RootLayout({ children }: { children: ReactNode }) { return ( - + {children} diff --git a/dashboard/app/page.tsx b/dashboard/app/page.tsx index 6f16e48..47914be 100644 --- a/dashboard/app/page.tsx +++ b/dashboard/app/page.tsx @@ -1,123 +1,5 @@ -import Link from "next/link"; -import { LandingConnectWalletButton } from "@/components/landing-connect-wallet-button"; -import { WalletControls } from "@/components/wallet-controls"; +import { LandingAuthScreen } from "@/components/landing-auth-screen"; export default function Home() { - return ( -
-
-
-
Home
- -
-
- -

Agent Guardrails landing page

- -
-
-
- -
-

- - Live on Solana devnet -

- -

- On-chain{" "} - - guardrails - -
- for AI agents -

- -

- A programmable policy layer that sits between your agent's session key and the blockchain. - Allow-list programs, cap spend, and stop compromised agents the moment they misbehave. -

- -
- - - Sign in with Solana - - - View demo incident - -
- -
- - - -
-
-
- -
- - - -
-
- ); -} - -function StatCard({ - number, - label, - tone = "text-zinc-100", -}: { - number: string; - label: string; - tone?: string; -}) { - return ( -
-
{number}
-

{label}

-
- ); -} - -function FeatureCard({ - title, - description, - iconPath, -}: { - title: string; - description: string; - iconPath: string; -}) { - return ( -
-
- - - -
-

{title}

-

{description}

-
- ); + return ; } diff --git a/dashboard/app/playground/page.tsx b/dashboard/app/playground/page.tsx new file mode 100644 index 0000000..1808306 --- /dev/null +++ b/dashboard/app/playground/page.tsx @@ -0,0 +1,5 @@ +import { PlaygroundView } from "./playground-view"; + +export default function PlaygroundPage() { + return ; +} diff --git a/dashboard/app/playground/playground-view.tsx b/dashboard/app/playground/playground-view.tsx new file mode 100644 index 0000000..11b47c5 --- /dev/null +++ b/dashboard/app/playground/playground-view.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { AppShell } from "@/components/dashboard-ui"; +import { TransactionCrafter } from "@/components/playground/transaction-crafter"; +import { AttackSimulator } from "@/components/playground/attack-simulator"; +import { SignalInspector } from "@/components/playground/signal-inspector"; +import { KillSwitchDemo } from "@/components/playground/kill-switch-demo"; +import { PolicySandbox } from "@/components/playground/policy-sandbox"; +import type { PlaygroundTab } from "@/lib/playground/types"; +import { usePlaygroundStore } from "@/lib/stores/playground"; + +const tabs: { id: PlaygroundTab; label: string }[] = [ + { id: "simulate", label: "Simulate" }, + { id: "inspect", label: "Signal inspector" }, + { id: "learn", label: "Reference" }, +]; + +export function PlaygroundView() { + const activeTab = usePlaygroundStore((s) => s.activeTab); + const setActiveTab = usePlaygroundStore((s) => s.setActiveTab); + + return ( + +
+ {tabs.map((t) => ( + + ))} +
+ + {activeTab === "simulate" ? ( +
+ +
+

+ Attack sequences +

+ +
+
+ ) : null} + {activeTab === "inspect" ? : null} + {activeTab === "learn" ? ( +
+ +
+ +
+
+ ) : null} +
+ ); +} diff --git a/dashboard/app/settings/page.tsx b/dashboard/app/settings/page.tsx new file mode 100644 index 0000000..f3ca557 --- /dev/null +++ b/dashboard/app/settings/page.tsx @@ -0,0 +1,5 @@ +import { SettingsView } from "@/app/settings/settings-view"; + +export default function SettingsPage() { + return ; +} diff --git a/dashboard/app/settings/settings-view.tsx b/dashboard/app/settings/settings-view.tsx new file mode 100644 index 0000000..679fbd3 --- /dev/null +++ b/dashboard/app/settings/settings-view.tsx @@ -0,0 +1,253 @@ +"use client"; + +import { useWallet } from "@solana/wallet-adapter-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Copy } from "lucide-react"; +import { toast } from "sonner"; +import { AppShell } from "@/components/dashboard-ui"; +import { QueryError } from "@/components/query-states"; +import { SkeletonStatCard } from "@/components/skeletons"; +import { + deleteAuthSessions, +} from "@/lib/api/client"; +import { useLlmSettingsQuery } from "@/lib/api/use-llm-settings-query"; +import { useOperatorSessionQuery } from "@/lib/api/use-operator-session-query"; +import { useWebhookStatusQuery } from "@/lib/api/use-webhook-status-query"; +import { clearSiwsAndRedirectHome } from "@/lib/auth/siws-session"; +import { useSiwsAuthStore } from "@/lib/stores/siws-auth"; +import { formatDateTime } from "@/lib/utils"; + +export function SettingsView() { + const router = useRouter(); + const wallet = useWallet(); + const webhookQ = useWebhookStatusQuery(); + const sessionQ = useOperatorSessionQuery(); + const llmQ = useLlmSettingsQuery(); + + const loading = webhookQ.isLoading || sessionQ.isLoading || llmQ.isLoading; + const error = webhookQ.error ?? sessionQ.error ?? llmQ.error; + + const onSignOutSession = () => { + useSiwsAuthStore.getState().clearSignedIn(); + void wallet.disconnect().catch(() => {}); + clearSiwsAndRedirectHome((path) => router.replace(path)); + }; + + const onKillAllSessions = async () => { + try { + await deleteAuthSessions(); + useSiwsAuthStore.getState().clearSignedIn(); + await wallet.disconnect().catch(() => {}); + toast.success("Signed out of all sessions"); + router.replace("/"); + } catch (e) { + toast.error("Could not revoke sessions"); + console.error(e); + } + }; + + const connectedPk = wallet.publicKey?.toBase58() ?? "—"; + + if (loading) { + return ( + +
+ {Array.from({ length: 4 }).map((_, idx) => ( + + ))} +
+
+ ); + } + + if (error) { + return ( + + { + void webhookQ.refetch(); + void sessionQ.refetch(); + void llmQ.refetch(); + }} + /> + + ); + } + + const webhook = webhookQ.data!; + const session = sessionQ.data!; + const llm = llmQ.data!; + + return ( + +
+
+

+ Webhook status +

+
+
+ {webhook.webhookUrl} + +
+
+ + Last webhook:{" "} + + {webhook.lastWebhookReceivedAt ? formatDateTime(webhook.lastWebhookReceivedAt) : "never"} + + + + Events (1h):{" "} + {webhook.eventsReceivedLastHour} + +
+
+ Configure this URL in your Helius dashboard under webhook settings for Enhanced Transactions. +
+
+
+ +
+

Active session

+
+
+
Connected wallet
+
+
+ {connectedPk} + {connectedPk !== "—" ? ( + + ) : null} +
+
+
+
+
JWT wallet
+
+
+ {session.walletPubkey} + +
+
+
+
+
Session expires
+
+ {session.expiresAt ? formatDateTime(session.expiresAt) : "—"} +
+
+
+ +
+ +
+

+ LLM judge configuration +

+

Read-only — server-side configuration.

+
+
+
Fast tier (judge)
+
{llm.judgeModel}
+
+
+
Report model
+
{llm.reportModel}
+
+
+ {llm.fallbackActive ? ( +
+ Rule-based fallback active — no Anthropic API key configured on the server. +
+ ) : ( +
Anthropic API configured — LLM judge enabled.
+ )} +
+ +
+
+ + Coming soon + +
+

+ Notification preferences +

+
    +
  • + Email on agent pause + +
  • +
  • + Email on escalation created + +
  • +
  • + Daily spend summary + +
  • +
+
+ +
+

Danger zone

+

+ Revokes server-side SIWS sessions for your wallet and clears the JWT cookie. +

+ +
+ +

+ Looking for agents? Go to{" "} + + Agents + + . +

+
+
+ ); +} diff --git a/dashboard/app/transactions/[sig]/page.tsx b/dashboard/app/transactions/[sig]/page.tsx new file mode 100644 index 0000000..686a6b8 --- /dev/null +++ b/dashboard/app/transactions/[sig]/page.tsx @@ -0,0 +1,5 @@ +import { TransactionDetailView } from "@/app/transactions/[sig]/transaction-detail-view"; + +export default function TransactionDetailPage({ params }: { params: { sig: string } }) { + return ; +} diff --git a/dashboard/app/transactions/[sig]/transaction-detail-view.tsx b/dashboard/app/transactions/[sig]/transaction-detail-view.tsx new file mode 100644 index 0000000..1d5052a --- /dev/null +++ b/dashboard/app/transactions/[sig]/transaction-detail-view.tsx @@ -0,0 +1,336 @@ +"use client"; + +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { ChevronDown, ChevronRight, ExternalLink } from "lucide-react"; +import { toast } from "sonner"; +import { AppShell, StatusChip } from "@/components/dashboard-ui"; +import { QueryError } from "@/components/query-states"; +import { SkeletonStatCard } from "@/components/skeletons"; +import { useTransactionQuery } from "@/lib/api/use-transaction-query"; +import type { TransactionDetail } from "@/lib/types/dashboard"; +import { + formatDateTime, + formatRelativeTime, + formatRelativeTooltip, + formatSol, + shortAddress, + verdictTone, +} from "@/lib/utils"; + +const EXPLORER_CLUSTER = process.env.NEXT_PUBLIC_SOLANA_CLUSTER ?? "devnet"; + +function explorerTxUrl(sig: string): string { + const q = EXPLORER_CLUSTER === "mainnet-beta" ? "" : `?cluster=${EXPLORER_CLUSTER}`; + return `https://explorer.solana.com/tx/${sig}${q}`; +} + +function CopyBtn({ text, label = "Copy" }: { text: string; label?: string }) { + return ( + + ); +} + +function deriveUiStatus(txn: TransactionDetail): { + label: string; + tone: "green" | "amber" | "red"; +} { + if (txn.status === "escalated") return { label: "Escalated", tone: "amber" }; + if (txn.status === "rejected") return { label: "Rejected", tone: "red" }; + const v = txn.verdict?.verdict; + if (v === "pause") return { label: "Paused", tone: "red" }; + if (v === "flag") return { label: "Flagged", tone: "amber" }; + return { label: "Allowed", tone: "green" }; +} + +function confidenceColor(confidence: number): string { + if (confidence >= 80) return "bg-teal-500"; + if (confidence >= 50) return "bg-amber-500"; + return "bg-red-500"; +} + +export function TransactionDetailView({ sig }: { sig: string }) { + const q = useTransactionQuery(sig); + const [rawOpen, setRawOpen] = useState(false); + + const uiStatus = useMemo(() => { + if (!q.data?.transaction) return null; + return deriveUiStatus(q.data.transaction); + }, [q.data?.transaction]); + + if (q.isLoading) { + return ( + +
+ + +
+
+ ); + } + + if (q.isError || !q.data) { + return ( + + void q.refetch()} /> + + ); + } + + const { transaction: txn, incident, prevTxnSig, nextTxnSig } = q.data; + const verdict = txn.verdict; + const esc = txn.escalation; + const explorerUrl = explorerTxUrl(txn.txnSig); + const lamports = txn.amountLamports ?? "0"; + + return ( + +
+
+
+
+ {txn.txnSig} + +
+
+ {uiStatus ? ( + {uiStatus.label.toUpperCase()} + ) : null} + + {formatRelativeTime(txn.blockTime)} ·{" "} + {formatDateTime(txn.blockTime)} + + slot {txn.slot} + + Explorer + +
+
+
+
+ +
+
+
+

+ Transaction details +

+
+
+
Target program
+
+ {txn.targetProgram} + +
+
+
+
Amount
+
+ {txn.amountLamports ?? "0"} lamports · {formatSol(lamports)} +
+
+ {txn.destination ? ( +
+
Destination
+
+ {txn.destination} + +
+
+ ) : null} +
+
Policy
+
+ + {shortAddress(txn.policyPubkey, 8)} + +
+
+ {txn.rejectReason ? ( +
+ + Reject reason + +

{txn.rejectReason}

+
+ ) : null} +
+
+ +
+ + {rawOpen ? ( +
+
+ +
+
+                  {JSON.stringify(txn.rawEvent, null, 2)}
+                
+
+ ) : null} +
+
+ +
+ {verdict ? ( +
+

AI verdict

+
+
+ {verdict.verdict.toUpperCase()} + + {verdict.model} · {verdict.latencyMs != null ? `${verdict.latencyMs}ms` : "latency —"} + +
+
+
+ Confidence + {verdict.confidence}% +
+
+
+
+
+
+ {verdict.prefilterSkipped ? ( + + Prefilter skipped + + ) : null} + {(verdict.promptTokens != null || verdict.completionTokens != null) && ( +
+ {verdict.promptTokens != null && ( + + prompt {verdict.promptTokens} tok + + )} + {verdict.completionTokens != null && ( + + completion {verdict.completionTokens} tok + + )} +
+ )} +
+ {verdict.reasoning} +
+
+
+
+ ) : ( +
+ No AI verdict recorded for this transaction. +
+ )} + + {esc ? ( +
+

Escalation

+
+
+ Status + {esc.status} +
+
+ Multisig {shortAddress(esc.squadsMultisig, 8)}{" "} + +
+

+ Approvals {esc.approvals.length} · Rejections {esc.rejections.length} +

+ {esc.proposalPda ? ( +

+ PDA {esc.proposalPda} +

+ ) : null} + + Open proposals → + + + Proposal detail → + +
+
+ ) : null} + + {incident ? ( +
+

+ Related incident +

+

{incident.reason}

+

+ Paused {formatDateTime(incident.pausedAt)} +

+

+ {incident.resolvedAt ? `Resolved ${formatDateTime(incident.resolvedAt)}` : "Open"} +

+ + Incident detail → + +
+ ) : null} +
+
+ + +
+ ); +} diff --git a/dashboard/components.json b/dashboard/components.json new file mode 100644 index 0000000..3412eb4 --- /dev/null +++ b/dashboard/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/dashboard/components/EmptyState.tsx b/dashboard/components/EmptyState.tsx new file mode 100644 index 0000000..b177211 --- /dev/null +++ b/dashboard/components/EmptyState.tsx @@ -0,0 +1,30 @@ +import Link from "next/link"; +import type { ComponentType } from "react"; + +interface EmptyStateProps { + icon: ComponentType<{ className?: string }>; + title: string; + description?: string; + action?: { label: string; href: string }; +} + +export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) { + return ( +
+
+ +
+
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
+ {action ? ( + + {action.label} → + + ) : null} +
+ ); +} diff --git a/dashboard/components/PageErrorBoundary.tsx b/dashboard/components/PageErrorBoundary.tsx new file mode 100644 index 0000000..3cddeb4 --- /dev/null +++ b/dashboard/components/PageErrorBoundary.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { Component, type ReactNode } from "react"; +import { AlertTriangle } from "lucide-react"; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error?: Error; +} + +export class PageErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + render() { + if (this.state.hasError) { + return ( +
+
+ +
+
+

Something went wrong

+

{this.state.error?.message}

+
+ +
+ ); + } + return this.props.children; + } +} diff --git a/dashboard/components/auth/require-session.tsx b/dashboard/components/auth/require-session.tsx new file mode 100644 index 0000000..2e6f3be --- /dev/null +++ b/dashboard/components/auth/require-session.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useWallet } from "@solana/wallet-adapter-react"; +import { usePathname, useRouter } from "next/navigation"; +import { type ReactNode, useEffect, useState } from "react"; +import { useSiwsAuthStore } from "@/lib/stores/siws-auth"; + +/** Routes that require wallet connect + SIWS; everything else (e.g. 404) stays reachable. */ +function isProtectedDashboardPath(path: string): boolean { + return ( + path.startsWith("/home") || + path.startsWith("/agents") || + path.startsWith("/activity") || + path.startsWith("/incidents") || + path.startsWith("/transactions") || + path.startsWith("/audit") || + path.startsWith("/escalations") || + path.startsWith("/playground") || + path.startsWith("/settings") + ); +} + +function readConnectedPubkey(adapter: ReturnType): string | null { + try { + if (adapter.connected && adapter.publicKey) { + return adapter.publicKey.toBase58(); + } + } catch { + /* WalletProvider missing in tests */ + } + return null; +} + +export function RequireSession({ children }: { children: ReactNode }) { + const pathname = usePathname() ?? ""; + const router = useRouter(); + const walletAdapter = useWallet(); + const siwsWallet = useSiwsAuthStore((s) => s.siwsWallet); + const siwsSignedInAt = useSiwsAuthStore((s) => s.siwsSignedInAt); + + const [hydrated, setHydrated] = useState(false); + + useEffect(() => { + const unsub = useSiwsAuthStore.persist.onFinishHydration(() => setHydrated(true)); + void useSiwsAuthStore.persist.rehydrate(); + setHydrated(useSiwsAuthStore.persist.hasHydrated()); + return unsub; + }, []); + + const connectedPk = readConnectedPubkey(walletAdapter); + const hasSiwsRecord = Boolean(siwsSignedInAt && siwsWallet); + const walletReady = Boolean(walletAdapter.connected && connectedPk); + const siwsOk = hasSiwsRecord && walletReady && connectedPk === siwsWallet; + const waitingForWallet = hasSiwsRecord && !walletReady; + const wrongWallet = hasSiwsRecord && walletReady && connectedPk !== siwsWallet; + + useEffect(() => { + if (!hydrated || !wrongWallet) return; + useSiwsAuthStore.getState().clearSignedIn(); + router.replace("/"); + }, [hydrated, router, wrongWallet]); + + useEffect(() => { + if (!hydrated) return; + if (siwsOk && (pathname === "/" || pathname === "/signin")) { + router.replace("/agents"); + } + }, [hydrated, pathname, router, siwsOk]); + + useEffect(() => { + if (!hydrated) return; + if (!isProtectedDashboardPath(pathname)) return; + if (siwsOk || waitingForWallet) return; + router.replace("/"); + }, [hydrated, pathname, router, siwsOk, waitingForWallet]); + + const protectedPath = isProtectedDashboardPath(pathname); + + if (!hydrated || (protectedPath && waitingForWallet)) { + return ( +
+
+
+ ); + } + + if ( + protectedPath && + !siwsOk && + !waitingForWallet + ) { + return ( +
+
+
+ ); + } + + return <>{children}; +} diff --git a/dashboard/components/auth/siws-sign-in.tsx b/dashboard/components/auth/siws-sign-in.tsx index 655ae8e..e39597f 100644 --- a/dashboard/components/auth/siws-sign-in.tsx +++ b/dashboard/components/auth/siws-sign-in.tsx @@ -1,7 +1,10 @@ "use client"; import { useWallet } from "@solana/wallet-adapter-react"; -import { useRouter } from "next/navigation"; +import { useRouter } from "nextjs-toploader/app"; +import { useSearchParams } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; +import { Wallet } from "lucide-react"; import { useCallback, useState } from "react"; import { ApiClientError, @@ -19,6 +22,13 @@ function uint8ArrayToBase64(bytes: Uint8Array): string { return btoa(binary); } +function resolveRedirectTarget(from: string | null): string { + if (from && from.startsWith("/") && !from.startsWith("//")) { + return from; + } + return "/agents"; +} + export function SiwsSignIn() { const router = useRouter(); const { publicKey, signMessage, connecting, connected } = useWallet(); @@ -54,32 +64,27 @@ export function SiwsSignIn() { } }, [markSignedIn, publicKey, router, signMessage]); - return ( -
-

- Connect a wallet, then sign the one-time message from the Guardrails API to create a session. -

- -

- Use the global wallet button in the top bar to connect your wallet before signing in. -

- - {connected && !signMessage ? ( -

This wallet does not support message signing.

- ) : null} + if (!connected) { + return null; + } - {connected && signMessage ? ( + return ( +
+ {signMessage ? ( - ) : null} + ) : ( +

This wallet does not support message signing.

+ )} - {error ?

{error}

: null} + {error ?

{error}

: null}
); } diff --git a/dashboard/components/close-policy-button.tsx b/dashboard/components/close-policy-button.tsx index 33cfac6..3789ad7 100644 --- a/dashboard/components/close-policy-button.tsx +++ b/dashboard/components/close-policy-button.tsx @@ -1,10 +1,11 @@ "use client"; import { useState } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter } from "nextjs-toploader/app"; import { useQueryClient } from "@tanstack/react-query"; import { useWallet } from "@solana/wallet-adapter-react"; import { PublicKey } from "@solana/web3.js"; +import { toast } from "sonner"; import { getErrorMessage } from "@/lib/api/client"; import { queryKeys } from "@/lib/api/query-keys"; import { GuardrailsClient } from "@/lib/sdk/client"; @@ -38,18 +39,22 @@ export function ClosePolicyButton({ policy }: { policy: PolicySummary }) { await client.closePolicy(new PublicKey(policy.pubkey)); queryClient.removeQueries({ queryKey: queryKeys.policy(policy.pubkey) }); - queryClient.invalidateQueries({ queryKey: queryKeys.policies() }); + queryClient.invalidateQueries({ queryKey: ["policies"] }); setOpen(false); + toast.success("Policy closed and refunded."); router.push("/agents"); } catch (e) { const msg = getErrorMessage(e).toLowerCase(); if (msg.includes("already been processed") || msg.includes("already processed")) { queryClient.removeQueries({ queryKey: queryKeys.policy(policy.pubkey) }); - queryClient.invalidateQueries({ queryKey: queryKeys.policies() }); + queryClient.invalidateQueries({ queryKey: ["policies"] }); + toast.success("Policy close was already processed."); router.push("/agents"); } else { - setError(getErrorMessage(e)); + const message = getErrorMessage(e); + setError(message); + toast.error(message); } } finally { setBusy(false); diff --git a/dashboard/components/create-policy-wizard/CreatePolicyWizard.tsx b/dashboard/components/create-policy-wizard/CreatePolicyWizard.tsx index 369e3a0..7756511 100644 --- a/dashboard/components/create-policy-wizard/CreatePolicyWizard.tsx +++ b/dashboard/components/create-policy-wizard/CreatePolicyWizard.tsx @@ -1,9 +1,10 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; +import { useCallback, useState } from "react"; +import { useRouter } from "nextjs-toploader/app"; import { useWallet } from "@solana/wallet-adapter-react"; import { Keypair } from "@solana/web3.js"; +import { toast } from "sonner"; import { WizardStepPanels } from "@/components/create-policy-wizard/wizard-step-panels"; import { AgentSecretBackupModal } from "@/components/create-policy-wizard/agent-secret-backup-modal"; import { getErrorMessage } from "@/lib/api/client"; @@ -27,7 +28,7 @@ function isIdempotentCreateError(error: unknown) { ); } -export function CreatePolicyWizard() { +export function CreatePolicyWizard({ onCreated }: { onCreated?: () => void }) { const router = useRouter(); const { publicKey } = useWallet(); const provider = useAnchorProvider(); @@ -42,17 +43,10 @@ export function CreatePolicyWizard() { const [agentKeypair, setAgentKeypair] = useState(null); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); - const [toastError, setToastError] = useState(null); - - useEffect(() => { - if (!toastError) return; - const timeout = window.setTimeout(() => setToastError(null), 5000); - return () => window.clearTimeout(timeout); - }, [toastError]); const publishError = useCallback((message: string) => { setSubmitError(message); - setToastError(message); + toast.error(message); }, []); const runCreate = useCallback( @@ -107,14 +101,20 @@ export function CreatePolicyWizard() { setAgentKeypair(null); resetWizard(); - router.push("/agents"); + toast.success("Policy created on-chain."); + if (onCreated) { + onCreated(); + router.refresh(); + } else { + router.push("/agents"); + } } catch (e) { publishError(getErrorMessage(e)); } finally { setSubmitting(false); } }, - [programId, provider, publicKey, publishError, resetWizard, router], + [onCreated, programId, provider, publicKey, publishError, resetWizard, router], ); const onCreateClick = () => { @@ -153,15 +153,6 @@ export function CreatePolicyWizard() { return (
- {toastError ? ( -
- {toastError} -
- ) : null} {agentKeypair ? ( }) Choose preset programs or paste custom program IDs. Up to 10 allow-listed programs.

{fieldErrors.allowedPrograms ? ( -

{fieldErrors.allowedPrograms}

+

{fieldErrors.allowedPrograms}

) : null} - {pasteError ?

{pasteError}

: null} + {pasteError ?

{pasteError}

: null}
{Object.entries(PROGRAM_LABELS).map(([pubkey, label]) => { @@ -199,7 +199,7 @@ function LimitsStep({ fieldErrors }: { fieldErrors: Record }) { onBlur={maxTxInput.commitValue} onChange={(e) => maxTxInput.setInputValue(e.target.value)} /> - {fieldErrors.maxTxSol ? {fieldErrors.maxTxSol} : null} + {fieldErrors.maxTxSol ?

{fieldErrors.maxTxSol}

: null}
@@ -255,7 +257,7 @@ function SessionStep({ fieldErrors }: { fieldErrors: Record }) { onBlur={sessionDaysInput.commitValue} onChange={(e) => sessionDaysInput.setInputValue(e.target.value)} /> - {fieldErrors.sessionDays ? {fieldErrors.sessionDays} : null} + {fieldErrors.sessionDays ?

{fieldErrors.sessionDays}

: null}

Expires on {dateStr} (relative to now, ~{sessionDays} days). @@ -330,7 +332,7 @@ function EscalationStep({ fieldErrors }: { fieldErrors: Record }

{multisigMode === "create" ? ( - <> +
{/* Member list */}
@@ -376,7 +378,7 @@ function EscalationStep({ fieldErrors }: { fieldErrors: Record }
{fieldErrors.multisigMembers ? ( - {fieldErrors.multisigMembers} +

{fieldErrors.multisigMembers}

) : null}
@@ -398,10 +400,10 @@ function EscalationStep({ fieldErrors }: { fieldErrors: Record } ))} {fieldErrors.multisigThreshold ? ( - {fieldErrors.multisigThreshold} +

{fieldErrors.multisigThreshold}

) : null} - +
) : ( )} @@ -430,7 +432,7 @@ function EscalationStep({ fieldErrors }: { fieldErrors: Record } onChange={(e) => escalationThresholdInput.setInputValue(e.target.value)} /> {fieldErrors.escalationThresholdSol ? ( - {fieldErrors.escalationThresholdSol} +

{fieldErrors.escalationThresholdSol}

) : null} diff --git a/dashboard/components/dashboard-ui.tsx b/dashboard/components/dashboard-ui.tsx index 0b81277..f436475 100644 --- a/dashboard/components/dashboard-ui.tsx +++ b/dashboard/components/dashboard-ui.tsx @@ -1,448 +1,12 @@ "use client"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { useWallet } from "@solana/wallet-adapter-react"; -import { RadialBar, RadialBarChart, ResponsiveContainer } from "recharts"; -import type { ReactNode } from "react"; -import { - effectiveVerdict, - policyLabel, - programLabel, - shortAddress, - formatDateTime, - formatRelativeTime, - lamportsToSol, - verdictTone, -} from "@/lib/utils"; -import type { IncidentSummary, PolicySummary, TransactionSummary } from "@/lib/types/dashboard"; -import { useLayoutStore } from "@/lib/stores/layout"; -import { WalletControls } from "./wallet-controls"; - -/* ── SVG icon paths (matching design reference) ── */ -const navIconPaths = { - agents: "M16 11a4 4 0 10-8 0 4 4 0 008 0zM3 21v-1a5 5 0 015-5h8a5 5 0 015 5v1", - activity: "M3 12h4l3-8 4 16 3-8h4", - incidents: "M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z", - plus: "M12 5v14M5 12h14", -} as const; - -function NavIcon({ d }: { d: string }) { - return ( - - - - ); -} - -export function AppShell({ - title, - subtitle, - actions, - children, -}: { - title: string; - subtitle?: string; - actions?: ReactNode; - children: ReactNode; -}) { - const pathname = usePathname(); - const { connected, publicKey } = useWallet(); - const { sidebarOpen, toggleSidebar, setSidebarOpen } = useLayoutStore(); - const walletAddress = publicKey ? shortAddress(publicKey.toBase58(), 4, 4) : "Not connected"; - const monitorLinks = [ - { href: "/agents", label: "Agents", icon: navIconPaths.agents, count: "5" }, - { href: "/activity", label: "Activity", icon: navIconPaths.activity, count: "21" }, - { href: "/incidents", label: "Incidents", icon: navIconPaths.incidents, count: "2" }, - ]; - const currentPath = pathname ?? ""; - const isLinkActive = (href: string, nested = true) => - href === "/" ? currentPath === "/" : currentPath === href || (nested && currentPath.startsWith(`${href}/`)); - - return ( -
- {/* Mobile backdrop */} - {sidebarOpen ? ( - - -
- -
-
-
-

{title}

- {subtitle ?

{subtitle}

: null} -
-
{actions}
-
-
{children}
-
- -
- ); -} - -export function StatusChip({ tone, children }: { tone: "green" | "amber" | "red"; children: ReactNode }) { - const toneClasses = { - green: "bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 shadow-emerald-500/10", - amber: "bg-amber-500/20 text-amber-300 border border-amber-500/30 shadow-amber-500/10", - red: "bg-red-500/20 text-red-300 border border-red-500/30 shadow-red-500/10", - }; - return {children}; -} - -export function PolicyCard({ policy }: { policy: PolicySummary }) { - const spent = lamportsToSol(policy.dailySpentLamports ?? "0"); - const budget = lamportsToSol(policy.dailyBudgetLamports); - const displayBudget = budget > 0 && budget < 1 ? budget.toFixed(2) : budget.toFixed(0); - const spendPct = budget > 0 ? Math.min((spent / budget) * 100, 100) : 0; - const progressTone = spendPct >= 90 ? "bg-red-500" : spendPct >= 66 ? "bg-amber-400" : "bg-emerald-400"; - const sessionExpired = new Date(policy.sessionExpiry).getTime() < Date.now(); - - return ( - -
-
-
- {policy.label ?? "Unlabeled agent"} -
-
- {shortAddress(policy.pubkey, 6, 6)} -
-
-
- {!policy.isActive ? ( - - PAUSED - - ) : sessionExpired ? ( - - SESSION EXPIRED - - ) : ( - - ACTIVE - - )} - {policy.squadsMultisig ? ( - - SQUADS - - ) : null} -
-
- -
-
- Daily spend - - {spent.toFixed(2)} / {displayBudget} SOL - -
-
-
-
-
- -
-
-
Session
-
{formatRelativeTime(policy.sessionExpiry)}
-
-
-
Per tx cap
-
{lamportsToSol(policy.maxTxLamports)} SOL
-
-
- -
- Programs: {policy.allowedPrograms.slice(0, 3).map(programLabel).join(", ")} - {policy.allowedPrograms.length > 3 ? ` +${policy.allowedPrograms.length - 3}` : ""} -
- - ); -} - -export function Metric({ label, value }: { label: string; value: ReactNode }) { - return ( -
-
{label}
-
{value}
-
- ); -} - -export function SpendGauge({ spentLamports, budgetLamports }: { spentLamports: string; budgetLamports: string }) { - const spent = lamportsToSol(spentLamports); - const budget = lamportsToSol(budgetLamports); - const ratio = budget === 0 ? 0 : (spent / budget) * 100; - const clampedRatio = Math.min(ratio, 100); - const tone = ratio >= 90 ? "#ff6b6b" : ratio >= 66 ? "#ffb84d" : "#29c780"; - - if (budget <= 0) { - return ( -
- No budget set. -
- ); - } - - return ( -
- - - - - -
- {ratio > 100 ? ( - <> -
- OVER BUDGET -
-
- {spent.toFixed(1)} / {budget.toFixed(1)} SOL -
- - ) : ( - <> -
{spent.toFixed(1)} SOL
-
of {budget.toFixed(1)} SOL budget
- - )} -
-
- ); -} - -export function TransactionRow({ - transaction, - showAgent = false, -}: { - transaction: TransactionSummary; - showAgent?: boolean; -}) { - const verdict = effectiveVerdict(transaction.verdict?.verdict); - const tone = verdictTone(verdict); - return ( -
-
-
-
- {verdict.toUpperCase()} - {programLabel(transaction.targetProgram)} - {showAgent ? {policyLabel(transaction.policyPubkey)} : null} -
-
- {transaction.verdict?.reasoning ?? "No anomaly reasoning stored for this transaction."} -
-
-
-
- {transaction.amountLamports ? `${lamportsToSol(transaction.amountLamports).toFixed(2)} SOL` : "—"} -
-
{formatRelativeTime(transaction.blockTime)}
-
-
-
- {shortAddress(transaction.txnSig, 10, 8)} - {formatDateTime(transaction.blockTime)} -
-
- ); -} - -export function IncidentTimeline({ - items, -}: { - items: Array<{ time: string; title: string; detail: string; tone: "green" | "amber" | "red" | "blue" }>; -}) { - const toneClasses: Record<"green" | "amber" | "red" | "blue", string> = { - green: "bg-emerald-500 shadow-[0_0_0_1px_#22c55e,0_0_12px_rgba(34,197,94,0.5)]", - amber: "bg-amber-500 shadow-[0_0_0_1px_#f59e0b,0_0_12px_rgba(245,158,11,0.5)]", - red: "bg-red-500 shadow-[0_0_0_1px_#ef4444,0_0_12px_rgba(239,68,68,0.5)]", - blue: "bg-blue-500 shadow-[0_0_0_1px_#3b82f6,0_0_12px_rgba(59,130,246,0.5)]", - }; - - return ( -
- {items.map((item) => ( -
- -
{item.time}
-
{item.title}
-
{item.detail}
-
- ))} -
- ); -} - -export function IncidentTable({ incidents }: { incidents: IncidentSummary[] }) { - if (!incidents.length) { - return
No incidents yet.
; - } - - return ( -
-
- - - - - - - - - - - {incidents.map((incident) => ( - - - - - - - ))} - -
AgentReasonPaused atStatus
- - {policyLabel(incident.policyPubkey)} - - {incident.reason}{formatDateTime(incident.pausedAt)} - {incident.resolvedAt ? "Resolved" : "Active"} -
-
-
- ); -} +export { AppShell } from "./dashboard-ui/app-shell"; +export { StatusChip } from "./dashboard-ui/status-chip"; +export { PolicyCard } from "./dashboard-ui/policy-card"; +export { Metric } from "./dashboard-ui/metric"; +export { SpendGauge } from "./dashboard-ui/spend-gauge"; +export { TransactionRow } from "./dashboard-ui/transaction-row"; +export { IncidentTimeline } from "./dashboard-ui/incident-timeline"; +export { IncidentTable } from "./dashboard-ui/incident-table"; +export { AnomalyRiskLabel, anomalyBarClass } from "./dashboard-ui/anomaly-risk-label"; diff --git a/dashboard/components/dashboard-ui/anomaly-risk-label.tsx b/dashboard/components/dashboard-ui/anomaly-risk-label.tsx new file mode 100644 index 0000000..cf71c93 --- /dev/null +++ b/dashboard/components/dashboard-ui/anomaly-risk-label.tsx @@ -0,0 +1,19 @@ +export function anomalyTone(score: number): "teal" | "amber" | "red" { + if (score <= 30) return "teal"; + if (score <= 60) return "amber"; + return "red"; +} + +export function anomalyBarClass(score: number): string { + const tone = anomalyTone(score); + if (tone === "teal") return "bg-teal-500"; + if (tone === "amber") return "bg-amber-500"; + return "bg-red-500"; +} + +export function AnomalyRiskLabel({ score }: { score: number }) { + const tone = anomalyTone(score); + if (tone === "teal") return normal; + if (tone === "amber") return elevated; + return critical; +} diff --git a/dashboard/components/dashboard-ui/app-shell.tsx b/dashboard/components/dashboard-ui/app-shell.tsx new file mode 100644 index 0000000..657e9c2 --- /dev/null +++ b/dashboard/components/dashboard-ui/app-shell.tsx @@ -0,0 +1,263 @@ +"use client"; + +import Link from "next/link"; +import Image from "next/image"; +import { usePathname } from "next/navigation"; +import { useWallet } from "@solana/wallet-adapter-react"; +import type { LucideIcon } from "lucide-react"; +import { + Activity, + FlaskConical, + Home, + LayoutPanelLeft, + LogOut, + ScrollText, + Settings, + TriangleAlert, + Users, +} from "lucide-react"; +import type { ReactNode } from "react"; +import { PageErrorBoundary } from "@/components/PageErrorBoundary"; +import { cn } from "@/lib/utils"; +import { useLayoutStore } from "@/lib/stores/layout"; +import { useSiwsAuthStore } from "@/lib/stores/siws-auth"; +import { ShellNavbarActions } from "../shell-navbar-actions"; + +type NavLink = { + href: string; + label: string; + icon: LucideIcon; + nested?: boolean; +}; + +const navGroups: NavLink[][] = [ + [{ href: "/home", label: "Home", icon: Home, nested: false }], + [ + { href: "/agents", label: "Agents", icon: Users }, + { href: "/activity", label: "Activity", icon: Activity }, + { href: "/incidents", label: "Incidents", icon: TriangleAlert }, + ], + [{ href: "/audit", label: "Audit", icon: ScrollText, nested: false }], + [{ href: "/playground", label: "Playground", icon: FlaskConical, nested: false }], + [{ href: "/settings", label: "Settings", icon: Settings, nested: false }], +]; + +export function AppShell({ + title, + subtitle, + actions, + brandedHeader = false, + children, +}: { + title: string; + subtitle?: string; + actions?: ReactNode; + /** When true, shows wordmark logo + Guardrails with `title` as the page subtitle line. */ + brandedHeader?: boolean; + children: ReactNode; +}) { + const pathname = usePathname(); + const walletAdapter = useWallet(); + const { sidebarOpen, sidebarCollapsed, toggleSidebar, toggleSidebarCollapsed, setSidebarOpen } = + useLayoutStore(); + + if (!walletAdapter.connected) { + return ( +
+
+
+ ); + } + + const currentPath = pathname ?? ""; + const isLinkActive = (href: string, nested: boolean | undefined) => { + if (href === "/agents?new=1") { + return currentPath === "/agents"; + } + return currentPath === href || (nested !== false && currentPath.startsWith(`${href}/`)); + }; + + const collapsed = sidebarCollapsed; + const sidebarWidth = collapsed ? "w-[60px]" : "w-60"; + + const renderNavItem = (link: NavLink) => { + const active = isLinkActive(link.href, link.nested); + const Icon = link.icon; + const iconSize = collapsed ? 18 : 16; + return ( + setSidebarOpen(false)} + className={cn( + "group relative flex items-center rounded-md text-sm transition-all duration-100", + collapsed ? "mx-auto h-10 w-10 justify-center" : "gap-2.5 px-3 py-2", + active + ? "bg-zinc-800 text-white font-medium [&_svg]:text-teal-400" + : "text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50 [&_svg]:text-zinc-500", + )} + > + + {!collapsed ? {link.label} : null} + + ); + }; + + const onSignOut = () => { + useSiwsAuthStore.getState().clearSignedIn(); + void walletAdapter.disconnect().catch(() => { + /* wallet may already be disconnected */ + }); + }; + + return ( +
+ {sidebarOpen ? ( + +
+ {/*
+ +
*/} +
+ + +
+
+
+ + + +
+ {/* {brandedHeader ? ( +

+ + + + Guardrails + + + {title} + + +

+ ) : ( */} +

+ {title} +

+ {/* )} */} +
+ +
+ +
+
+ +
+ {subtitle || actions ? ( +
+ {subtitle ? ( +

{subtitle}

+ ) : ( + + )} +
{actions}
+
+ ) : null} + +
{children}
+
+
+
+
+
+ ); +} diff --git a/dashboard/components/dashboard-ui/incident-table.tsx b/dashboard/components/dashboard-ui/incident-table.tsx new file mode 100644 index 0000000..8d7a993 --- /dev/null +++ b/dashboard/components/dashboard-ui/incident-table.tsx @@ -0,0 +1,51 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { formatDateTime, policyLabel } from "@/lib/utils"; +import type { IncidentSummary } from "@/lib/types/dashboard"; +import { StatusChip } from "./status-chip"; + +export function IncidentTable({ incidents }: { incidents: IncidentSummary[] }) { + const router = useRouter(); + if (!incidents.length) { + return
No incidents yet.
; + } + + return ( +
+
+ + + + + + + + + + + {incidents.map((incident) => ( + router.push(`/incidents/${incident.id}`)} + className="cursor-pointer border-b border-zinc-800/60 transition-colors duration-100 hover:bg-zinc-800/40 last:border-0" + > + + + + + + ))} + +
AgentReasonPaused atStatus
+ + {policyLabel(incident.policyPubkey)} + + {incident.reason}{formatDateTime(incident.pausedAt)} + {incident.resolvedAt ? "Resolved" : "Active"} +
+
+
+ ); +} diff --git a/dashboard/components/dashboard-ui/incident-timeline.tsx b/dashboard/components/dashboard-ui/incident-timeline.tsx new file mode 100644 index 0000000..f1e4613 --- /dev/null +++ b/dashboard/components/dashboard-ui/incident-timeline.tsx @@ -0,0 +1,25 @@ +export function IncidentTimeline({ + items, +}: { + items: Array<{ time: string; title: string; detail: string; tone: "green" | "amber" | "red" | "blue" }>; +}) { + const toneClasses: Record<"green" | "amber" | "red" | "blue", string> = { + green: "bg-teal-500 shadow-[0_0_0_1px_hsl(var(--teal)),0_0_12px_hsl(var(--teal)/0.45)]", + amber: "bg-amber-500 shadow-[0_0_0_1px_hsl(var(--amber)),0_0_12px_hsl(var(--amber)/0.45)]", + red: "bg-crimson-500 shadow-[0_0_0_1px_hsl(var(--crimson)),0_0_12px_hsl(var(--crimson)/0.45)]", + blue: "bg-primary shadow-[0_0_0_1px_hsl(var(--primary)),0_0_12px_hsl(var(--primary)/0.45)]", + }; + + return ( +
+ {items.map((item) => ( +
+ +
{item.time}
+
{item.title}
+
{item.detail}
+
+ ))} +
+ ); +} diff --git a/dashboard/components/dashboard-ui/metric.tsx b/dashboard/components/dashboard-ui/metric.tsx new file mode 100644 index 0000000..93773c2 --- /dev/null +++ b/dashboard/components/dashboard-ui/metric.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; + +export function Metric({ label, value }: { label: string; value: ReactNode }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/dashboard/components/dashboard-ui/policy-card.tsx b/dashboard/components/dashboard-ui/policy-card.tsx new file mode 100644 index 0000000..566877f --- /dev/null +++ b/dashboard/components/dashboard-ui/policy-card.tsx @@ -0,0 +1,90 @@ +import Link from "next/link"; +import { + formatRelativeTime, + formatRelativeTooltip, + formatSol, + lamportsToSol, + programLabel, + shortAddress, +} from "@/lib/utils"; +import type { PolicySummary } from "@/lib/types/dashboard"; + +export function PolicyCard({ policy }: { policy: PolicySummary }) { + const MAX_VISIBLE = 3; + const visiblePrograms = policy.allowedPrograms.slice(0, MAX_VISIBLE); + const overflow = policy.allowedPrograms.length - MAX_VISIBLE; + const spent = lamportsToSol(policy.dailySpentLamports ?? "0"); + const budget = lamportsToSol(policy.dailyBudgetLamports); + const spendPct = budget > 0 ? Math.min((spent / budget) * 100, 100) : 0; + const progressTone = spendPct >= 90 ? "bg-crimson-500" : spendPct >= 66 ? "bg-amber-500" : "bg-teal-500"; + const sessionExpired = new Date(policy.sessionExpiry).getTime() < Date.now(); + + return ( + +
+
+
+ {policy.label ?? "Unlabeled agent"} +
+
+ {shortAddress(policy.pubkey, 6, 6)} +
+
+
+ {!policy.isActive ? ( + + PAUSED + + ) : sessionExpired ? ( + + SESSION EXPIRED + + ) : ( + + ACTIVE + + )} + {policy.squadsMultisig ? ( + + SQUADS + + ) : null} +
+
+ +
+
+ Daily spend + + {formatSol(policy.dailySpentLamports ?? "0")}{" "} + / {formatSol(policy.dailyBudgetLamports)} + +
+
+
+
+
+ +
+
+
Session
+
+ {formatRelativeTime(policy.sessionExpiry)} +
+
+
+
Per tx cap
+
{formatSol(policy.maxTxLamports)}
+
+
+ +

+ Programs: {visiblePrograms.map((p) => programLabel(p)).join(", ")} + {overflow > 0 && +{overflow} more} +

+ + ); +} diff --git a/dashboard/components/dashboard-ui/spend-gauge.tsx b/dashboard/components/dashboard-ui/spend-gauge.tsx new file mode 100644 index 0000000..ee1e8f1 --- /dev/null +++ b/dashboard/components/dashboard-ui/spend-gauge.tsx @@ -0,0 +1,60 @@ +import { RadialBar, RadialBarChart, ResponsiveContainer } from "recharts"; +import { lamportsToSol } from "@/lib/utils"; + +export function SpendGauge({ + spentLamports, + budgetLamports, + size = 220, +}: { + spentLamports: string; + budgetLamports: string; + size?: number; +}) { + const spent = lamportsToSol(spentLamports); + const budget = lamportsToSol(budgetLamports); + const ratio = budget === 0 ? 0 : (spent / budget) * 100; + const clampedRatio = Math.min(ratio, 100); + const tone = ratio >= 90 ? "hsl(var(--crimson))" : ratio >= 66 ? "hsl(var(--amber))" : "hsl(var(--teal))"; + + if (budget <= 0) { + return ( +
+ No budget set. +
+ ); + } + + return ( +
+ + + + + +
+ {ratio > 100 ? ( + <> +
+ OVER BUDGET +
+
+ {spent.toFixed(1)} / {budget.toFixed(1)} SOL +
+ + ) : ( + <> +
{spent.toFixed(1)} SOL
+
of {budget.toFixed(1)} SOL budget
+ + )} +
+
+ ); +} diff --git a/dashboard/components/dashboard-ui/status-chip.tsx b/dashboard/components/dashboard-ui/status-chip.tsx new file mode 100644 index 0000000..0ddb2a4 --- /dev/null +++ b/dashboard/components/dashboard-ui/status-chip.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; + +export function StatusChip({ tone, children }: { tone: "green" | "amber" | "red"; children: ReactNode }) { + const toneClasses = { + green: "bg-teal-500/15 text-teal-400 border border-teal-500/30 shadow-teal-500/10", + amber: "bg-amber-500/15 text-amber-400 border border-amber-500/30 shadow-amber-500/10", + red: "bg-red-500/15 text-red-400 border border-red-500/30 shadow-red-500/10", + }; + return {children}; +} diff --git a/dashboard/components/dashboard-ui/transaction-row.tsx b/dashboard/components/dashboard-ui/transaction-row.tsx new file mode 100644 index 0000000..3c3f309 --- /dev/null +++ b/dashboard/components/dashboard-ui/transaction-row.tsx @@ -0,0 +1,56 @@ +import Link from "next/link"; +import { + effectiveVerdict, + formatDateTime, + formatRelativeTime, + formatRelativeTooltip, + formatSol, + policyLabel, + programLabel, + shortAddress, + verdictTone, +} from "@/lib/utils"; +import type { TransactionSummary } from "@/lib/types/dashboard"; +import { StatusChip } from "./status-chip"; + +export function TransactionRow({ + transaction, + showAgent = false, +}: { + transaction: TransactionSummary; + showAgent?: boolean; +}) { + const verdict = effectiveVerdict(transaction.verdict?.verdict); + const tone = verdictTone(verdict); + return ( + +
+
+
+ {verdict.toUpperCase()} + {programLabel(transaction.targetProgram)} + {showAgent ? {policyLabel(transaction.policyPubkey)} : null} +
+
+ {transaction.verdict?.reasoning ?? "No anomaly reasoning stored for this transaction."} +
+
+
+
+ {transaction.amountLamports ? formatSol(transaction.amountLamports) : "—"} +
+
+ {formatRelativeTime(transaction.blockTime)} +
+
+
+
+ {shortAddress(transaction.txnSig, 10, 8)} + {formatDateTime(transaction.blockTime)} +
+ + ); +} diff --git a/dashboard/components/edit-policy-form.tsx b/dashboard/components/edit-policy-form.tsx index 2e4fa26..05ac30a 100644 --- a/dashboard/components/edit-policy-form.tsx +++ b/dashboard/components/edit-policy-form.tsx @@ -4,9 +4,11 @@ import { useEffect, useRef, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useWallet } from "@solana/wallet-adapter-react"; import { PublicKey } from "@solana/web3.js"; +import { toast } from "sonner"; import { getErrorMessage } from "@/lib/api/client"; import { queryKeys } from "@/lib/api/query-keys"; import { usePolicyQuery } from "@/lib/api/use-policy-query"; +import { EditPolicyFormSkeleton } from "@/components/skeletons"; import { buildUpdatePolicyFullReplace } from "@/lib/create-policy/build-update-args"; import { permissionPolicyToSummary } from "@/lib/create-policy/map-permission-policy"; import { policySummaryToDraft } from "@/lib/create-policy/policy-to-draft"; @@ -56,16 +58,8 @@ export function EditPolicyForm({ policyPubkey }: { policyPubkey: string }) { const [pasteError, setPasteError] = useState(null); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); - const [saveBanner, setSaveBanner] = useState(null); - const [toastError, setToastError] = useState<{ id: number; message: string } | null>(null); const initializedDraftForPubkeyRef = useRef(null); - useEffect(() => { - if (!toastError) return; - const timeout = window.setTimeout(() => setToastError(null), 5000); - return () => window.clearTimeout(timeout); - }, [toastError]); - useEffect(() => { if (policyQuery.data && initializedDraftForPubkeyRef.current !== policyPubkey) { setDraft(policySummaryToDraft(policyQuery.data)); @@ -78,11 +72,7 @@ export function EditPolicyForm({ policyPubkey }: { policyPubkey: string }) { const walletReady = Boolean(publicKey && provider && programId); if (policyQuery.isLoading) { - return ( -
- Loading policy… -
- ); + return ; } if (policyQuery.isError || !policy) { @@ -95,17 +85,12 @@ export function EditPolicyForm({ policyPubkey }: { policyPubkey: string }) { } if (!draft) { - return ( -
- Preparing form… -
- ); + return ; } const updateDraft = (partial: Partial) => { setDraft((d) => (d ? { ...d, ...partial } : d)); setFieldErrors({}); - setSaveBanner(null); }; const addProgram = (pubkey: string) => { @@ -121,7 +106,6 @@ export function EditPolicyForm({ policyPubkey }: { policyPubkey: string }) { const onSave = async () => { setSaveError(null); - setSaveBanner(null); const { ok, errors } = validateFullDraft(draft); if (!ok) { setFieldErrors(errors); @@ -145,7 +129,7 @@ export function EditPolicyForm({ policyPubkey }: { policyPubkey: string }) { }); queryClient.setQueryData(queryKeys.policy(policyPubkey), summary); - queryClient.setQueryData(queryKeys.policies(), (old: PolicySummary[] | undefined) => { + queryClient.setQueriesData({ queryKey: ["policies"] }, (old: PolicySummary[] | undefined) => { if (!old?.length) return [summary]; let found = false; const next = old.map((row) => { @@ -157,11 +141,11 @@ export function EditPolicyForm({ policyPubkey }: { policyPubkey: string }) { }); setDraft(policySummaryToDraft(summary)); - setSaveBanner("Policy updated on-chain. Cache refreshed."); + toast.success("Policy updated on-chain. Cache refreshed."); } catch (e) { const message = getErrorMessage(e); setSaveError(message); - setToastError({ id: Date.now(), message }); + toast.error(message); } finally { setSaving(false); } @@ -169,20 +153,6 @@ export function EditPolicyForm({ policyPubkey }: { policyPubkey: string }) { return (
- {toastError ? ( -
- {toastError.message} -
- ) : null} - {saveBanner ? ( -
- {saveBanner} -
- ) : null} {saveError ? (
{saveError} diff --git a/dashboard/components/fund-agent-button.tsx b/dashboard/components/fund-agent-button.tsx index 30bac9a..1bfae5c 100644 --- a/dashboard/components/fund-agent-button.tsx +++ b/dashboard/components/fund-agent-button.tsx @@ -8,6 +8,7 @@ import { SystemProgram, Transaction, } from "@solana/web3.js"; +import { toast } from "sonner"; import { getErrorMessage } from "@/lib/api/client"; import type { PolicySummary } from "@/lib/types/dashboard"; @@ -44,14 +45,18 @@ export function FundAgentButton({ policy }: { policy: PolicySummary }) { const sig = await sendTransaction(tx, connection); await connection.confirmTransaction(sig, "confirmed"); setBanner(`Funded ${parsedAmount} SOL to policy PDA.`); + toast.success(`Funded ${parsedAmount} SOL.`); setOpen(false); } catch (e) { const msg = getErrorMessage(e).toLowerCase(); if (msg.includes("already been processed") || msg.includes("already processed")) { setBanner(`Funded ${parsedAmount} SOL to policy PDA.`); + toast.success(`Funding transaction already processed (${parsedAmount} SOL).`); setOpen(false); } else { - setError(getErrorMessage(e)); + const message = getErrorMessage(e); + setError(message); + toast.error(message); } } finally { setBusy(false); diff --git a/dashboard/components/kill-switch-button.tsx b/dashboard/components/kill-switch-button.tsx index a7f3807..89b94c5 100644 --- a/dashboard/components/kill-switch-button.tsx +++ b/dashboard/components/kill-switch-button.tsx @@ -1,9 +1,10 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useWallet } from "@solana/wallet-adapter-react"; import { PublicKey } from "@solana/web3.js"; +import { toast } from "sonner"; import { getErrorMessage } from "@/lib/api/client"; import { queryKeys } from "@/lib/api/query-keys"; import { GuardrailsClient } from "@/lib/sdk/client"; @@ -22,14 +23,6 @@ export function KillSwitchButton({ policy }: { policy: PolicySummary }) { const [reason, setReason] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const [banner, setBanner] = useState(null); - const [toastError, setToastError] = useState(null); - - useEffect(() => { - if (!toastError) return; - const timeout = window.setTimeout(() => setToastError(null), 5000); - return () => window.clearTimeout(timeout); - }, [toastError]); const isOwner = Boolean(publicKey && publicKey.toBase58() === policy.owner); const walletReady = Boolean(provider && programId); @@ -45,7 +38,7 @@ export function KillSwitchButton({ policy }: { policy: PolicySummary }) { queryClient.setQueryData(queryKeys.policy(policy.pubkey), (prev: PolicySummary | undefined) => prev ? { ...prev, isActive, updatedAt: now } : prev, ); - queryClient.setQueryData(queryKeys.policies(), (old: PolicySummary[] | undefined) => { + queryClient.setQueriesData({ queryKey: ["policies"] }, (old: PolicySummary[] | undefined) => { if (!old) return old; return old.map((row) => row.pubkey === policy.pubkey ? { ...row, isActive, updatedAt: now } : row, @@ -68,24 +61,23 @@ export function KillSwitchButton({ policy }: { policy: PolicySummary }) { if (!reasonOk || !provider || !programId || busy) return; setBusy(true); setError(null); - setBanner(null); try { const client = new GuardrailsClient(provider, programId); await client.pauseAgent(new PublicKey(policy.pubkey), trimmedReason); updateCache(false); - setBanner("Agent paused on-chain."); + toast.success("Agent paused on-chain."); setOpen(false); setReason(""); } catch (e) { if (isAlreadyInState(e, true)) { updateCache(false); - setBanner("Agent is paused on-chain."); + toast.success("Agent is paused on-chain."); setOpen(false); setReason(""); } else { const message = getErrorMessage(e); setError(message); - setToastError(message); + toast.error(message); } } finally { setBusy(false); @@ -96,20 +88,19 @@ export function KillSwitchButton({ policy }: { policy: PolicySummary }) { if (!provider || !programId || busy) return; setBusy(true); setError(null); - setBanner(null); try { const client = new GuardrailsClient(provider, programId); await client.resumeAgent(new PublicKey(policy.pubkey)); updateCache(true); - setBanner("Agent resumed on-chain."); + toast.success("Agent resumed on-chain."); } catch (e) { if (isAlreadyInState(e, false)) { updateCache(true); - setBanner("Agent is active on-chain."); + toast.success("Agent is active on-chain."); } else { const message = getErrorMessage(e); setError(message); - setToastError(message); + toast.error(message); } } finally { setBusy(false); @@ -118,21 +109,6 @@ export function KillSwitchButton({ policy }: { policy: PolicySummary }) { return (
- {toastError ? ( -
- {toastError} -
- ) : null} - {banner ? ( -
- {banner} -
- ) : null} - {policy.isActive ? (
+ ); +} + +export function LandingAuthScreen() { + const walletAdapter = useWallet(); + const { setVisible } = useWalletModal(); + const markSignedIn = useSiwsAuthStore((s) => s.markSignedIn); + const { publicKey, signMessage, connecting, connected, disconnect } = walletAdapter; + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const openConnect = useCallback(() => { + if (!walletAdapter.wallet) { + setVisible(true); + return; + } + void walletAdapter.connect().catch(() => { + /* user cancelled */ + }); + }, [setVisible, walletAdapter]); + + const onSignIn = useCallback(async () => { + setError(null); + if (!publicKey || !signMessage) { + setError("This wallet cannot sign messages. Try another wallet."); + return; + } + + const pubkey = publicKey.toBase58(); + setBusy(true); + try { + const { message } = await requestSiwsNonce(pubkey); + const encoded = new TextEncoder().encode(message); + const signatureBytes = await signMessage(encoded); + const signature = uint8ArrayToBase64(signatureBytes); + await verifySiwsSignature({ pubkey, message, signature }); + markSignedIn(pubkey, new Date().toISOString()); + } catch (err) { + if (err instanceof ApiClientError) { + setError(err.message); + } else { + setError(getErrorMessage(err)); + } + } finally { + setBusy(false); + } + }, [markSignedIn, publicKey, signMessage]); + + const onBackToStepOne = useCallback(() => { + setError(null); + useSiwsAuthStore.getState().clearSignedIn(); + void disconnect().catch(() => { + /* wallet disconnect cancelled/failed */ + }); + }, [disconnect]); + + let connectedLabel = "Connect"; + try { + if (walletAdapter.connected && walletAdapter.publicKey) { + const a = shortAddress(walletAdapter.publicKey.toBase58(), 4, 4); + connectedLabel = a; + } + } catch { + /* ok */ + } + + return ( +
+
+ +
+ +
+
+ + {!connected ? ( +
+ + Step 1 of 2 + +

+ Connect your wallet +

+

+ After you connect, you'll sign a short message off-chain to unlock the dashboard. +

+ +
+ ) : ( +
+ +
+ + Step 2 of 2 + +
+

+ Sign in with Solana +

+

+ Approve the signature request in your wallet. This confirms you own the address. +

+

+ Connected + {connectedLabel} +

+ + {signMessage ? ( + + ) : ( +

This wallet does not support message signing.

+ )} + + {error ?

{error}

: null} +
+ )} +
+
+
+ ); +} diff --git a/dashboard/components/landing-connect-wallet-button.tsx b/dashboard/components/landing-connect-wallet-button.tsx deleted file mode 100644 index 632eb9e..0000000 --- a/dashboard/components/landing-connect-wallet-button.tsx +++ /dev/null @@ -1,45 +0,0 @@ -"use client"; - -import { useWallet } from "@solana/wallet-adapter-react"; -import { useWalletModal } from "@solana/wallet-adapter-react-ui"; -import { shortAddress } from "@/lib/utils"; - -export function LandingConnectWalletButton() { - const walletAdapter = useWallet(); - const { setVisible } = useWalletModal(); - - let connected = false; - let wallet = "Connect wallet"; - - try { - connected = Boolean(walletAdapter.connected); - if (walletAdapter.publicKey) { - wallet = shortAddress(walletAdapter.publicKey.toBase58(), 4, 4); - } - } catch { - // Graceful fallback when wallet providers are unavailable. - } - - return ( - - ); -} diff --git a/dashboard/components/playground/attack-simulator.tsx b/dashboard/components/playground/attack-simulator.tsx new file mode 100644 index 0000000..9c8cd0c --- /dev/null +++ b/dashboard/components/playground/attack-simulator.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { POLICIES } from "@/lib/mock/policies"; +import { mergeScenarioStep, SCENARIOS } from "@/lib/playground/scenarios"; +import { runSimulation } from "@/lib/playground/engine"; +import type { CrafterParams, PlaygroundPolicySlice, SimulationResult } from "@/lib/playground/types"; +import { usePoliciesQuery } from "@/lib/api/use-policies-query"; +import type { PolicySummary } from "@/lib/types/dashboard"; +import { usePlaygroundStore } from "@/lib/stores/playground"; +import { StatusChip } from "@/components/dashboard-ui"; +import { programLabel } from "@/lib/utils"; + +function resolvePolicies(remote: PolicySummary[] | undefined): PlaygroundPolicySlice[] { + const list = remote?.length ? remote : POLICIES; + return list.map((p) => ({ + pubkey: p.pubkey, + maxTxLamports: p.maxTxLamports, + dailyBudgetLamports: p.dailyBudgetLamports, + allowedPrograms: p.allowedPrograms, + })); +} + +const STEP_DELAY_MS = 1600; + +export function AttackSimulator() { + const policiesQuery = usePoliciesQuery(); + const policies = useMemo(() => resolvePolicies(policiesQuery.data), [policiesQuery.data]); + const crafterPolicyPubkey = usePlaygroundStore((s) => s.crafterParams.policyPubkey); + const playback = usePlaygroundStore((s) => s.playback); + const setPlayback = usePlaygroundStore((s) => s.setPlayback); + + const [scenarioId, setScenarioId] = useState(SCENARIOS[0]!.id); + const timerRef = useRef | null>(null); + + const scenario = SCENARIOS.find((s) => s.id === scenarioId) ?? SCENARIOS[0]!; + + const effectivePolicy = + policies.find((p) => p.pubkey === crafterPolicyPubkey) ?? policies[0] ?? POLICIES[0]; + + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + + const stopPlayback = () => { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = null; + setPlayback(null); + }; + + const runScenario = () => { + stopPlayback(); + + const base: CrafterParams = { + ...mergeScenarioStep({}), + policyPubkey: crafterPolicyPubkey ?? effectivePolicy.pubkey, + }; + + const runStep = (idx: number, acc: SimulationResult[], prev: CrafterParams) => { + const stepPartial = scenario.steps[idx]; + if (stepPartial === undefined) { + setPlayback({ + scenarioId: scenario.id, + stepIndex: idx, + stepResults: acc, + isPlaying: false, + }); + return; + } + + const merged: CrafterParams = { + ...prev, + ...stepPartial, + policyPubkey: prev.policyPubkey ?? effectivePolicy.pubkey, + }; + + const policy = + policies.find((p) => p.pubkey === (merged.policyPubkey ?? effectivePolicy.pubkey)) ?? + effectivePolicy; + + const result = runSimulation(merged, policy); + const nextAcc = [...acc, result]; + + const done = idx + 1 >= scenario.steps.length; + + setPlayback({ + scenarioId: scenario.id, + stepIndex: idx, + stepResults: nextAcc, + isPlaying: !done, + }); + + if (done) return; + + timerRef.current = setTimeout(() => runStep(idx + 1, nextAcc, merged), STEP_DELAY_MS); + }; + + runStep(0, [], base); + }; + + const replay = () => { + stopPlayback(); + runScenario(); + }; + + const results = playback?.scenarioId === scenario.id ? playback.stepResults : []; + + return ( +
+
+

Attack simulator

+

+ Scripted sequences stepped over ~{STEP_DELAY_MS / 1000}s. Uses the crafter policy selection when set. +

+ +
+ + + +
+

{scenario.description}

+
+ +
+ {results.map((r, i) => { + const stepMerge = mergeScenarioStep(scenario.steps[i] ?? {}); + return ( +
+
+ + Step {i + 1} / {scenario.steps.length} + + + {r.verdict.toUpperCase()} + +
+

+ {programLabel(stepMerge.targetProgram)} + + · {stepMerge.amountSol.toFixed(1)} SOL · {stepMerge.velocityPerMin} tx/min + + + score {Math.round(r.dangerScore)} + +

+

{r.reasoning}

+ {r.verdict === "pause" ? ( +
+ Incident card (mock): autonomous pause engaged — synthetic report snippet would render here. +
+ ) : null} +
+ ); + })} +
+
+ ); +} diff --git a/dashboard/components/playground/danger-gauge.tsx b/dashboard/components/playground/danger-gauge.tsx new file mode 100644 index 0000000..8062ab8 --- /dev/null +++ b/dashboard/components/playground/danger-gauge.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { RadialBar, RadialBarChart, ResponsiveContainer } from "recharts"; + +export function DangerGauge({ score }: { score: number }) { + const clamped = Math.min(100, Math.max(0, score)); + const fill = + clamped <= 30 ? "hsl(var(--teal))" : clamped <= 50 ? "hsl(var(--amber))" : "hsl(var(--crimson))"; + + return ( +
+ + + + + +
+
{Math.round(clamped)}
+
danger score
+
+
+ ); +} diff --git a/dashboard/components/playground/kill-switch-demo.tsx b/dashboard/components/playground/kill-switch-demo.tsx new file mode 100644 index 0000000..8e608e1 --- /dev/null +++ b/dashboard/components/playground/kill-switch-demo.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { usePlaygroundStore } from "@/lib/stores/playground"; + +export function KillSwitchDemo() { + const killSwitchState = usePlaygroundStore((s) => s.killSwitchState); + const setKillSwitchState = usePlaygroundStore((s) => s.setKillSwitchState); + + const paused = killSwitchState === "paused"; + + return ( +
+

Kill switch demo

+

+ Lightweight state machine illustrating pause semantics — no chain transactions. +

+ +
+
+ + {paused ? "Paused" : "Active"} + + +
+ [Active] —pause→ [Paused] —resume→ [Active] +
+ + {paused ? ( +
+

Guarded executes return PolicyPaused.

+

An incident record is opened and an Opus-style report can be generated asynchronously.

+
+ ) : ( +

+ Agent sessions within policy limits proceed through guarded execution as usual. +

+ )} +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
RolePauseResume
OwnerYesYes
MonitorYesNo
AgentNoNo
+
+ +
+ + +
+
+ ); +} diff --git a/dashboard/components/playground/policy-sandbox.tsx b/dashboard/components/playground/policy-sandbox.tsx new file mode 100644 index 0000000..6b100e9 --- /dev/null +++ b/dashboard/components/playground/policy-sandbox.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useMemo } from "react"; +import { POLICIES } from "@/lib/mock/policies"; +import { runSimulation } from "@/lib/playground/engine"; +import type { CrafterParams, PlaygroundPolicySlice } from "@/lib/playground/types"; +import { usePoliciesQuery } from "@/lib/api/use-policies-query"; +import type { PolicySummary } from "@/lib/types/dashboard"; +import { usePlaygroundStore } from "@/lib/stores/playground"; +import { formatSol } from "@/lib/utils"; +import { VerdictPanel } from "./verdict-panel"; + +function resolvePolicies(remote: PolicySummary[] | undefined): PlaygroundPolicySlice[] { + const list = remote?.length ? remote : POLICIES; + return list.map((p) => ({ + pubkey: p.pubkey, + maxTxLamports: p.maxTxLamports, + dailyBudgetLamports: p.dailyBudgetLamports, + allowedPrograms: p.allowedPrograms, + })); +} + +export function PolicySandbox() { + const policiesQuery = usePoliciesQuery(); + const policies = useMemo(() => resolvePolicies(policiesQuery.data), [policiesQuery.data]); + const crafterParams = usePlaygroundStore((s) => s.crafterParams); + const sandboxOverrides = usePlaygroundStore((s) => s.sandboxOverrides); + const setSandboxOverrides = usePlaygroundStore((s) => s.setSandboxOverrides); + + const basePolicy = + policies.find((p) => p.pubkey === crafterParams.policyPubkey) ?? policies[0] ?? POLICIES[0]; + + const baselinePolicy: PlaygroundPolicySlice = basePolicy; + + const tweakedPolicy: PlaygroundPolicySlice = { + ...baselinePolicy, + maxTxLamports: String(Math.round(sandboxOverrides.maxTxSol * 1e9)), + dailyBudgetLamports: String(Math.round(sandboxOverrides.dailyBudgetSol * 1e9)), + }; + + const txnParams: CrafterParams = { + ...crafterParams, + policyPubkey: baselinePolicy.pubkey, + }; + + const baselineResult = useMemo( + () => runSimulation(txnParams, baselinePolicy), + [ + baselinePolicy.pubkey, + baselinePolicy.maxTxLamports, + baselinePolicy.dailyBudgetLamports, + baselinePolicy.allowedPrograms.join(","), + crafterParams.amountSol, + crafterParams.velocityPerMin, + crafterParams.budgetConsumedPercent, + crafterParams.sessionRemaining, + crafterParams.targetProgram, + crafterParams.isProgramNew, + crafterParams.outsideActiveHours, + ], + ); + + const tweakedResult = useMemo( + () => runSimulation(txnParams, tweakedPolicy), + [ + tweakedPolicy.pubkey, + tweakedPolicy.maxTxLamports, + tweakedPolicy.dailyBudgetLamports, + tweakedPolicy.allowedPrograms.join(","), + crafterParams.amountSol, + crafterParams.velocityPerMin, + crafterParams.budgetConsumedPercent, + crafterParams.sessionRemaining, + crafterParams.targetProgram, + crafterParams.isProgramNew, + crafterParams.outsideActiveHours, + ], + ); + + const verdictChanged = baselineResult.verdict !== tweakedResult.verdict; + + return ( +
+
+

Policy sandbox

+

+ Same simulated transaction as the crafter tab against live policy caps vs adjusted caps — purely hypothetical. +

+ +
+ + +
+ +
+ Baseline caps from chain/API mirror:{" "} + + {formatSol(baselinePolicy.maxTxLamports)} tx / {formatSol(baselinePolicy.dailyBudgetLamports)} day + + {verdictChanged ? ( + + Verdict differs under tweaked caps ({baselineResult.verdict} → {tweakedResult.verdict}). + + ) : ( + Verdict unchanged for this hypothetical tweak. + )} +
+
+ +
+
+

+ Current policy mirror +

+ +
+
+

+ Sandbox caps +

+ +
+
+
+ ); +} diff --git a/dashboard/components/playground/progress-bar.tsx b/dashboard/components/playground/progress-bar.tsx new file mode 100644 index 0000000..21daea9 --- /dev/null +++ b/dashboard/components/playground/progress-bar.tsx @@ -0,0 +1,22 @@ +"use client"; + +export function PlaygroundProgressBar({ + progress, + durationMs, +}: { + progress: number; + durationMs: number; +}) { + const pct = Math.min(100, Math.max(0, progress)); + return ( +
+
+
+ ); +} diff --git a/dashboard/components/playground/signal-inspector.tsx b/dashboard/components/playground/signal-inspector.tsx new file mode 100644 index 0000000..e9a9fbd --- /dev/null +++ b/dashboard/components/playground/signal-inspector.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useMemo } from "react"; +import { SIGNAL_WEIGHTS } from "@/lib/playground/constants"; +import { + computeDangerScore, + determineVerdict, + generateReasoning, + simulateLatency, +} from "@/lib/playground/engine"; +import type { PrefilterSignal, SimulationResult } from "@/lib/playground/types"; +import { usePlaygroundStore, inspectorSignalsToList } from "@/lib/stores/playground"; +import { VerdictPanel } from "./verdict-panel"; + +const ORDER: PrefilterSignal[] = [ + "burst_detected", + "elevated_frequency", + "high_amount", + "budget_nearly_exhausted", + "new_or_uncommon_program", + "outside_active_hours", + "session_expiring_soon", +]; + +export function SignalInspector() { + const inspectorSignals = usePlaygroundStore((s) => s.inspectorSignals); + const toggleInspectorSignal = usePlaygroundStore((s) => s.toggleInspectorSignal); + const resetInspector = usePlaygroundStore((s) => s.resetInspector); + + const result: SimulationResult | null = useMemo(() => { + const signals = inspectorSignalsToList(inspectorSignals); + const dangerScore = computeDangerScore(signals); + const sessionRemaining = "gt24h"; + const { verdict, confidence } = determineVerdict(signals, dangerScore, sessionRemaining); + const prefilterSkipped = signals.length === 0; + const reasoning = prefilterSkipped + ? "Prefilter skipped the LLM judge — no anomaly signals matched configured thresholds." + : generateReasoning(signals, verdict); + + return { + signals, + dangerScore, + verdict, + confidence, + reasoning, + prefilterSkipped, + latencyMs: simulateLatency(), + model: "guardian", + }; + }, [inspectorSignals]); + + return ( +
+
+
+
+

Signal inspector

+

+ Toggle synthetic signals — weights sum into the danger score (session assumed healthy). +

+
+ +
+ +
    + {ORDER.map((sig) => ( +
  • + + w{SIGNAL_WEIGHTS[sig]} +
  • + ))} +
+ +

+ Override rules still apply when combining burst + high amount (forced pause). Danger bands: 0–30 allow bias, + 31–50 flag, 51+ pause unless refined by overrides. +

+
+ + +
+ ); +} diff --git a/dashboard/components/playground/transaction-crafter.tsx b/dashboard/components/playground/transaction-crafter.tsx new file mode 100644 index 0000000..0ea1048 --- /dev/null +++ b/dashboard/components/playground/transaction-crafter.tsx @@ -0,0 +1,255 @@ +"use client"; + +import { useMemo } from "react"; +import { POLICIES, PROGRAM_LABELS } from "@/lib/mock/policies"; +import { PLAYGROUND_PROGRAM_OPTIONS } from "@/lib/playground/constants"; +import { runSimulation } from "@/lib/playground/engine"; +import type { CrafterParams, PlaygroundPolicySlice, SessionRemainingBucket } from "@/lib/playground/types"; +import { usePoliciesQuery } from "@/lib/api/use-policies-query"; +import type { PolicySummary } from "@/lib/types/dashboard"; +import { usePlaygroundStore } from "@/lib/stores/playground"; +import { VerdictPanel } from "./verdict-panel"; +import { PlaygroundProgressBar } from "./progress-bar"; + +function resolvePolicies(remote: PolicySummary[] | undefined): PlaygroundPolicySlice[] { + const list = remote?.length ? remote : POLICIES; + return list.map((p) => ({ + pubkey: p.pubkey, + maxTxLamports: p.maxTxLamports, + dailyBudgetLamports: p.dailyBudgetLamports, + allowedPrograms: p.allowedPrograms, + })); +} + +function policyLabel(p: PlaygroundPolicySlice): string { + const full = POLICIES.find((x) => x.pubkey === p.pubkey); + const lbl = full?.label; + return lbl ? `${lbl}` : `${p.pubkey.slice(0, 4)}…${p.pubkey.slice(-4)}`; +} + +function SessionSelect({ + value, + onChange, +}: { + value: SessionRemainingBucket; + onChange: (v: SessionRemainingBucket) => void; +}) { + const opts: { value: SessionRemainingBucket; label: string }[] = [ + { value: "gt24h", label: "> 24h remaining" }, + { value: "h1to6", label: "1–6 hours" }, + { value: "m30", label: "~30 minutes" }, + { value: "lt10min", label: "< 10 minutes" }, + { value: "expired", label: "Expired" }, + ]; + return ( + + ); +} + +function shortProg(pk: string): string { + return `${pk.slice(0, 4)}…`; +} + +function SliderBlock({ + label, + min, + max, + step, + value, + onChange, + display, +}: { + label: string; + min: number; + max: number; + step: number; + value: number; + onChange: (v: number) => void; + display: (v: number) => string; +}) { + return ( + + ); +} + +export function TransactionCrafter() { + const policiesQuery = usePoliciesQuery(); + const policies = useMemo(() => resolvePolicies(policiesQuery.data), [policiesQuery.data]); + + const crafterParams = usePlaygroundStore((s) => s.crafterParams); + const crafterResult = usePlaygroundStore((s) => s.crafterResult); + const crafterRunning = usePlaygroundStore((s) => s.crafterRunning); + const crafterProgress = usePlaygroundStore((s) => s.crafterProgress); + const setCrafterParams = usePlaygroundStore((s) => s.setCrafterParams); + const setCrafterResult = usePlaygroundStore((s) => s.setCrafterResult); + const setCrafterRunning = usePlaygroundStore((s) => s.setCrafterRunning); + const setCrafterProgress = usePlaygroundStore((s) => s.setCrafterProgress); + + const selectedPolicy = + policies.find((p) => p.pubkey === crafterParams.policyPubkey) ?? policies[0] ?? POLICIES[0]; + + const effectivePubkey = crafterParams.policyPubkey ?? selectedPolicy?.pubkey ?? POLICIES[0]!.pubkey; + + const runJudge = async () => { + const policy = policies.find((p) => p.pubkey === effectivePubkey) ?? policies[0]; + if (!policy) return; + + setCrafterRunning(true); + setCrafterProgress(0); + setCrafterResult(null); + + const params: CrafterParams = { ...crafterParams, policyPubkey: effectivePubkey }; + const duration = 900 + Math.floor(Math.random() * 400); + const steps = 24; + for (let i = 1; i <= steps; i++) { + await new Promise((r) => setTimeout(r, duration / steps)); + setCrafterProgress((i / steps) * 100); + } + + const result = runSimulation(params, policy); + setCrafterResult(result); + setCrafterRunning(false); + setCrafterProgress(100); + }; + + return ( +
+
+
+

Transaction parameters

+

+ Tune inputs — simulation uses PLAYGROUND signal weights (frontend-only). +

+
+ + + + + + setCrafterParams({ amountSol })} + display={(v) => `${v.toFixed(1)} SOL`} + /> + + setCrafterParams({ velocityPerMin })} + display={(v) => String(v)} + /> + + setCrafterParams({ budgetConsumedPercent })} + display={(v) => `${v}%`} + /> + + + + + + + +
+ + {crafterRunning ? ( + + ) : null} +
+
+ + +
+ ); +} diff --git a/dashboard/components/playground/verdict-panel.tsx b/dashboard/components/playground/verdict-panel.tsx new file mode 100644 index 0000000..85da185 --- /dev/null +++ b/dashboard/components/playground/verdict-panel.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { StatusChip } from "@/components/dashboard-ui"; +import type { SimulationResult } from "@/lib/playground/types"; +import { Play } from "lucide-react"; +import { DangerGauge } from "./danger-gauge"; + +function verdictTone(v: SimulationResult["verdict"]): "green" | "amber" | "red" { + if (v === "allow") return "green"; + if (v === "flag") return "amber"; + return "red"; +} + +export function VerdictPanel({ + result, + latencyDisplay, +}: { + result: SimulationResult | null; + latencyDisplay?: number | null; +}) { + if (!result) { + return ( +
+
+
+ +
+

Run a simulation to see verdict output

+

+ Configure parameters on the left and click Run Judge +

+
+
+ ); + } + + return ( +
+
+
+ Verdict + {result.verdict.toUpperCase()} + + Model · {result.model} + +
+ +
+ +
+
+ Confidence + {result.confidence}% +
+
+
+
+
+ +
+ {result.reasoning} +
+ +
+ Prefilter:{" "} + {result.prefilterSkipped ? ( + skipped LLM — safe path + ) : ( + judge invoked + )} + {latencyDisplay != null ? ( + <> + {" "} + · Simulated latency{" "} + {latencyDisplay}ms + + ) : null} +
+ + {result.signals.length > 0 ? ( +
+ Active signals +
    + {result.signals.map((s) => ( +
  • + {s} +
  • + ))} +
+
+ ) : ( +

No anomaly signals for this run.

+ )} +
+ ); +} diff --git a/dashboard/components/proposal-card.tsx b/dashboard/components/proposal-card.tsx index 21b2c2b..c882b13 100644 --- a/dashboard/components/proposal-card.tsx +++ b/dashboard/components/proposal-card.tsx @@ -1,45 +1,15 @@ "use client"; +import Link from "next/link"; import { useState } from "react"; import { useConnection, useWallet } from "@solana/wallet-adapter-react"; import { useAnchorWallet } from "@solana/wallet-adapter-react"; import { StatusChip } from "@/components/dashboard-ui"; -import { shortAddress, lamportsToSol, formatRelativeTime, programLabel } from "@/lib/utils"; -import { approveProposal, executeProposal, createEscalationProposal } from "@/lib/squads/create-proposal"; -import { getErrorMessage } from "@/lib/api/client"; +import { shortAddress, formatRelativeTime, formatRelativeTooltip, formatSol, programLabel } from "@/lib/utils"; +import { approveProposal, executeViaGuardrails, createEscalationProposal } from "@/lib/squads/create-proposal"; +import { getErrorMessage, fetchEscalation } from "@/lib/api/client"; import type { EscalationSummary } from "@/lib/types/dashboard"; - -function escalationTone(status: string): "green" | "amber" | "red" { - switch (status) { - case "executed": - return "green"; - case "pending": - case "approved": - case "awaiting_proposal": - return "amber"; - default: - return "red"; - } -} - -function escalationLabel(status: string): string { - switch (status) { - case "awaiting_proposal": - return "AWAITING PROPOSAL"; - case "pending": - return "PENDING APPROVAL"; - case "approved": - return "APPROVED"; - case "executed": - return "EXECUTED"; - case "rejected": - return "REJECTED"; - case "cancelled": - return "CANCELLED"; - default: - return status.toUpperCase(); - } -} +import { escalationLabel, escalationTone } from "@/lib/utils/escalation-display"; export function ProposalCard({ escalation, @@ -55,7 +25,6 @@ export function ProposalCard({ const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const amountSol = lamportsToSol(escalation.amountLamports); const approvals = escalation.approvals ?? []; const rejections = escalation.rejections ?? []; @@ -101,12 +70,8 @@ export function ProposalCard({ setBusy(true); setError(null); try { - await executeProposal( - connection, - wallet, - escalation.squadsMultisig, - escalation.transactionIndex, - ); + const detail = await fetchEscalation(escalation.id); + await executeViaGuardrails(connection, wallet, detail); onUpdate?.(); } catch (e) { setError(getErrorMessage(e)); @@ -120,7 +85,7 @@ export function ProposalCard({
- {amountSol.toFixed(4)} SOL to {programLabel(escalation.targetProgram)} + {formatSol(escalation.amountLamports)} to {programLabel(escalation.targetProgram)} Multisig: {shortAddress(escalation.squadsMultisig)} @@ -131,6 +96,13 @@ export function ProposalCard({
+ + Proposal detail → + + {/* Approval progress */} {approvals.length > 0 || rejections.length > 0 ? (
@@ -167,7 +139,7 @@ export function ProposalCard({ ) : null} {/* Time */} - + Created {formatRelativeTime(escalation.createdAt)} diff --git a/dashboard/components/providers.tsx b/dashboard/components/providers.tsx index 6a48857..61f92c7 100644 --- a/dashboard/components/providers.tsx +++ b/dashboard/components/providers.tsx @@ -4,13 +4,17 @@ import React, { type ComponentType, useMemo, useState, type ReactNode } from "re import { AnchorProvider } from "@coral-xyz/anchor"; import { Connection, PublicKey } from "@solana/web3.js"; import { QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ConnectionProvider, WalletProvider, useWallet } from "@solana/wallet-adapter-react"; import { WalletModalProvider } from "@solana/wallet-adapter-react-ui"; import type { Adapter } from "@solana/wallet-adapter-base"; import { PhantomWalletAdapter, SolflareWalletAdapter } from "@solana/wallet-adapter-wallets"; import { BackpackWalletAdapter } from "@solana/wallet-adapter-backpack"; +import NextTopLoader from "nextjs-toploader"; +import { Toaster } from "sonner"; +import { RequireSession } from "@/components/auth/require-session"; import { ApiClientError, isUnauthorizedError } from "@/lib/api/client"; -import { clearSiwsAndRedirectToSignin } from "@/lib/auth/siws-session"; +import { clearSiwsAndRedirectHome } from "@/lib/auth/siws-session"; import { useSSE } from "@/lib/sse/useSSE"; const RPC_ENDPOINT = process.env.NEXT_PUBLIC_SOLANA_RPC_URL ?? "https://api.devnet.solana.com"; @@ -44,9 +48,9 @@ export function AppProviders({ children }: { children: ReactNode }) { queryCache: new QueryCache({ onError: (error) => { if (typeof window === "undefined") return; - if (window.location.pathname === "/signin") return; + if (window.location.pathname === "/") return; if (isUnauthorizedError(error)) { - clearSiwsAndRedirectToSignin(); + clearSiwsAndRedirectHome(); } }, }), @@ -76,7 +80,33 @@ export function AppProviders({ children }: { children: ReactNode }) { - {children} + + + {children} + {process.env.NODE_ENV === "development" ? ( + + ) : null} diff --git a/dashboard/components/query-states.tsx b/dashboard/components/query-states.tsx index ecb3412..5be2889 100644 --- a/dashboard/components/query-states.tsx +++ b/dashboard/components/query-states.tsx @@ -60,10 +60,10 @@ export function QueryEmpty({ action?: ReactNode; }) { return ( -
-

{title}

- {description ?

{description}

: null} - {action ?
{action}
: null} +
+

{title}

+ {description ?

{description}

: null} + {action ?
{action}
: null}
); } diff --git a/dashboard/components/report-markdown.tsx b/dashboard/components/report-markdown.tsx index d5cb1cc..31e663a 100644 --- a/dashboard/components/report-markdown.tsx +++ b/dashboard/components/report-markdown.tsx @@ -5,24 +5,24 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; const components: Components = { - h1: ({ children }) =>

{children}

, - h2: ({ children }) =>

{children}

, - h3: ({ children }) =>

{children}

, - p: ({ children }) =>

{children}

, - ul: ({ children }) =>
    {children}
, - ol: ({ children }) =>
    {children}
, + h1: ({ children }) =>

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + p: ({ children }) =>

{children}

, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, li: ({ children }) =>
  • {children}
  • , - strong: ({ children }) => {children}, - em: ({ children }) => {children}, + strong: ({ children }) => {children}, + em: ({ children }) => {children}, blockquote: ({ children }) => ( -
    {children}
    +
    {children}
    ), a: ({ href, children }) => ( {children} @@ -33,25 +33,25 @@ const components: Components = { return {children}; } return ( - {children} + {children} ); }, pre: ({ children }) => ( -
    +    
           {children}
         
    ), - hr: () =>
    , + hr: () =>
    , table: ({ children }) => ( -
    +
    {children}
    ), - thead: ({ children }) => {children}, + thead: ({ children }) => {children}, tbody: ({ children }) => {children}, - tr: ({ children }) => {children}, - th: ({ children }) => {children}, - td: ({ children }) => {children}, + tr: ({ children }) => {children}, + th: ({ children }) => {children}, + td: ({ children }) => {children}, }; export function ReportMarkdown({ markdown }: { markdown: string }) { diff --git a/dashboard/components/rotate-agent-key-button.tsx b/dashboard/components/rotate-agent-key-button.tsx index 2cb35f4..abbc0c0 100644 --- a/dashboard/components/rotate-agent-key-button.tsx +++ b/dashboard/components/rotate-agent-key-button.tsx @@ -1,10 +1,11 @@ "use client"; import { useState } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter } from "nextjs-toploader/app"; import { useQueryClient } from "@tanstack/react-query"; import { useWallet } from "@solana/wallet-adapter-react"; import { Keypair, PublicKey } from "@solana/web3.js"; +import { toast } from "sonner"; import { getErrorMessage } from "@/lib/api/client"; import { queryKeys } from "@/lib/api/query-keys"; import { GuardrailsClient } from "@/lib/sdk/client"; @@ -58,20 +59,24 @@ export function RotateAgentKeyButton({ policy }: { policy: PolicySummary }) { newKeypair.publicKey, ); queryClient.removeQueries({ queryKey: queryKeys.policy(policy.pubkey) }); - queryClient.invalidateQueries({ queryKey: queryKeys.policies() }); + queryClient.invalidateQueries({ queryKey: ["policies"] }); + toast.success("Agent key rotated successfully."); router.push(`/agents/${newPolicyPda.toBase58()}`); } catch (e) { // "Already processed" means the first click succeeded — treat as success const msg = getErrorMessage(e).toLowerCase(); if (msg.includes("already been processed") || msg.includes("already processed")) { queryClient.removeQueries({ queryKey: queryKeys.policy(policy.pubkey) }); - queryClient.invalidateQueries({ queryKey: queryKeys.policies() }); + queryClient.invalidateQueries({ queryKey: ["policies"] }); + toast.success("Agent key rotation already processed."); // Derive the new PDA to redirect const client = new GuardrailsClient(provider, programId); const [newPda] = client.findPolicyPda(new PublicKey(policy.owner), newKeypair.publicKey); router.push(`/agents/${newPda.toBase58()}`); } else { - setError(getErrorMessage(e)); + const message = getErrorMessage(e); + setError(message); + toast.error(message); } } finally { setBusy(false); @@ -125,7 +130,12 @@ export function RotateAgentKeyButton({ policy }: { policy: PolicySummary }) { diff --git a/dashboard/components/shell-navbar-actions.tsx b/dashboard/components/shell-navbar-actions.tsx new file mode 100644 index 0000000..f8ed659 --- /dev/null +++ b/dashboard/components/shell-navbar-actions.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useWallet } from "@solana/wallet-adapter-react"; +import { Bell, Check, Copy, LogOut, UserCircle } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { useSiwsAuthStore } from "@/lib/stores/siws-auth"; +import { subscribeSSEEvents } from "@/lib/sse/useSSE"; + +export function ShellNavbarActions() { + const router = useRouter(); + const walletAdapter = useWallet(); + const [menuOpen, setMenuOpen] = useState(false); + const [copied, setCopied] = useState(false); + const [unreadCount, setUnreadCount] = useState(0); + const wrapRef = useRef(null); + const copyResetRef = useRef | null>(null); + + let pubkey = ""; + try { + pubkey = walletAdapter.publicKey?.toBase58() ?? ""; + } catch { + /* WalletProvider missing */ + } + + useEffect(() => { + if (!menuOpen) return; + const onPointerDown = (e: PointerEvent) => { + if (wrapRef.current?.contains(e.target as Node)) return; + setMenuOpen(false); + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") setMenuOpen(false); + }; + document.addEventListener("pointerdown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [menuOpen]); + + useEffect(() => { + return subscribeSSEEvents(({ type }) => { + if (type === "agent_paused" || type === "escalation_created") { + setUnreadCount((count) => count + 1); + } + }); + }, []); + + useEffect(() => { + return () => { + if (copyResetRef.current) clearTimeout(copyResetRef.current); + }; + }, []); + + const copyAddress = useCallback(async () => { + if (!pubkey || typeof navigator === "undefined" || !navigator.clipboard?.writeText) { + toast.error("Clipboard unavailable"); + return; + } + try { + await navigator.clipboard.writeText(pubkey); + setCopied(true); + toast.success("Wallet address copied"); + if (copyResetRef.current) clearTimeout(copyResetRef.current); + copyResetRef.current = setTimeout(() => setCopied(false), 2000); + } catch { + toast.error("Could not copy address"); + } + }, [pubkey]); + + const signOut = useCallback(() => { + useSiwsAuthStore.getState().clearSignedIn(); + void walletAdapter.disconnect(); + setMenuOpen(false); + router.push("/"); + }, [router, walletAdapter]); + + return ( +
    + + +
    + + + {menuOpen ? ( +
    +

    Connected wallet

    +

    + {pubkey || "—"} +

    +
    + + +
    +
    + ) : null} +
    +
    + ); +} diff --git a/dashboard/components/simulate-panel.tsx b/dashboard/components/simulate-panel.tsx new file mode 100644 index 0000000..b61df53 --- /dev/null +++ b/dashboard/components/simulate-panel.tsx @@ -0,0 +1,453 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Connection, LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js"; +import { X, Play, Square, CheckCircle2, XCircle, ExternalLink, Zap, Shield, Wrench } from "lucide-react"; +import { toast } from "sonner"; +import { useSimulationStore, type SimulationMode } from "@/lib/stores/simulation"; +import { useSimulationRunner } from "@/hooks/use-simulation-runner"; +import type { PolicySummary } from "@/lib/types/dashboard"; + +const RPC_URL = process.env.NEXT_PUBLIC_SOLANA_RPC_URL ?? "https://api.devnet.solana.com"; + +const MODE_CONFIG: { key: SimulationMode; label: string; icon: typeof Shield }[] = [ + { key: "honest", label: "Honest", icon: Shield }, + { key: "attack", label: "Attack", icon: Zap }, + { key: "custom", label: "Custom", icon: Wrench }, +]; + +function shortenSig(sig: string): string { + return sig.length > 16 ? `${sig.slice(0, 8)}...${sig.slice(-8)}` : sig; +} + +function shortenKey(key: string): string { + return key.length > 8 ? `${key.slice(0, 4)}...${key.slice(-4)}` : key; +} + +export function SimulatePanel({ policy }: { policy: PolicySummary }) { + const sim = useSimulationStore(); + const { start, stop } = useSimulationRunner(policy); + const logEndRef = useRef(null); + + const [agentBalance, setAgentBalance] = useState(null); + const [balanceLoading, setBalanceLoading] = useState(false); + const [airdropping, setAirdropping] = useState(false); + + const keyMatchesAgent = + sim.derivedPubkey !== null && sim.derivedPubkey === policy.agent; + const keyValid = sim.agentKeypairBytes !== null; + + // Fetch agent SOL balance when key is validated + useEffect(() => { + if (!keyValid || !sim.derivedPubkey) { + setAgentBalance(null); + return; + } + let cancelled = false; + setBalanceLoading(true); + const conn = new Connection(RPC_URL, "confirmed"); + conn + .getBalance(new PublicKey(sim.derivedPubkey)) + .then((bal) => { + if (!cancelled) setAgentBalance(bal / LAMPORTS_PER_SOL); + }) + .catch(() => { + if (!cancelled) setAgentBalance(null); + }) + .finally(() => { + if (!cancelled) setBalanceLoading(false); + }); + return () => { + cancelled = true; + }; + }, [keyValid, sim.derivedPubkey]); + + // Auto-scroll log + useEffect(() => { + logEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [sim.log.length]); + + const handleAirdrop = useCallback(async () => { + if (!sim.derivedPubkey) return; + setAirdropping(true); + try { + const conn = new Connection(RPC_URL, "confirmed"); + const sig = await conn.requestAirdrop( + new PublicKey(sim.derivedPubkey), + 1 * LAMPORTS_PER_SOL, + ); + await conn.confirmTransaction(sig, "confirmed"); + const bal = await conn.getBalance(new PublicKey(sim.derivedPubkey)); + setAgentBalance(bal / LAMPORTS_PER_SOL); + toast.success("Airdropped 1 SOL"); + } catch { + toast.error("Airdrop failed — devnet may be rate-limited"); + } finally { + setAirdropping(false); + } + }, [sim.derivedPubkey]); + + const canStart = + keyValid && + keyMatchesAgent && + policy.isActive && + !sim.isRunning && + agentBalance !== null && + agentBalance > 0; + + return ( + <> + {/* Backdrop */} +
    { + if (!sim.isRunning) sim.setPanelOpen(false); + }} + /> + + {/* Panel */} +
    + {/* Header */} +
    +

    + Simulate Transactions +

    + +
    + +
    +
    + {/* Secret Key Input */} +
    + +