feat(05-03): policy-level BudgetSummary, incident-based BudgetAlertBanner, AgentEditor policy wiring
- BudgetSummary shows policy-level utilization bars when companyId provided, falls back to agent-level - BudgetAlertBanner loads open incidents for policy-based alerts (warn_threshold/hard_stop), falls back to agent thresholds - AgentEditor reads budget from budget_policies and writes budget as policy on save (D-11) - Dashboard passes companyId through to BudgetAlertBanner
This commit is contained in:
1 parent
3a8e4c40ad
commit
1c462d571c
5 files changed
+217
-46
No files matched your search
@@ -1,12 +1,13 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { Warning, X } from "@phosphor-icons/react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import type { AgentRecord, SectionId } from "@shared/types";
|
||||
import type { AgentRecord, BudgetIncidentRecord, SectionId } from "@shared/types";
|
||||
import { useReducedMotion } from "../ui";
|
||||
|
||||
export interface BudgetAlertBannerProps {
|
||||
agents: AgentRecord[];
|
||||
onNavigate: (section: SectionId, entityId?: string) => void;
|
||||
companyId?: string;
|
||||
}
|
||||
|
||||
interface AgentOverThreshold {
|
||||
@@ -15,10 +16,21 @@ interface AgentOverThreshold {
|
||||
pct: number;
|
||||
}
|
||||
|
||||
export function BudgetAlertBanner({ agents, onNavigate }: BudgetAlertBannerProps) {
|
||||
export function BudgetAlertBanner({ agents, onNavigate, companyId }: BudgetAlertBannerProps) {
|
||||
const reducedMotion = useReducedMotion();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [incidents, setIncidents] = useState<BudgetIncidentRecord[]>([]);
|
||||
|
||||
// Load policy-based incidents when companyId is provided
|
||||
useEffect(() => {
|
||||
if (!companyId) return;
|
||||
void (async () => {
|
||||
const result = await window.agentCompany.listBudgetIncidents({ companyId, status: "open" });
|
||||
if (result.ok) setIncidents(result.data);
|
||||
})();
|
||||
}, [companyId]);
|
||||
|
||||
// Fallback: agent-level threshold detection (existing logic)
|
||||
const agentsOverThreshold = useMemo<AgentOverThreshold[]>(() => {
|
||||
return agents
|
||||
.filter((a) => a.budgetMonthlyUsd > 0 && a.spentMonthlyUsd / a.budgetMonthlyUsd >= 0.8)
|
||||
@@ -30,34 +42,102 @@ export function BudgetAlertBanner({ agents, onNavigate }: BudgetAlertBannerProps
|
||||
.sort((a, b) => b.pct - a.pct);
|
||||
}, [agents]);
|
||||
|
||||
// Reset dismissed state when the set of agents over threshold changes
|
||||
const overThresholdKey = useMemo(
|
||||
() => agentsOverThreshold.map((a) => a.id).sort().join(","),
|
||||
[agentsOverThreshold],
|
||||
);
|
||||
// Determine whether to use policy-based or agent-based alerts
|
||||
const usePolicyAlerts = companyId && incidents.length > 0;
|
||||
|
||||
// Reset dismissed state when the set of alerts changes
|
||||
const alertKey = useMemo(() => {
|
||||
if (usePolicyAlerts) {
|
||||
return incidents.map((i) => i.id).sort().join(",");
|
||||
}
|
||||
return agentsOverThreshold.map((a) => a.id).sort().join(",");
|
||||
}, [usePolicyAlerts, incidents, agentsOverThreshold]);
|
||||
|
||||
useEffect(() => {
|
||||
setDismissed(false);
|
||||
}, [overThresholdKey]);
|
||||
}, [alertKey]);
|
||||
|
||||
const handleDismiss = useCallback(() => setDismissed(true), []);
|
||||
const handleViewCosts = useCallback(() => onNavigate("costs"), [onNavigate]);
|
||||
|
||||
if (agentsOverThreshold.length === 0) return null;
|
||||
// Nothing to show
|
||||
if (!usePolicyAlerts && agentsOverThreshold.length === 0) return null;
|
||||
if (dismissed) return null;
|
||||
|
||||
const content = (
|
||||
function getScopeName(scopeType: string, scopeId: string): string {
|
||||
if (scopeType === "company") return "Company";
|
||||
if (scopeType === "agent") {
|
||||
const agent = agents.find((a) => a.id === scopeId);
|
||||
return agent ? agent.name : scopeId.slice(0, 8);
|
||||
}
|
||||
return scopeId.slice(0, 8);
|
||||
}
|
||||
|
||||
// Policy-based alert content
|
||||
const policyContent = usePolicyAlerts ? (
|
||||
<div className="space-y-2">
|
||||
{incidents.map((incident) => {
|
||||
const isHardStop = incident.thresholdType === "hard_stop";
|
||||
const borderColor = isHardStop ? "border-[color:var(--danger)]" : "border-[color:var(--warn)]";
|
||||
const bgColor = isHardStop ? "bg-[color:var(--danger-soft)]" : "bg-[color:var(--warn-soft)]";
|
||||
const textColor = isHardStop ? "text-[color:var(--danger)]" : "text-[color:var(--warn)]";
|
||||
const pct = incident.amountLimit > 0 ? Math.round((incident.amountObserved / incident.amountLimit) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={incident.id}
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className={`rounded-[8px] border ${borderColor} border-opacity-30 ${bgColor} px-4 py-3`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 shrink-0">
|
||||
<Warning size={18} weight="bold" className={textColor} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`text-[13px] font-semibold ${textColor}`}>
|
||||
{isHardStop ? "Budget Hard Stop" : "Budget Warning"}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[13px] text-[color:var(--muted-strong)]">
|
||||
{getScopeName(incident.scopeType, incident.scopeId)} — ${(incident.amountObserved / 100).toFixed(2)} / ${(incident.amountLimit / 100).toFixed(2)} ({pct}%)
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleViewCosts}
|
||||
className="mt-1.5 text-[11px] font-medium text-[color:var(--accent)] hover:underline cursor-pointer"
|
||||
>
|
||||
View Costs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
title="Dismiss budget alerts"
|
||||
aria-label="Dismiss budget alerts"
|
||||
className="shrink-0 rounded p-0.5 text-[color:var(--muted-strong)] transition hover:text-[color:var(--text)]"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
// Fallback agent-level alert content (existing logic)
|
||||
const agentContent = !usePolicyAlerts ? (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="rounded-[8px] border border-[color:var(--warn)] border-opacity-30 bg-[color:var(--warn-soft)] px-4 py-3"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<div className="mt-0.5 shrink-0">
|
||||
<Warning size={18} weight="bold" className="text-[color:var(--warn)]" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13px] font-semibold text-[color:var(--warn)]">
|
||||
Budget Alert
|
||||
@@ -88,8 +168,6 @@ export function BudgetAlertBanner({ agents, onNavigate }: BudgetAlertBannerProps
|
||||
View Costs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dismiss button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
@@ -101,9 +179,9 @@ export function BudgetAlertBanner({ agents, onNavigate }: BudgetAlertBannerProps
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
) : null;
|
||||
|
||||
if (dismissed) return null;
|
||||
const content = usePolicyAlerts ? policyContent : agentContent;
|
||||
|
||||
if (reducedMotion) {
|
||||
return content;
|
||||
|
||||
@@ -30,6 +30,7 @@ import { BudgetAlertBanner } from "./BudgetAlertBanner";
|
||||
|
||||
export interface DashboardProps {
|
||||
companyName?: string;
|
||||
companyId?: string;
|
||||
agents: AgentRecord[];
|
||||
tasks: TaskRecord[];
|
||||
runs: RunRecord[];
|
||||
@@ -44,7 +45,7 @@ export interface DashboardProps {
|
||||
|
||||
export function Dashboard(props: DashboardProps) {
|
||||
const t = useT();
|
||||
const { companyName, agents, tasks, runs, approvals, activity, costs, messages = [], onNavigate } = props;
|
||||
const { companyName, companyId, agents, tasks, runs, approvals, activity, costs, messages = [], onNavigate } = props;
|
||||
|
||||
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
|
||||
const taskMap = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]);
|
||||
@@ -95,7 +96,7 @@ export function Dashboard(props: DashboardProps) {
|
||||
|
||||
{/* Conditional banners */}
|
||||
<RecoveryBanner />
|
||||
<BudgetAlertBanner agents={agents} onNavigate={onNavigate} />
|
||||
<BudgetAlertBanner agents={agents} onNavigate={onNavigate} companyId={companyId} />
|
||||
|
||||
{/* Key Metrics — 4 cards */}
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
|
||||
@@ -1,11 +1,31 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Warning } from "@phosphor-icons/react"
|
||||
import type { ProfileSnapshot } from "@shared/types"
|
||||
import type { BudgetStatusSummary, ProfileSnapshot } from "@shared/types"
|
||||
import { formatMoney } from "../../lib/formatters"
|
||||
import { ListFrame, ListRow } from "../ui"
|
||||
|
||||
export function BudgetSummary({ agents, costs }: { agents: ProfileSnapshot["agents"]; costs: ProfileSnapshot["costs"] }) {
|
||||
interface BudgetSummaryProps {
|
||||
agents: ProfileSnapshot["agents"]
|
||||
costs: ProfileSnapshot["costs"]
|
||||
companyId?: string
|
||||
}
|
||||
|
||||
export function BudgetSummary({ agents, costs, companyId }: BudgetSummaryProps) {
|
||||
const [budgetStatus, setBudgetStatus] = useState<BudgetStatusSummary | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!companyId) return
|
||||
void (async () => {
|
||||
const result = await window.agentCompany.getBudgetStatus(companyId)
|
||||
if (result.ok) setBudgetStatus(result.data)
|
||||
})()
|
||||
}, [companyId])
|
||||
|
||||
const knownSpend = costs.reduce((sum, entry) => sum + (entry.amountUsd ?? 0), 0)
|
||||
const unattributedCount = costs.filter((entry) => entry.unattributed).length
|
||||
|
||||
const hasPolicies = budgetStatus && budgetStatus.policies.length > 0
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="border-y border-[color:var(--line)]">
|
||||
@@ -18,30 +38,68 @@ export function BudgetSummary({ agents, costs }: { agents: ProfileSnapshot["agen
|
||||
<div className="mono text-[18px] text-[color:var(--text)]">{String(unattributedCount)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<ListFrame>
|
||||
{agents.map((agent) => {
|
||||
const pct = agent.budgetMonthlyUsd > 0 ? (agent.spentMonthlyUsd / agent.budgetMonthlyUsd) * 100 : 0
|
||||
const isWarning = pct > 80 && pct <= 100; const isDanger = pct > 100
|
||||
const barColor = isDanger ? "bg-[color:var(--danger)]" : isWarning ? "bg-[color:var(--warn)]" : "bg-[color:var(--success)]"
|
||||
const nameColor = isDanger ? "text-[color:var(--danger)]" : isWarning ? "text-[color:var(--warn)]" : "text-[color:var(--text)]"
|
||||
return (
|
||||
<ListRow key={agent.id}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`text-[14px] font-medium ${nameColor}`}>{agent.name}</div>
|
||||
{isDanger ? <Warning size={14} weight="fill" className="text-[color:var(--danger)]" /> : null}
|
||||
{isWarning ? <Warning size={14} weight="fill" className="text-[color:var(--warn)]" /> : null}
|
||||
|
||||
{hasPolicies ? (
|
||||
<ListFrame>
|
||||
{budgetStatus.policies.map((status) => {
|
||||
const pct = status.amount > 0 ? (status.spent / status.amount) * 100 : 0
|
||||
const isWarning = pct > 80 && pct <= 100
|
||||
const isDanger = pct > 100
|
||||
const barColor = isDanger ? "bg-[color:var(--danger)]" : isWarning ? "bg-[color:var(--warn)]" : "bg-[color:var(--success)]"
|
||||
const nameColor = isDanger ? "text-[color:var(--danger)]" : isWarning ? "text-[color:var(--warn)]" : "text-[color:var(--text)]"
|
||||
|
||||
const scopeLabel = status.scopeType === "company"
|
||||
? "Company"
|
||||
: status.scopeType === "agent"
|
||||
? (agents.find((a) => a.id === status.scopeId)?.name ?? status.scopeId.slice(0, 8))
|
||||
: status.scopeId.slice(0, 8)
|
||||
|
||||
return (
|
||||
<ListRow key={status.policyId}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-full bg-[color:var(--panel-soft)] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em] text-[color:var(--muted-strong)]">
|
||||
{status.scopeType}
|
||||
</span>
|
||||
<div className={`text-[14px] font-medium ${nameColor}`}>{scopeLabel}</div>
|
||||
{status.hasOpenIncident ? <Warning size={14} weight="fill" className="text-[color:var(--warn)]" /> : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mono text-[12px] text-[color:var(--text)]">{formatMoney(status.spent / 100)} / {formatMoney(status.amount / 100)}</div>
|
||||
<span className={`mono text-[11px] font-semibold ${isDanger ? "text-[color:var(--danger)]" : isWarning ? "text-[color:var(--warn)]" : "text-[color:var(--muted)]"}`}>{Math.round(pct)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mono text-[12px] text-[color:var(--text)]">{formatMoney(agent.spentMonthlyUsd)} / {formatMoney(agent.budgetMonthlyUsd)}</div>
|
||||
<span className={`mono text-[11px] font-semibold ${isDanger ? "text-[color:var(--danger)]" : isWarning ? "text-[color:var(--warn)]" : "text-[color:var(--muted)]"}`}>{Math.round(pct)}%</span>
|
||||
<div className="h-2 rounded-full bg-[color:var(--line)]"><div className={`h-2 rounded-full ${barColor}`} style={{ width: `${Math.min(100, pct)}%` }} /></div>
|
||||
</ListRow>
|
||||
)
|
||||
})}
|
||||
</ListFrame>
|
||||
) : (
|
||||
<ListFrame>
|
||||
{agents.map((agent) => {
|
||||
const pct = agent.budgetMonthlyUsd > 0 ? (agent.spentMonthlyUsd / agent.budgetMonthlyUsd) * 100 : 0
|
||||
const isWarning = pct > 80 && pct <= 100; const isDanger = pct > 100
|
||||
const barColor = isDanger ? "bg-[color:var(--danger)]" : isWarning ? "bg-[color:var(--warn)]" : "bg-[color:var(--success)]"
|
||||
const nameColor = isDanger ? "text-[color:var(--danger)]" : isWarning ? "text-[color:var(--warn)]" : "text-[color:var(--text)]"
|
||||
return (
|
||||
<ListRow key={agent.id}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`text-[14px] font-medium ${nameColor}`}>{agent.name}</div>
|
||||
{isDanger ? <Warning size={14} weight="fill" className="text-[color:var(--danger)]" /> : null}
|
||||
{isWarning ? <Warning size={14} weight="fill" className="text-[color:var(--warn)]" /> : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mono text-[12px] text-[color:var(--text)]">{formatMoney(agent.spentMonthlyUsd)} / {formatMoney(agent.budgetMonthlyUsd)}</div>
|
||||
<span className={`mono text-[11px] font-semibold ${isDanger ? "text-[color:var(--danger)]" : isWarning ? "text-[color:var(--warn)]" : "text-[color:var(--muted)]"}`}>{Math.round(pct)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-[color:var(--line)]"><div className={`h-2 rounded-full ${barColor}`} style={{ width: `${Math.min(100, pct)}%` }} /></div>
|
||||
</ListRow>
|
||||
)
|
||||
})}
|
||||
</ListFrame>
|
||||
<div className="h-2 rounded-full bg-[color:var(--line)]"><div className={`h-2 rounded-full ${barColor}`} style={{ width: `${Math.min(100, pct)}%` }} /></div>
|
||||
</ListRow>
|
||||
)
|
||||
})}
|
||||
</ListFrame>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Pencil, Trash } from "@phosphor-icons/react";
|
||||
import type { AgentRecord, ConnectorRecord, WorkspaceRecord } from "@shared/types";
|
||||
import type { AgentRecord, BudgetPolicyRecord, ConnectorRecord, WorkspaceRecord } from "@shared/types";
|
||||
import { getConnectorExecutionReadinessIssue, isConnectorExecutionReady } from "@shared/connector-policy";
|
||||
import { unwrap } from "../../lib/desktop";
|
||||
import { useAppStore } from "../../state/store";
|
||||
@@ -87,6 +87,23 @@ export function AgentEditor({
|
||||
}
|
||||
}, [companyId, skills.length, setSkills]);
|
||||
|
||||
// Load agent's budget policy (D-11: read from budget_policies)
|
||||
const [agentPolicy, setAgentPolicy] = useState<BudgetPolicyRecord | null>(null);
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const result = await window.agentCompany.listBudgetPolicies(companyId);
|
||||
if (result.ok) {
|
||||
const policy = result.data.find(
|
||||
(p) => p.scopeType === "agent" && p.scopeId === agent.id
|
||||
);
|
||||
if (policy) {
|
||||
setAgentPolicy(policy);
|
||||
setBudget(String((policy.amount / 100).toFixed(2)));
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [companyId, agent.id]);
|
||||
|
||||
const managerCandidates = agents.filter((a) => a.id !== agent.id);
|
||||
|
||||
return (
|
||||
@@ -194,6 +211,7 @@ export function AgentEditor({
|
||||
if (thinkingEffort) meta.thinkingEffort = thinkingEffort;
|
||||
else delete meta.thinkingEffort;
|
||||
try {
|
||||
const budgetDollars = Number(budget || 0);
|
||||
unwrap(await window.agentCompany.saveAgent({
|
||||
id: agent.id,
|
||||
companyId,
|
||||
@@ -206,9 +224,24 @@ export function AgentEditor({
|
||||
reportsTo: reportsTo || null,
|
||||
model: model || null,
|
||||
metadataJson: JSON.stringify(meta),
|
||||
budgetMonthlyUsd: Number(budget || 0),
|
||||
budgetMonthlyUsd: budgetDollars,
|
||||
capabilities,
|
||||
}));
|
||||
// D-11: Also write to budget_policies for policy-based enforcement
|
||||
if (budgetDollars > 0 && agent.id) {
|
||||
await window.agentCompany.saveBudgetPolicy({
|
||||
companyId,
|
||||
scopeType: "agent",
|
||||
scopeId: agent.id,
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: Math.round(budgetDollars * 100),
|
||||
warnPercent: agentPolicy?.warnPercent ?? 80,
|
||||
hardStopEnabled: agentPolicy?.hardStopEnabled ?? true,
|
||||
notifyEnabled: agentPolicy?.notifyEnabled ?? true,
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
await onSaved();
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof Error ? error.message : "Failed to save agent.");
|
||||
|
||||
Reference in new issue
Block a user