Files
aiops/frontend/src/components/cloud/CloudBilling.tsx
T
attacker c68aef4444 feat(alert): A2 告警引擎分布式安全 — Redis 分布式锁(SetNX)
- Cache 接口新增 SetNX 方法(go-redis 原子 / redka check-then-set 单副本安全)
- AlertEngine 改造为支持分布式锁: tryAcquireLock/releaseLock/GetEngineStatus
- 多副本部署时仅 Leader 执行告警检查, 60s TTL 防死锁, fail-open 容错
- 新增 GET /alerts/engine/status 监控端点(is_leader/instance_id/lock_holder)
- 修复 GrafanaDashboard.tsx Panel.options 类型不匹配导致构建中断
- 同步未提交的前端类型安全迁移(140+ 文件)
- ROADMAP/TDD_PLAN/PROJECT_EVALUATION 三文档同步更新

验证: go build 零错误, Docker 部署 healthy, 引擎状态端点正常,
锁竞争测试通过(非 Leader 跳过检查), 5/5 核心 API 回归通过
2026-06-26 21:13:15 +08:00

450 lines
19 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Plus,
Trash2,
Pencil,
RefreshCw,
Loader2,
Wallet,
Bell,
} from 'lucide-react';
import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { billingApi } from '@/api';
import { formatCurrency, TrendChart, providerLabels, providerColors } from './CloudBillingHelpers';
import { StatStrip } from '@/components/ui/stat-strip';
import { BillingRecordDialog } from './BillingRecordDialog';
import { BillingRuleDialog } from './BillingRuleDialog';
import type { JsonData } from '@/types/common';
export function CloudBilling() {
const confirm = useConfirm();
const [activeTab, setActiveTab] = useState<'overview' | 'records' | 'alerts'>('overview');
const [stats, setStats] = useState<JsonData>(null);
const [statsLoading, setStatsLoading] = useState(true);
const [records, setRecords] = useState<JsonData[]>([]);
const [recordsLoading, setRecordsLoading] = useState(true);
const [filterProvider, setFilterProvider] = useState('');
const [filterStart, setFilterStart] = useState('');
const [filterEnd, setFilterEnd] = useState('');
const [rules, setRules] = useState<JsonData[]>([]);
const [rulesLoading, setRulesLoading] = useState(true);
// 弹窗状态(open + 编辑目标) // Logan.Li
const [recordDialogOpen, setRecordDialogOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState<JsonData>(null);
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [editingRule, setEditingRule] = useState<JsonData>(null);
const loadStats = useCallback(async () => {
setStatsLoading(true);
try {
const res = await billingApi.getStats();
setStats(res);
} catch (e) {
toast.error('加载统计失败', { description: (e as JsonData).message });
} finally {
setStatsLoading(false);
}
}, []);
const loadRecords = useCallback(async () => {
setRecordsLoading(true);
try {
const params: JsonData = {};
if (filterProvider) params.provider = filterProvider;
if (filterStart) params.start_date = filterStart;
if (filterEnd) params.end_date = filterEnd;
const res = await billingApi.list(params);
setRecords(res.list || []);
} catch (e) {
toast.error('加载账单失败', { description: (e as JsonData).message });
} finally {
setRecordsLoading(false);
}
}, [filterProvider, filterStart, filterEnd]);
const loadRules = useCallback(async () => {
setRulesLoading(true);
try {
const res = await billingApi.listAlertRules();
setRules(res.list || []);
} catch (e) {
toast.error('加载预警规则失败', { description: (e as JsonData).message });
} finally {
setRulesLoading(false);
}
}, []);
useEffect(() => {
loadStats();
}, [loadStats]);
useEffect(() => {
if (activeTab === 'records') loadRecords();
}, [activeTab, loadRecords]);
useEffect(() => {
if (activeTab === 'alerts') loadRules();
}, [activeTab, loadRules]);
const openAddRecord = () => {
setEditingRecord(null);
setRecordDialogOpen(true);
};
const openEditRecord = (r: JsonData) => {
setEditingRecord(r);
setRecordDialogOpen(true);
};
const openAddRule = () => {
setEditingRule(null);
setRuleDialogOpen(true);
};
const openEditRule = (r: JsonData) => {
setEditingRule(r);
setRuleDialogOpen(true);
};
const handleDeleteRecord = async (id: number) => {
const ok = await confirm({ title: '删除账单记录', description: '确定删除这条成本记录?', variant: 'destructive' });
if (!ok) return;
try {
await billingApi.remove(id);
loadRecords();
loadStats();
toast('删除成功');
} catch (e) {
toast.error('删除失败', { description: (e as JsonData).message });
}
};
const handleDeleteRule = async (id: number) => {
const ok = await confirm({ title: '删除预警规则', description: '确定删除此预警规则?', variant: 'destructive' });
if (!ok) return;
try {
await billingApi.deleteAlertRule(id);
loadRules();
toast('删除成功');
} catch (e) {
toast.error('删除失败', { description: (e as JsonData).message });
}
};
const handleToggleRule = async (id: number) => {
try {
await billingApi.toggleAlertRule(id);
loadRules();
loadStats();
} catch (e) {
toast.error('操作失败', { description: (e as JsonData).message });
}
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-100">云账单管理</h2>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => { loadStats(); if (activeTab === 'records') loadRecords(); if (activeTab === 'alerts') loadRules(); }}>
<RefreshCw className="h-4 w-4 mr-1" />刷新
</Button>
{activeTab === 'records' && (
<Button size="sm" onClick={openAddRecord}>
<Plus className="h-4 w-4 mr-1" />记录成本
</Button>
)}
{activeTab === 'alerts' && (
<Button size="sm" onClick={openAddRule}>
<Plus className="h-4 w-4 mr-1" />添加规则
</Button>
)}
</div>
</div>
<div className="flex gap-1 border-b border-slate-800">
{([
{ key: 'overview', label: '概览统计' },
{ key: 'records', label: '成本记录' },
{ key: 'alerts', label: '预警规则' },
] as const).map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeTab === tab.key
? 'text-blue-400 border-blue-400'
: 'text-slate-400 border-transparent hover:text-slate-200'
}`}
>
{tab.label}
</button>
))}
</div>
{activeTab === 'overview' && (
<div className="space-y-4">
{statsLoading ? (
<div className="flex items-center justify-center py-20 text-slate-500"><Loader2 className="h-6 w-6 animate-spin mr-2" />加载中...</div>
) : stats ? (
<>
<StatStrip
items={[
{ label: '今日', value: formatCurrency(stats.today_cost || 0), color: 'blue' },
{ label: '本月', value: formatCurrency(stats.month_cost || 0), color: 'amber' },
{ label: '年度', value: formatCurrency(stats.year_cost || 0), color: 'purple' },
{ label: '预警', value: `${stats.alerts?.length || 0} 条`, color: stats.alerts?.length > 0 ? 'red' : 'muted' },
]}
/>
{stats.alerts?.length > 0 && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4">
<div className="flex items-center gap-2 mb-3">
<Bell className="h-4 w-4 text-amber-400" />
<span className="text-sm font-medium text-amber-300">成本预警</span>
</div>
<div className="space-y-2">
{stats.alerts.map((a: JsonData, i: number) => (
<div key={i} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Badge variant="outline" className="border-amber-500/50 text-amber-300">{a.rule_name}</Badge>
<span className="text-slate-400">
{a.provider} · {a.metric === 'daily' ? '日' : '月'}成本
</span>
</div>
<span className="text-slate-300">
当前 <span className="text-amber-300 font-medium">{formatCurrency(a.current)}</span>
{' '}{a.operator}{' '}
阈值 <span className="text-red-400 font-medium">{formatCurrency(a.threshold)}</span>
</span>
</div>
))}
</div>
</div>
)}
<Card className="bg-slate-900/50 border-slate-800">
<CardContent className="pt-4">
<h3 className="text-sm font-medium text-slate-200 mb-4">近 30 天成本趋势</h3>
<TrendChart data={stats.month_trend || []} />
</CardContent>
</Card>
<Card className="bg-slate-900/50 border-slate-800">
<CardContent className="pt-4">
<h3 className="text-sm font-medium text-slate-200 mb-4">本月厂商成本分布</h3>
{(stats.provider_dist || []).length === 0 ? (
<p className="text-sm text-slate-500 py-8 text-center">暂无数据</p>
) : (
<div className="space-y-3">
<div className="flex h-8 rounded-lg overflow-hidden">
{stats.provider_dist.map((p: JsonData, i: number) => (
<div
key={i}
className={`${providerColors[p.provider] || 'bg-slate-600'} flex items-center justify-center text-xs text-white font-medium`}
style={{ width: `${p.percent}%` }}
title={`${providerLabels[p.provider] || p.provider}: ${formatCurrency(p.cost)} (${p.percent.toFixed(1)}%)`}
>
{p.percent > 8 ? (providerLabels[p.provider] || p.provider) : ''}
</div>
))}
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{stats.provider_dist.map((p: JsonData, i: number) => (
<div key={i} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<div className={`w-3 h-3 rounded ${providerColors[p.provider] || 'bg-slate-600'}`} />
<span className="text-slate-300">{providerLabels[p.provider] || p.provider}</span>
</div>
<div className="text-right">
<span className="text-slate-200 font-medium">{formatCurrency(p.cost)}</span>
<span className="text-slate-500 ml-2">{p.percent.toFixed(1)}%</span>
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
</>
) : null}
</div>
)}
{activeTab === 'records' && (
<div className="space-y-3">
<div className="flex flex-wrap items-end gap-3">
<div>
<Label className="text-xs text-slate-400 mb-1">厂商</Label>
<Select value={filterProvider || 'all'} onValueChange={(v) => setFilterProvider(v === 'all' ? '' : v)}>
<SelectTrigger className="w-32 h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">全部</SelectItem>
{Object.entries(providerLabels).map(([k, v]) => (
<SelectItem key={k} value={k}>{v}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-slate-400 mb-1">开始日期</Label>
<Input type="date" className="w-40 h-9" value={filterStart} onChange={(e) => setFilterStart(e.target.value)} />
</div>
<div>
<Label className="text-xs text-slate-400 mb-1">结束日期</Label>
<Input type="date" className="w-40 h-9" value={filterEnd} onChange={(e) => setFilterEnd(e.target.value)} />
</div>
<Button variant="outline" size="sm" onClick={loadRecords}>
<RefreshCw className="h-4 w-4 mr-1" />查询
</Button>
</div>
{recordsLoading ? (
<div className="flex items-center justify-center py-20 text-slate-500"><Loader2 className="h-6 w-6 animate-spin mr-2" />加载中...</div>
) : records.length === 0 ? (
<div className="py-20 text-center text-slate-500">
<Wallet className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">暂无成本记录</p>
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-slate-800">
<table className="w-full text-sm">
<thead className="bg-slate-900/80 text-slate-400">
<tr>
<th className="px-4 py-2.5 text-left font-medium">日期</th>
<th className="px-4 py-2.5 text-left font-medium">厂商</th>
<th className="px-4 py-2.5 text-left font-medium">服务</th>
<th className="px-4 py-2.5 text-left font-medium">区域</th>
<th className="px-4 py-2.5 text-left font-medium">账号</th>
<th className="px-4 py-2.5 text-right font-medium">费用</th>
<th className="px-4 py-2.5 text-left font-medium">说明</th>
<th className="px-4 py-2.5 text-center font-medium">操作</th>
</tr>
</thead>
<tbody>
{records.map((r) => (
<tr key={r.id} className="border-t border-slate-800 hover:bg-slate-800/30">
<td className="px-4 py-2.5 text-slate-300">{r.billing_date}</td>
<td className="px-4 py-2.5">
<span className="inline-flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${providerColors[r.provider] || 'bg-slate-500'}`} />
{providerLabels[r.provider] || r.provider}
</span>
</td>
<td className="px-4 py-2.5 text-slate-400">{r.service || '-'}</td>
<td className="px-4 py-2.5 text-slate-400">{r.region || '-'}</td>
<td className="px-4 py-2.5 text-slate-400">{r.account_name || '-'}</td>
<td className="px-4 py-2.5 text-right text-slate-200 font-medium">{formatCurrency(r.cost)}</td>
<td className="px-4 py-2.5 text-slate-500 max-w-xs truncate">{r.description || '-'}</td>
<td className="px-4 py-2.5">
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openEditRecord(r)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300" onClick={() => handleDeleteRecord(r.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{activeTab === 'alerts' && (
<div className="space-y-3">
{rulesLoading ? (
<div className="flex items-center justify-center py-20 text-slate-500"><Loader2 className="h-6 w-6 animate-spin mr-2" />加载中...</div>
) : rules.length === 0 ? (
<div className="py-20 text-center text-slate-500">
<Bell className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">暂无预警规则</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{rules.map((r) => (
<Card key={r.id} className="bg-slate-900/50 border-slate-800">
<CardContent className="pt-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Bell className={`h-4 w-4 ${r.enabled ? 'text-amber-400' : 'text-slate-600'}`} />
<span className="text-sm font-medium text-slate-200">{r.name}</span>
</div>
<Switch checked={r.enabled} onCheckedChange={() => handleToggleRule(r.id)} />
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<span className="text-slate-500">厂商</span>
<span className="ml-2 text-slate-300">{r.provider ? (providerLabels[r.provider] || r.provider) : '全部'}</span>
</div>
<div>
<span className="text-slate-500">指标</span>
<span className="ml-2 text-slate-300">{r.metric === 'daily' ? '日成本' : '月累计'}</span>
</div>
<div>
<span className="text-slate-500">阈值</span>
<span className="ml-2 text-amber-300 font-medium">{r.operator} {formatCurrency(r.threshold)}</span>
</div>
{r.last_triggered && (
<div>
<span className="text-slate-500">最后触发</span>
<span className="ml-2 text-slate-400">{new Date(r.last_triggered).toLocaleString('zh-CN')}</span>
</div>
)}
</div>
<div className="flex justify-end gap-1 mt-3">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openEditRule(r)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300" onClick={() => handleDeleteRule(r.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
)}
<BillingRecordDialog
open={recordDialogOpen}
onOpenChange={setRecordDialogOpen}
editingRecord={editingRecord}
onSuccess={() => { loadRecords(); loadStats(); }}
/>
<BillingRuleDialog
open={ruleDialogOpen}
onOpenChange={setRuleDialogOpen}
editingRule={editingRule}
onSuccess={() => { loadRules(); loadStats(); }}
/>
</div>
);
}