feat(KB-039): restore deleted files — UsageIndicator, useUsageData hook and tests
This commit is contained in:
@@ -164,6 +164,34 @@ export function fetchModels(): Promise<ModelInfo[]> {
|
||||
return api<ModelInfo[]>("/models");
|
||||
}
|
||||
|
||||
// --- Usage API ---
|
||||
|
||||
/** Usage window for a provider (e.g., "Session (5h)", "Weekly") */
|
||||
export interface UsageWindow {
|
||||
label: string;
|
||||
percentUsed: number; // 0-100
|
||||
percentLeft: number; // 0-100
|
||||
resetText: string | null; // e.g., "resets in 2h"
|
||||
resetMs?: number; // ms until reset
|
||||
windowDurationMs?: number; // total window length
|
||||
}
|
||||
|
||||
/** Provider usage data */
|
||||
export interface ProviderUsage {
|
||||
name: string;
|
||||
icon: string; // emoji
|
||||
status: "ok" | "error" | "no-auth";
|
||||
error?: string;
|
||||
plan?: string | null;
|
||||
email?: string | null;
|
||||
windows: UsageWindow[];
|
||||
}
|
||||
|
||||
/** Fetch usage data from all configured AI providers */
|
||||
export function fetchUsageData(): Promise<{ providers: ProviderUsage[] }> {
|
||||
return api<{ providers: ProviderUsage[] }>("/usage");
|
||||
}
|
||||
|
||||
// --- Auth API ---
|
||||
|
||||
/** OAuth provider with current authentication status */
|
||||
|
||||
233
packages/dashboard/app/components/UsageIndicator.tsx
Normal file
233
packages/dashboard/app/components/UsageIndicator.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { X, RefreshCw, Activity } from "lucide-react";
|
||||
import type { ProviderUsage, UsageWindow } from "../api";
|
||||
import { useUsageData } from "../hooks/useUsageData";
|
||||
|
||||
interface UsageIndicatorProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function getUsageColorClass(percentUsed: number): string {
|
||||
if (percentUsed > 90) return "usage-progress-fill--high";
|
||||
if (percentUsed > 70) return "usage-progress-fill--medium";
|
||||
return "usage-progress-fill--low";
|
||||
}
|
||||
|
||||
function UsageWindowRow({ window }: { window: UsageWindow }) {
|
||||
const colorClass = getUsageColorClass(window.percentUsed);
|
||||
|
||||
return (
|
||||
<div className="usage-window">
|
||||
<div className="usage-window-header">
|
||||
<span className="usage-window-label">{window.label}</span>
|
||||
<span className="usage-window-percentage">{window.percentUsed}% used</span>
|
||||
</div>
|
||||
<div className="usage-progress-bar">
|
||||
<div
|
||||
className={`usage-progress-fill ${colorClass}`}
|
||||
style={{ width: `${window.percentUsed}%` }}
|
||||
role="progressbar"
|
||||
aria-valuenow={window.percentUsed}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={`${window.label} usage: ${window.percentUsed}%`}
|
||||
/>
|
||||
</div>
|
||||
<div className="usage-window-footer">
|
||||
<span className="usage-window-left">{window.percentLeft}% left</span>
|
||||
{window.resetText && (
|
||||
<span className="usage-window-reset">{window.resetText}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderCard({ provider }: { provider: ProviderUsage }) {
|
||||
const getStatusBadge = () => {
|
||||
switch (provider.status) {
|
||||
case "ok":
|
||||
return (
|
||||
<span className="usage-status-badge usage-status-badge--connected">
|
||||
Connected
|
||||
</span>
|
||||
);
|
||||
case "error":
|
||||
return (
|
||||
<span className="usage-status-badge usage-status-badge--error">
|
||||
Error
|
||||
</span>
|
||||
);
|
||||
case "no-auth":
|
||||
default:
|
||||
return (
|
||||
<span className="usage-status-badge usage-status-badge--not-configured">
|
||||
Not configured
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="usage-provider" data-provider={provider.name} data-status={provider.status}>
|
||||
<div className="usage-provider-header">
|
||||
<div className="usage-provider-info">
|
||||
<span className="usage-provider-icon" role="img" aria-label={provider.name}>
|
||||
{provider.icon}
|
||||
</span>
|
||||
<span className="usage-provider-name">{provider.name}</span>
|
||||
</div>
|
||||
{getStatusBadge()}
|
||||
</div>
|
||||
|
||||
{provider.error && (
|
||||
<div className="usage-provider-error">
|
||||
{provider.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(provider.plan || provider.email) && (
|
||||
<div className="usage-provider-meta">
|
||||
{provider.plan && <span className="usage-provider-plan">{provider.plan}</span>}
|
||||
{provider.email && <span className="usage-provider-email">{provider.email}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provider.windows.length > 0 ? (
|
||||
<div className="usage-provider-windows">
|
||||
{provider.windows.map((window, index) => (
|
||||
<UsageWindowRow key={`${provider.name}-${window.label}-${index}`} window={window} />
|
||||
))}
|
||||
</div>
|
||||
) : provider.status === "ok" ? (
|
||||
<div className="usage-provider-empty">No usage data available</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageSkeleton() {
|
||||
return (
|
||||
<div className="usage-skeleton">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="usage-skeleton-provider">
|
||||
<div className="usage-skeleton-header">
|
||||
<div className="usage-skeleton-icon" />
|
||||
<div className="usage-skeleton-name" />
|
||||
<div className="usage-skeleton-badge" />
|
||||
</div>
|
||||
<div className="usage-skeleton-bar" />
|
||||
<div className="usage-skeleton-text" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UsageIndicator({ isOpen, onClose }: UsageIndicatorProps) {
|
||||
const { providers, loading, error, lastUpdated, refresh } = useUsageData({
|
||||
autoRefresh: isOpen,
|
||||
});
|
||||
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
await refresh();
|
||||
setIsRefreshing(false);
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={handleOverlayClick} data-testid="usage-modal-overlay">
|
||||
<div className="modal usage-modal" data-testid="usage-modal">
|
||||
<div className="modal-header">
|
||||
<div className="usage-header">
|
||||
<Activity size={18} className="usage-header-icon" />
|
||||
<h3>Usage</h3>
|
||||
</div>
|
||||
<button
|
||||
className="modal-close"
|
||||
onClick={onClose}
|
||||
aria-label="Close usage modal"
|
||||
data-testid="usage-modal-close"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="usage-content" ref={contentRef}>
|
||||
{loading && providers.length === 0 ? (
|
||||
<UsageSkeleton />
|
||||
) : error && providers.length === 0 ? (
|
||||
<div className="usage-error">
|
||||
<p>Failed to load usage data</p>
|
||||
<p className="usage-error-message">{error}</p>
|
||||
<button className="btn btn-sm" onClick={handleRefresh}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : providers.length === 0 ? (
|
||||
<div className="usage-empty">
|
||||
<p>No AI providers configured</p>
|
||||
<p className="usage-empty-hint">
|
||||
Configure authentication in Settings to see usage data.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="usage-providers">
|
||||
{providers.map((provider) => (
|
||||
<ProviderCard key={provider.name} provider={provider} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-actions usage-actions">
|
||||
<div className="usage-last-updated">
|
||||
{lastUpdated && (
|
||||
<span>Last updated: {lastUpdated.toLocaleTimeString()}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading || isRefreshing}
|
||||
data-testid="usage-refresh-btn"
|
||||
>
|
||||
<RefreshCw size={14} className={isRefreshing ? "spin" : ""} />
|
||||
Refresh
|
||||
</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
packages/dashboard/app/hooks/useUsageData.test.ts
Normal file
108
packages/dashboard/app/hooks/useUsageData.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { useUsageData } from "./useUsageData";
|
||||
import * as api from "../api";
|
||||
|
||||
describe("useUsageData", () => {
|
||||
const mockFetchUsageData = vi.spyOn(api, "fetchUsageData");
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetchUsageData.mockClear();
|
||||
});
|
||||
|
||||
it("fetches data on initial mount", async () => {
|
||||
const mockData = {
|
||||
providers: [
|
||||
{
|
||||
name: "Claude",
|
||||
icon: "🟠",
|
||||
status: "ok" as const,
|
||||
windows: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
mockFetchUsageData.mockResolvedValue(mockData);
|
||||
|
||||
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
||||
|
||||
// Should be loading initially
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.providers).toEqual([]);
|
||||
|
||||
// Wait for data to load
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.providers).toEqual(mockData.providers);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.lastUpdated).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("handles fetch errors", async () => {
|
||||
mockFetchUsageData.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.error).toBe("Network error");
|
||||
expect(result.current.providers).toEqual([]);
|
||||
});
|
||||
|
||||
it("manual refresh fetches new data", async () => {
|
||||
const mockData1 = {
|
||||
providers: [{ name: "Claude", icon: "🟠", status: "ok" as const, windows: [] }],
|
||||
};
|
||||
const mockData2 = {
|
||||
providers: [{ name: "Codex", icon: "🟢", status: "ok" as const, windows: [] }],
|
||||
};
|
||||
|
||||
mockFetchUsageData
|
||||
.mockResolvedValueOnce(mockData1)
|
||||
.mockResolvedValueOnce(mockData2);
|
||||
|
||||
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.providers).toEqual(mockData1.providers);
|
||||
|
||||
// Manual refresh
|
||||
await result.current.refresh();
|
||||
|
||||
await waitFor(() => expect(result.current.providers).toEqual(mockData2.providers));
|
||||
});
|
||||
|
||||
it("clears error on successful manual refresh after error", async () => {
|
||||
mockFetchUsageData
|
||||
.mockRejectedValueOnce(new Error("Network error"))
|
||||
.mockResolvedValueOnce({
|
||||
providers: [{ name: "Claude", icon: "🟠", status: "ok" as const, windows: [] }],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.error).toBe("Network error");
|
||||
|
||||
// Manual refresh
|
||||
await result.current.refresh();
|
||||
|
||||
await waitFor(() => expect(result.current.error).toBeNull());
|
||||
expect(result.current.providers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("exports the correct interface", () => {
|
||||
expect(typeof useUsageData).toBe("function");
|
||||
});
|
||||
|
||||
it("returns expected default values before first fetch", () => {
|
||||
mockFetchUsageData.mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||
|
||||
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
||||
|
||||
expect(result.current.providers).toEqual([]);
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.lastUpdated).toBeNull();
|
||||
expect(typeof result.current.refresh).toBe("function");
|
||||
});
|
||||
});
|
||||
116
packages/dashboard/app/hooks/useUsageData.ts
Normal file
116
packages/dashboard/app/hooks/useUsageData.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { fetchUsageData, type ProviderUsage } from "../api";
|
||||
|
||||
interface UsageDataState {
|
||||
providers: ProviderUsage[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
lastUpdated: Date | null;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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(),
|
||||
});
|
||||
} catch (err: any) {
|
||||
// Don't update state if the request was aborted
|
||||
if (err.name === "AbortError") return;
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: err.message || "Failed to fetch usage data",
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 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,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user