Files
fusion/packages/dashboard/app/hooks/useUsageData.ts
gsxdsm cf3b9a575e FN-5886: show empty usage state after empty fetch
Handle empty usage responses without leaving the panel in its loading skeleton.

- add a hasFetched flag to useUsageData so the UI can distinguish initial loading from an empty completed response
- update UsageIndicator to render the skeleton only before the first fetch completes and show the empty state afterward
- refresh dashboard hook and component tests to cover empty fetches, refresh behavior, and the new loading semantics

Files changed:
 .../dashboard/app/components/UsageIndicator.tsx    |  19 +-
 .../components/__tests__/UsageIndicator.test.tsx   | 395 +++++++++++----------
 .../app/hooks/__tests__/useUsageData.test.ts       |  42 ++-
 packages/dashboard/app/hooks/useUsageData.ts       |   5 +
 4 files changed, 245 insertions(+), 216 deletions(-)

Fusion-Task-Id: FN-5886

Fusion-Task-Lineage: 8fa901a9-3167-4e33-a082-e5de95f692da
2026-06-02 09:24:42 -07:00

123 lines
2.9 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from "react";
import { getErrorMessage } from "@fusion/core";
import { fetchUsageData, type ProviderUsage } from "../api";
interface UsageDataState {
providers: ProviderUsage[];
loading: boolean;
error: string | null;
lastUpdated: Date | null;
hasFetched: boolean;
}
interface UseUsageDataOptions {
/** Auto-refresh interval in ms (default: 30 seconds) */
pollInterval?: number;
/** Whether to auto-refresh (default: true) */
autoRefresh?: boolean;
}
/**
* Hook for fetching and polling provider usage data.
*
* Features:
* - Initial fetch on mount
* - Auto-refresh every 30 seconds when enabled
* - Manual refresh capability
* - Loading and error states
* - Cleanup on unmount
*/
export function useUsageData(options: UseUsageDataOptions = {}) {
const { pollInterval = 30_000, autoRefresh = true } = options;
const [state, setState] = useState<UsageDataState>({
providers: [],
loading: true,
error: null,
lastUpdated: null,
hasFetched: false,
});
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const abortRef = useRef<AbortController | null>(null);
const fetchData = useCallback(async (isManual = false) => {
// Cancel any in-flight request
if (abortRef.current) {
abortRef.current.abort();
}
abortRef.current = new AbortController();
if (isManual) {
setState((prev) => ({ ...prev, loading: true, error: null }));
}
try {
const { providers } = await fetchUsageData();
setState({
providers,
loading: false,
error: null,
lastUpdated: new Date(),
hasFetched: true,
});
} catch (err) {
// Don't update state if the request was aborted
if (err instanceof Error && err.name === "AbortError") return;
setState((prev) => ({
...prev,
loading: false,
error: getErrorMessage(err) || "Failed to fetch usage data",
hasFetched: true,
}));
}
}, []);
// Initial fetch
useEffect(() => {
fetchData();
}, [fetchData]);
// Auto-refresh
useEffect(() => {
if (!autoRefresh) return;
pollRef.current = setInterval(() => {
fetchData(false);
}, pollInterval);
return () => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
};
}, [autoRefresh, pollInterval, fetchData]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (abortRef.current) {
abortRef.current.abort();
}
if (pollRef.current) {
clearInterval(pollRef.current);
}
};
}, []);
const refresh = useCallback(() => {
return fetchData(true);
}, [fetchData]);
return {
providers: state.providers,
loading: state.loading,
error: state.error,
lastUpdated: state.lastUpdated,
hasFetched: state.hasFetched,
refresh,
};
}