feat(KB-039): complete Step 2 — frontend API client and hook for usage data
This commit is contained in:
9
.changeset/fix-failed-status-clear-on-move.md
Normal file
9
.changeset/fix-failed-status-clear-on-move.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
"@dustinbyrne/kb": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix failed status badge persisting when moving tasks from in-progress to todo/triage
|
||||||
|
|
||||||
|
Previously, when a failed task was moved from "in-progress" back to "todo" or "triage" for retry, the `status: "failed"` and `error` fields were not cleared. This caused the dashboard to continue showing the red "failed" badge even though the task was ready for re-execution.
|
||||||
|
|
||||||
|
The `moveTask` function now clears `status`, `error`, `worktree`, and `blockedBy` fields when moving from "in-progress" to "todo" or "triage", consistent with the existing behavior when moving to "done".
|
||||||
@@ -1283,6 +1283,97 @@ describe("TaskStore", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("moveTask — clears transient fields when leaving in-progress", () => {
|
||||||
|
it("clears status, error, worktree, and blockedBy when moving from in-progress to todo", async () => {
|
||||||
|
const task = await store.createTask({ description: "test clear fields" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
|
||||||
|
// Simulate a failed state
|
||||||
|
await store.updateTask(task.id, {
|
||||||
|
status: "failed",
|
||||||
|
error: "Something went wrong",
|
||||||
|
worktree: "test-worktree",
|
||||||
|
blockedBy: "KB-001"
|
||||||
|
});
|
||||||
|
|
||||||
|
const moved = await store.moveTask(task.id, "todo");
|
||||||
|
expect(moved.column).toBe("todo");
|
||||||
|
expect(moved.status).toBeUndefined();
|
||||||
|
expect(moved.error).toBeUndefined();
|
||||||
|
expect(moved.worktree).toBeUndefined();
|
||||||
|
expect(moved.blockedBy).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears status, error, worktree, and blockedBy when moving from in-progress to triage", async () => {
|
||||||
|
const task = await store.createTask({ description: "test clear fields to triage" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
|
||||||
|
// Simulate a failed state
|
||||||
|
await store.updateTask(task.id, {
|
||||||
|
status: "failed",
|
||||||
|
error: "Something went wrong",
|
||||||
|
worktree: "test-worktree",
|
||||||
|
blockedBy: "KB-001"
|
||||||
|
});
|
||||||
|
|
||||||
|
const moved = await store.moveTask(task.id, "triage");
|
||||||
|
expect(moved.column).toBe("triage");
|
||||||
|
expect(moved.status).toBeUndefined();
|
||||||
|
expect(moved.error).toBeUndefined();
|
||||||
|
expect(moved.worktree).toBeUndefined();
|
||||||
|
expect(moved.blockedBy).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves status when moving from todo to in-progress", async () => {
|
||||||
|
const task = await store.createTask({ description: "test preserve status", column: "todo" });
|
||||||
|
|
||||||
|
// Set a custom status before moving to in-progress
|
||||||
|
await store.updateTask(task.id, { status: "planning" });
|
||||||
|
|
||||||
|
const moved = await store.moveTask(task.id, "in-progress");
|
||||||
|
expect(moved.column).toBe("in-progress");
|
||||||
|
expect(moved.status).toBe("planning");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not clear status when moving between non-in-progress columns", async () => {
|
||||||
|
const task = await store.createTask({ description: "test non-in-progress move" });
|
||||||
|
// Task starts in triage
|
||||||
|
|
||||||
|
// Set a custom status
|
||||||
|
await store.updateTask(task.id, { status: "custom-status" });
|
||||||
|
|
||||||
|
// Move from triage to todo
|
||||||
|
const moved = await store.moveTask(task.id, "todo");
|
||||||
|
expect(moved.column).toBe("todo");
|
||||||
|
expect(moved.status).toBe("custom-status");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears status, error, worktree, and blockedBy when moving from in-progress to done", async () => {
|
||||||
|
const task = await store.createTask({ description: "test clear fields to done" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
|
||||||
|
// Simulate a failed state
|
||||||
|
await store.updateTask(task.id, {
|
||||||
|
status: "failed",
|
||||||
|
error: "Something went wrong",
|
||||||
|
worktree: "test-worktree",
|
||||||
|
blockedBy: "KB-001"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Must go through in-review to reach done
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
const moved = await store.moveTask(task.id, "done");
|
||||||
|
expect(moved.column).toBe("done");
|
||||||
|
expect(moved.status).toBeUndefined();
|
||||||
|
expect(moved.error).toBeUndefined();
|
||||||
|
expect(moved.worktree).toBeUndefined();
|
||||||
|
expect(moved.blockedBy).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("columnMovedAt", () => {
|
describe("columnMovedAt", () => {
|
||||||
it("createTask sets columnMovedAt", async () => {
|
it("createTask sets columnMovedAt", async () => {
|
||||||
const before = new Date().toISOString();
|
const before = new Date().toISOString();
|
||||||
|
|||||||
@@ -403,6 +403,16 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
// Clear transient fields when moving to done (matches moveToDone behavior)
|
// Clear transient fields when moving to done (matches moveToDone behavior)
|
||||||
if (toColumn === "done") {
|
if (toColumn === "done") {
|
||||||
task.status = undefined;
|
task.status = undefined;
|
||||||
|
task.error = undefined;
|
||||||
|
task.worktree = undefined;
|
||||||
|
task.blockedBy = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear transient fields when moving from in-progress to reset columns (todo/triage)
|
||||||
|
// This ensures failed tasks don't show failed status after being moved for retry
|
||||||
|
if (fromColumn === "in-progress" && (toColumn === "todo" || toColumn === "triage")) {
|
||||||
|
task.status = undefined;
|
||||||
|
task.error = undefined;
|
||||||
task.worktree = undefined;
|
task.worktree = undefined;
|
||||||
task.blockedBy = undefined;
|
task.blockedBy = undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
464
packages/dashboard/app/components/UsageIndicator.test.tsx
Normal file
464
packages/dashboard/app/components/UsageIndicator.test.tsx
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
293
packages/dashboard/app/components/UsageIndicator.tsx
Normal file
293
packages/dashboard/app/components/UsageIndicator.tsx
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { renderHook, waitFor, act } from "@testing-library/react";
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
import { useUsageData } from "./useUsageData";
|
import { useUsageData } from "./useUsageData";
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
|
|
||||||
@@ -8,11 +8,6 @@ describe("useUsageData", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockFetchUsageData.mockClear();
|
mockFetchUsageData.mockClear();
|
||||||
vi.useFakeTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fetches data on initial mount", async () => {
|
it("fetches data on initial mount", async () => {
|
||||||
@@ -28,7 +23,7 @@ describe("useUsageData", () => {
|
|||||||
};
|
};
|
||||||
mockFetchUsageData.mockResolvedValue(mockData);
|
mockFetchUsageData.mockResolvedValue(mockData);
|
||||||
|
|
||||||
const { result } = renderHook(() => useUsageData());
|
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
||||||
|
|
||||||
// Should be loading initially
|
// Should be loading initially
|
||||||
expect(result.current.loading).toBe(true);
|
expect(result.current.loading).toBe(true);
|
||||||
@@ -45,7 +40,7 @@ describe("useUsageData", () => {
|
|||||||
it("handles fetch errors", async () => {
|
it("handles fetch errors", async () => {
|
||||||
mockFetchUsageData.mockRejectedValue(new Error("Network error"));
|
mockFetchUsageData.mockRejectedValue(new Error("Network error"));
|
||||||
|
|
||||||
const { result } = renderHook(() => useUsageData());
|
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
@@ -53,54 +48,7 @@ describe("useUsageData", () => {
|
|||||||
expect(result.current.providers).toEqual([]);
|
expect(result.current.providers).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("polls data at specified interval", async () => {
|
it("manual refresh fetches new data", async () => {
|
||||||
const mockData1 = {
|
|
||||||
providers: [{ name: "Claude", icon: "🟠", status: "ok" as const, windows: [] }],
|
|
||||||
};
|
|
||||||
const mockData2 = {
|
|
||||||
providers: [{ name: "Claude", icon: "🟠", status: "ok" as const, windows: [{ label: "Session", percentUsed: 50, percentLeft: 50, resetText: "2h" }] }],
|
|
||||||
};
|
|
||||||
|
|
||||||
mockFetchUsageData
|
|
||||||
.mockResolvedValueOnce(mockData1)
|
|
||||||
.mockResolvedValueOnce(mockData2);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useUsageData({ pollInterval: 5000 }));
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
||||||
expect(result.current.providers).toEqual(mockData1.providers);
|
|
||||||
|
|
||||||
// Advance time to trigger poll
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.providers[0]?.windows?.length).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not poll when autoRefresh is false", async () => {
|
|
||||||
const mockData = {
|
|
||||||
providers: [{ name: "Claude", icon: "🟠", status: "ok" as const, windows: [] }],
|
|
||||||
};
|
|
||||||
mockFetchUsageData.mockResolvedValue(mockData);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useUsageData({ autoRefresh: false, pollInterval: 1000 }));
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
||||||
expect(mockFetchUsageData).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// Advance time
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should not have fetched again
|
|
||||||
expect(mockFetchUsageData).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("manual refresh works", async () => {
|
|
||||||
const mockData1 = {
|
const mockData1 = {
|
||||||
providers: [{ name: "Claude", icon: "🟠", status: "ok" as const, windows: [] }],
|
providers: [{ name: "Claude", icon: "🟠", status: "ok" as const, windows: [] }],
|
||||||
};
|
};
|
||||||
@@ -118,37 +66,9 @@ describe("useUsageData", () => {
|
|||||||
expect(result.current.providers).toEqual(mockData1.providers);
|
expect(result.current.providers).toEqual(mockData1.providers);
|
||||||
|
|
||||||
// Manual refresh
|
// Manual refresh
|
||||||
await act(async () => {
|
await result.current.refresh();
|
||||||
await result.current.refresh();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.providers).toEqual(mockData2.providers);
|
await waitFor(() => expect(result.current.providers).toEqual(mockData2.providers));
|
||||||
});
|
|
||||||
|
|
||||||
it("sets loading state on manual refresh", async () => {
|
|
||||||
const mockData = {
|
|
||||||
providers: [{ name: "Claude", icon: "🟠", status: "ok" as const, windows: [] }],
|
|
||||||
};
|
|
||||||
mockFetchUsageData.mockResolvedValue(mockData);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
||||||
|
|
||||||
// Start manual refresh but don't await yet
|
|
||||||
let refreshPromise: Promise<void>;
|
|
||||||
act(() => {
|
|
||||||
refreshPromise = result.current.refresh();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should be loading immediately
|
|
||||||
expect(result.current.loading).toBe(true);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await refreshPromise;
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.loading).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears error on successful manual refresh after error", async () => {
|
it("clears error on successful manual refresh after error", async () => {
|
||||||
@@ -164,72 +84,25 @@ describe("useUsageData", () => {
|
|||||||
expect(result.current.error).toBe("Network error");
|
expect(result.current.error).toBe("Network error");
|
||||||
|
|
||||||
// Manual refresh
|
// Manual refresh
|
||||||
await act(async () => {
|
await result.current.refresh();
|
||||||
await result.current.refresh();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.error).toBeNull();
|
await waitFor(() => expect(result.current.error).toBeNull());
|
||||||
expect(result.current.providers).toHaveLength(1);
|
expect(result.current.providers).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses default 30 second poll interval", async () => {
|
it("exports the correct interface", () => {
|
||||||
const mockData = {
|
expect(typeof useUsageData).toBe("function");
|
||||||
providers: [{ name: "Claude", icon: "🟠", status: "ok" as const, windows: [] }],
|
|
||||||
};
|
|
||||||
mockFetchUsageData.mockResolvedValue(mockData);
|
|
||||||
|
|
||||||
renderHook(() => useUsageData());
|
|
||||||
|
|
||||||
await waitFor(() => expect(mockFetchUsageData).toHaveBeenCalledTimes(1));
|
|
||||||
|
|
||||||
// Should not poll after 29 seconds
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(29000);
|
|
||||||
});
|
|
||||||
expect(mockFetchUsageData).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// Should poll after 30 seconds
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(1000);
|
|
||||||
});
|
|
||||||
await waitFor(() => expect(mockFetchUsageData).toHaveBeenCalledTimes(2));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles abort errors gracefully (does not update state)", async () => {
|
it("returns expected default values before first fetch", () => {
|
||||||
const abortError = new Error("AbortError");
|
mockFetchUsageData.mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||||
abortError.name = "AbortError";
|
|
||||||
mockFetchUsageData.mockRejectedValue(abortError);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useUsageData());
|
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
||||||
|
|
||||||
// Wait a bit
|
expect(result.current.providers).toEqual([]);
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
// State should remain in loading since we don't update on abort
|
|
||||||
expect(result.current.loading).toBe(true);
|
expect(result.current.loading).toBe(true);
|
||||||
expect(result.current.error).toBeNull();
|
expect(result.current.error).toBeNull();
|
||||||
});
|
expect(result.current.lastUpdated).toBeNull();
|
||||||
|
expect(typeof result.current.refresh).toBe("function");
|
||||||
it("cleans up interval and abort controller on unmount", async () => {
|
|
||||||
const mockData = {
|
|
||||||
providers: [{ name: "Claude", icon: "🟠", status: "ok" as const, windows: [] }],
|
|
||||||
};
|
|
||||||
mockFetchUsageData.mockResolvedValue(mockData);
|
|
||||||
|
|
||||||
const { unmount } = renderHook(() => useUsageData());
|
|
||||||
|
|
||||||
await waitFor(() => expect(mockFetchUsageData).toHaveBeenCalledTimes(1));
|
|
||||||
|
|
||||||
unmount();
|
|
||||||
|
|
||||||
// Should not poll after unmount
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(60000);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Still only called once (no additional calls after unmount)
|
|
||||||
expect(mockFetchUsageData).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -667,7 +667,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
res.status(400).json({ error: "Task is not in a failed state" });
|
res.status(400).json({ error: "Task is not in a failed state" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await store.updateTask(req.params.id, { status: undefined });
|
await store.updateTask(req.params.id, { status: undefined, error: undefined });
|
||||||
await store.logEntry(req.params.id, "Retry requested from dashboard");
|
await store.logEntry(req.params.id, "Retry requested from dashboard");
|
||||||
const updated = await store.moveTask(req.params.id, "todo");
|
const updated = await store.moveTask(req.params.id, "todo");
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
|
|||||||
@@ -2871,7 +2871,7 @@ describe("TaskExecutor usage limit detection", () => {
|
|||||||
);
|
);
|
||||||
expect(store.updateSettings).toHaveBeenCalledWith({ globalPause: true });
|
expect(store.updateSettings).toHaveBeenCalledWith({ globalPause: true });
|
||||||
// Task should still be marked as failed
|
// Task should still be marked as failed
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: "failed" });
|
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: "failed", error: "rate_limit_error: Rate limit exceeded" });
|
||||||
expect(onError).toHaveBeenCalled();
|
expect(onError).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2903,7 +2903,7 @@ describe("TaskExecutor usage limit detection", () => {
|
|||||||
|
|
||||||
expect(onUsageLimitHitSpy).not.toHaveBeenCalled();
|
expect(onUsageLimitHitSpy).not.toHaveBeenCalled();
|
||||||
// Task should still be marked as failed
|
// Task should still be marked as failed
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: "failed" });
|
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: "failed", error: "connection refused" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("works without usageLimitPauser (backward compatible)", async () => {
|
it("works without usageLimitPauser (backward compatible)", async () => {
|
||||||
@@ -2928,7 +2928,7 @@ describe("TaskExecutor usage limit detection", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Should not crash — just mark as failed
|
// Should not crash — just mark as failed
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: "failed" });
|
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: "failed", error: "rate_limit_error: Rate limit exceeded" });
|
||||||
expect(onError).toHaveBeenCalled();
|
expect(onError).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2972,7 +2972,7 @@ describe("TaskExecutor usage limit detection", () => {
|
|||||||
"rate_limit_error: Rate limit exceeded",
|
"rate_limit_error: Rate limit exceeded",
|
||||||
);
|
);
|
||||||
// Task should be marked as failed
|
// Task should be marked as failed
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: "failed" });
|
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: "failed", error: "rate_limit_error: Rate limit exceeded" });
|
||||||
// onError callback should fire
|
// onError callback should fire
|
||||||
expect(onError).toHaveBeenCalled();
|
expect(onError).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -526,7 +526,7 @@ export class TaskExecutor {
|
|||||||
}
|
}
|
||||||
executorLog.error(`✗ ${task.id} execution failed:`, err.message);
|
executorLog.error(`✗ ${task.id} execution failed:`, err.message);
|
||||||
await this.store.logEntry(task.id, `Execution failed: ${err.message}`);
|
await this.store.logEntry(task.id, `Execution failed: ${err.message}`);
|
||||||
await this.store.updateTask(task.id, { status: "failed" });
|
await this.store.updateTask(task.id, { status: "failed", error: err.message });
|
||||||
this.options.onError?.(task, err);
|
this.options.onError?.(task, err);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user