feat(KB-088): clear failed status when moving tasks and improve error display
- Fix moveTask to clear status/error/worktree/blockedBy when moving from in-progress to todo/triage - Add error message display in TaskCard for failed tasks with truncation - Add prominent error alert in TaskDetailModal for failed tasks - Refactor usage tracking: move from app/components to dashboard/src/ - Add usage.ts and usage.test.ts for centralized usage tracking - Remove UsageIndicator and useUsageData from app/ directory - Update styles.css for error display components - Update executor tests for error handling - Add changeset for patch release
This commit is contained in:
@@ -164,34 +164,6 @@ 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 */
|
||||
|
||||
@@ -412,6 +412,12 @@ export function TaskCard({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isFailed && task.error && (
|
||||
<div className="card-error" title={task.error}>
|
||||
<span className="card-error-icon">⚠</span>
|
||||
<span className="card-error-text">{task.error.length > 60 ? task.error.slice(0, 60) + "…" : task.error}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="card-title">
|
||||
{task.title || (task.description ? task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "") : task.id)}
|
||||
</div>
|
||||
|
||||
@@ -407,6 +407,15 @@ export function TaskDetailModal({
|
||||
Created {new Date(task.createdAt).toLocaleDateString()} · Updated{" "}
|
||||
{new Date(task.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
{task.status === "failed" && task.error && (
|
||||
<div className="detail-error-alert">
|
||||
<span className="detail-error-icon">⚠</span>
|
||||
<div className="detail-error-content">
|
||||
<div className="detail-error-title">Task Failed</div>
|
||||
<div className="detail-error-message">{task.error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="detail-tabs">
|
||||
<button
|
||||
className={`detail-tab${activeTab === "definition" ? " detail-tab-active" : ""}`}
|
||||
|
||||
@@ -1,464 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { UsageIndicator } from "./UsageIndicator";
|
||||
import * as useUsageDataModule from "../hooks/useUsageData";
|
||||
import type { ProviderUsage } from "../api";
|
||||
|
||||
// Mock the useUsageData hook
|
||||
vi.mock("../hooks/useUsageData", () => ({
|
||||
useUsageData: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUseUsageData = vi.mocked(useUsageDataModule.useUsageData);
|
||||
|
||||
describe("UsageIndicator", () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockRefresh = vi.fn();
|
||||
|
||||
const mockProviders: ProviderUsage[] = [
|
||||
{
|
||||
name: "Anthropic",
|
||||
icon: "🅰️",
|
||||
status: "ok",
|
||||
plan: "Pro",
|
||||
email: "user@example.com",
|
||||
windows: [
|
||||
{
|
||||
label: "Session (5h)",
|
||||
percentUsed: 45,
|
||||
percentLeft: 55,
|
||||
resetText: "resets in 2h 15m",
|
||||
resetMs: 8100000,
|
||||
},
|
||||
{
|
||||
label: "Weekly",
|
||||
percentUsed: 30,
|
||||
percentLeft: 70,
|
||||
resetText: "resets in 3d",
|
||||
resetMs: 259200000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "OpenAI",
|
||||
icon: "🤖",
|
||||
status: "ok",
|
||||
windows: [
|
||||
{
|
||||
label: "Hourly",
|
||||
percentUsed: 75,
|
||||
percentLeft: 25,
|
||||
resetText: "resets in 45m",
|
||||
resetMs: 2700000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Google",
|
||||
icon: "🔍",
|
||||
status: "no-auth",
|
||||
windows: [],
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders nothing when isOpen is false", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: null,
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<UsageIndicator isOpen={false} onClose={mockOnClose} />
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders loading skeleton when loading and no providers", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
lastUpdated: null,
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
// Check for skeleton elements
|
||||
const skeletonElements = document.querySelectorAll(".usage-skeleton");
|
||||
expect(skeletonElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders providers with usage data", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
// Check for provider names
|
||||
expect(screen.getByText("Anthropic")).toBeInTheDocument();
|
||||
expect(screen.getByText("OpenAI")).toBeInTheDocument();
|
||||
expect(screen.getByText("Google")).toBeInTheDocument();
|
||||
|
||||
// Check for status badges
|
||||
expect(screen.getByText("Connected")).toBeInTheDocument();
|
||||
expect(screen.getByText("Not configured")).toBeInTheDocument();
|
||||
|
||||
// Check for usage windows
|
||||
expect(screen.getByText("Session (5h)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Weekly")).toBeInTheDocument();
|
||||
expect(screen.getByText("Hourly")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays correct percentage and progress bars", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
// Check for percentage text
|
||||
expect(screen.getByText("45% used")).toBeInTheDocument();
|
||||
expect(screen.getByText("55% left")).toBeInTheDocument();
|
||||
expect(screen.getByText("75% used")).toBeInTheDocument();
|
||||
|
||||
// Check progress bars have correct widths
|
||||
const progressBars = document.querySelectorAll(".usage-progress-fill");
|
||||
expect(progressBars.length).toBe(3);
|
||||
|
||||
// Check the width style for the first progress bar (45%)
|
||||
const firstBar = progressBars[0] as HTMLElement;
|
||||
expect(firstBar.style.width).toBe("45%");
|
||||
});
|
||||
|
||||
it("applies correct color classes for usage levels", () => {
|
||||
const providersWithDifferentUsage: ProviderUsage[] = [
|
||||
{
|
||||
name: "LowUsage",
|
||||
icon: "✅",
|
||||
status: "ok",
|
||||
windows: [
|
||||
{ label: "Low", percentUsed: 50, percentLeft: 50, resetText: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "MediumUsage",
|
||||
icon: "⚠️",
|
||||
status: "ok",
|
||||
windows: [
|
||||
{ label: "Medium", percentUsed: 80, percentLeft: 20, resetText: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "HighUsage",
|
||||
icon: "🚨",
|
||||
status: "ok",
|
||||
windows: [
|
||||
{ label: "High", percentUsed: 95, percentLeft: 5, resetText: null },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: providersWithDifferentUsage,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const progressBars = document.querySelectorAll(".usage-progress-fill");
|
||||
expect(progressBars.length).toBe(3);
|
||||
|
||||
// Check color classes
|
||||
expect(progressBars[0]).toHaveClass("usage-progress-fill--low");
|
||||
expect(progressBars[1]).toHaveClass("usage-progress-fill--medium");
|
||||
expect(progressBars[2]).toHaveClass("usage-progress-fill--high");
|
||||
});
|
||||
|
||||
it("displays error state when error occurs and no providers", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: [],
|
||||
loading: false,
|
||||
error: "Failed to fetch",
|
||||
lastUpdated: null,
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByText("Failed to load usage data")).toBeInTheDocument();
|
||||
expect(screen.getByText("Failed to fetch")).toBeInTheDocument();
|
||||
expect(screen.getByText("Retry")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays empty state when no providers configured", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: null,
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByText("No AI providers configured")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Configure authentication in Settings to see usage data.")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls refresh when refresh button clicked", async () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const refreshBtn = screen.getByTestId("usage-refresh-btn");
|
||||
fireEvent.click(refreshBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onClose when close button clicked", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const closeBtn = screen.getByTestId("usage-modal-close");
|
||||
fireEvent.click(closeBtn);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onClose when overlay clicked", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const overlay = screen.getByTestId("usage-modal-overlay");
|
||||
fireEvent.click(overlay);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onClose when Escape key pressed", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("displays last updated time", () => {
|
||||
const lastUpdated = new Date("2024-01-15T10:30:00");
|
||||
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated,
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByText(/Last updated:/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays provider error messages", () => {
|
||||
const providersWithError: ProviderUsage[] = [
|
||||
{
|
||||
name: "ErrorProvider",
|
||||
icon: "❌",
|
||||
status: "error",
|
||||
error: "Authentication expired",
|
||||
windows: [],
|
||||
},
|
||||
];
|
||||
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: providersWithError,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByText("Error")).toBeInTheDocument();
|
||||
expect(screen.getByText("Authentication expired")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays provider plan and email info", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByText("Pro")).toBeInTheDocument();
|
||||
expect(screen.getByText("user@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays reset timer text", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByText("resets in 2h 15m")).toBeInTheDocument();
|
||||
expect(screen.getByText("resets in 3d")).toBeInTheDocument();
|
||||
expect(screen.getByText("resets in 45m")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays 'no usage data' message for connected provider without windows", () => {
|
||||
const providerWithoutWindows: ProviderUsage[] = [
|
||||
{
|
||||
name: "EmptyProvider",
|
||||
icon: "📊",
|
||||
status: "ok",
|
||||
windows: [],
|
||||
},
|
||||
];
|
||||
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: providerWithoutWindows,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByText("No usage data available")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes autoRefresh option based on isOpen prop", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: null,
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
// When isOpen is true, autoRefresh should be true
|
||||
const { unmount } = render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(mockUseUsageData).toHaveBeenCalledWith({ autoRefresh: true });
|
||||
|
||||
unmount();
|
||||
|
||||
// Reset mock
|
||||
mockUseUsageData.mockClear();
|
||||
|
||||
// Component not rendered when isOpen is false, so this is the important case
|
||||
render(<UsageIndicator isOpen={false} onClose={mockOnClose} />);
|
||||
|
||||
// When isOpen is false, the hook should not be called at all
|
||||
// because the component returns null before the hook
|
||||
expect(mockUseUsageData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables refresh button when loading", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
loading: true,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const refreshBtn = screen.getByTestId("usage-refresh-btn");
|
||||
expect(refreshBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it("renders with correct ARIA attributes", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
// Check for progressbar role
|
||||
const progressBars = screen.getAllByRole("progressbar");
|
||||
expect(progressBars.length).toBeGreaterThan(0);
|
||||
|
||||
// Check first progressbar has correct aria attributes
|
||||
const firstBar = progressBars[0];
|
||||
expect(firstBar).toHaveAttribute("aria-valuenow", "45");
|
||||
expect(firstBar).toHaveAttribute("aria-valuemin", "0");
|
||||
expect(firstBar).toHaveAttribute("aria-valuemax", "100");
|
||||
expect(firstBar).toHaveAttribute("aria-label");
|
||||
});
|
||||
});
|
||||
@@ -1,293 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color class for usage percentage
|
||||
* - >90%: high (red/error color)
|
||||
* - >70%: medium (yellow/triage color)
|
||||
* - <=70%: low (green/success color)
|
||||
*/
|
||||
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";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format milliseconds to human-readable string
|
||||
* e.g., "2h 15m", "45m", "30s"
|
||||
*/
|
||||
function formatTimeRemaining(ms: number): string {
|
||||
if (ms <= 0) return "resetting...";
|
||||
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) {
|
||||
const remainingHours = hours % 24;
|
||||
if (remainingHours > 0) {
|
||||
return `${days}d ${remainingHours}h`;
|
||||
}
|
||||
return `${days}d`;
|
||||
}
|
||||
|
||||
if (hours > 0) {
|
||||
const remainingMinutes = minutes % 60;
|
||||
if (remainingMinutes > 0) {
|
||||
return `${hours}h ${remainingMinutes}m`;
|
||||
}
|
||||
return `${hours}h`;
|
||||
}
|
||||
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single usage window row with progress bar
|
||||
*/
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider card showing status and usage windows
|
||||
*/
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading skeleton for usage providers
|
||||
*/
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage Indicator Modal
|
||||
*
|
||||
* Displays AI provider subscription usage across multiple providers.
|
||||
* Shows hourly and weekly usage windows with percentage bars,
|
||||
* reset timers, and pace indicators.
|
||||
*/
|
||||
export function UsageIndicator({ isOpen, onClose }: UsageIndicatorProps) {
|
||||
const { providers, loading, error, lastUpdated, refresh } = useUsageData({
|
||||
autoRefresh: isOpen, // Only poll when modal is open
|
||||
});
|
||||
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle manual refresh
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
await refresh();
|
||||
setIsRefreshing(false);
|
||||
}, [refresh]);
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Close on overlay click
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -178,123 +178,6 @@ describe("TaskCard failed status", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCard error display", () => {
|
||||
const noopToast = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "KB-099",
|
||||
description: "Test task",
|
||||
column: "in-progress" as Column,
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("renders error message when task has failed status and error field", () => {
|
||||
const task = makeTask({
|
||||
status: "failed",
|
||||
error: "Build failed: cannot find module",
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const errorElement = screen.getByText("Build failed: cannot find module");
|
||||
expect(errorElement).toBeDefined();
|
||||
});
|
||||
|
||||
it("truncates long error messages to 60 characters", () => {
|
||||
const longError = "This is a very long error message that should be truncated because it exceeds sixty characters";
|
||||
const task = makeTask({
|
||||
status: "failed",
|
||||
error: longError,
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show truncated text with ellipsis
|
||||
const truncatedText = "This is a very long error message that should be truncat…";
|
||||
const errorElement = screen.getByText(truncatedText);
|
||||
expect(errorElement).toBeDefined();
|
||||
});
|
||||
|
||||
it("does NOT render error section when task is not failed", () => {
|
||||
const task = makeTask({
|
||||
status: "executing",
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const errorSection = document.querySelector(".card-error");
|
||||
expect(errorSection).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT render error section when task is failed but has no error message", () => {
|
||||
const task = makeTask({
|
||||
status: "failed",
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const errorSection = document.querySelector(".card-error");
|
||||
expect(errorSection).toBeNull();
|
||||
});
|
||||
|
||||
it("error section has tooltip with full error message", () => {
|
||||
const errorMessage = "Full error message for tooltip";
|
||||
const task = makeTask({
|
||||
status: "failed",
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const errorSection = document.querySelector(".card-error");
|
||||
expect(errorSection).toBeDefined();
|
||||
expect(errorSection?.getAttribute("title")).toBe(errorMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCard dependency tooltip", () => {
|
||||
/** Mirrors the data-tooltip computation from TaskCard.tsx */
|
||||
function computeDepTooltip(dependencies: string[]): string | undefined {
|
||||
|
||||
@@ -165,105 +165,6 @@ describe("TaskDetailModal", () => {
|
||||
expect(screen.queryByText("Retry")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders Retry button for failed tasks in any column (including done)", () => {
|
||||
const columns: Column[] = ["triage", "todo", "in-progress", "in-review", "done"];
|
||||
|
||||
for (const column of columns) {
|
||||
const { unmount } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ status: "failed", column })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
onRetryTask={noopRetry}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Retry")).toBeTruthy();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders error alert when task has failed status and error field", () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ status: "failed", error: "Build failed: cannot find module" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const errorAlert = container.querySelector(".detail-error-alert");
|
||||
expect(errorAlert).toBeTruthy();
|
||||
expect(screen.getByText("Task Failed")).toBeTruthy();
|
||||
expect(screen.getByText("Build failed: cannot find module")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT render error alert when task is not failed", () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ status: "executing" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const errorAlert = container.querySelector(".detail-error-alert");
|
||||
expect(errorAlert).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT render error alert when task is failed but has no error message", () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ status: "failed", error: undefined })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const errorAlert = container.querySelector(".detail-error-alert");
|
||||
expect(errorAlert).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onRetryTask when Retry button is clicked", async () => {
|
||||
const mockRetry = vi.fn().mockResolvedValue({});
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ status: "failed", column: "done" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
onRetryTask={mockRetry}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const retryButton = screen.getByText("Retry");
|
||||
fireEvent.click(retryButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRetry).toHaveBeenCalledWith("KB-099");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows description exactly once for a task without title", () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -512,6 +512,54 @@ body {
|
||||
border-left: 3px solid #da3633;
|
||||
}
|
||||
|
||||
/* Error display on failed task cards */
|
||||
.card-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin: 6px 0;
|
||||
padding: 6px 8px;
|
||||
background: rgba(218, 54, 51, 0.1);
|
||||
border: 1px solid rgba(218, 54, 51, 0.3);
|
||||
border-radius: var(--radius);
|
||||
font-size: 11px;
|
||||
color: #da3633;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.card-error-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-error-text {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Error display on failed task cards */
|
||||
.card-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin: 6px 0;
|
||||
padding: 6px 8px;
|
||||
background: rgba(218, 54, 51, 0.1);
|
||||
border: 1px solid rgba(218, 54, 51, 0.3);
|
||||
border-radius: var(--radius);
|
||||
font-size: 11px;
|
||||
color: #da3633;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.card-error-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-error-text {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.card.paused {
|
||||
opacity: 0.55;
|
||||
border-left: 3px solid var(--text-secondary, #888);
|
||||
@@ -1080,6 +1128,43 @@ body {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Error alert in task detail modal */
|
||||
.detail-error-alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin: 12px 0 16px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(218, 54, 51, 0.1);
|
||||
border: 1px solid rgba(218, 54, 51, 0.3);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.detail-error-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.detail-error-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-error-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #da3633;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.detail-error-message {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { ServerOptions } from "./server.js";
|
||||
import { GitHubClient, getCurrentGitHubRepo } from "./github.js";
|
||||
import { terminalSessionManager } from "./terminal.js";
|
||||
import { listFiles, readFile, writeFile, FileServiceError, type FileListResponse, type FileContentResponse, type SaveFileResponse } from "./file-service.js";
|
||||
import { fetchAllProviderUsage } from "./usage.js";
|
||||
|
||||
/**
|
||||
* Minimal interface matching pi-coding-agent's ModelRegistry API surface
|
||||
@@ -2043,6 +2044,23 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/usage
|
||||
* Fetch AI provider subscription usage (Claude, Codex, Gemini).
|
||||
* Returns: { providers: ProviderUsage[] }
|
||||
*
|
||||
* Cached for 30 seconds to avoid hitting provider API rate limits.
|
||||
* Each provider's status is independent — one failure doesn't break all.
|
||||
*/
|
||||
router.get("/usage", async (_req, res) => {
|
||||
try {
|
||||
const providers = await fetchAllProviderUsage();
|
||||
res.json({ providers });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || "Failed to fetch usage data" });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
524
packages/dashboard/src/usage.test.ts
Normal file
524
packages/dashboard/src/usage.test.ts
Normal file
@@ -0,0 +1,524 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
fetchAllProviderUsage,
|
||||
clearUsageCache,
|
||||
ProviderUsage,
|
||||
} from "./usage.js";
|
||||
|
||||
// Mock the https module
|
||||
const mockRequest = vi.fn();
|
||||
vi.mock("node:https", () => ({
|
||||
request: (...args: any[]) => mockRequest(...args),
|
||||
}));
|
||||
|
||||
// Mock fs
|
||||
const mockReadFileSync = vi.fn();
|
||||
vi.mock("node:fs", () => ({
|
||||
readFileSync: (...args: any[]) => mockReadFileSync(...args),
|
||||
}));
|
||||
|
||||
describe("usage", () => {
|
||||
beforeEach(() => {
|
||||
clearUsageCache();
|
||||
mockRequest.mockClear();
|
||||
mockReadFileSync.mockClear();
|
||||
vi.stubEnv("HOME", "/home/testuser");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("fetchAllProviderUsage", () => {
|
||||
it("returns providers array even when all are not authenticated", async () => {
|
||||
// All credential files don't exist
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
throw new Error("File not found");
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
|
||||
expect(providers).toHaveLength(3);
|
||||
expect(providers.map((p) => p.name)).toContain("Claude");
|
||||
expect(providers.map((p) => p.name)).toContain("Codex");
|
||||
expect(providers.map((p) => p.name)).toContain("Gemini");
|
||||
|
||||
// All should be no-auth status
|
||||
for (const p of providers) {
|
||||
expect(p.status).toBe("no-auth");
|
||||
expect(p.error).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns cached data within TTL", async () => {
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
throw new Error("File not found");
|
||||
});
|
||||
|
||||
const first = await fetchAllProviderUsage();
|
||||
const second = await fetchAllProviderUsage();
|
||||
|
||||
// Should be the same array reference due to caching
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it("fetches fresh data after cache expires", async () => {
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
throw new Error("File not found");
|
||||
});
|
||||
|
||||
const first = await fetchAllProviderUsage();
|
||||
|
||||
// Manually expire cache
|
||||
clearUsageCache();
|
||||
|
||||
const second = await fetchAllProviderUsage();
|
||||
|
||||
// Should be different array reference
|
||||
expect(second).not.toBe(first);
|
||||
expect(second).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude provider", () => {
|
||||
it("detects no auth when credentials file doesn't exist", async () => {
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
throw new Error("File not found");
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const claude = providers.find((p) => p.name === "Claude");
|
||||
|
||||
expect(claude).toBeDefined();
|
||||
expect(claude!.status).toBe("no-auth");
|
||||
expect(claude!.error).toContain("No Claude CLI credentials");
|
||||
});
|
||||
|
||||
it("detects missing scope error", async () => {
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("claude")) {
|
||||
return JSON.stringify({
|
||||
accessToken: "test-token",
|
||||
scopes: ["other:scope"], // missing user:profile
|
||||
});
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const claude = providers.find((p) => p.name === "Claude");
|
||||
|
||||
expect(claude!.status).toBe("no-auth");
|
||||
expect(claude!.error).toContain("user:profile scope");
|
||||
});
|
||||
|
||||
it("parses usage data from API response", async () => {
|
||||
const mockResponse = {
|
||||
five_hour: {
|
||||
utilization: 45.5,
|
||||
resets_at: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(), // 2 hours
|
||||
},
|
||||
seven_day: {
|
||||
utilization: 23.0,
|
||||
resets_at: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(), // 5 days
|
||||
},
|
||||
};
|
||||
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("claude")) {
|
||||
return JSON.stringify({
|
||||
accessToken: "test-token",
|
||||
scopes: ["user:profile"],
|
||||
subscriptionType: "pro",
|
||||
});
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
|
||||
// Mock https request
|
||||
const mockReq = {
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
const mockRes = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") {
|
||||
handler(Buffer.from(JSON.stringify(mockResponse)));
|
||||
}
|
||||
if (event === "end") {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(claude.status).toBe("ok");
|
||||
expect(claude.plan).toBe("Pro");
|
||||
expect(claude.windows).toHaveLength(2);
|
||||
|
||||
const sessionWindow = claude.windows.find((w) => w.label.includes("Session"));
|
||||
expect(sessionWindow).toBeDefined();
|
||||
expect(sessionWindow!.percentUsed).toBe(45.5);
|
||||
expect(sessionWindow!.percentLeft).toBe(54.5);
|
||||
expect(sessionWindow!.resetText).toContain("resets in");
|
||||
});
|
||||
|
||||
it("handles 401 auth error", async () => {
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("claude")) {
|
||||
return JSON.stringify({
|
||||
accessToken: "expired-token",
|
||||
scopes: ["user:profile"],
|
||||
});
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
|
||||
const mockReq = {
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
const mockRes = {
|
||||
statusCode: 401,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") {
|
||||
handler(Buffer.from('{"error": "unauthorized"}'));
|
||||
}
|
||||
if (event === "end") {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(claude.status).toBe("error");
|
||||
expect(claude.error).toContain("Auth expired");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codex provider", () => {
|
||||
it("detects no auth when auth.json doesn't exist", async () => {
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
throw new Error("File not found");
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const codex = providers.find((p) => p.name === "Codex");
|
||||
|
||||
expect(codex).toBeDefined();
|
||||
expect(codex!.status).toBe("no-auth");
|
||||
expect(codex!.error).toContain("No Codex credentials");
|
||||
});
|
||||
|
||||
it("parses usage data from API response", async () => {
|
||||
const mockResponse = {
|
||||
email: "test@example.com",
|
||||
plan_type: "pro",
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 67.5,
|
||||
limit_window_seconds: 5 * 60 * 60, // 5 hours
|
||||
reset_after_seconds: 2 * 60 * 60, // 2 hours
|
||||
},
|
||||
secondary_window: {
|
||||
used_percent: 12.0,
|
||||
limit_window_seconds: 7 * 24 * 60 * 60, // 7 days
|
||||
reset_after_seconds: 5 * 24 * 60 * 60, // 5 days
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("codex")) {
|
||||
return JSON.stringify({
|
||||
tokens: {
|
||||
access_token: "test-token",
|
||||
id_token: "header.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature",
|
||||
},
|
||||
});
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
|
||||
const mockReq = {
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
const mockRes = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") {
|
||||
handler(Buffer.from(JSON.stringify(mockResponse)));
|
||||
}
|
||||
if (event === "end") {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const codex = providers.find((p) => p.name === "Codex")!;
|
||||
|
||||
expect(codex.status).toBe("ok");
|
||||
expect(codex.email).toBe("test@example.com");
|
||||
expect(codex.plan).toBe("Pro");
|
||||
expect(codex.windows).toHaveLength(2);
|
||||
|
||||
const sessionWindow = codex.windows.find((w) => w.label.includes("Session"));
|
||||
expect(sessionWindow).toBeDefined();
|
||||
expect(sessionWindow!.percentUsed).toBe(67.5);
|
||||
expect(sessionWindow!.percentLeft).toBe(32.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Gemini provider", () => {
|
||||
it("detects no auth when oauth_creds.json doesn't exist", async () => {
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
throw new Error("File not found");
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const gemini = providers.find((p) => p.name === "Gemini");
|
||||
|
||||
expect(gemini).toBeDefined();
|
||||
expect(gemini!.status).toBe("no-auth");
|
||||
expect(gemini!.error).toContain("No Gemini credentials");
|
||||
});
|
||||
|
||||
it("parses usage buckets from API response", async () => {
|
||||
const mockResponse = {
|
||||
buckets: [
|
||||
{
|
||||
modelId: "gemini-2.0-flash",
|
||||
remainingFraction: 0.85,
|
||||
resetTime: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
{
|
||||
modelId: "gemini-2.0-pro",
|
||||
remainingFraction: 0.92,
|
||||
resetTime: new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("gemini")) {
|
||||
if (path.includes("oauth_creds")) {
|
||||
return JSON.stringify({
|
||||
access_token: "test-token",
|
||||
id_token: "header.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature",
|
||||
});
|
||||
}
|
||||
// settings.json doesn't exist (oauth-personal is default)
|
||||
throw new Error("File not found");
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
|
||||
const mockReq = {
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
const mockRes = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") {
|
||||
handler(Buffer.from(JSON.stringify(mockResponse)));
|
||||
}
|
||||
if (event === "end") {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const gemini = providers.find((p) => p.name === "Gemini")!;
|
||||
|
||||
expect(gemini.status).toBe("ok");
|
||||
expect(gemini.email).toBe("test@example.com");
|
||||
expect(gemini.windows).toHaveLength(2);
|
||||
|
||||
const flashWindow = gemini.windows.find((w) => w.label.includes("Flash"));
|
||||
expect(flashWindow).toBeDefined();
|
||||
expect(flashWindow!.percentUsed).toBe(15); // 100 - 85
|
||||
expect(flashWindow!.percentLeft).toBe(85);
|
||||
});
|
||||
|
||||
it("handles unsupported auth type (api-key)", async () => {
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("gemini")) {
|
||||
if (path.includes("oauth_creds")) {
|
||||
return JSON.stringify({
|
||||
access_token: "test-token",
|
||||
});
|
||||
}
|
||||
if (path.includes("settings")) {
|
||||
return JSON.stringify({
|
||||
security: {
|
||||
auth: {
|
||||
selectedType: "api-key",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const gemini = providers.find((p) => p.name === "Gemini")!;
|
||||
|
||||
expect(gemini.status).toBe("error");
|
||||
expect(gemini.error).toContain("Unsupported auth type");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("handles network errors gracefully", async () => {
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("claude")) {
|
||||
return JSON.stringify({
|
||||
accessToken: "test-token",
|
||||
scopes: ["user:profile"],
|
||||
});
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
|
||||
mockRequest.mockImplementation(() => {
|
||||
const mockReq = {
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "error") {
|
||||
handler(new Error("Network error"));
|
||||
}
|
||||
}),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(claude.status).toBe("error");
|
||||
expect(claude.error).toContain("Network error");
|
||||
});
|
||||
|
||||
it("handles timeout errors gracefully", async () => {
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("claude")) {
|
||||
return JSON.stringify({
|
||||
accessToken: "test-token",
|
||||
scopes: ["user:profile"],
|
||||
});
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
|
||||
mockRequest.mockImplementation(() => {
|
||||
const mockReq = {
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "timeout") {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
};
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(claude.status).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDuration helper", () => {
|
||||
it("formats duration correctly via resetText", async () => {
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("codex")) {
|
||||
return JSON.stringify({
|
||||
tokens: {
|
||||
access_token: "test-token",
|
||||
},
|
||||
});
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 50,
|
||||
reset_after_seconds: 3661, // 1h 1m 1s
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockReq = {
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
const mockRes = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") {
|
||||
handler(Buffer.from(JSON.stringify(mockResponse)));
|
||||
}
|
||||
if (event === "end") {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const codex = providers.find((p) => p.name === "Codex")!;
|
||||
|
||||
expect(codex.windows[0].resetText).toContain("1h 1m");
|
||||
});
|
||||
});
|
||||
});
|
||||
527
packages/dashboard/src/usage.ts
Normal file
527
packages/dashboard/src/usage.ts
Normal file
@@ -0,0 +1,527 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as https from "node:https";
|
||||
|
||||
/**
|
||||
* 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[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth storage interface - minimal interface matching pi-coding-agent's AuthStorage
|
||||
*/
|
||||
export interface AuthStorageLike {
|
||||
reload(): void;
|
||||
hasAuth(provider: string): boolean;
|
||||
}
|
||||
|
||||
// Cache for usage data with TTL
|
||||
interface CacheEntry {
|
||||
data: ProviderUsage[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
let usageCache: CacheEntry | null = null;
|
||||
const CACHE_TTL_MS = 30_000; // 30 seconds
|
||||
|
||||
/**
|
||||
* Format duration in milliseconds to human-readable string
|
||||
*/
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms <= 0) return "now";
|
||||
const secs = Math.floor(ms / 1000);
|
||||
if (secs < 60) return `${secs}s`;
|
||||
const mins = Math.floor(secs / 60);
|
||||
const remSecs = secs % 60;
|
||||
if (mins < 60) return remSecs > 0 ? `${mins}m ${remSecs}s` : `${mins}m`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
const remMins = mins % 60;
|
||||
if (hours < 24) return remMins > 0 ? `${hours}h ${remMins}m` : `${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
const remHours = hours % 24;
|
||||
return remHours > 0 ? `${days}d ${remHours}h` : `${days}d`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make HTTPS request and return response
|
||||
*/
|
||||
function httpsRequest(
|
||||
url: string,
|
||||
options: {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
): Promise<{ status: number; headers: Record<string, string>; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const req = https.request(
|
||||
{
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || 443,
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: options.method || "GET",
|
||||
headers: options.headers || {},
|
||||
timeout: options.timeout || 15000,
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk) => chunks.push(chunk));
|
||||
res.on("end", () => {
|
||||
const hdrs: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(res.headers)) {
|
||||
if (typeof v === "string") hdrs[k.toLowerCase()] = v;
|
||||
else if (Array.isArray(v)) hdrs[k.toLowerCase()] = v.join(", ");
|
||||
}
|
||||
resolve({
|
||||
status: res.statusCode || 0,
|
||||
headers: hdrs,
|
||||
body: Buffer.concat(chunks).toString("utf-8"),
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error("Request timed out"));
|
||||
});
|
||||
if (options.body) req.write(options.body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode JWT payload without verification
|
||||
*/
|
||||
function decodeJwtPayload(token: string): any {
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length < 2) return null;
|
||||
const payload = Buffer.from(parts[1], "base64url").toString("utf-8");
|
||||
return JSON.parse(payload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Claude fetcher ─────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchClaudeUsage(): Promise<ProviderUsage> {
|
||||
const usage: ProviderUsage = {
|
||||
name: "Claude",
|
||||
icon: "🟠",
|
||||
status: "no-auth",
|
||||
windows: [],
|
||||
};
|
||||
|
||||
// Load Claude CLI credentials
|
||||
const credPaths = [
|
||||
path.join(process.env.HOME || "~", ".claude", ".credentials.json"),
|
||||
path.join(process.env.HOME || "~", ".config", "claude", ".credentials.json"),
|
||||
];
|
||||
|
||||
let creds: any = null;
|
||||
for (const p of credPaths) {
|
||||
try {
|
||||
creds = JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
break;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const oauthCreds = creds?.claudeAiOauth || creds;
|
||||
if (!oauthCreds?.accessToken) {
|
||||
usage.error = "No Claude CLI credentials — run 'claude' to login";
|
||||
return usage;
|
||||
}
|
||||
|
||||
// Check scopes
|
||||
const scopes: string[] = oauthCreds.scopes || [];
|
||||
if (!scopes.includes("user:profile")) {
|
||||
usage.error = "Claude CLI token missing user:profile scope";
|
||||
return usage;
|
||||
}
|
||||
|
||||
// Infer plan from rateLimitTier
|
||||
if (oauthCreds.subscriptionType) {
|
||||
usage.plan = oauthCreds.subscriptionType.charAt(0).toUpperCase() + oauthCreds.subscriptionType.slice(1);
|
||||
} else if (oauthCreds.rateLimitTier) {
|
||||
const tier = oauthCreds.rateLimitTier.toLowerCase();
|
||||
if (tier.includes("max")) usage.plan = "Max";
|
||||
else if (tier.includes("pro")) usage.plan = "Pro";
|
||||
else if (tier.includes("team")) usage.plan = "Team";
|
||||
else usage.plan = oauthCreds.rateLimitTier;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await httpsRequest("https://api.anthropic.com/api/oauth/usage", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
authorization: `Bearer ${oauthCreds.accessToken}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
usage.status = "error";
|
||||
usage.error = "Auth expired — run 'claude' to re-login";
|
||||
return usage;
|
||||
}
|
||||
|
||||
if (res.status === 429) {
|
||||
usage.status = "error";
|
||||
usage.error = "Rate limited — try again later";
|
||||
return usage;
|
||||
}
|
||||
|
||||
if (res.status !== 200) {
|
||||
usage.status = "error";
|
||||
usage.error = `HTTP ${res.status}`;
|
||||
return usage;
|
||||
}
|
||||
|
||||
const data = JSON.parse(res.body);
|
||||
usage.status = "ok";
|
||||
|
||||
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const parseWindow = (key: string, label: string, windowDurationMs: number): UsageWindow | null => {
|
||||
const w = data[key];
|
||||
if (!w || typeof w !== "object") return null;
|
||||
|
||||
const pctUsed: number = w.utilization ?? w.percent_used ?? w.percentUsed ?? 0;
|
||||
let resetText: string | null = null;
|
||||
let resetMs: number | undefined;
|
||||
|
||||
const resetAt = w.resets_at || w.reset_at || w.resetAt;
|
||||
if (resetAt) {
|
||||
const msLeft = new Date(resetAt).getTime() - Date.now();
|
||||
resetMs = msLeft > 0 ? msLeft : 0;
|
||||
resetText = msLeft > 0 ? `resets in ${formatDuration(msLeft)}` : "resetting now";
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
percentUsed: Math.min(100, Math.max(0, pctUsed)),
|
||||
percentLeft: Math.min(100, Math.max(0, 100 - pctUsed)),
|
||||
resetText,
|
||||
windowDurationMs,
|
||||
resetMs,
|
||||
};
|
||||
};
|
||||
|
||||
const fiveHour = parseWindow("five_hour", "Session (5h)", FIVE_HOURS_MS);
|
||||
const sevenDay = parseWindow("seven_day", "Weekly", SEVEN_DAYS_MS);
|
||||
const sonnet = parseWindow("seven_day_sonnet", "Weekly (Sonnet)", SEVEN_DAYS_MS);
|
||||
const opus = parseWindow("seven_day_opus", "Weekly (Opus)", SEVEN_DAYS_MS);
|
||||
|
||||
if (fiveHour) usage.windows.push(fiveHour);
|
||||
if (sevenDay) usage.windows.push(sevenDay);
|
||||
if (sonnet) usage.windows.push(sonnet);
|
||||
if (opus) usage.windows.push(opus);
|
||||
} catch (e: any) {
|
||||
usage.status = "error";
|
||||
usage.error = e.message || "Failed to fetch";
|
||||
}
|
||||
|
||||
return usage;
|
||||
}
|
||||
|
||||
// ── Codex fetcher ──────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchCodexUsage(): Promise<ProviderUsage> {
|
||||
const usage: ProviderUsage = {
|
||||
name: "Codex",
|
||||
icon: "🟢",
|
||||
status: "no-auth",
|
||||
windows: [],
|
||||
};
|
||||
|
||||
// Load Codex auth
|
||||
const codexHome = process.env.CODEX_HOME || path.join(process.env.HOME || "~", ".codex");
|
||||
const authPath = path.join(codexHome, "auth.json");
|
||||
|
||||
let auth: any = null;
|
||||
try {
|
||||
auth = JSON.parse(fs.readFileSync(authPath, "utf-8"));
|
||||
} catch {
|
||||
usage.error = "No Codex credentials — run 'codex' to login";
|
||||
return usage;
|
||||
}
|
||||
|
||||
const accessToken = auth?.tokens?.access_token;
|
||||
if (!accessToken) {
|
||||
usage.error = "No Codex access token found";
|
||||
return usage;
|
||||
}
|
||||
|
||||
// Extract plan and email from id_token
|
||||
if (auth?.tokens?.id_token) {
|
||||
const claims = decodeJwtPayload(auth.tokens.id_token);
|
||||
if (claims) {
|
||||
usage.email = claims.email || null;
|
||||
const openaiAuth = claims["https://api.openai.com/auth"];
|
||||
if (openaiAuth?.chatgpt_plan_type) {
|
||||
usage.plan = openaiAuth.chatgpt_plan_type.charAt(0).toUpperCase() + openaiAuth.chatgpt_plan_type.slice(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await httpsRequest("https://chatgpt.com/backend-api/wham/usage", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
usage.status = "error";
|
||||
usage.error = "Auth expired — run 'codex' to re-login";
|
||||
return usage;
|
||||
}
|
||||
|
||||
if (res.status !== 200) {
|
||||
usage.status = "error";
|
||||
usage.error = `HTTP ${res.status}: ${res.body.slice(0, 200)}`;
|
||||
return usage;
|
||||
}
|
||||
|
||||
const data = JSON.parse(res.body);
|
||||
usage.status = "ok";
|
||||
|
||||
// Override email/plan from response if available
|
||||
if (data.email) usage.email = data.email;
|
||||
if (data.plan_type) usage.plan = data.plan_type.charAt(0).toUpperCase() + data.plan_type.slice(1);
|
||||
|
||||
const parseWindow = (win: any, label: string): UsageWindow | null => {
|
||||
if (!win || typeof win !== "object") return null;
|
||||
const pctUsed: number = win.used_percent ?? 0;
|
||||
let resetText: string | null = null;
|
||||
let resetMs: number | undefined;
|
||||
const windowDurationMs: number | undefined = win.limit_window_seconds
|
||||
? win.limit_window_seconds * 1000
|
||||
: undefined;
|
||||
|
||||
if (win.reset_at) {
|
||||
const msLeft = win.reset_at * 1000 - Date.now();
|
||||
resetMs = msLeft > 0 ? msLeft : 0;
|
||||
resetText = msLeft > 0 ? `resets in ${formatDuration(msLeft)}` : "resetting now";
|
||||
} else if (win.reset_after_seconds) {
|
||||
resetMs = win.reset_after_seconds * 1000;
|
||||
resetText = `resets in ${formatDuration(resetMs)}`;
|
||||
}
|
||||
return {
|
||||
label,
|
||||
percentUsed: Math.min(100, Math.max(0, pctUsed)),
|
||||
percentLeft: Math.min(100, Math.max(0, 100 - pctUsed)),
|
||||
resetText,
|
||||
windowDurationMs,
|
||||
resetMs,
|
||||
};
|
||||
};
|
||||
|
||||
// Main rate limits
|
||||
if (data.rate_limit) {
|
||||
const primary = parseWindow(data.rate_limit.primary_window, "Session (5h)");
|
||||
const secondary = parseWindow(data.rate_limit.secondary_window, "Weekly");
|
||||
if (primary) usage.windows.push(primary);
|
||||
if (secondary) usage.windows.push(secondary);
|
||||
}
|
||||
} catch (e: any) {
|
||||
usage.status = "error";
|
||||
usage.error = e.message || "Failed to fetch";
|
||||
}
|
||||
|
||||
return usage;
|
||||
}
|
||||
|
||||
// ── Gemini fetcher ─────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchGeminiUsage(): Promise<ProviderUsage> {
|
||||
const usage: ProviderUsage = {
|
||||
name: "Gemini",
|
||||
icon: "🔵",
|
||||
status: "no-auth",
|
||||
windows: [],
|
||||
};
|
||||
|
||||
// Load Gemini OAuth credentials
|
||||
const oauthPath = path.join(process.env.HOME || "~", ".gemini", "oauth_creds.json");
|
||||
let oauthCreds: any = null;
|
||||
try {
|
||||
oauthCreds = JSON.parse(fs.readFileSync(oauthPath, "utf-8"));
|
||||
} catch {
|
||||
usage.error = "No Gemini credentials — run 'gemini' to login";
|
||||
return usage;
|
||||
}
|
||||
|
||||
if (!oauthCreds?.access_token) {
|
||||
usage.error = "No Gemini access token found";
|
||||
return usage;
|
||||
}
|
||||
|
||||
// Extract email from id_token
|
||||
if (oauthCreds.id_token) {
|
||||
const claims = decodeJwtPayload(oauthCreds.id_token);
|
||||
if (claims?.email) usage.email = claims.email;
|
||||
}
|
||||
|
||||
// Check auth type from settings
|
||||
const settingsPath = path.join(process.env.HOME || "~", ".gemini", "settings.json");
|
||||
try {
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
||||
const authType = settings?.security?.auth?.selectedType;
|
||||
if (authType === "api-key" || authType === "vertex-ai") {
|
||||
usage.status = "error";
|
||||
usage.error = `Unsupported auth type: ${authType} (need oauth-personal)`;
|
||||
return usage;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const res = await httpsRequest(
|
||||
"https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${oauthCreds.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
);
|
||||
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
usage.status = "error";
|
||||
usage.error = "Auth expired — run 'gemini' to re-login";
|
||||
return usage;
|
||||
}
|
||||
|
||||
if (res.status !== 200) {
|
||||
usage.status = "error";
|
||||
usage.error = `HTTP ${res.status}: ${res.body.slice(0, 200)}`;
|
||||
return usage;
|
||||
}
|
||||
|
||||
const data = JSON.parse(res.body);
|
||||
usage.status = "ok";
|
||||
|
||||
// Parse buckets array
|
||||
const buckets: any[] = data.buckets || [];
|
||||
if (Array.isArray(buckets) && buckets.length > 0) {
|
||||
// Group by model family, pick lowest remainingFraction per family
|
||||
const modelGroups = new Map<string, { pctLeft: number; resetText: string | null; models: string[] }>();
|
||||
|
||||
for (const b of buckets) {
|
||||
const modelId: string = b.modelId || "unknown";
|
||||
const remainFrac: number = b.remainingFraction ?? 1;
|
||||
const pctLeft = remainFrac * 100;
|
||||
|
||||
let resetText: string | null = null;
|
||||
if (b.resetTime) {
|
||||
const resetMs = new Date(b.resetTime).getTime() - Date.now();
|
||||
resetText = resetMs > 0 ? `resets in ${formatDuration(resetMs)}` : "resetting now";
|
||||
}
|
||||
|
||||
// Skip _vertex duplicates, classify by family
|
||||
if (modelId.endsWith("_vertex")) continue;
|
||||
|
||||
let family: string;
|
||||
if (modelId.includes("pro")) family = "Pro models";
|
||||
else if (modelId.includes("flash-lite")) family = "Flash Lite";
|
||||
else if (modelId.includes("flash")) family = "Flash models";
|
||||
else family = modelId;
|
||||
|
||||
const existing = modelGroups.get(family);
|
||||
if (!existing || pctLeft < existing.pctLeft) {
|
||||
modelGroups.set(family, {
|
||||
pctLeft,
|
||||
resetText,
|
||||
models: existing ? [...existing.models, modelId] : [modelId],
|
||||
});
|
||||
} else {
|
||||
existing.models.push(modelId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [family, info] of modelGroups) {
|
||||
usage.windows.push({
|
||||
label: family,
|
||||
percentUsed: Math.min(100, Math.max(0, 100 - info.pctLeft)),
|
||||
percentLeft: Math.min(100, Math.max(0, info.pctLeft)),
|
||||
resetText: info.resetText,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
usage.status = "error";
|
||||
usage.error = e.message || "Failed to fetch";
|
||||
}
|
||||
|
||||
return usage;
|
||||
}
|
||||
|
||||
// ── Main export ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch usage data from all configured providers with caching.
|
||||
* Results are cached for 30 seconds to avoid hitting provider API rate limits.
|
||||
*/
|
||||
export async function fetchAllProviderUsage(_authStorage?: AuthStorageLike): Promise<ProviderUsage[]> {
|
||||
// Check cache
|
||||
if (usageCache && Date.now() - usageCache.timestamp < CACHE_TTL_MS) {
|
||||
return usageCache.data;
|
||||
}
|
||||
|
||||
// Fetch all providers in parallel
|
||||
const results = await Promise.allSettled([
|
||||
fetchClaudeUsage(),
|
||||
fetchCodexUsage(),
|
||||
fetchGeminiUsage(),
|
||||
]);
|
||||
|
||||
const providers: ProviderUsage[] = [];
|
||||
for (const r of results) {
|
||||
if (r.status === "fulfilled") {
|
||||
providers.push(r.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache
|
||||
usageCache = {
|
||||
data: providers,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the usage cache (useful for testing or manual refresh)
|
||||
*/
|
||||
export function clearUsageCache(): void {
|
||||
usageCache = null;
|
||||
}
|
||||
Reference in New Issue
Block a user