diff --git a/dashboard/src/__tests__/dashboard-header-health.test.tsx b/dashboard/src/__tests__/dashboard-header-health.test.tsx new file mode 100644 index 0000000..5998b0b --- /dev/null +++ b/dashboard/src/__tests__/dashboard-header-health.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { DashboardHeader } from "../components/dashboard-header"; +import type { FetchSourceHealth } from "../hooks/fetch-health"; + +const recipient = { + name: "Rosa Garcia", + age: 78, +}; + +const healthySources: FetchSourceHealth[] = [ + { id: "agent-info", label: "Agent info", error: null, lastSuccessAt: 1 }, + { id: "spending", label: "Spending", error: null, lastSuccessAt: 1 }, + { id: "transactions", label: "Transactions", error: null, lastSuccessAt: 1 }, +]; + +function renderHeader(fetchHealthSources: FetchSourceHealth[]) { + return render( + , + ); +} + +describe("DashboardHeader fetch health", () => { + it("shows a healthy chip when all sources are successful", () => { + renderHeader(healthySources); + + const chip = screen.getByLabelText("Data source health"); + expect(chip.textContent).toContain("Data healthy"); + expect(chip.className).toContain("bg-green-50"); + expect(chip.getAttribute("title")).toContain("Agent info"); + }); + + it("turns red and explains which source failed", () => { + renderHeader([ + healthySources[0], + { + id: "spending", + label: "Spending", + error: "Spending failed (500)", + lastSuccessAt: null, + }, + healthySources[2], + ]); + + const chip = screen.getByLabelText("Data source health"); + expect(chip.textContent).toContain("Data issue"); + expect(chip.className).toContain("bg-red-50"); + expect(chip.getAttribute("title")).toContain("Spending"); + expect(chip.getAttribute("title")).toContain("Spending failed (500)"); + }); +}); diff --git a/dashboard/src/app/page.tsx b/dashboard/src/app/page.tsx index 043d002..87d8b26 100644 --- a/dashboard/src/app/page.tsx +++ b/dashboard/src/app/page.tsx @@ -79,6 +79,7 @@ export default function Dashboard() { agentConnected={state.agentConnected} agentPaused={state.agentPaused} walletBalance={state.walletBalance} + fetchHealthSources={state.fetchHealthSources} onTogglePause={state.togglePause} />
diff --git a/dashboard/src/components/dashboard-header.tsx b/dashboard/src/components/dashboard-header.tsx index a2ec512..7e32f9b 100644 --- a/dashboard/src/components/dashboard-header.tsx +++ b/dashboard/src/components/dashboard-header.tsx @@ -3,6 +3,10 @@ import type { RecipientProfile } from "../lib/types"; import type { AgentInfo } from "./types"; import { EXPLORER_ACCOUNT_URL } from "../lib/stellar-network"; +import { + buildFetchHealthSummary, + type FetchSourceHealth, +} from "../hooks/fetch-health"; export interface DashboardHeaderProps { recipient: RecipientProfile; @@ -11,6 +15,7 @@ export interface DashboardHeaderProps { agentConnected: boolean; agentPaused: boolean; walletBalance: string | null; + fetchHealthSources: FetchSourceHealth[]; onTogglePause: () => void; } @@ -21,8 +26,11 @@ export function DashboardHeader({ agentConnected, agentPaused, walletBalance, + fetchHealthSources, onTogglePause, }: DashboardHeaderProps) { + const fetchHealth = buildFetchHealthSummary(fetchHealthSources); + return (
@@ -56,6 +64,16 @@ export function DashboardHeader({ {agentPaused ? "Resume" : "Pause"} )} +
+
+ {fetchHealth.label} +
{walletBalance && agentInfo?.agentWallet && ( diff --git a/dashboard/src/hooks/fetch-health.ts b/dashboard/src/hooks/fetch-health.ts new file mode 100644 index 0000000..310590c --- /dev/null +++ b/dashboard/src/hooks/fetch-health.ts @@ -0,0 +1,43 @@ +export interface FetchSourceHealth { + id: string; + label: string; + error: string | null; + lastSuccessAt: number | null; +} + +export interface FetchHealthSummary { + ok: boolean; + label: string; + title: string; + failingSources: FetchSourceHealth[]; +} + +export function getFetchErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) return error.message; + if (typeof error === "string" && error.trim()) return error; + return "Unknown error"; +} + +export function buildFetchHealthSummary( + sources: FetchSourceHealth[], +): FetchHealthSummary { + const failingSources = sources.filter((source) => source.error); + + if (failingSources.length === 0) { + return { + ok: true, + label: "Data healthy", + title: "Agent info, spending, and transactions are updating.", + failingSources, + }; + } + + return { + ok: false, + label: "Data issue", + title: failingSources + .map((source) => `${source.label}: ${source.error}`) + .join("\n"), + failingSources, + }; +} diff --git a/dashboard/src/hooks/use-agent-state.ts b/dashboard/src/hooks/use-agent-state.ts index 6c7f444..cdfa397 100644 --- a/dashboard/src/hooks/use-agent-state.ts +++ b/dashboard/src/hooks/use-agent-state.ts @@ -19,6 +19,8 @@ import type { } from '../components/types'; import { usePoll } from './use-poll'; import { AGENT_URL } from '../lib/agent-url'; +import { useFetch } from './use-fetch'; +import type { FetchSourceHealth } from './fetch-health'; const DEFAULT_POLICY = { @@ -36,6 +38,12 @@ export interface UseAgentStateOptions { activeTab: Tab; } +interface TransactionsFetchData { + transactions: Transaction[]; + pagination: PaginationData | null; + auditEvents: AuditLogEvent[] | null; +} + export function useAgentState({ activeTab }: UseAgentStateOptions) { const [spending, setSpending] = useState(null); const [allTransactions, setAllTransactions] = useState([]); @@ -65,6 +73,9 @@ export function useAgentState({ activeTab }: UseAgentStateOptions) { const [loadingAgentInfo, setLoadingAgentInfo] = useState(false); const [loadingSpending, setLoadingSpending] = useState(false); const [loadingTransactions, setLoadingTransactions] = useState(false); + const agentInfoFetch = useFetch(); + const spendingFetch = useFetch(); + const transactionsFetch = useFetch(); const activeTabRef = useRef(activeTab); const policyDirtyRef = useRef(policyDirty); @@ -107,13 +118,13 @@ export function useAgentState({ activeTab }: UseAgentStateOptions) { const fetchAgentInfo = useCallback(async () => { setLoadingAgentInfo(true); try { - const res = await fetch(`${AGENT_URL}/`); - if (!res.ok) { - setAgentConnected(false); - setLoadingAgentInfo(false); - return; - } - const data = await res.json(); + const data = await agentInfoFetch.run(async () => { + const res = await fetch(`${AGENT_URL}/`); + if (!res.ok) { + throw new Error(`Agent info failed (${res.status})`); + } + return (await res.json()) as AgentInfo; + }); setAgentInfo(data); setAgentConnected(true); setAgentPaused(Boolean(data.paused)); @@ -136,18 +147,19 @@ export function useAgentState({ activeTab }: UseAgentStateOptions) { } finally { setLoadingAgentInfo(false); } - }, []); + }, [agentInfoFetch.run]); const fetchSpending = useCallback( async (opts?: { forcePolicySync?: boolean }) => { setLoadingSpending(true); try { - const res = await fetch(`${AGENT_URL}/agent/spending`); - if (!res.ok) { - setLoadingSpending(false); - return; - } - const data = SpendingDataSchema.parse(await res.json()); + const data = await spendingFetch.run(async () => { + const res = await fetch(`${AGENT_URL}/agent/spending`); + if (!res.ok) { + throw new Error(`Spending failed (${res.status})`); + } + return SpendingDataSchema.parse(await res.json()); + }); setSpending(data); const forcePolicySync = Boolean(opts?.forcePolicySync); const shouldSyncPolicy = @@ -161,42 +173,49 @@ export function useAgentState({ activeTab }: UseAgentStateOptions) { setLoadingSpending(false); } }, - [], + [spendingFetch.run], ); const fetchTransactions = useCallback( async (limit?: number, offset?: number) => { setLoadingTransactions(true); try { - const params = new URLSearchParams(); - if (limit) params.append('limit', limit.toString()); - if (offset) params.append('offset', offset.toString()); - const res = await fetch(`${AGENT_URL}/agent/transactions?${params}`); - if (!res.ok) { - setLoadingTransactions(false); - return; - } - const data = await res.json(); - const txs = Array.isArray(data.transactions) - ? data.transactions.map((t: unknown) => TransactionSchema.parse(t)) - : []; - setAllTransactions(txs); - if (data.pagination) setPagination(data.pagination); - - // Fetch audit events independently (don't block on this) - const auditRes = await fetch(`${AGENT_URL}/agent/audit?limit=100`); - if (auditRes.ok) { - const auditData = await auditRes.json(); - const logs = Array.isArray(auditData.data) - ? auditData.data.map((l: unknown) => AuditLogSchema.parse(l)) + const data = await transactionsFetch.run(async () => { + const params = new URLSearchParams(); + if (limit) params.append('limit', limit.toString()); + if (offset) params.append('offset', offset.toString()); + const res = await fetch(`${AGENT_URL}/agent/transactions?${params}`); + if (!res.ok) { + throw new Error(`Transactions failed (${res.status})`); + } + const payload = await res.json(); + const txs = Array.isArray(payload.transactions) + ? payload.transactions.map((t: unknown) => TransactionSchema.parse(t)) : []; - setAuditEvents(logs); - } + + let auditEventsData: AuditLogEvent[] | null = null; + const auditRes = await fetch(`${AGENT_URL}/agent/audit?limit=100`); + if (auditRes.ok) { + const auditData = await auditRes.json(); + auditEventsData = Array.isArray(auditData.data) + ? auditData.data.map((l: unknown) => AuditLogSchema.parse(l)) + : []; + } + + return { + transactions: txs, + pagination: payload.pagination ?? null, + auditEvents: auditEventsData, + }; + }); + setAllTransactions(data.transactions); + if (data.pagination) setPagination(data.pagination); + if (data.auditEvents) setAuditEvents(data.auditEvents); } catch {} finally { setLoadingTransactions(false); } }, - [], + [transactionsFetch.run], ); // Poll spending and transactions every 3s with backoff @@ -274,6 +293,7 @@ export function useAgentState({ activeTab }: UseAgentStateOptions) { const data: AgentResult = await res.json(); setAgentResult(data); setSpending(data.spending); + spendingFetch.setData(data.spending); setLiveMessage(`Task complete — ${data.toolCalls.length} tool calls`); for (const tc of data.toolCalls) { const resultPreview = tc.result?.error @@ -306,7 +326,7 @@ export function useAgentState({ activeTab }: UseAgentStateOptions) { setAbortController(null); } }, - [agentConnected, addLogEntry, fetchAgentInfo, fetchTransactions, pageSize], + [agentConnected, addLogEntry, fetchAgentInfo, fetchTransactions, pageSize, spendingFetch.setData], ); const cancelAgentTask = useCallback(() => { @@ -336,6 +356,7 @@ export function useAgentState({ activeTab }: UseAgentStateOptions) { if (spendingRes.ok) { const data = SpendingDataSchema.parse(await spendingRes.json()); setSpending(data); + spendingFetch.setData(data); setPolicyForm(data.policy); setPolicyDirty(false); } @@ -352,7 +373,28 @@ export function useAgentState({ activeTab }: UseAgentStateOptions) { ); return { ok: false, error: err.message }; } - }, [addLogEntry, policyForm]); + }, [addLogEntry, policyForm, spendingFetch.setData]); + + const fetchHealthSources: FetchSourceHealth[] = [ + { + id: 'agent-info', + label: 'Agent info', + error: agentInfoFetch.error, + lastSuccessAt: agentInfoFetch.lastSuccessAt, + }, + { + id: 'spending', + label: 'Spending', + error: spendingFetch.error, + lastSuccessAt: spendingFetch.lastSuccessAt, + }, + { + id: 'transactions', + label: 'Transactions', + error: transactionsFetch.error, + lastSuccessAt: transactionsFetch.lastSuccessAt, + }, + ]; const resetAgent = useCallback(async () => { await fetch(`${AGENT_URL}/agent/reset`, { method: 'POST' }); @@ -411,6 +453,7 @@ export function useAgentState({ activeTab }: UseAgentStateOptions) { policyDirty, setPolicyDirty, policySaved, + fetchHealthSources, // individual loading states (Issue #283) loadingAgentInfo, loadingSpending, diff --git a/dashboard/src/hooks/use-fetch.ts b/dashboard/src/hooks/use-fetch.ts new file mode 100644 index 0000000..70e9c6e --- /dev/null +++ b/dashboard/src/hooks/use-fetch.ts @@ -0,0 +1,50 @@ +import { useCallback, useState } from "react"; +import { getFetchErrorMessage } from "./fetch-health"; + +export interface UseFetchState { + data: T | null; + error: string | null; + lastSuccessAt: number | null; + loading: boolean; + run: (fetcher: () => Promise) => Promise; + setData: (data: T) => void; +} + +export function useFetch(): UseFetchState { + const [data, setDataState] = useState(null); + const [error, setError] = useState(null); + const [lastSuccessAt, setLastSuccessAt] = useState(null); + const [loading, setLoading] = useState(false); + + const setData = useCallback((nextData: T) => { + setDataState(nextData); + setError(null); + setLastSuccessAt(Date.now()); + }, []); + + const run = useCallback( + async (fetcher: () => Promise) => { + setLoading(true); + try { + const nextData = await fetcher(); + setData(nextData); + return nextData; + } catch (err) { + setError(getFetchErrorMessage(err)); + throw err; + } finally { + setLoading(false); + } + }, + [setData], + ); + + return { + data, + error, + lastSuccessAt, + loading, + run, + setData, + }; +}