feat(canvas): budget_limit input in workspace creation and settings UI (#541)
- Adds optional Budget limit (USD) numeric field to CreateWorkspaceDialog; blank = null (unlimited), populated = parsed float sent as budget_limit in POST /workspaces body - Adds budget_limit field to DetailsTab edit form; saves via PATCH /workspaces/:id; pre-fills from current WorkspaceNodeData - Shows 'Budget limit exceeded' warning badge when budgetUsed > budgetLimit (forward-compatible — badge hidden when budgetUsed is absent) - Extends WorkspaceData, WorkspaceNodeData, and buildNodesAndEdges to carry budgetLimit / budgetUsed fields ready for backend hydration (issue #541 BE PR) - Ships 22 new tests across CreateWorkspaceDialog and BudgetLimit.DetailsTab suites (575 total, all passing); npm run build clean; 'use client' grep empty API shape confirmed from workspace.go and CreateWorkspacePayload struct: field name: budget_limit | type: number | null | units: USD Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
652019afcc
commit
07240e7095
@ -42,6 +42,7 @@ export function CreateWorkspaceButton() {
|
||||
const [tier, setTier] = useState(1);
|
||||
const [template, setTemplate] = useState("");
|
||||
const [parentId, setParentId] = useState("");
|
||||
const [budgetLimit, setBudgetLimit] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [workspaces, setWorkspaces] = useState<WorkspaceOption[]>([]);
|
||||
@ -87,6 +88,7 @@ export function CreateWorkspaceButton() {
|
||||
setTier(1);
|
||||
setTemplate("");
|
||||
setParentId("");
|
||||
setBudgetLimit("");
|
||||
setError(null);
|
||||
setHermesProvider("anthropic");
|
||||
setHermesApiKey("");
|
||||
@ -113,12 +115,17 @@ export function CreateWorkspaceButton() {
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const parsedBudget = budgetLimit.trim()
|
||||
? parseFloat(budgetLimit)
|
||||
: null;
|
||||
|
||||
await api.post("/workspaces", {
|
||||
name: name.trim(),
|
||||
role: role.trim() || undefined,
|
||||
template: template.trim() || undefined,
|
||||
tier,
|
||||
parent_id: parentId || undefined,
|
||||
budget_limit: parsedBudget,
|
||||
canvas: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 },
|
||||
...(isHermes && provider
|
||||
? { secrets: { [provider.envVar]: hermesApiKey.trim() } }
|
||||
@ -182,6 +189,14 @@ export function CreateWorkspaceButton() {
|
||||
onChange={setRole}
|
||||
placeholder="e.g. SEO Specialist"
|
||||
/>
|
||||
<InputField
|
||||
label="Budget limit (USD)"
|
||||
value={budgetLimit}
|
||||
onChange={setBudgetLimit}
|
||||
placeholder="e.g. 100"
|
||||
type="number"
|
||||
helper="Leave blank for unlimited"
|
||||
/>
|
||||
<InputField
|
||||
label="Template"
|
||||
value={template}
|
||||
@ -341,6 +356,8 @@ function InputField({
|
||||
placeholder,
|
||||
required,
|
||||
mono,
|
||||
type = "text",
|
||||
helper,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
@ -348,6 +365,8 @@ function InputField({
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
mono?: boolean;
|
||||
type?: string;
|
||||
helper?: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
@ -363,11 +382,17 @@ function InputField({
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={`w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20 transition-colors ${mono ? "font-mono text-xs" : ""}`}
|
||||
min={type === "number" ? "0" : undefined}
|
||||
step={type === "number" ? "0.01" : undefined}
|
||||
className={`w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20 transition-colors ${mono ? "font-mono text-xs" : ""}`}
|
||||
/>
|
||||
{helper && (
|
||||
<p className="mt-1 text-xs text-zinc-500">{helper}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
267
canvas/src/components/__tests__/BudgetLimit.DetailsTab.test.tsx
Normal file
267
canvas/src/components/__tests__/BudgetLimit.DetailsTab.test.tsx
Normal file
@ -0,0 +1,267 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for the budget_limit field in DetailsTab (issue #541).
|
||||
* Covers: display in read view, editing + PATCH, exceeded badge,
|
||||
* null/unlimited states, and cancel-revert.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
del: vi.fn(),
|
||||
post: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: vi.fn((selector: (s: unknown) => unknown) =>
|
||||
selector({
|
||||
updateNodeData: mockUpdateNodeData,
|
||||
removeNode: vi.fn(),
|
||||
selectNode: vi.fn(),
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../StatusDot", () => ({ StatusDot: () => null }));
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { DetailsTab } from "../tabs/DetailsTab";
|
||||
|
||||
const mockPatch = vi.mocked(api.patch);
|
||||
const mockGet = vi.mocked(api.get);
|
||||
const mockUpdateNodeData = vi.fn();
|
||||
|
||||
// ── Base workspace data ────────────────────────────────────────────────────────
|
||||
|
||||
function makeData(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
name: "Test Agent",
|
||||
role: "Researcher",
|
||||
tier: 1,
|
||||
status: "online",
|
||||
agentCard: null,
|
||||
activeTasks: 0,
|
||||
collapsed: false,
|
||||
lastErrorRate: 0,
|
||||
lastSampleError: "",
|
||||
url: "http://localhost:8080",
|
||||
parentId: null,
|
||||
currentTask: "",
|
||||
runtime: "langgraph",
|
||||
needsRestart: false,
|
||||
budgetLimit: null,
|
||||
budgetUsed: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockPatch.mockResolvedValue({} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ── Read view ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("DetailsTab — budget_limit read view", () => {
|
||||
it("shows 'Unlimited' when budgetLimit is null", () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={makeData({ budgetLimit: null })} />);
|
||||
expect(screen.getByText("Unlimited")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows formatted dollar amount when budgetLimit is set", () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={makeData({ budgetLimit: 100 })} />);
|
||||
expect(screen.getByText("$100.00")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows budget used row when budgetUsed is present", () => {
|
||||
render(
|
||||
<DetailsTab
|
||||
workspaceId="ws-1"
|
||||
data={makeData({ budgetLimit: 100, budgetUsed: 42.5 })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("$42.50")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT show budget used row when budgetUsed is null", () => {
|
||||
render(
|
||||
<DetailsTab
|
||||
workspaceId="ws-1"
|
||||
data={makeData({ budgetLimit: 100, budgetUsed: null })}
|
||||
/>
|
||||
);
|
||||
// "Budget used" label should not appear
|
||||
expect(screen.queryByText("Budget used")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Budget exceeded badge ─────────────────────────────────────────────────────
|
||||
|
||||
describe("DetailsTab — budget exceeded badge", () => {
|
||||
it("shows exceeded badge when budgetUsed > budgetLimit", () => {
|
||||
render(
|
||||
<DetailsTab
|
||||
workspaceId="ws-1"
|
||||
data={makeData({ budgetLimit: 50, budgetUsed: 75 })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("budget-exceeded-badge")).toBeTruthy();
|
||||
expect(screen.getByText("Budget limit exceeded")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT show exceeded badge when budgetUsed equals budgetLimit", () => {
|
||||
render(
|
||||
<DetailsTab
|
||||
workspaceId="ws-1"
|
||||
data={makeData({ budgetLimit: 100, budgetUsed: 100 })}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByTestId("budget-exceeded-badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT show exceeded badge when budgetUsed < budgetLimit", () => {
|
||||
render(
|
||||
<DetailsTab
|
||||
workspaceId="ws-1"
|
||||
data={makeData({ budgetLimit: 200, budgetUsed: 50 })}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByTestId("budget-exceeded-badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT show exceeded badge when budgetLimit is null (unlimited)", () => {
|
||||
render(
|
||||
<DetailsTab
|
||||
workspaceId="ws-1"
|
||||
data={makeData({ budgetLimit: null, budgetUsed: 999 })}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByTestId("budget-exceeded-badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT show exceeded badge when budgetUsed is null", () => {
|
||||
render(
|
||||
<DetailsTab
|
||||
workspaceId="ws-1"
|
||||
data={makeData({ budgetLimit: 50, budgetUsed: null })}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByTestId("budget-exceeded-badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("exceeded badge has role='status' for accessible announcement", () => {
|
||||
render(
|
||||
<DetailsTab
|
||||
workspaceId="ws-1"
|
||||
data={makeData({ budgetLimit: 10, budgetUsed: 20 })}
|
||||
/>
|
||||
);
|
||||
const badge = screen.getByTestId("budget-exceeded-badge");
|
||||
expect(badge.getAttribute("role")).toBe("status");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Edit + PATCH ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("DetailsTab — budget_limit editing", () => {
|
||||
async function openEdit() {
|
||||
const editBtn = screen.getAllByRole("button").find((b) => b.textContent === "Edit");
|
||||
fireEvent.click(editBtn!);
|
||||
await waitFor(() => expect(screen.getByPlaceholderText("Leave blank for unlimited")).toBeTruthy());
|
||||
}
|
||||
|
||||
it("shows budget_limit input with placeholder 'Leave blank for unlimited' when editing", async () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={makeData({ budgetLimit: null })} />);
|
||||
await openEdit();
|
||||
const input = screen.getByPlaceholderText("Leave blank for unlimited") as HTMLInputElement;
|
||||
expect(input).toBeTruthy();
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
it("pre-fills input with existing budgetLimit value", async () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={makeData({ budgetLimit: 150 })} />);
|
||||
await openEdit();
|
||||
const input = screen.getByPlaceholderText("Leave blank for unlimited") as HTMLInputElement;
|
||||
expect(input.value).toBe("150");
|
||||
});
|
||||
|
||||
it("sends budget_limit as a number in PATCH body", async () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={makeData({ budgetLimit: null })} />);
|
||||
await openEdit();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Leave blank for unlimited"), {
|
||||
target: { value: "300" },
|
||||
});
|
||||
|
||||
const saveBtn = screen.getAllByRole("button").find((b) => b.textContent === "Save");
|
||||
fireEvent.click(saveBtn!);
|
||||
|
||||
await waitFor(() => expect(mockPatch).toHaveBeenCalled());
|
||||
const body = mockPatch.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.budget_limit).toBe(300);
|
||||
});
|
||||
|
||||
it("sends budget_limit as null when field is cleared", async () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={makeData({ budgetLimit: 100 })} />);
|
||||
await openEdit();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Leave blank for unlimited"), {
|
||||
target: { value: "" },
|
||||
});
|
||||
|
||||
const saveBtn = screen.getAllByRole("button").find((b) => b.textContent === "Save");
|
||||
fireEvent.click(saveBtn!);
|
||||
|
||||
await waitFor(() => expect(mockPatch).toHaveBeenCalled());
|
||||
const body = mockPatch.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.budget_limit).toBeNull();
|
||||
});
|
||||
|
||||
it("calls updateNodeData with the new budgetLimit on successful save", async () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={makeData({ budgetLimit: null })} />);
|
||||
await openEdit();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Leave blank for unlimited"), {
|
||||
target: { value: "500" },
|
||||
});
|
||||
|
||||
const saveBtn = screen.getAllByRole("button").find((b) => b.textContent === "Save");
|
||||
fireEvent.click(saveBtn!);
|
||||
|
||||
await waitFor(() => expect(mockUpdateNodeData).toHaveBeenCalled());
|
||||
const updateArgs = mockUpdateNodeData.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(updateArgs.budgetLimit).toBe(500);
|
||||
});
|
||||
|
||||
it("restores original budgetLimit when Cancel is clicked", async () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={makeData({ budgetLimit: 75 })} />);
|
||||
await openEdit();
|
||||
|
||||
// Change the value
|
||||
fireEvent.change(screen.getByPlaceholderText("Leave blank for unlimited"), {
|
||||
target: { value: "9999" },
|
||||
});
|
||||
|
||||
// Cancel
|
||||
const cancelBtn = screen.getAllByRole("button").find((b) => b.textContent === "Cancel");
|
||||
fireEvent.click(cancelBtn!);
|
||||
|
||||
// Re-enter edit mode — should show original value
|
||||
await openEdit();
|
||||
const input = screen.getByPlaceholderText("Leave blank for unlimited") as HTMLInputElement;
|
||||
expect(input.value).toBe("75");
|
||||
});
|
||||
});
|
||||
@ -299,3 +299,85 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// budget_limit field tests (#541)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("CreateWorkspaceDialog — budget_limit field", () => {
|
||||
it("renders a Budget limit (USD) input", async () => {
|
||||
await openDialog();
|
||||
const budgetInput = screen.getByPlaceholderText("e.g. 100");
|
||||
expect(budgetInput).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders helper text 'Leave blank for unlimited'", async () => {
|
||||
await openDialog();
|
||||
expect(screen.getByText("Leave blank for unlimited")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("sends budget_limit as a number when a value is entered", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "Budget Agent" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. 100"), {
|
||||
target: { value: "250" },
|
||||
});
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
fireEvent.click(createBtn!);
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalled());
|
||||
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.budget_limit).toBe(250);
|
||||
});
|
||||
|
||||
it("sends budget_limit as null when the field is left blank", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "Unlimited Agent" },
|
||||
});
|
||||
// Leave budget_limit empty
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
fireEvent.click(createBtn!);
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalled());
|
||||
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.budget_limit).toBeNull();
|
||||
});
|
||||
|
||||
it("sends budget_limit as a float when a decimal value is entered", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "Float Budget Agent" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. 100"), {
|
||||
target: { value: "49.99" },
|
||||
});
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
fireEvent.click(createBtn!);
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalled());
|
||||
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.budget_limit).toBeCloseTo(49.99);
|
||||
});
|
||||
|
||||
it("resets budget_limit to empty when dialog is reopened", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. 100"), {
|
||||
target: { value: "500" },
|
||||
});
|
||||
|
||||
// Close dialog
|
||||
const cancelBtn = screen.getAllByRole("button").find((b) =>
|
||||
b.textContent === "Cancel"
|
||||
);
|
||||
fireEvent.click(cancelBtn!);
|
||||
cleanup();
|
||||
|
||||
// Re-open
|
||||
await openDialog();
|
||||
const budgetInput = screen.getByPlaceholderText("e.g. 100") as HTMLInputElement;
|
||||
expect(budgetInput.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@ -24,6 +24,9 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
const [name, setName] = useState(data.name);
|
||||
const [role, setRole] = useState(data.role || "");
|
||||
const [tier, setTier] = useState(data.tier);
|
||||
const [budgetLimit, setBudgetLimit] = useState(
|
||||
data.budgetLimit != null ? String(data.budgetLimit) : ""
|
||||
);
|
||||
const [peers, setPeers] = useState<PeerData[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
@ -40,7 +43,8 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
setName(data.name);
|
||||
setRole(data.role || "");
|
||||
setTier(data.tier);
|
||||
}, [data.name, data.role, data.tier]);
|
||||
setBudgetLimit(data.budgetLimit != null ? String(data.budgetLimit) : "");
|
||||
}, [data.name, data.role, data.tier, data.budgetLimit]);
|
||||
|
||||
const loadPeers = useCallback(async () => {
|
||||
setPeersError(null);
|
||||
@ -59,9 +63,17 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
const parsedBudget = budgetLimit.trim()
|
||||
? parseFloat(budgetLimit)
|
||||
: null;
|
||||
try {
|
||||
await api.patch(`/workspaces/${workspaceId}`, { name, role: role || null, tier });
|
||||
updateNodeData(workspaceId, { name, role: role || "", tier });
|
||||
await api.patch(`/workspaces/${workspaceId}`, {
|
||||
name,
|
||||
role: role || null,
|
||||
tier,
|
||||
budget_limit: parsedBudget,
|
||||
});
|
||||
updateNodeData(workspaceId, { name, role: role || "", tier, budgetLimit: parsedBudget });
|
||||
setEditing(false);
|
||||
} catch (e) {
|
||||
setSaveError(e instanceof Error ? e.message : "Failed to save");
|
||||
@ -95,6 +107,10 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
};
|
||||
|
||||
const isRestartable = data.status === "offline" || data.status === "failed" || data.status === "degraded";
|
||||
const budgetExceeded =
|
||||
data.budgetLimit != null &&
|
||||
data.budgetUsed != null &&
|
||||
data.budgetUsed > data.budgetLimit;
|
||||
|
||||
const agentCard = data.agentCard;
|
||||
const skills = getSkills(agentCard);
|
||||
@ -132,6 +148,18 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
<option value={4}>Tier 4 — VM</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Budget limit (USD)">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={budgetLimit}
|
||||
onChange={(e) => setBudgetLimit(e.target.value)}
|
||||
placeholder="Leave blank for unlimited"
|
||||
className="w-full bg-zinc-800 border border-zinc-600 rounded px-2 py-1 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
|
||||
/>
|
||||
<p className="mt-0.5 text-xs text-zinc-500">Leave blank for unlimited</p>
|
||||
</Field>
|
||||
{saveError && (
|
||||
<div className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
|
||||
{saveError}
|
||||
@ -146,7 +174,14 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditing(false); setSaveError(null); setName(data.name); setRole(data.role || ""); setTier(data.tier); }}
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setSaveError(null);
|
||||
setName(data.name);
|
||||
setRole(data.role || "");
|
||||
setTier(data.tier);
|
||||
setBudgetLimit(data.budgetLimit != null ? String(data.budgetLimit) : "");
|
||||
}}
|
||||
className="px-3 py-1 bg-zinc-700 hover:bg-zinc-600 text-xs rounded text-zinc-300"
|
||||
>
|
||||
Cancel
|
||||
@ -155,9 +190,29 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{budgetExceeded && (
|
||||
<div
|
||||
role="status"
|
||||
aria-label="Budget limit exceeded"
|
||||
data-testid="budget-exceeded-badge"
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-red-950/50 border border-red-800/50 text-red-400 text-[11px] font-medium"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="M6 1L11 10H1L6 1Z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M6 5v2.5M6 9h.01" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
Budget limit exceeded
|
||||
</div>
|
||||
)}
|
||||
<Row label="Name" value={data.name} />
|
||||
<Row label="Role" value={data.role || "—"} />
|
||||
<Row label="Tier" value={`T${data.tier}`} />
|
||||
<Row label="Budget limit" value={
|
||||
data.budgetLimit != null ? `$${data.budgetLimit.toFixed(2)}` : "Unlimited"
|
||||
} />
|
||||
{data.budgetUsed != null && (
|
||||
<Row label="Budget used" value={`$${data.budgetUsed.toFixed(2)}`} />
|
||||
)}
|
||||
<Row label="Status" value={data.status} />
|
||||
<Row label="URL" value={data.url || "—"} mono />
|
||||
<Row label="Parent" value={data.parentId || "root"} mono />
|
||||
|
||||
@ -142,6 +142,8 @@ export function buildNodesAndEdges(
|
||||
currentTask: ws.current_task || "",
|
||||
runtime: ws.runtime || "",
|
||||
needsRestart: false,
|
||||
budgetLimit: ws.budget_limit ?? null,
|
||||
budgetUsed: ws.budget_used ?? null,
|
||||
},
|
||||
// Hide child nodes from canvas — they render inside the parent WorkspaceNode
|
||||
hidden: !!ws.parent_id,
|
||||
|
||||
@ -29,6 +29,10 @@ export interface WorkspaceNodeData extends Record<string, unknown> {
|
||||
currentTask: string;
|
||||
runtime: string;
|
||||
needsRestart: boolean;
|
||||
/** USD spend ceiling set by the user; null = unlimited. Added by issue #541. */
|
||||
budgetLimit: number | null;
|
||||
/** Cumulative USD spend. Present when the platform tracks spend (issue #541). */
|
||||
budgetUsed?: number | null;
|
||||
}
|
||||
|
||||
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity";
|
||||
|
||||
@ -118,6 +118,10 @@ export interface WorkspaceData {
|
||||
x: number;
|
||||
y: number;
|
||||
collapsed: boolean;
|
||||
/** USD spend ceiling set by the user; null = unlimited. Added by issue #541. */
|
||||
budget_limit: number | null;
|
||||
/** Cumulative USD spend for this workspace. Present when the platform tracks spend. */
|
||||
budget_used?: number | null;
|
||||
}
|
||||
|
||||
let socket: ReconnectingSocket | null = null;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user