feat(FN-1265): add agent budget settings UI and API
- Add Budget Settings section to ConfigTab with token limit, usage threshold, budget period, and reset day inputs - Add budget usage badge to DashboardTab showing token consumption and threshold warning - Add budget API functions to api.ts (getBudgetSummary, updateBudgetConfig, deleteBudgetUsage) - Add budget routes to routes.ts for GET/PATCH/DELETE agent budget endpoints - Add CSS styles for budget progress bar, warning indicators, and section layout - Add comprehensive tests for budget UI components and API routes
This commit is contained in:
@@ -5,8 +5,8 @@ import {
|
||||
ExternalLink, CheckCircle, XCircle, Loader2, GitBranch, ListChecks,
|
||||
ChevronDown, ChevronRight, BarChart3, Star
|
||||
} from "lucide-react";
|
||||
import type { AgentDetail, AgentState, AgentHeartbeatRun } from "../api";
|
||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentTasks, fetchChainOfCommand } from "../api";
|
||||
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus } from "../api";
|
||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget } from "../api";
|
||||
import type { Agent } from "../api";
|
||||
import type { AgentLogEntry, Task } from "@fusion/core";
|
||||
import { AgentLogViewer } from "./AgentLogViewer";
|
||||
@@ -502,6 +502,14 @@ function DashboardTab({
|
||||
const stateStyle = STATE_COLORS[agent.state];
|
||||
const [chainOfCommand, setChainOfCommand] = useState<Agent[]>([]);
|
||||
const [isLoadingChainOfCommand, setIsLoadingChainOfCommand] = useState(true);
|
||||
const [budgetStatus, setBudgetStatus] = useState<AgentBudgetStatus | null>(null);
|
||||
|
||||
// Fetch budget status on mount
|
||||
useEffect(() => {
|
||||
fetchAgentBudgetStatus(agent.id, projectId)
|
||||
.then(setBudgetStatus)
|
||||
.catch(() => setBudgetStatus(null));
|
||||
}, [agent.id, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -556,6 +564,14 @@ function DashboardTab({
|
||||
|
||||
return (
|
||||
<div className="dashboard-tab">
|
||||
{/* Budget Exhausted Warning */}
|
||||
{budgetStatus?.isOverBudget && (
|
||||
<div className="budget-warning-banner" role="alert">
|
||||
<span>⚠️</span>
|
||||
<span><strong>Budget Exhausted:</strong> This agent has exceeded its token budget and may be operating with limited functionality.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent Info Card */}
|
||||
<div className="dashboard-section">
|
||||
<h3>Agent Information</h3>
|
||||
@@ -585,6 +601,33 @@ function DashboardTab({
|
||||
{health.label}
|
||||
</span>
|
||||
</div>
|
||||
{budgetStatus?.budgetLimit != null && (
|
||||
<div className="info-item">
|
||||
<span className="info-label">Budget</span>
|
||||
<span className="info-value">
|
||||
<span
|
||||
className="budget-badge"
|
||||
style={{
|
||||
background: budgetStatus.isOverBudget
|
||||
? "var(--state-error-bg, rgba(248,81,73,0.15))"
|
||||
: budgetStatus.isOverThreshold
|
||||
? "var(--state-paused-bg, rgba(227,181,65,0.15))"
|
||||
: "var(--state-active-bg, rgba(63,185,80,0.15))",
|
||||
color: budgetStatus.isOverBudget
|
||||
? "var(--state-error-text, #f85149)"
|
||||
: budgetStatus.isOverThreshold
|
||||
? "var(--state-paused-text, #e3b541)"
|
||||
: "var(--state-active-text, #3fb950)",
|
||||
border: `1px solid ${budgetStatus.isOverBudget ? "var(--state-error-border, #f85149)" : budgetStatus.isOverThreshold ? "var(--state-paused-border, #e3b541)" : "var(--state-active-border, #3fb950)"}`,
|
||||
}}
|
||||
>
|
||||
{budgetStatus.isOverBudget
|
||||
? "⚠ Budget Exhausted"
|
||||
: `${Math.round(budgetStatus.usagePercent ?? 0)}% used`}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="info-item">
|
||||
<span className="info-label">Created</span>
|
||||
<span className="info-value">{new Date(agent.createdAt).toLocaleDateString()}</span>
|
||||
@@ -1901,6 +1944,32 @@ function ConfigTab({
|
||||
return initial;
|
||||
});
|
||||
|
||||
// Budget status for progress bar display
|
||||
const [budgetStatus, setBudgetStatus] = useState<AgentBudgetStatus | null>(null);
|
||||
const [isResettingBudget, setIsResettingBudget] = useState(false);
|
||||
|
||||
// Fetch budget status on mount
|
||||
useEffect(() => {
|
||||
fetchAgentBudgetStatus(agent.id, projectId)
|
||||
.then(setBudgetStatus)
|
||||
.catch(() => setBudgetStatus(null));
|
||||
}, [agent.id, projectId]);
|
||||
|
||||
const handleResetBudget = async () => {
|
||||
setIsResettingBudget(true);
|
||||
try {
|
||||
await resetAgentBudget(agent.id, projectId);
|
||||
addToast("Budget usage reset successfully", "success");
|
||||
// Refresh budget status
|
||||
const status = await fetchAgentBudgetStatus(agent.id, projectId);
|
||||
setBudgetStatus(status);
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to reset budget: ${err.message}`, "error");
|
||||
} finally {
|
||||
setIsResettingBudget(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isSavingInstructions, setIsSavingInstructions] = useState(false);
|
||||
const [errors, setErrors] = useState<ValidationErrors>({});
|
||||
@@ -2365,6 +2434,54 @@ function ConfigTab({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Budget Usage Progress Bar */}
|
||||
{budgetStatus?.budgetLimit != null && (
|
||||
<div className="config-field">
|
||||
<label>Current Usage</label>
|
||||
<div className="budget-progress-container">
|
||||
<div className="budget-progress-bar">
|
||||
<div
|
||||
className={cn(
|
||||
"budget-progress-bar__fill",
|
||||
(budgetStatus.usagePercent ?? 0) >= 100
|
||||
? "budget-progress-bar__fill--red"
|
||||
: (budgetStatus.usagePercent ?? 0) >= 80
|
||||
? "budget-progress-bar__fill--amber"
|
||||
: "budget-progress-bar__fill--green"
|
||||
)}
|
||||
style={{ width: `${Math.min(budgetStatus.usagePercent ?? 0, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="budget-progress-label">
|
||||
{(budgetStatus.currentUsage ?? 0).toLocaleString()} / {(budgetStatus.budgetLimit ?? 0).toLocaleString()} tokens ({Math.round(budgetStatus.usagePercent ?? 0)}% used)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reset Budget Button */}
|
||||
{budgetStatus?.budgetLimit != null && (
|
||||
<div className="config-field">
|
||||
<button
|
||||
className="btn btn-reset-budget"
|
||||
onClick={() => void handleResetBudget()}
|
||||
disabled={isResettingBudget}
|
||||
>
|
||||
{isResettingBudget ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Resetting…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw size={14} />
|
||||
Reset Budget Usage
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ vi.mock("../../api", () => ({
|
||||
updateAgentMemory: vi.fn(),
|
||||
fetchAgentTasks: vi.fn(),
|
||||
fetchChainOfCommand: vi.fn(),
|
||||
fetchAgentBudgetStatus: vi.fn(),
|
||||
resetAgentBudget: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../AgentLogViewer", () => ({
|
||||
@@ -32,7 +34,7 @@ vi.mock("../AgentLogViewer", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
import { fetchAgent, updateAgent, updateAgentState, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand } from "../../api";
|
||||
import { fetchAgent, updateAgent, updateAgentState, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget } from "../../api";
|
||||
|
||||
const mockFetchAgent = vi.mocked(fetchAgent);
|
||||
const mockUpdateAgent = vi.mocked(updateAgent);
|
||||
@@ -43,6 +45,8 @@ const mockFetchAgentRuns = vi.mocked(fetchAgentRuns);
|
||||
const mockFetchAgentRunDetail = vi.mocked(fetchAgentRunDetail);
|
||||
const mockFetchAgentTasks = vi.mocked(fetchAgentTasks);
|
||||
const mockFetchChainOfCommand = vi.mocked(fetchChainOfCommand);
|
||||
const mockFetchAgentBudgetStatus = vi.mocked(fetchAgentBudgetStatus);
|
||||
const mockResetAgentBudget = vi.mocked(resetAgentBudget);
|
||||
|
||||
describe("AgentDetailView", () => {
|
||||
const createMockAgent = (overrides: Partial<{
|
||||
@@ -98,6 +102,19 @@ describe("AgentDetailView", () => {
|
||||
mockFetchAgentChildren.mockResolvedValue([]);
|
||||
mockFetchAgentTasks.mockResolvedValue([]);
|
||||
mockFetchChainOfCommand.mockResolvedValue([mockAgent]);
|
||||
// Default: no budget limit configured
|
||||
mockFetchAgentBudgetStatus.mockResolvedValue({
|
||||
agentId: "agent-001",
|
||||
currentUsage: 0,
|
||||
budgetLimit: null,
|
||||
usagePercent: 0,
|
||||
isOverBudget: false,
|
||||
isOverThreshold: false,
|
||||
budgetPeriod: "lifetime",
|
||||
lastResetAt: null,
|
||||
nextResetAt: null,
|
||||
});
|
||||
mockResetAgentBudget.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
@@ -1604,6 +1621,146 @@ describe("AgentDetailView", () => {
|
||||
expect(screen.getByText("Save Settings")).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows budget progress bar when budget status has limit configured", async () => {
|
||||
// Need to mock twice: once for DashboardTab and once for ConfigTab
|
||||
mockFetchAgentBudgetStatus.mockResolvedValue({
|
||||
agentId: "agent-001",
|
||||
currentUsage: 40000,
|
||||
budgetLimit: 50000,
|
||||
usagePercent: 80,
|
||||
isOverBudget: false,
|
||||
isOverThreshold: true,
|
||||
budgetPeriod: "monthly",
|
||||
lastResetAt: "2026-01-01T00:00:00.000Z",
|
||||
nextResetAt: null,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("40,000 / 50,000 tokens (80% used)")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides progress bar when no budget limit is configured", async () => {
|
||||
mockFetchAgentBudgetStatus.mockResolvedValueOnce({
|
||||
agentId: "agent-001",
|
||||
currentUsage: 10000,
|
||||
budgetLimit: null,
|
||||
usagePercent: 0,
|
||||
isOverBudget: false,
|
||||
isOverThreshold: false,
|
||||
budgetPeriod: "lifetime",
|
||||
lastResetAt: null,
|
||||
nextResetAt: null,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
await waitFor(() => {
|
||||
// Progress bar should not be visible
|
||||
expect(screen.queryByText(/tokens/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Reset Budget button when budget limit is configured", async () => {
|
||||
// Need to mock twice: once for DashboardTab and once for ConfigTab
|
||||
mockFetchAgentBudgetStatus.mockResolvedValue({
|
||||
agentId: "agent-001",
|
||||
currentUsage: 30000,
|
||||
budgetLimit: 50000,
|
||||
usagePercent: 60,
|
||||
isOverBudget: false,
|
||||
isOverThreshold: false,
|
||||
budgetPeriod: "lifetime",
|
||||
lastResetAt: "2026-01-01T00:00:00.000Z",
|
||||
nextResetAt: null,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Reset Budget Usage")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls resetAgentBudget when Reset Budget button is clicked", async () => {
|
||||
const addToast = vi.fn();
|
||||
// First call (ConfigTab on mount)
|
||||
mockFetchAgentBudgetStatus.mockResolvedValueOnce({
|
||||
agentId: "agent-001",
|
||||
currentUsage: 30000,
|
||||
budgetLimit: 50000,
|
||||
usagePercent: 60,
|
||||
isOverBudget: false,
|
||||
isOverThreshold: false,
|
||||
budgetPeriod: "lifetime",
|
||||
lastResetAt: "2026-01-01T00:00:00.000Z",
|
||||
nextResetAt: null,
|
||||
});
|
||||
// Second call (after reset)
|
||||
mockFetchAgentBudgetStatus.mockResolvedValueOnce({
|
||||
agentId: "agent-001",
|
||||
currentUsage: 0,
|
||||
budgetLimit: 50000,
|
||||
usagePercent: 0,
|
||||
isOverBudget: false,
|
||||
isOverThreshold: false,
|
||||
budgetPeriod: "lifetime",
|
||||
lastResetAt: "2026-04-10T00:00:00.000Z",
|
||||
nextResetAt: null,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={addToast}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Reset Budget Usage")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("Reset Budget Usage"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockResetAgentBudget).toHaveBeenCalledWith("agent-001", undefined);
|
||||
expect(addToast).toHaveBeenCalledWith("Budget usage reset successfully", "success");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Runs Tab — Click to show logs ──────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user