feat: AI预测分析接入真实后端数据 + 修复CloudBilling文件损坏
- AIPredictiveAnalytics: 替换3组硬编码mock为实时数据驱动预测 - 指标预测: 基于dashboard metrics时序数据(cpu/memory/disk)线性回归计算斜率与R² - 容量规划: 基于日均增长率推算阈值触及时间(天),动态生成AI扩容建议 - 异常预测: 基于告警规则阈值+当前趋势+事件频率计算异常触发概率 - 概览卡片数据驱动化(置信度基于R²拟合度,高风险/容量预警计数实时计算) - CloudBilling.tsx: 修复read_file导致的行号前缀损坏(501行全部含N|前缀)
This commit is contained in:
1 parent
0ebe22d142
commit
2da4f52209
17 files changed
+2299
-322
No files matched your search
@@ -2,17 +2,16 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import { MainLayout } from '@/components/layout/MainLayout';
|
||||
import { ConfirmProvider } from '@/components/ui/confirm-dialog';
|
||||
import { Login } from '@/components/auth/Login';
|
||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||
import { Dashboard } from '@/components/dashboard/Dashboard';
|
||||
import { ServerManager } from '@/components/server/ServerManager';
|
||||
import { WebShell } from '@/components/server/WebShell';
|
||||
import { BatchImport } from '@/components/server/BatchImport';
|
||||
import { ResourceModelManager } from '@/components/server/ResourceModelManager';
|
||||
import { CloudAccountManager } from '@/components/cloud/CloudAccountManager';
|
||||
import { DomainManager } from '@/components/cloud/DomainManager';
|
||||
import { RegionManager } from '@/components/cloud/RegionManager';
|
||||
import { PlatformManager } from '@/components/cloud/PlatformManager';
|
||||
import { CloudBilling } from '@/components/cloud/CloudBilling';
|
||||
import { K8sClusters } from '@/components/k8s/K8sClusters';
|
||||
import { TopologyView } from '@/components/k8s/TopologyView';
|
||||
import { AlertManager } from '@/components/monitor/AlertManager';
|
||||
@@ -50,19 +49,18 @@ function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConfirmProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={<ProtectedRoute><MainLayout /></ProtectedRoute>}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="servers" element={<ServerManager />} />
|
||||
<Route path="webshell" element={<WebShell />} />
|
||||
<Route path="batch-import" element={<BatchImport />} />
|
||||
<Route path="resource-models" element={<ResourceModelManager />} />
|
||||
<Route path="cloud-accounts" element={<CloudAccountManager />} />
|
||||
<Route path="domains" element={<DomainManager />} />
|
||||
<Route path="regions" element={<RegionManager />} />
|
||||
<Route path="platforms" element={<PlatformManager />} />
|
||||
<Route path="cloud-billing" element={<CloudBilling />} />
|
||||
<Route path="clusters" element={<K8sClusters />} />
|
||||
<Route path="nodes" element={<K8sClusters />} />
|
||||
<Route path="workloads" element={<K8sClusters />} />
|
||||
@@ -102,6 +100,7 @@ function App() {
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ConfirmProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -18,25 +16,22 @@ import {
|
||||
TrendingDown,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Server,
|
||||
HardDrive,
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
HardDrive,
|
||||
Network,
|
||||
Database,
|
||||
Activity,
|
||||
BarChart3,
|
||||
LineChart,
|
||||
PieChart,
|
||||
Calendar,
|
||||
Sparkles,
|
||||
Zap,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
Minus,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { dashboardStatsApi, serverApi, alertApi } from '@/services/api';
|
||||
|
||||
interface PredictionMetric {
|
||||
name: string;
|
||||
@@ -50,6 +45,7 @@ interface PredictionMetric {
|
||||
|
||||
interface CapacityForecast {
|
||||
resource: string;
|
||||
metricKey: string;
|
||||
currentUsage: number;
|
||||
predictedUsage: number;
|
||||
threshold: number;
|
||||
@@ -65,73 +61,279 @@ interface AnomalyPrediction {
|
||||
suggestedAction: string;
|
||||
}
|
||||
|
||||
const mockPredictions: PredictionMetric[] = [
|
||||
{ name: 'CPU使用率', current: 45, predicted: 68, unit: '%', trend: 'up', confidence: 87, timeRange: '24小时后' },
|
||||
{ name: '内存使用率', current: 62, predicted: 58, unit: '%', trend: 'down', confidence: 82, timeRange: '24小时后' },
|
||||
{ name: '磁盘使用率', current: 78, predicted: 82, unit: '%', trend: 'up', confidence: 95, timeRange: '7天后' },
|
||||
{ name: '网络流量', current: 450, predicted: 680, unit: 'Mbps', trend: 'up', confidence: 76, timeRange: '1小时后' },
|
||||
];
|
||||
/** 指标定义:后端 metric name → 展示标签 / 告警表达式前缀 / 阈值 */
|
||||
const METRIC_DEFS = [
|
||||
{ key: 'cpu', label: 'CPU使用率', exprKey: 'cpu_usage', threshold: 85 },
|
||||
{ key: 'memory', label: '内存使用率', exprKey: 'memory_usage', threshold: 85 },
|
||||
{ key: 'disk', label: '磁盘使用率', exprKey: 'disk_usage', threshold: 90 },
|
||||
] as const;
|
||||
|
||||
const mockCapacityForecasts: CapacityForecast[] = [
|
||||
{
|
||||
resource: '数据库磁盘空间',
|
||||
currentUsage: 78,
|
||||
predictedUsage: 95,
|
||||
threshold: 90,
|
||||
daysUntilThreshold: 12,
|
||||
recommendation: '建议在未来10天内扩容磁盘空间或清理历史数据',
|
||||
},
|
||||
{
|
||||
resource: '应用服务器内存',
|
||||
currentUsage: 62,
|
||||
predictedUsage: 88,
|
||||
threshold: 85,
|
||||
daysUntilThreshold: 5,
|
||||
recommendation: '内存增长趋势明显,建议增加内存或优化应用',
|
||||
},
|
||||
{
|
||||
resource: 'Redis缓存容量',
|
||||
currentUsage: 45,
|
||||
predictedUsage: 72,
|
||||
threshold: 80,
|
||||
daysUntilThreshold: 21,
|
||||
recommendation: '缓存使用正常,建议持续监控',
|
||||
},
|
||||
];
|
||||
const TIME_RANGE_LABELS: Record<string, string> = {
|
||||
'1h': '1小时后',
|
||||
'24h': '24小时后',
|
||||
'7d': '7天后',
|
||||
'30d': '30天后',
|
||||
};
|
||||
|
||||
const mockAnomalyPredictions: AnomalyPrediction[] = [
|
||||
{
|
||||
metric: '订单服务响应时间',
|
||||
predictedAnomaly: '响应时间可能超过阈值',
|
||||
probability: 73,
|
||||
timeframe: '未来2小时',
|
||||
suggestedAction: '检查订单服务负载,考虑扩容',
|
||||
},
|
||||
{
|
||||
metric: '数据库连接数',
|
||||
predictedAnomaly: '连接数可能达到上限',
|
||||
probability: 65,
|
||||
timeframe: '未来4小时',
|
||||
suggestedAction: '优化连接池配置,检查慢查询',
|
||||
},
|
||||
{
|
||||
metric: '日志写入速率',
|
||||
predictedAnomaly: '磁盘IO可能达到瓶颈',
|
||||
probability: 45,
|
||||
timeframe: '未来6小时',
|
||||
suggestedAction: '监控磁盘IO,考虑日志归档',
|
||||
},
|
||||
];
|
||||
const TIME_RANGE_HOURS: Record<string, number> = {
|
||||
'1h': 1,
|
||||
'24h': 24,
|
||||
'7d': 168,
|
||||
'30d': 720,
|
||||
};
|
||||
|
||||
/** 简单最小二乘线性回归,返回斜率和 R² */
|
||||
function linearRegression(values: number[]): { slope: number; r2: number } {
|
||||
const n = values.length;
|
||||
if (n < 2) return { slope: 0, r2: 0 };
|
||||
const xs = values.map((_, i) => i);
|
||||
const meanX = xs.reduce((a, b) => a + b, 0) / n;
|
||||
const meanY = values.reduce((a, b) => a + b, 0) / n;
|
||||
let num = 0,
|
||||
den = 0,
|
||||
ssTot = 0,
|
||||
ssRes = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
num += (xs[i] - meanX) * (values[i] - meanY);
|
||||
den += (xs[i] - meanX) ** 2;
|
||||
ssTot += (values[i] - meanY) ** 2;
|
||||
}
|
||||
const slope = den === 0 ? 0 : num / den;
|
||||
const intercept = meanY - slope * meanX;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const pred = intercept + slope * xs[i];
|
||||
ssRes += (values[i] - pred) ** 2;
|
||||
}
|
||||
const r2 = ssTot === 0 ? 1 : Math.max(0, 1 - ssRes / ssTot);
|
||||
return { slope, r2 };
|
||||
}
|
||||
|
||||
/** 从时序指标中提取按时间排序的数值序列 */
|
||||
function extractSeries(metrics: any[], name: string): number[] {
|
||||
return metrics
|
||||
.filter((m) => m.name === name)
|
||||
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
|
||||
.map((m) => Number(m.value) || 0);
|
||||
}
|
||||
|
||||
/** 从告警表达式中解析阈值,如 "cpu_usage > 80" → 80 */
|
||||
function parseThreshold(expr: string): number | null {
|
||||
const m = expr.match(/[><=]\s*([\d.]+)/);
|
||||
return m ? parseFloat(m[1]) : null;
|
||||
}
|
||||
|
||||
/** 容量规划建议 */
|
||||
function capacityRecommendation(metricKey: string, days: number): string {
|
||||
const level = days <= 7 ? 'urgent' : days <= 14 ? 'warning' : 'normal';
|
||||
const recs: Record<string, Record<string, string>> = {
|
||||
urgent: {
|
||||
cpu: 'CPU增长趋势明显,建议尽快扩容计算资源或优化高负载进程',
|
||||
memory: '内存增长趋势明显,建议增加内存或排查内存泄漏',
|
||||
disk: '磁盘空间即将耗尽,建议立即扩容或清理历史数据与日志',
|
||||
},
|
||||
warning: {
|
||||
cpu: 'CPU使用率呈上升趋势,建议持续监控并准备扩容计划',
|
||||
memory: '内存使用率呈上升趋势,建议优化应用内存使用',
|
||||
disk: '磁盘使用率持续增长,建议规划数据归档和清理策略',
|
||||
},
|
||||
normal: {
|
||||
cpu: 'CPU使用正常,建议持续监控',
|
||||
memory: '内存使用正常,建议持续监控',
|
||||
disk: '磁盘空间充足,建议持续监控',
|
||||
},
|
||||
};
|
||||
return recs[level]?.[metricKey] || '建议持续监控资源使用趋势';
|
||||
}
|
||||
|
||||
/** 异常建议操作 */
|
||||
function anomalyAction(metricKey: string): string {
|
||||
const actions: Record<string, string> = {
|
||||
cpu: '检查高负载进程,优化CPU密集型任务,考虑横向扩容',
|
||||
memory: '排查内存泄漏,优化进程内存配置,考虑增加节点',
|
||||
disk: '清理历史数据和日志,实施数据归档策略,扩容磁盘',
|
||||
service: '检查服务健康状态与依赖组件,查看服务日志',
|
||||
pod: '检查Pod重启原因,查看容器日志,排查OOM或健康检查失败',
|
||||
};
|
||||
return actions[metricKey] || '建议检查相关服务状态并持续监控';
|
||||
}
|
||||
|
||||
export function AIPredictiveAnalytics() {
|
||||
const [selectedTimeRange, setSelectedTimeRange] = useState('24h');
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setIsAnalyzing(true);
|
||||
setTimeout(() => setIsAnalyzing(false), 1500);
|
||||
// 原始数据
|
||||
const [rawMetrics, setRawMetrics] = useState<any[]>([]);
|
||||
const [serverStats, setServerStats] = useState<any>({});
|
||||
const [alertEvents, setAlertEvents] = useState<any[]>([]);
|
||||
const [alertRules, setAlertRules] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [metricsData, statsResp, alertsResp, rulesResp] = await Promise.all([
|
||||
dashboardStatsApi.getMetrics(72),
|
||||
serverApi.getServerStats(),
|
||||
alertApi.getAlerts({ page: 1, page_size: 100 }),
|
||||
alertApi.getRules(),
|
||||
]);
|
||||
setRawMetrics(Array.isArray(metricsData) ? metricsData : []);
|
||||
setServerStats(statsResp || {});
|
||||
const alertsRaw = (alertsResp as any)?.list || (alertsResp as any) || [];
|
||||
setAlertEvents(Array.isArray(alertsRaw) ? alertsRaw : []);
|
||||
const rulesRaw = (rulesResp as any)?.list || (rulesResp as any) || [];
|
||||
setAlertRules(Array.isArray(rulesRaw) ? rulesRaw : []);
|
||||
} catch {
|
||||
// 保持空数据,组件显示空状态
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsAnalyzing(true);
|
||||
await loadData();
|
||||
setIsAnalyzing(false);
|
||||
};
|
||||
|
||||
// ============ 计算指标预测 ============
|
||||
const horizonHours = TIME_RANGE_HOURS[selectedTimeRange] || 24;
|
||||
const timeRangeLabel = TIME_RANGE_LABELS[selectedTimeRange] || '24小时后';
|
||||
|
||||
const predictions: PredictionMetric[] = METRIC_DEFS.map((def) => {
|
||||
const series = extractSeries(rawMetrics, def.key);
|
||||
const { slope, r2 } = linearRegression(series);
|
||||
const avgFallback =
|
||||
def.key === 'cpu' ? serverStats.avg_cpu : def.key === 'memory' ? serverStats.avg_memory : serverStats.avg_disk;
|
||||
const current = series.length > 0 ? series[series.length - 1] : avgFallback || 0;
|
||||
let predicted = current + slope * horizonHours;
|
||||
predicted = Math.max(0, Math.min(100, predicted));
|
||||
const trend: 'up' | 'down' | 'stable' = slope > 0.05 ? 'up' : slope < -0.05 ? 'down' : 'stable';
|
||||
const confidence = Math.round(60 + r2 * 35);
|
||||
return {
|
||||
name: def.label,
|
||||
current: Math.round(current),
|
||||
predicted: Math.round(predicted),
|
||||
unit: '%',
|
||||
trend,
|
||||
confidence,
|
||||
timeRange: timeRangeLabel,
|
||||
};
|
||||
});
|
||||
|
||||
// ============ 计算容量规划 ============
|
||||
const capacityForecasts: CapacityForecast[] = METRIC_DEFS.map((def) => {
|
||||
const series = extractSeries(rawMetrics, def.key);
|
||||
const { slope } = linearRegression(series);
|
||||
const avgFallback =
|
||||
def.key === 'cpu' ? serverStats.avg_cpu : def.key === 'memory' ? serverStats.avg_memory : serverStats.avg_disk;
|
||||
const currentUsage = Math.round(series.length > 0 ? series[series.length - 1] : avgFallback || 0);
|
||||
const slopePerDay = slope * 24;
|
||||
const predictedUsage = Math.round(Math.min(100, currentUsage + slopePerDay * 7));
|
||||
const threshold = def.threshold;
|
||||
let daysUntilThreshold = 999;
|
||||
if (slopePerDay > 0.01) {
|
||||
daysUntilThreshold = Math.round((threshold - currentUsage) / slopePerDay);
|
||||
daysUntilThreshold = daysUntilThreshold < 1 ? 1 : daysUntilThreshold;
|
||||
}
|
||||
const resourceNames: Record<string, string> = {
|
||||
cpu: '服务器CPU算力',
|
||||
memory: '服务器内存',
|
||||
disk: '存储磁盘空间',
|
||||
};
|
||||
return {
|
||||
resource: resourceNames[def.key] || def.label,
|
||||
metricKey: def.key,
|
||||
currentUsage,
|
||||
predictedUsage,
|
||||
threshold,
|
||||
daysUntilThreshold,
|
||||
recommendation: capacityRecommendation(def.key, daysUntilThreshold),
|
||||
};
|
||||
}).filter((f) => f.currentUsage > 0);
|
||||
|
||||
// ============ 计算异常预测 ============
|
||||
const anomalyPredictions: AnomalyPrediction[] = [];
|
||||
|
||||
// 基于 metric 告警规则 + 当前趋势计算异常概率
|
||||
for (const rule of alertRules) {
|
||||
if (!rule.enabled) continue;
|
||||
const threshold = parseThreshold(rule.expression || '');
|
||||
const exprLower = (rule.expression || '').toLowerCase();
|
||||
const matchedDef = METRIC_DEFS.find((def) => exprLower.includes(def.exprKey));
|
||||
|
||||
if (matchedDef && threshold) {
|
||||
const series = extractSeries(rawMetrics, matchedDef.key);
|
||||
const { slope } = linearRegression(series);
|
||||
const avgFallback =
|
||||
matchedDef.key === 'cpu'
|
||||
? serverStats.avg_cpu
|
||||
: matchedDef.key === 'memory'
|
||||
? serverStats.avg_memory
|
||||
: serverStats.avg_disk;
|
||||
const current = series.length > 0 ? series[series.length - 1] : avgFallback || 0;
|
||||
const ratio = threshold > 0 ? current / threshold : 0;
|
||||
let probability = Math.round(ratio * 55);
|
||||
if (slope > 0.05) probability += 15;
|
||||
const eventCount = alertEvents.filter(
|
||||
(a) => a.rule_id === rule.id || a.rule_name === rule.name,
|
||||
).length;
|
||||
probability += Math.min(20, eventCount * 3);
|
||||
probability = Math.max(5, Math.min(90, probability));
|
||||
|
||||
// 时间范围:基于斜率估算何时触及阈值
|
||||
let timeframe = '未来24小时';
|
||||
if (slope > 0.01 && threshold > current) {
|
||||
const hours = (threshold - current) / slope;
|
||||
if (hours < 4) timeframe = '未来4小时';
|
||||
else if (hours < 12) timeframe = '未来12小时';
|
||||
else if (hours < 24) timeframe = '未来24小时';
|
||||
else timeframe = `未来${Math.ceil(hours / 24)}天`;
|
||||
}
|
||||
|
||||
anomalyPredictions.push({
|
||||
metric: rule.name,
|
||||
predictedAnomaly: rule.description || `${rule.name}可能触发`,
|
||||
probability,
|
||||
timeframe,
|
||||
suggestedAction: anomalyAction(matchedDef.key),
|
||||
});
|
||||
} else {
|
||||
// 非 metric 规则(如服务不可用、Pod重启)基于事件频率
|
||||
const eventCount = alertEvents.filter(
|
||||
(a) => a.rule_id === rule.id || a.rule_name === rule.name,
|
||||
).length;
|
||||
if (eventCount > 0) {
|
||||
const exprKey = exprLower.includes('service') ? 'service' : exprLower.includes('pod') ? 'pod' : 'service';
|
||||
anomalyPredictions.push({
|
||||
metric: rule.name,
|
||||
predictedAnomaly: rule.description || `${rule.name}可能再次发生`,
|
||||
probability: Math.min(60, 20 + eventCount * 5),
|
||||
timeframe: '未来24小时',
|
||||
suggestedAction: anomalyAction(exprKey),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 去重:同一指标名只保留概率最高的一条
|
||||
const dedupedAnomalies = anomalyPredictions
|
||||
.sort((a, b) => b.probability - a.probability)
|
||||
.filter((item, idx, arr) => arr.findIndex((x) => x.metric === item.metric) === idx);
|
||||
|
||||
// ============ 概览卡片数据 ============
|
||||
const avgR2 = METRIC_DEFS.length > 0
|
||||
? METRIC_DEFS.reduce((sum, def) => sum + linearRegression(extractSeries(rawMetrics, def.key)).r2, 0) /
|
||||
METRIC_DEFS.length
|
||||
: 0;
|
||||
const accuracy = Math.round(78 + avgR2 * 17);
|
||||
const predictedAlertCount = dedupedAnomalies.filter((a) => a.probability >= 50).length;
|
||||
const capacityWarningCount = capacityForecasts.filter((f) => f.daysUntilThreshold <= 14).length;
|
||||
|
||||
const getTrendIcon = (trend: string) => {
|
||||
switch (trend) {
|
||||
case 'up':
|
||||
@@ -164,7 +366,7 @@ export function AIPredictiveAnalytics() {
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-100">AI 预测分析</h1>
|
||||
<p className="text-sm text-slate-500">基于历史数据的智能预测和容量规划</p>
|
||||
<p className="text-sm text-slate-500">基于实时指标时序数据的线性回归预测与容量规划</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -181,7 +383,7 @@ export function AIPredictiveAnalytics() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={handleRefresh}>
|
||||
{isAnalyzing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Activity className="h-4 w-4" />}
|
||||
{isAnalyzing || loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Activity className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,16 +394,15 @@ export function AIPredictiveAnalytics() {
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-400">预测准确率</p>
|
||||
<p className="text-2xl font-bold text-slate-100">87.3%</p>
|
||||
<p className="text-sm text-slate-400">预测置信度</p>
|
||||
<p className="text-2xl font-bold text-slate-100">{accuracy}%</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-lg bg-emerald-500/20 flex items-center justify-center">
|
||||
<CheckCircle className="h-5 w-5 text-emerald-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-2 text-xs text-emerald-400">
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
<span>+2.1% 较上周</span>
|
||||
<div className="flex items-center gap-1 mt-2 text-xs text-slate-500">
|
||||
<span>基于 {rawMetrics.length} 个时序数据点回归拟合 R²</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -210,15 +411,15 @@ export function AIPredictiveAnalytics() {
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-400">预测告警数</p>
|
||||
<p className="text-2xl font-bold text-slate-100">12</p>
|
||||
<p className="text-sm text-slate-400">高风险预测</p>
|
||||
<p className="text-2xl font-bold text-slate-100">{predictedAlertCount}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-lg bg-amber-500/20 flex items-center justify-center">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-2 text-xs text-amber-400">
|
||||
<span>未来24小时内</span>
|
||||
<span>概率 ≥50% 的异常预测</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -228,14 +429,14 @@ export function AIPredictiveAnalytics() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-400">容量预警</p>
|
||||
<p className="text-2xl font-bold text-slate-100">3</p>
|
||||
<p className="text-2xl font-bold text-slate-100">{capacityWarningCount}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-lg bg-red-500/20 flex items-center justify-center">
|
||||
<HardDrive className="h-5 w-5 text-red-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-2 text-xs text-red-400">
|
||||
<span>需要关注</span>
|
||||
<div className="flex items-center gap-1 mt-2 text-xs text-slate-500">
|
||||
<span>14天内触及阈值</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -244,15 +445,15 @@ export function AIPredictiveAnalytics() {
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-400">AI模型版本</p>
|
||||
<p className="text-2xl font-bold text-slate-100">v2.4.1</p>
|
||||
<p className="text-sm text-slate-400">监控指标数</p>
|
||||
<p className="text-2xl font-bold text-slate-100">{predictions.length}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
|
||||
<Sparkles className="h-5 w-5 text-purple-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-2 text-xs text-purple-400">
|
||||
<span>已是最新版本</span>
|
||||
<div className="flex items-center gap-1 mt-2 text-xs text-slate-500">
|
||||
<span>CPU / 内存 / 磁盘</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -284,63 +485,70 @@ export function AIPredictiveAnalytics() {
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="border-purple-500/30 text-purple-400 bg-purple-500/10">
|
||||
<Sparkles className="h-3 w-3 mr-1" />
|
||||
AI 预测
|
||||
线性回归预测
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{mockPredictions.map((prediction, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-4 rounded-lg border border-slate-700 bg-slate-800/50"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{prediction.name.includes('CPU') && <Cpu className="h-4 w-4 text-blue-400" />}
|
||||
{prediction.name.includes('内存') && <MemoryStick className="h-4 w-4 text-emerald-400" />}
|
||||
{prediction.name.includes('磁盘') && <HardDrive className="h-4 w-4 text-amber-400" />}
|
||||
{prediction.name.includes('网络') && <Network className="h-4 w-4 text-purple-400" />}
|
||||
<span className="font-medium text-slate-200">{prediction.name}</span>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-slate-500">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" /> 加载指标数据...
|
||||
</div>
|
||||
) : predictions.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">暂无指标数据</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{predictions.map((prediction, idx) => (
|
||||
<div key={idx} className="p-4 rounded-lg border border-slate-700 bg-slate-800/50">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{prediction.name.includes('CPU') && <Cpu className="h-4 w-4 text-blue-400" />}
|
||||
{prediction.name.includes('内存') && <MemoryStick className="h-4 w-4 text-emerald-400" />}
|
||||
{prediction.name.includes('磁盘') && <HardDrive className="h-4 w-4 text-amber-400" />}
|
||||
{prediction.name.includes('网络') && <Network className="h-4 w-4 text-purple-400" />}
|
||||
<span className="font-medium text-slate-200">{prediction.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{getTrendIcon(prediction.trend)}
|
||||
<span className={`text-sm ${getTrendColor(prediction.trend)}`}>
|
||||
{prediction.trend === 'up' ? '上升' : prediction.trend === 'down' ? '下降' : '平稳'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{getTrendIcon(prediction.trend)}
|
||||
<span className={`text-sm ${getTrendColor(prediction.trend)}`}>
|
||||
{prediction.trend === 'up' ? '上升' : prediction.trend === 'down' ? '下降' : '平稳'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-3">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">当前值</p>
|
||||
<p className="text-lg font-bold text-slate-100">
|
||||
{prediction.current}{prediction.unit}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">预测值 ({prediction.timeRange})</p>
|
||||
<p className={`text-lg font-bold ${getTrendColor(prediction.trend)}`}>
|
||||
{prediction.predicted}{prediction.unit}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4 mb-3">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">当前值</p>
|
||||
<p className="text-lg font-bold text-slate-100">
|
||||
{prediction.current}
|
||||
{prediction.unit}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">预测值 ({prediction.timeRange})</p>
|
||||
<p className={`text-lg font-bold ${getTrendColor(prediction.trend)}`}>
|
||||
{prediction.predicted}
|
||||
{prediction.unit}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-400">置信度</span>
|
||||
<div className="w-16 h-1.5 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-purple-500"
|
||||
style={{ width: `${prediction.confidence}%` }}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-400">置信度</span>
|
||||
<div className="w-16 h-1.5 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-purple-500"
|
||||
style={{ width: `${prediction.confidence}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-purple-400">{prediction.confidence}%</span>
|
||||
</div>
|
||||
<span className="text-xs text-purple-400">{prediction.confidence}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
@@ -353,76 +561,89 @@ export function AIPredictiveAnalytics() {
|
||||
<HardDrive className="h-5 w-5 text-amber-400" />
|
||||
容量规划预警
|
||||
</CardTitle>
|
||||
<span className="text-xs text-slate-500">基于日均增长率推算阈值触及时间</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{mockCapacityForecasts.map((forecast, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-4 rounded-lg border border-slate-700 bg-slate-800/50"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5 text-blue-400" />
|
||||
<span className="font-medium text-slate-200">{forecast.resource}</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
forecast.daysUntilThreshold <= 7
|
||||
? 'border-red-500/30 text-red-400 bg-red-500/10'
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-slate-500">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" /> 加载容量数据...
|
||||
</div>
|
||||
) : capacityForecasts.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">暂无容量数据</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{capacityForecasts.map((forecast, idx) => (
|
||||
<div key={idx} className="p-4 rounded-lg border border-slate-700 bg-slate-800/50">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5 text-blue-400" />
|
||||
<span className="font-medium text-slate-200">{forecast.resource}</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
forecast.daysUntilThreshold <= 7
|
||||
? 'border-red-500/30 text-red-400 bg-red-500/10'
|
||||
: forecast.daysUntilThreshold <= 14
|
||||
? 'border-amber-500/30 text-amber-400 bg-amber-500/10'
|
||||
: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
}
|
||||
>
|
||||
{forecast.daysUntilThreshold <= 7
|
||||
? '紧急'
|
||||
: forecast.daysUntilThreshold <= 14
|
||||
? 'border-amber-500/30 text-amber-400 bg-amber-500/10'
|
||||
: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
}
|
||||
>
|
||||
{forecast.daysUntilThreshold <= 7
|
||||
? '紧急'
|
||||
: forecast.daysUntilThreshold <= 14
|
||||
? '警告'
|
||||
: '正常'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-slate-400">使用率趋势</span>
|
||||
<span className="text-slate-400">
|
||||
预计 <span className="text-amber-400 font-medium">{forecast.daysUntilThreshold}天</span> 后达到阈值
|
||||
</span>
|
||||
? '警告'
|
||||
: '正常'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="h-3 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div className="flex h-full">
|
||||
<div
|
||||
className="bg-blue-500"
|
||||
style={{ width: `${forecast.currentUsage}%` }}
|
||||
/>
|
||||
<div
|
||||
className="bg-gradient-to-r from-blue-500/50 to-amber-500"
|
||||
style={{ width: `${forecast.predictedUsage - forecast.currentUsage}%` }}
|
||||
/>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-slate-400">7天使用率预测</span>
|
||||
<span className="text-slate-400">
|
||||
{forecast.daysUntilThreshold >= 999 ? (
|
||||
'增长平缓,暂无触顶风险'
|
||||
) : (
|
||||
<>
|
||||
预计{' '}
|
||||
<span className="text-amber-400 font-medium">{forecast.daysUntilThreshold}天</span>{' '}
|
||||
后达到阈值
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div className="flex h-full">
|
||||
<div className="bg-blue-500" style={{ width: `${forecast.currentUsage}%` }} />
|
||||
<div
|
||||
className="bg-gradient-to-r from-blue-500/50 to-amber-500"
|
||||
style={{
|
||||
width: `${Math.max(0, forecast.predictedUsage - forecast.currentUsage)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs mt-1">
|
||||
<span className="text-slate-500">当前 {forecast.currentUsage}%</span>
|
||||
<span className="text-amber-400">预测 {forecast.predictedUsage}%</span>
|
||||
<span className="text-red-400">阈值 {forecast.threshold}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs mt-1">
|
||||
<span className="text-slate-500">当前 {forecast.currentUsage}%</span>
|
||||
<span className="text-amber-400">预测 {forecast.predictedUsage}%</span>
|
||||
<span className="text-red-400">阈值 {forecast.threshold}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-slate-900 rounded-lg border border-slate-700">
|
||||
<div className="flex items-start gap-2">
|
||||
<Zap className="h-4 w-4 text-emerald-400 mt-0.5" />
|
||||
<div>
|
||||
<span className="text-sm text-slate-400">AI建议:</span>
|
||||
<p className="text-sm text-slate-300 mt-1">{forecast.recommendation}</p>
|
||||
<div className="p-3 bg-slate-900 rounded-lg border border-slate-700">
|
||||
<div className="flex items-start gap-2">
|
||||
<Zap className="h-4 w-4 text-emerald-400 mt-0.5" />
|
||||
<div>
|
||||
<span className="text-sm text-slate-400">AI建议:</span>
|
||||
<p className="text-sm text-slate-300 mt-1">{forecast.recommendation}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
@@ -435,95 +656,101 @@ export function AIPredictiveAnalytics() {
|
||||
<AlertTriangle className="h-5 w-5 text-red-400" />
|
||||
异常事件预测
|
||||
</CardTitle>
|
||||
<span className="text-xs text-slate-500">基于告警规则阈值与实时趋势概率推算</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{mockAnomalyPredictions.map((anomaly, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-4 rounded-lg border border-slate-700 bg-slate-800/50"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
anomaly.probability >= 70
|
||||
? 'bg-red-500/20'
|
||||
: anomaly.probability >= 50
|
||||
? 'bg-amber-500/20'
|
||||
: 'bg-blue-500/20'
|
||||
}`}
|
||||
>
|
||||
<AlertTriangle
|
||||
className={`h-5 w-5 ${
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-slate-500">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" /> 加载异常预测...
|
||||
</div>
|
||||
) : dedupedAnomalies.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">暂无异常预测数据</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{dedupedAnomalies.map((anomaly, idx) => (
|
||||
<div key={idx} className="p-4 rounded-lg border border-slate-700 bg-slate-800/50">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
anomaly.probability >= 70
|
||||
? 'text-red-400'
|
||||
? 'bg-red-500/20'
|
||||
: anomaly.probability >= 50
|
||||
? 'text-amber-400'
|
||||
: 'text-blue-400'
|
||||
? 'bg-amber-500/20'
|
||||
: 'bg-blue-500/20'
|
||||
}`}
|
||||
/>
|
||||
>
|
||||
<AlertTriangle
|
||||
className={`h-5 w-5 ${
|
||||
anomaly.probability >= 70
|
||||
? 'text-red-400'
|
||||
: anomaly.probability >= 50
|
||||
? 'text-amber-400'
|
||||
: 'text-blue-400'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-slate-200">{anomaly.metric}</h4>
|
||||
<p className="text-sm text-slate-400 mt-1">{anomaly.predictedAnomaly}</p>
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
<span className="text-xs text-slate-500">{anomaly.timeframe}</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
概率:{' '}
|
||||
<span className={anomaly.probability >= 70 ? 'text-red-400' : 'text-amber-400'}>
|
||||
{anomaly.probability}%
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-slate-200">{anomaly.metric}</h4>
|
||||
<p className="text-sm text-slate-400 mt-1">{anomaly.predictedAnomaly}</p>
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
<span className="text-xs text-slate-500 flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{anomaly.timeframe}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
概率: <span className={anomaly.probability >= 70 ? 'text-red-400' : 'text-amber-400'}>{anomaly.probability}%</span>
|
||||
</span>
|
||||
<div className="w-16 h-16 relative shrink-0">
|
||||
<svg className="w-full h-full transform -rotate-90">
|
||||
<circle
|
||||
cx="32"
|
||||
cy="32"
|
||||
r="28"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="transparent"
|
||||
className="text-slate-700"
|
||||
/>
|
||||
<circle
|
||||
cx="32"
|
||||
cy="32"
|
||||
r="28"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="transparent"
|
||||
strokeDasharray={`${anomaly.probability * 1.76} 176`}
|
||||
className={
|
||||
anomaly.probability >= 70
|
||||
? 'text-red-400'
|
||||
: anomaly.probability >= 50
|
||||
? 'text-amber-400'
|
||||
: 'text-blue-400'
|
||||
}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-sm font-bold text-slate-200">{anomaly.probability}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-16 h-16 relative">
|
||||
<svg className="w-full h-full transform -rotate-90">
|
||||
<circle
|
||||
cx="32"
|
||||
cy="32"
|
||||
r="28"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="transparent"
|
||||
className="text-slate-700"
|
||||
/>
|
||||
<circle
|
||||
cx="32"
|
||||
cy="32"
|
||||
r="28"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="transparent"
|
||||
strokeDasharray={`${anomaly.probability * 1.76} 176`}
|
||||
className={
|
||||
anomaly.probability >= 70
|
||||
? 'text-red-400'
|
||||
: anomaly.probability >= 50
|
||||
? 'text-amber-400'
|
||||
: 'text-blue-400'
|
||||
}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-sm font-bold text-slate-200">{anomaly.probability}%</span>
|
||||
<div className="mt-3 p-3 bg-slate-900 rounded-lg border border-slate-700">
|
||||
<div className="flex items-start gap-2">
|
||||
<Zap className="h-4 w-4 text-emerald-400 mt-0.5" />
|
||||
<div>
|
||||
<span className="text-sm text-slate-400">建议操作:</span>
|
||||
<p className="text-sm text-slate-300 mt-1">{anomaly.suggestedAction}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 p-3 bg-slate-900 rounded-lg border border-slate-700">
|
||||
<div className="flex items-start gap-2">
|
||||
<Zap className="h-4 w-4 text-emerald-400 mt-0.5" />
|
||||
<div>
|
||||
<span className="text-sm text-slate-400">建议操作:</span>
|
||||
<p className="text-sm text-slate-300 mt-1">{anomaly.suggestedAction}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { ansibleApi } from '@/services/api';
|
||||
|
||||
interface Playbook {
|
||||
@@ -80,6 +81,7 @@ interface Group {
|
||||
}
|
||||
|
||||
const AnsibleManager: React.FC = () => {
|
||||
const _confirm = useConfirm();
|
||||
const [playbooks, setPlaybooks] = useState<Playbook[]>([]);
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [inventories, setInventories] = useState<Inventory[]>([]);
|
||||
@@ -181,7 +183,7 @@ const AnsibleManager: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定删除此Playbook吗?')) return;
|
||||
{ const ok = await _confirm({ title: '删除 Playbook', description: '确定删除此Playbook吗?', confirmText: '删除', variant: 'destructive' }); if (!ok) return; }
|
||||
try {
|
||||
await ansibleApi.deletePlaybook(id);
|
||||
toast.success('删除成功');
|
||||
|
||||
@@ -0,0 +1,728 @@
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Wallet,
|
||||
CalendarDays,
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { billingApi } from '@/services/api';
|
||||
|
||||
const providerLabels: Record<string, string> = {
|
||||
aliyun: '阿里云',
|
||||
tencent: '腾讯云',
|
||||
aws: 'AWS',
|
||||
huawei: '华为云',
|
||||
azure: 'Azure',
|
||||
};
|
||||
|
||||
const providerColors: Record<string, string> = {
|
||||
aliyun: 'bg-orange-500',
|
||||
tencent: 'bg-blue-500',
|
||||
aws: 'bg-amber-500',
|
||||
huawei: 'bg-red-500',
|
||||
azure: 'bg-sky-500',
|
||||
};
|
||||
|
||||
function formatCurrency(n: number) {
|
||||
return `¥${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
export function CloudBilling() {
|
||||
const confirm = useConfirm();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'records' | 'alerts'>('overview');
|
||||
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(true);
|
||||
|
||||
const [records, setRecords] = useState<any[]>([]);
|
||||
const [recordsLoading, setRecordsLoading] = useState(true);
|
||||
const [filterProvider, setFilterProvider] = useState('');
|
||||
const [filterStart, setFilterStart] = useState('');
|
||||
const [filterEnd, setFilterEnd] = useState('');
|
||||
|
||||
const [rules, setRules] = useState<any[]>([]);
|
||||
const [rulesLoading, setRulesLoading] = useState(true);
|
||||
|
||||
const [recordDialogOpen, setRecordDialogOpen] = useState(false);
|
||||
const [editingRecord, setEditingRecord] = useState<any>(null);
|
||||
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<any>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const emptyRecord = { provider: 'aliyun', billing_date: '', cost: '', service: '', region: '', account_name: '', description: '' };
|
||||
const [recordForm, setRecordForm] = useState<any>(emptyRecord);
|
||||
const emptyRule = { name: '', provider: '', metric: 'daily', threshold: '', operator: '>', enabled: true };
|
||||
const [ruleForm, setRuleForm] = useState<any>(emptyRule);
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
setStatsLoading(true);
|
||||
try {
|
||||
const res = await billingApi.getStats();
|
||||
setStats(res);
|
||||
} catch (e: any) {
|
||||
toast.error('加载统计失败', { description: e.message });
|
||||
} finally {
|
||||
setStatsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadRecords = useCallback(async () => {
|
||||
setRecordsLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
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: any) {
|
||||
toast.error('加载账单失败', { description: e.message });
|
||||
} finally {
|
||||
setRecordsLoading(false);
|
||||
}
|
||||
}, [filterProvider, filterStart, filterEnd]);
|
||||
|
||||
const loadRules = useCallback(async () => {
|
||||
setRulesLoading(true);
|
||||
try {
|
||||
const res = await billingApi.listAlertRules();
|
||||
setRules(res.list || []);
|
||||
} catch (e: any) {
|
||||
toast.error('加载预警规则失败', { description: e.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);
|
||||
setRecordForm({ ...emptyRecord, billing_date: new Date().toISOString().slice(0, 10) });
|
||||
setRecordDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEditRecord = (r: any) => {
|
||||
setEditingRecord(r);
|
||||
setRecordForm({ provider: r.provider, billing_date: r.billing_date, cost: r.cost, service: r.service || '', region: r.region || '', account_name: r.account_name || '', description: r.description || '' });
|
||||
setRecordDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveRecord = async () => {
|
||||
if (!recordForm.provider || !recordForm.billing_date || !recordForm.cost) {
|
||||
toast.error('请填写必填字段');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const data = { ...recordForm, cost: parseFloat(recordForm.cost) };
|
||||
if (editingRecord) {
|
||||
await billingApi.remove(editingRecord.id);
|
||||
await billingApi.create(data);
|
||||
} else {
|
||||
await billingApi.create(data);
|
||||
}
|
||||
setRecordDialogOpen(false);
|
||||
loadRecords();
|
||||
loadStats();
|
||||
toast('保存成功');
|
||||
} catch (e: any) {
|
||||
toast.error('保存失败', { description: e.message });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
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: any) {
|
||||
toast.error('删除失败', { description: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const openAddRule = () => {
|
||||
setEditingRule(null);
|
||||
setRuleForm({ ...emptyRule });
|
||||
setRuleDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEditRule = (r: any) => {
|
||||
setEditingRule(r);
|
||||
setRuleForm({ name: r.name, provider: r.provider || '', metric: r.metric || 'daily', threshold: r.threshold, operator: r.operator || '>', enabled: r.enabled });
|
||||
setRuleDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveRule = async () => {
|
||||
if (!ruleForm.name || !ruleForm.threshold) {
|
||||
toast.error('请填写规则名称和阈值');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const data = { ...ruleForm, threshold: parseFloat(ruleForm.threshold) };
|
||||
if (editingRule) {
|
||||
await billingApi.updateAlertRule(editingRule.id, data);
|
||||
} else {
|
||||
await billingApi.createAlertRule(data);
|
||||
}
|
||||
setRuleDialogOpen(false);
|
||||
loadRules();
|
||||
loadStats();
|
||||
toast('保存成功');
|
||||
} catch (e: any) {
|
||||
toast.error('保存失败', { description: e.message });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
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: any) {
|
||||
toast.error('删除失败', { description: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleRule = async (id: number) => {
|
||||
try {
|
||||
await billingApi.toggleAlertRule(id);
|
||||
loadRules();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
toast.error('操作失败', { description: e.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 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<StatCard
|
||||
icon={<Wallet className="h-5 w-5" />}
|
||||
label="今日成本"
|
||||
value={formatCurrency(stats.today_cost || 0)}
|
||||
trend={stats.today_cost - (stats.yesterday_cost || 0)}
|
||||
trendLabel={`昨日 ${formatCurrency(stats.yesterday_cost || 0)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<CalendarDays className="h-5 w-5" />}
|
||||
label="本月成本"
|
||||
value={formatCurrency(stats.month_cost || 0)}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<TrendingUp className="h-5 w-5" />}
|
||||
label="年度成本"
|
||||
value={formatCurrency(stats.year_cost || 0)}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<AlertTriangle className="h-5 w-5" />}
|
||||
label="活跃预警"
|
||||
value={`${stats.alerts?.length || 0} 条`}
|
||||
alert={stats.alerts?.length > 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{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: any, 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: any, 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: any, 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>
|
||||
)}
|
||||
|
||||
<Dialog open={recordDialogOpen} onOpenChange={setRecordDialogOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingRecord ? '编辑成本记录' : '记录成本'}</DialogTitle>
|
||||
<DialogDescription>记录一条每日云资源成本</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">厂商 *</Label>
|
||||
<Select value={recordForm.provider} onValueChange={(v) => setRecordForm({ ...recordForm, provider: v })}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(providerLabels).map(([k, v]) => (
|
||||
<SelectItem key={k} value={k}>{v}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">日期 *</Label>
|
||||
<Input type="date" className="mt-1" value={recordForm.billing_date} onChange={(e) => setRecordForm({ ...recordForm, billing_date: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">费用(元) *</Label>
|
||||
<Input type="number" step="0.01" className="mt-1" placeholder="0.00" value={recordForm.cost} onChange={(e) => setRecordForm({ ...recordForm, cost: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">服务</Label>
|
||||
<Input className="mt-1" placeholder="ECS, RDS..." value={recordForm.service} onChange={(e) => setRecordForm({ ...recordForm, service: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">区域</Label>
|
||||
<Input className="mt-1" placeholder="cn-hangzhou" value={recordForm.region} onChange={(e) => setRecordForm({ ...recordForm, region: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">账号名称</Label>
|
||||
<Input className="mt-1" placeholder="主账号" value={recordForm.account_name} onChange={(e) => setRecordForm({ ...recordForm, account_name: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">备注</Label>
|
||||
<Input className="mt-1" placeholder="可选" value={recordForm.description} onChange={(e) => setRecordForm({ ...recordForm, description: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRecordDialogOpen(false)}>取消</Button>
|
||||
<Button onClick={handleSaveRecord} disabled={submitting}>
|
||||
{submitting && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={ruleDialogOpen} onOpenChange={setRuleDialogOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingRule ? '编辑预警规则' : '添加预警规则'}</DialogTitle>
|
||||
<DialogDescription>设置成本阈值,超标时自动预警</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">规则名称 *</Label>
|
||||
<Input className="mt-1" placeholder="如:日成本超 500 元预警" value={ruleForm.name} onChange={(e) => setRuleForm({ ...ruleForm, name: e.target.value })} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">厂商</Label>
|
||||
<Select value={ruleForm.provider || 'all'} onValueChange={(v) => setRuleForm({ ...ruleForm, provider: v === 'all' ? '' : v })}>
|
||||
<SelectTrigger className="mt-1"><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-sm text-slate-300">指标</Label>
|
||||
<Select value={ruleForm.metric} onValueChange={(v) => setRuleForm({ ...ruleForm, metric: v })}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">日成本</SelectItem>
|
||||
<SelectItem value="monthly">月累计</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">比较</Label>
|
||||
<Select value={ruleForm.operator} onValueChange={(v) => setRuleForm({ ...ruleForm, operator: v })}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value=">">大于 ></SelectItem>
|
||||
<SelectItem value=">=">大于等于 ≥</SelectItem>
|
||||
<SelectItem value="<">小于 <</SelectItem>
|
||||
<SelectItem value="<=">小于等于 ≤</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">阈值(元) *</Label>
|
||||
<Input type="number" step="0.01" className="mt-1" placeholder="500.00" value={ruleForm.threshold} onChange={(e) => setRuleForm({ ...ruleForm, threshold: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={ruleForm.enabled} onCheckedChange={(v) => setRuleForm({ ...ruleForm, enabled: v })} />
|
||||
<Label className="text-sm text-slate-300">启用</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRuleDialogOpen(false)}>取消</Button>
|
||||
<Button onClick={handleSaveRule} disabled={submitting}>
|
||||
{submitting && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ icon, label, value, trend, trendLabel, alert }: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
trend?: number;
|
||||
trendLabel?: string;
|
||||
alert?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={`rounded-lg border p-4 ${alert ? 'border-amber-500/30 bg-amber-500/5' : 'border-slate-800 bg-slate-900/50'}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-slate-400 text-sm">{label}</span>
|
||||
<span className={alert ? 'text-amber-400' : 'text-slate-500'}>{icon}</span>
|
||||
</div>
|
||||
<div className={`text-2xl font-bold ${alert ? 'text-amber-300' : 'text-slate-100'}`}>{value}</div>
|
||||
{trendLabel && (
|
||||
<div className="flex items-center gap-1 mt-1 text-xs">
|
||||
{trend !== undefined && trend > 0 && <TrendingUp className="h-3 w-3 text-red-400" />}
|
||||
{trend !== undefined && trend < 0 && <TrendingDown className="h-3 w-3 text-green-400" />}
|
||||
<span className="text-slate-500">{trendLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendChart({ data }: { data: { date: string; cost: number }[] }) {
|
||||
if (data.length === 0) {
|
||||
return <p className="text-sm text-slate-500 py-8 text-center">暂无数据</p>;
|
||||
}
|
||||
const max = Math.max(...data.map((d) => d.cost), 1);
|
||||
const recent = data.slice(-30);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-end gap-0.5 h-40">
|
||||
{recent.map((d, i) => {
|
||||
const h = Math.max((d.cost / max) * 100, 2);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 group relative"
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
<div
|
||||
className="absolute bottom-0 w-full rounded-t-sm bg-gradient-to-t from-blue-600/60 to-blue-400/80 group-hover:from-blue-500 group-hover:to-blue-300 transition-colors"
|
||||
style={{ height: `${h}%` }}
|
||||
/>
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block whitespace-nowrap text-xs bg-slate-800 text-slate-200 px-2 py-1 rounded border border-slate-700 z-10">
|
||||
{d.date}: {formatCurrency(d.cost)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-between mt-2 text-xs text-slate-500">
|
||||
<span>{recent[0]?.date}</span>
|
||||
<span>{recent[recent.length - 1]?.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
Users,
|
||||
Menu,
|
||||
X,
|
||||
Box,
|
||||
Network,
|
||||
CloudCog,
|
||||
Globe,
|
||||
@@ -61,14 +60,12 @@ const menuItems: MenuItem[] = [
|
||||
{ id: 'domains', name: '域名管理', icon: Globe, path: '/domains' },
|
||||
{ id: 'regions', name: '区域管理', icon: MapPin, path: '/regions' },
|
||||
{ id: 'platforms', name: '云平台', icon: CloudCog, path: '/platforms' },
|
||||
{ id: 'resource-models', name: '资源模型', icon: Box, path: '/resource-models' },
|
||||
{ id: 'webshell', name: 'WebShell', icon: Terminal, path: '/webshell' },
|
||||
{ id: 'batch-import', name: '批量导入', icon: FileCode, path: '/batch-import' },
|
||||
{ id: 'cloud-billing', name: '云账单', icon: BarChart3, path: '/cloud-billing' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'k8s',
|
||||
name: 'K8s集群管理',
|
||||
name: 'Kubernetes管理',
|
||||
icon: Cloud,
|
||||
children: [
|
||||
{ id: 'clusters', name: '集群列表', icon: Cloud, path: '/clusters' },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -93,6 +94,7 @@ function resolveChannelRef(value: string, channels: any[]): { type: string; name
|
||||
}
|
||||
|
||||
export function AlertManager() {
|
||||
const _confirm = useConfirm();
|
||||
const [activeTab, setActiveTab] = useState('events');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [severityFilter, setSeverityFilter] = useState<string>('all');
|
||||
@@ -164,7 +166,7 @@ export function AlertManager() {
|
||||
};
|
||||
|
||||
const handleDeleteRule = async (id: any) => {
|
||||
if (!window.confirm('确定删除此告警规则?')) return;
|
||||
{ const ok = await _confirm({ title: '删除告警规则', description: '确定删除此告警规则?', confirmText: '删除', variant: 'destructive' }); if (!ok) return; }
|
||||
try {
|
||||
await alertApi.deleteRule(id);
|
||||
loadData();
|
||||
@@ -275,7 +277,7 @@ export function AlertManager() {
|
||||
};
|
||||
|
||||
const handleDeleteChannel = async (id: any) => {
|
||||
if (!window.confirm('确定删除此通知渠道?')) return;
|
||||
{ const ok = await _confirm({ title: '删除通知渠道', description: '确定删除此通知渠道?', confirmText: '删除', variant: 'destructive' }); if (!ok) return; }
|
||||
try {
|
||||
await alertApi.deleteNotifyChannel(id);
|
||||
if (editingChannel && editingChannel.id === id) resetChannelForm();
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Switch } from '@/components/ui/switch';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { alertCenterApi, alertApi } from '@/services/api';
|
||||
|
||||
interface Alert {
|
||||
@@ -68,6 +69,7 @@ interface NotificationChannel {
|
||||
}
|
||||
|
||||
const AlertCenter: React.FC = () => {
|
||||
const _confirm = useConfirm();
|
||||
const [alerts, setAlerts] = useState<Alert[]>([]);
|
||||
const [rules, setRules] = useState<AlertRule[]>([]);
|
||||
const [channels, setChannels] = useState<NotificationChannel[]>([]);
|
||||
@@ -215,7 +217,7 @@ const AlertCenter: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleDeleteChannel = async (id: number) => {
|
||||
if (!window.confirm('确定删除此通知渠道?')) return;
|
||||
{ const ok = await _confirm({ title: '删除通知渠道', description: '确定删除此通知渠道?', confirmText: '删除', variant: 'destructive' }); if (!ok) return; }
|
||||
try {
|
||||
await alertApi.deleteNotifyChannel(id);
|
||||
toast.success('渠道已删除');
|
||||
@@ -407,10 +409,6 @@ const AlertCenter: React.FC = () => {
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => toast.info('批量操作功能开发中')}>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
批量确认
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -516,10 +514,6 @@ const AlertCenter: React.FC = () => {
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={() => toast.info('创建规则功能开发中')}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新建规则
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{rules.map((rule) => (
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { dashboardApi } from '@/services/api';
|
||||
|
||||
interface Dashboard {
|
||||
@@ -73,6 +74,7 @@ interface Datasource {
|
||||
}
|
||||
|
||||
const GrafanaDashboard: React.FC = () => {
|
||||
const _confirm = useConfirm();
|
||||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||
const [datasources, setDatasources] = useState<Datasource[]>([]);
|
||||
const [activeDashboard, setActiveDashboard] = useState<Dashboard | null>(null);
|
||||
@@ -201,7 +203,7 @@ const GrafanaDashboard: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleDeleteDashboard = async (id: string) => {
|
||||
if (!confirm('确定删除此仪表盘吗?')) return;
|
||||
{ const ok = await _confirm({ title: '删除仪表盘', description: '确定删除此仪表盘吗?', confirmText: '删除', variant: 'destructive' }); if (!ok) return; }
|
||||
try {
|
||||
await dashboardApi.deleteDashboard(id);
|
||||
toast.success('删除成功');
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -85,6 +87,7 @@ const emptyForm = {
|
||||
};
|
||||
|
||||
export function SystemManager() {
|
||||
const confirm = useConfirm();
|
||||
const [activeTab, setActiveTab] = useState('users');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
@@ -108,6 +111,8 @@ export function SystemManager() {
|
||||
const [ldapUsers, setLdapUsers] = useState<any[]>([]);
|
||||
const [ldapSelected, setLdapSelected] = useState<Set<string>>(new Set());
|
||||
const [ldapLoading, setLdapLoading] = useState(false);
|
||||
const [ldapImportOUs, setLdapImportOUs] = useState<any[]>([]);
|
||||
const [ldapImportOUDN, setLdapImportOUDN] = useState('');
|
||||
|
||||
// LDAP 配置
|
||||
const [ldapConfig, setLdapConfig] = useState<any>(null);
|
||||
@@ -135,6 +140,8 @@ export function SystemManager() {
|
||||
const [ldapTestPass, setLdapTestPass] = useState('');
|
||||
const [ldapUserTesting, setLdapUserTesting] = useState(false);
|
||||
const [ldapUserTestResult, setLdapUserTestResult] = useState<any>(null);
|
||||
const [editLdapUser, setEditLdapUser] = useState<any>(null);
|
||||
const [editLdapForm, setEditLdapForm] = useState({ cn: '', mail: '', phone: '', title: '' });
|
||||
|
||||
const loadUsers = async () => {
|
||||
setLoading(true);
|
||||
@@ -155,6 +162,14 @@ export function SystemManager() {
|
||||
loadLdapConfig();
|
||||
}, []);
|
||||
|
||||
// LDAP 导入 Dialog 打开时加载 OU 和用户
|
||||
useEffect(() => {
|
||||
if (importDialogOpen === 'ldap') {
|
||||
ldapApi.listOUs().then(ous => setLdapImportOUs(Array.isArray(ous) ? ous : [])).catch(() => {});
|
||||
loadLdapUsers('');
|
||||
}
|
||||
}, [importDialogOpen]);
|
||||
|
||||
const filteredUsers = users.filter(
|
||||
(u) =>
|
||||
(u.username || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
@@ -244,7 +259,8 @@ export function SystemManager() {
|
||||
};
|
||||
|
||||
const handleDelete = async (user: any) => {
|
||||
if (!window.confirm(`确认删除用户「${user.nickname || user.username}」吗?`)) return;
|
||||
const ok = await confirm({ title: '删除用户', description: `确认删除用户「${user.nickname || user.username}」吗?`, confirmText: '删除', variant: 'destructive' });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await userApi.deleteUser(user.id);
|
||||
await loadUsers();
|
||||
@@ -304,10 +320,10 @@ export function SystemManager() {
|
||||
};
|
||||
|
||||
// 加载 LDAP 用户列表
|
||||
const loadLdapUsers = async () => {
|
||||
const loadLdapUsers = async (ouDn?: string) => {
|
||||
setLdapLoading(true);
|
||||
try {
|
||||
const res = await ldapApi.listUsers();
|
||||
const res = await ldapApi.listUsers(ouDn);
|
||||
setLdapUsers(Array.isArray(res) ? res : []);
|
||||
setLdapSelected(new Set());
|
||||
} catch {
|
||||
@@ -425,9 +441,9 @@ export function SystemManager() {
|
||||
} catch (e: any) {
|
||||
const msg = e?.message || '';
|
||||
if (msg.includes('Already Exists') || msg.includes('68')) {
|
||||
alert(`OU "${ouForm.ou_name}" 已存在,请使用其他名称`);
|
||||
toast.error(`OU "${ouForm.ou_name}" 已存在,请使用其他名称`);
|
||||
} else {
|
||||
alert('创建失败: ' + msg);
|
||||
toast.error('创建失败: ' + msg);
|
||||
}
|
||||
} finally { setLdapSaving(false); }
|
||||
};
|
||||
@@ -439,16 +455,30 @@ export function SystemManager() {
|
||||
setCreateUserOpen(false);
|
||||
setNewUser({ username: '', password: '', display_name: '', email: '', ou_dn: '' });
|
||||
await loadLdapOUUsers(selectedOUDN);
|
||||
} catch (e: any) { alert(e?.message || '创建失败'); }
|
||||
} catch (e: any) { toast.error(e?.message || '创建失败'); }
|
||||
finally { setLdapSaving(false); }
|
||||
};
|
||||
|
||||
const handleDeleteLDAPEntry = async (dn: string, silent = false) => {
|
||||
if (!silent && !confirm(`确定删除 ${dn}?`)) return;
|
||||
if (!silent) { const ok = await confirm({ title: '删除 LDAP 条目', description: `确定删除 ${dn}?`, confirmText: '删除', variant: 'destructive' }); if (!ok) return; }
|
||||
try {
|
||||
await ldapApi.deleteUser(dn);
|
||||
if (!silent) await loadLdapOUUsers(selectedOUDN);
|
||||
} catch (e: any) { if (!silent) alert(e?.message || '删除失败'); }
|
||||
} catch (e: any) { if (!silent) toast.error(e?.message || '删除失败'); }
|
||||
};
|
||||
|
||||
const openEditLdapUser = (user: any) => {
|
||||
setEditLdapUser(user);
|
||||
setEditLdapForm({ cn: user.cn || '', mail: user.mail || '', phone: user.phone || '', title: user.title || '' });
|
||||
};
|
||||
|
||||
const handleUpdateLdapUser = async () => {
|
||||
if (!editLdapUser) return;
|
||||
try {
|
||||
await ldapApi.updateUser({ dn: editLdapUser.dn, ...editLdapForm });
|
||||
setEditLdapUser(null);
|
||||
await loadLdapOUUsers(selectedOUDN);
|
||||
} catch (e: any) { toast.error(e?.message || '修改失败'); }
|
||||
};
|
||||
|
||||
const handleLdapUserTest = async () => {
|
||||
@@ -508,7 +538,7 @@ export function SystemManager() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800" onClick={() => setImportDialogOpen('csv')}>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
批量导入用户
|
||||
批量导入
|
||||
</Button>
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800" onClick={() => setImportDialogOpen('ldap')}>
|
||||
<Network className="h-4 w-4 mr-2" />
|
||||
@@ -1264,14 +1294,24 @@ export function SystemManager() {
|
||||
<code className="text-xs text-slate-500">{user.dn}</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
onClick={() => handleDeleteLDAPEntry(user.dn)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-slate-400 hover:text-blue-400 hover:bg-blue-500/10"
|
||||
onClick={() => openEditLdapUser(user)}
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
onClick={() => handleDeleteLDAPEntry(user.dn)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
@@ -1286,6 +1326,49 @@ export function SystemManager() {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 编辑 LDAP 用户 Dialog */}
|
||||
<Dialog open={!!editLdapUser} onOpenChange={(v) => !v && setEditLdapUser(null)}>
|
||||
<DialogContent className="max-w-md bg-slate-900 border-slate-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
||||
<Edit className="h-5 w-5 text-blue-400" />
|
||||
编辑 LDAP 用户
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">
|
||||
修改 {editLdapUser?.uid} 的属性
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{editLdapUser && (
|
||||
<div className="space-y-3 py-2">
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 mb-1 block">用户名(只读)</label>
|
||||
<Input value={editLdapUser.uid || ''} disabled className="bg-slate-800/50 border-slate-700 text-slate-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 mb-1 block">显示名称 (cn)</label>
|
||||
<Input value={editLdapForm.cn} onChange={(e) => setEditLdapForm({ ...editLdapForm, cn: e.target.value })} className="bg-slate-800/50 border-slate-700 text-slate-200" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 mb-1 block">邮箱</label>
|
||||
<Input value={editLdapForm.mail} onChange={(e) => setEditLdapForm({ ...editLdapForm, mail: e.target.value })} placeholder="user@example.com" className="bg-slate-800/50 border-slate-700 text-slate-200" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 mb-1 block">电话</label>
|
||||
<Input value={editLdapForm.phone} onChange={(e) => setEditLdapForm({ ...editLdapForm, phone: e.target.value })} placeholder="13800138000" className="bg-slate-800/50 border-slate-700 text-slate-200" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 mb-1 block">职位/头衔</label>
|
||||
<Input value={editLdapForm.title} onChange={(e) => setEditLdapForm({ ...editLdapForm, title: e.target.value })} placeholder="运维工程师" className="bg-slate-800/50 border-slate-700 text-slate-200" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setEditLdapUser(null)}>取消</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={handleUpdateLdapUser}>保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* User Detail Dialog */}
|
||||
<Dialog open={!!selectedUser} onOpenChange={() => setSelectedUser(null)}>
|
||||
<DialogContent className="bg-slate-900 border-slate-800">
|
||||
@@ -1476,7 +1559,7 @@ export function SystemManager() {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
||||
<Upload className="h-5 w-5 text-blue-400" />
|
||||
批量导入用户
|
||||
批量导入用户(CSV)
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">
|
||||
上传 CSV 文件批量创建用户。请先下载模板,按格式填写后上传。
|
||||
@@ -1586,16 +1669,33 @@ export function SystemManager() {
|
||||
{!importResult ? (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-slate-500">
|
||||
{ldapUsers.length > 0 ? `找到 ${ldapUsers.length} 个用户` : '点击刷新从 LDAP 获取用户列表'}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300" onClick={loadLdapUsers} disabled={ldapLoading}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 whitespace-nowrap">选择 OU</span>
|
||||
<select
|
||||
className="bg-slate-800 border border-slate-700 text-slate-200 text-xs rounded-md px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
value={ldapImportOUDN}
|
||||
onChange={(e) => {
|
||||
setLdapImportOUDN(e.target.value);
|
||||
loadLdapUsers(e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="">根 (全部)</option>
|
||||
{ldapImportOUs.map((ou) => (
|
||||
<option key={ou.dn} value={ou.dn}>{ou.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300 ml-auto" onClick={() => loadLdapUsers(ldapImportOUDN)} disabled={ldapLoading}>
|
||||
{ldapLoading ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : <RefreshCw className="h-4 w-4 mr-1.5" />}
|
||||
刷新列表
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-500">
|
||||
{ldapUsers.length > 0 ? `找到 ${ldapUsers.length} 个用户` : '选择 OU 或点击刷新获取用户列表'}
|
||||
</p>
|
||||
|
||||
{ldapUsers.length > 0 && (
|
||||
<div className="max-h-80 overflow-auto rounded-lg border border-slate-800">
|
||||
<Table>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState, useCallback, createContext, useContext } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle,
|
||||
DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface ConfirmOptions {
|
||||
title?: string;
|
||||
description?: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: 'default' | 'destructive';
|
||||
}
|
||||
|
||||
interface ConfirmContextValue {
|
||||
confirm: (options: ConfirmOptions | string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const ConfirmContext = createContext<ConfirmContextValue | null>(null);
|
||||
|
||||
export function useConfirm() {
|
||||
const ctx = useContext(ConfirmContext);
|
||||
if (!ctx) throw new Error('useConfirm must be used within ConfirmProvider');
|
||||
return ctx.confirm;
|
||||
}
|
||||
|
||||
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [options, setOptions] = useState<Required<ConfirmOptions>>({
|
||||
title: '确认操作',
|
||||
description: '',
|
||||
confirmText: '确认',
|
||||
cancelText: '取消',
|
||||
variant: 'default',
|
||||
});
|
||||
const [resolver, setResolver] = useState<((v: boolean) => void) | null>(null);
|
||||
|
||||
const confirm = useCallback((opts: ConfirmOptions | string) => {
|
||||
const normalized = typeof opts === 'string'
|
||||
? { title: '确认操作', description: opts }
|
||||
: opts;
|
||||
setOptions({
|
||||
title: normalized.title || '确认操作',
|
||||
description: normalized.description || '',
|
||||
confirmText: normalized.confirmText || '确认',
|
||||
cancelText: normalized.cancelText || '取消',
|
||||
variant: (normalized.variant || 'default') as 'default' | 'destructive',
|
||||
});
|
||||
setOpen(true);
|
||||
return new Promise<boolean>((resolve) => setResolver(() => resolve));
|
||||
}, []);
|
||||
|
||||
const handleConfirm = () => {
|
||||
setOpen(false);
|
||||
resolver?.(true);
|
||||
setResolver(null);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setOpen(false);
|
||||
resolver?.(false);
|
||||
setResolver(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={{ confirm }}>
|
||||
{children}
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) handleCancel(); }}>
|
||||
<DialogContent className="max-w-sm bg-slate-900 border-slate-800" onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100">{options.title}</DialogTitle>
|
||||
{options.description && (
|
||||
<DialogDescription className="text-slate-400">{options.description}</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-2">
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={handleCancel}>
|
||||
{options.cancelText}
|
||||
</Button>
|
||||
<Button
|
||||
variant={options.variant === 'destructive' ? 'destructive' : 'default'}
|
||||
className={options.variant === 'destructive'
|
||||
? 'bg-red-600 hover:bg-red-700 text-white'
|
||||
: 'bg-blue-600 hover:bg-blue-700 text-white'}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{options.confirmText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</ConfirmContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export const ldapApi = {
|
||||
return http.post('/admin/ldap-config/test', data || {});
|
||||
},
|
||||
listUsers: async (ouDn?: string) => {
|
||||
return http.post('/admin/ldap-config/users', { base_dn: ouDn || '' });
|
||||
return http.post('/admin/ldap-config/users', { ou_dn: ouDn || '' });
|
||||
},
|
||||
batchImport: async (users: any[]) => {
|
||||
return http.post('/admin/ldap-config/import', { users });
|
||||
@@ -75,6 +75,9 @@ export const ldapApi = {
|
||||
deleteUser: async (dn: string) => {
|
||||
return http.post('/admin/ldap-config/delete-user', { dn });
|
||||
},
|
||||
updateUser: async (data: { dn: string; cn?: string; mail?: string; phone?: string; title?: string }) => {
|
||||
return http.post('/admin/ldap-config/update-user', data);
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 仪表盘 API ====================
|
||||
@@ -403,6 +406,44 @@ export const platformApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 云账单 API ====================
|
||||
export const billingApi = {
|
||||
getStats: async () => {
|
||||
return http.get('/cloud-billings/stats');
|
||||
},
|
||||
list: async (params?: { provider?: string; start_date?: string; end_date?: string; page?: number; page_size?: number }) => {
|
||||
return http.get<ListResponse>('/cloud-billings', params);
|
||||
},
|
||||
create: async (data: any) => {
|
||||
return http.post('/cloud-billings', data);
|
||||
},
|
||||
batchCreate: async (records: any[]) => {
|
||||
return http.post('/cloud-billings/batch', { records });
|
||||
},
|
||||
remove: async (id: number | string) => {
|
||||
return http.delete(`/cloud-billings/${id}`);
|
||||
},
|
||||
getProviders: async () => {
|
||||
return http.get('/cloud-billings/providers');
|
||||
},
|
||||
// 预警规则
|
||||
listAlertRules: async () => {
|
||||
return http.get<ListResponse>('/billing-alerts');
|
||||
},
|
||||
createAlertRule: async (data: any) => {
|
||||
return http.post('/billing-alerts', data);
|
||||
},
|
||||
updateAlertRule: async (id: number | string, data: any) => {
|
||||
return http.put(`/billing-alerts/${id}`, data);
|
||||
},
|
||||
deleteAlertRule: async (id: number | string) => {
|
||||
return http.delete(`/billing-alerts/${id}`);
|
||||
},
|
||||
toggleAlertRule: async (id: number | string) => {
|
||||
return http.post(`/billing-alerts/${id}/toggle`);
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 区域管理 API ====================
|
||||
export const regionApi = {
|
||||
list: async () => {
|
||||
|
||||
Reference in new issue
Block a user