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,
+ };
+}