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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions dashboard/src/__tests__/dashboard-header-health.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<DashboardHeader
recipient={recipient}
recipientInitials="RG"
agentInfo={null}
agentConnected={true}
agentPaused={false}
walletBalance={null}
fetchHealthSources={fetchHealthSources}
onTogglePause={vi.fn()}
/>,
);
}

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)");
});
});
1 change: 1 addition & 0 deletions dashboard/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export default function Dashboard() {
agentConnected={state.agentConnected}
agentPaused={state.agentPaused}
walletBalance={state.walletBalance}
fetchHealthSources={state.fetchHealthSources}
onTogglePause={state.togglePause}
/>
<div className="max-w-7xl mx-auto px-4 py-6">
Expand Down
18 changes: 18 additions & 0 deletions dashboard/src/components/dashboard-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -11,6 +15,7 @@ export interface DashboardHeaderProps {
agentConnected: boolean;
agentPaused: boolean;
walletBalance: string | null;
fetchHealthSources: FetchSourceHealth[];
onTogglePause: () => void;
}

Expand All @@ -21,8 +26,11 @@ export function DashboardHeader({
agentConnected,
agentPaused,
walletBalance,
fetchHealthSources,
onTogglePause,
}: DashboardHeaderProps) {
const fetchHealth = buildFetchHealthSummary(fetchHealthSources);

return (
<header className="bg-white border-b border-slate-200 sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
Expand Down Expand Up @@ -56,6 +64,16 @@ export function DashboardHeader({
{agentPaused ? "Resume" : "Pause"}
</button>
)}
<div
aria-label="Data source health"
title={fetchHealth.title}
className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-xs ${fetchHealth.ok ? "bg-green-50 text-green-700" : "bg-red-50 text-red-600"}`}
>
<div
className={`w-1.5 h-1.5 rounded-full ${fetchHealth.ok ? "bg-green-500" : "bg-red-500"}`}
/>
{fetchHealth.label}
</div>
</div>
<div className="flex items-center gap-4">
{walletBalance && agentInfo?.agentWallet && (
Expand Down
43 changes: 43 additions & 0 deletions dashboard/src/hooks/fetch-health.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
125 changes: 84 additions & 41 deletions dashboard/src/hooks/use-agent-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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<SpendingData | null>(null);
const [allTransactions, setAllTransactions] = useState<Transaction[]>([]);
Expand Down Expand Up @@ -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<AgentInfo>();
const spendingFetch = useFetch<SpendingData>();
const transactionsFetch = useFetch<TransactionsFetchData>();

const activeTabRef = useRef(activeTab);
const policyDirtyRef = useRef(policyDirty);
Expand Down Expand Up @@ -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));
Expand All @@ -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 =
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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);
}
Expand All @@ -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' });
Expand Down Expand Up @@ -411,6 +453,7 @@ export function useAgentState({ activeTab }: UseAgentStateOptions) {
policyDirty,
setPolicyDirty,
policySaved,
fetchHealthSources,
// individual loading states (Issue #283)
loadingAgentInfo,
loadingSpending,
Expand Down
Loading