feat(audit): F1+F2 告警静默规则标记完成 + 审计日志CSV导出实现
F1 告警静默规则 [done]: - 后端 alert_silence.go: CRUD + Toggle (onetime/daily/weekly 三模式) - 告警引擎 isSilenced 集成 (alert_engine.go) - 前端 SilenceRuleTab + SilenceDialog - 路由 /alerts/silences 已注册 F2 审计日志导出 [done]: - 重写 ExportAuditLogs: 真正的 CSV 导出 (encoding/csv + UTF-8 BOM) - 支持 module/action/username/startTime/endTime 筛选 - 导出限制 10000 条防溢出,自动记录审计日志 - JSON 格式向后兼容 (format=json) - 前端 exportLogs 传递查询参数 同时包含累积的前期工作: - 目录重构 internal/ -> controller/model/router (v0.55-v0.57) - 后端分层架构 dto/repository/service/utils - 审计模块全功能 (终端审计/操作审计/登录日志) - asciicast v2 录制格式 - SFTP 文件浏览器 - StatStrip 紧凑统计条 - 在线会话自动刷新 - LiveMonitor WebSocket 诊断修复 验证: go build ✅ | npm run build ✅ | Docker 部署 ✅ | 10 回归端点 ✅ | CSV 导出 ✅ | 静默 CRUD ✅
This commit is contained in:
1 parent
e57718998a
commit
77c234b060
314 files changed
+10050
-4065
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { StatStrip } from '@/components/ui/stat-strip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
@@ -137,75 +138,14 @@ export function AIPredictiveAnalytics() {
|
||||
</div>
|
||||
|
||||
{/* Overview Cards */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<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">{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-slate-500">
|
||||
<span>基于 {rawMetrics.length} 个时序数据点回归拟合 R²</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<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">{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>概率 ≥50% 的异常预测</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<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">{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-slate-500">
|
||||
<span>14天内触及阈值</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<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">{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-slate-500">
|
||||
<span>CPU / 内存 / 磁盘</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<StatStrip
|
||||
items={[
|
||||
{ label: '置信度', value: `${accuracy}%`, color: 'emerald' },
|
||||
{ label: '高风险', value: predictedAlertCount, color: 'amber' },
|
||||
{ label: '容量预警', value: capacityWarningCount, color: 'red' },
|
||||
{ label: '指标', value: predictions.length, color: 'purple' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="metrics" className="space-y-4">
|
||||
<TabsList className="bg-slate-800 border border-slate-700">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { StatStrip } from '@/components/ui/stat-strip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -194,38 +195,14 @@ export function CronJobManager() {
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-[var(--dg-text-secondary)] text-sm">任务总数</p>
|
||||
<p className="text-2xl font-bold text-[var(--dg-text-primary)]">{jobs.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-[var(--dg-text-secondary)] text-sm">已启用</p>
|
||||
<p className="text-2xl font-bold text-emerald-400">
|
||||
{jobs.filter((j) => j.enabled).length}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-[var(--dg-text-secondary)] text-sm">累计成功</p>
|
||||
<p className="text-2xl font-bold text-emerald-400">
|
||||
{jobs.reduce((sum, j) => sum + (j.success_count || 0), 0)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-[var(--dg-text-secondary)] text-sm">累计失败</p>
|
||||
<p className="text-2xl font-bold text-red-400">
|
||||
{jobs.reduce((sum, j) => sum + (j.fail_count || 0), 0)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<StatStrip
|
||||
items={[
|
||||
{ label: '任务总数', value: jobs.length, color: 'blue' },
|
||||
{ label: '已启用', value: jobs.filter((j) => j.enabled).length, color: 'emerald' },
|
||||
{ label: '累计成功', value: jobs.reduce((sum, j) => sum + (j.success_count || 0), 0), color: 'emerald' },
|
||||
{ label: '累计失败', value: jobs.reduce((sum, j) => sum + (j.fail_count || 0), 0), color: 'red' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
// ScriptStatsCards — 脚本库统计卡片 // Logan.Li
|
||||
import { FileCode, Loader2, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
interface Props {
|
||||
total: number;
|
||||
running: number;
|
||||
todaySuccess: number;
|
||||
todayFailed: number;
|
||||
}
|
||||
|
||||
// 四个统计卡片:脚本总数 / 执行中 / 今日成功 / 今日失败 // Logan.Li
|
||||
export default function ScriptStatsCards({ total, running, todaySuccess, todayFailed }: Props) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">脚本总数</p>
|
||||
<p className="text-2xl font-bold">{total}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-purple-500/10 rounded-lg">
|
||||
<FileCode className="w-5 h-5 text-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">执行中</p>
|
||||
<p className="text-2xl font-bold text-blue-600">{running}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-500/10 rounded-lg">
|
||||
<Loader2 className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">今日成功</p>
|
||||
<p className="text-2xl font-bold text-green-600">{todaySuccess}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-green-500/10 rounded-lg">
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">今日失败</p>
|
||||
<p className="text-2xl font-bold text-red-600">{todayFailed}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-red-500/10 rounded-lg">
|
||||
<XCircle className="w-5 h-5 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -172,62 +172,6 @@ export function CloudAccountManager() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-400 text-sm">账号总数</p>
|
||||
<p className="text-2xl font-bold text-slate-100 mt-1">{stats.total}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
|
||||
<Cloud className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-400 text-sm">正常</p>
|
||||
<p className="text-2xl font-bold text-green-400 mt-1">{stats.active}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-lg bg-green-500/20 flex items-center justify-center">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-400 text-sm">已停用</p>
|
||||
<p className="text-2xl font-bold text-slate-400 mt-1">{stats.inactive}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-lg bg-slate-500/20 flex items-center justify-center">
|
||||
<XCircle className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-400 text-sm">异常</p>
|
||||
<p className="text-2xl font-bold text-red-400 mt-1">{stats.error}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-lg bg-red-500/20 flex items-center justify-center">
|
||||
<Key className="h-5 w-5 text-red-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
|
||||
@@ -28,6 +28,7 @@ import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { billingApi } from '@/services/api';
|
||||
import { formatCurrency, StatCard, TrendChart, providerLabels, providerColors } from './CloudBillingHelpers';
|
||||
import { StatStrip } from '@/components/ui/stat-strip';
|
||||
import { BillingRecordDialog } from './BillingRecordDialog';
|
||||
import { BillingRuleDialog } from './BillingRuleDialog';
|
||||
|
||||
@@ -208,31 +209,14 @@ export function CloudBilling() {
|
||||
<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>
|
||||
<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">
|
||||
|
||||
@@ -178,75 +178,6 @@ export function DomainManager() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-400 text-sm">域名总数</p>
|
||||
<p className="text-2xl font-bold text-slate-100 mt-1">{stats.total}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
|
||||
<Globe className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-400 text-sm">正常</p>
|
||||
<p className="text-2xl font-bold text-green-400 mt-1">{stats.active}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-lg bg-green-500/20 flex items-center justify-center">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-400 text-sm">已过期</p>
|
||||
<p className="text-2xl font-bold text-red-400 mt-1">{stats.expired}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-lg bg-red-500/20 flex items-center justify-center">
|
||||
<XCircle className="h-5 w-5 text-red-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-400 text-sm">待审核</p>
|
||||
<p className="text-2xl font-bold text-yellow-400 mt-1">{stats.pending}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center">
|
||||
<Clock className="h-5 w-5 text-yellow-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-400 text-sm">SSL证书</p>
|
||||
<p className="text-2xl font-bold text-cyan-400 mt-1">{stats.sslEnabled}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-lg bg-cyan-500/20 flex items-center justify-center">
|
||||
<CheckCircle2 className="h-5 w-5 text-cyan-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
|
||||
@@ -2,10 +2,10 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Clock, Server, Cloud, Bell, DollarSign, ArrowRight} from 'lucide-react';
|
||||
import { Clock, Server, ArrowRight } from 'lucide-react';
|
||||
import { dashboardStatsApi, serverApi, kubernetesApi } from '@/services/api';
|
||||
import { StatCard } from './DashboardStats';
|
||||
import { CostOverviewCard, TopResourceUsageCard, ResourceUsageCard, computeMonthChange } from './DashboardCharts';
|
||||
import { StatStrip } from '@/components/ui/stat-strip';
|
||||
import { CostOverviewCard, TopResourceUsageCard, ResourceUsageCard } from './DashboardCharts';
|
||||
import {
|
||||
AlertMessagesCard,
|
||||
CertificateExpiryCard,
|
||||
@@ -98,7 +98,6 @@ export function Dashboard() {
|
||||
const k8sStats = stats?.kubernetes_stats;
|
||||
const alertStats = stats?.alert_stats;
|
||||
const costStats = stats?.cost_stats;
|
||||
const costChange = computeMonthChange(costStats?.month_trend);
|
||||
|
||||
return (
|
||||
<div className="dg-page">
|
||||
@@ -129,56 +128,19 @@ export function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid with staggered animation */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="animate-fade-in-delay-1">
|
||||
<StatCard
|
||||
title="服务器总数"
|
||||
value={serverStats?.total ?? 0}
|
||||
description={`${serverStats?.running ?? 0} 台运行中`}
|
||||
icon={Server}
|
||||
trend={Number(serverStats?.error ?? 0) > 0 ? 'down' : 'neutral'}
|
||||
trendValue={Number(serverStats?.error ?? 0) > 0 ? `${serverStats?.error} 台异常` : '运行正常'}
|
||||
color="blue"
|
||||
onClick={() => navigate('/servers')}
|
||||
/>
|
||||
</div>
|
||||
<div className="animate-fade-in-delay-2">
|
||||
<StatCard
|
||||
title="Kubernetes 集群"
|
||||
value={k8sStats?.total ?? 0}
|
||||
description={`${k8sStats?.healthy ?? 0} 个健康`}
|
||||
icon={Cloud}
|
||||
trend="neutral"
|
||||
trendValue="稳定"
|
||||
color="purple"
|
||||
onClick={() => navigate('/kubernetes')}
|
||||
/>
|
||||
</div>
|
||||
<div className="animate-fade-in-delay-3">
|
||||
<StatCard
|
||||
title="待处理告警"
|
||||
value={alertStats?.firing ?? 0}
|
||||
description={`${alertStats?.critical ?? 0} 个严重`}
|
||||
icon={Bell}
|
||||
trend={Number(alertStats?.today_count ?? 0) > 0 ? 'up' : 'down'}
|
||||
trendValue={`本周 ${alertStats?.week_count ?? 0} 条`}
|
||||
color="rose"
|
||||
onClick={() => navigate('/alert-center')}
|
||||
/>
|
||||
</div>
|
||||
<div className="animate-fade-in-delay-4">
|
||||
<StatCard
|
||||
title="本月云成本"
|
||||
value={`¥${Number(costStats?.month_cost ?? 0).toLocaleString()}`}
|
||||
description={costChange.label}
|
||||
icon={DollarSign}
|
||||
trend={costChange.direction}
|
||||
trendValue={`${costChange.pct > 0 ? '+' : ''}${costChange.pct.toFixed(1)}%`}
|
||||
color="orange"
|
||||
onClick={() => navigate('/cloud-billing')}
|
||||
/>
|
||||
</div>
|
||||
{/* Stats — compact strip */}
|
||||
<div className="animate-fade-in-delay-1">
|
||||
<StatStrip
|
||||
items={[
|
||||
{ label: '服务器', value: serverStats?.total ?? 0, color: 'blue' },
|
||||
{ label: '运行中', value: serverStats?.running ?? 0, color: 'emerald' },
|
||||
{ label: 'K8s 集群', value: k8sStats?.total ?? 0, color: 'purple' },
|
||||
{ label: '健康集群', value: k8sStats?.healthy ?? 0, color: 'cyan' },
|
||||
{ label: '告警', value: alertStats?.firing ?? 0, color: alertStats?.firing > 0 ? 'red' : 'muted' },
|
||||
{ label: '严重', value: alertStats?.critical ?? 0, color: 'red' },
|
||||
{ label: '本月成本', value: `¥${Number(costStats?.month_cost ?? 0).toLocaleString()}`, color: 'amber' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resource Usage Chart */}
|
||||
|
||||
@@ -296,22 +296,6 @@ export function PipelineManager() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: '流水线总数', val: stats.total, icon: Workflow, color: '#3B82F6' },
|
||||
{ label: '运行中', val: stats.running, icon: Loader2, color: '#3B82F6' },
|
||||
{ label: '成功', val: stats.success, icon: CheckCircle, color: '#10B981' },
|
||||
{ label: '失败', val: stats.failed, icon: XCircle, color: '#EF4444' },
|
||||
].map((s, i) => (
|
||||
<Card key={i} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<div><p className="text-[var(--dg-text-secondary)] text-sm">{s.label}</p><p className="text-2xl font-bold text-[var(--dg-text-primary)]">{s.val}</p></div>
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center" style={{ backgroundColor: s.color + '20' }}><s.icon className="h-5 w-5" style={{ color: s.color }} /></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ══════════ 全流程可视化横幅 — 双路径 ══════════ */}
|
||||
<div className="flex items-stretch gap-3 px-4 py-3 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
|
||||
{/* 路径 1: SSH 传统主机部署 */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { StatStrip } from '@/components/ui/stat-strip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
@@ -139,29 +140,15 @@ export default function ClusterComparePage() {
|
||||
)}
|
||||
|
||||
{/* 概览卡片 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{[
|
||||
{ label: '集群总数', value: totals.cluster_count || 0, icon: Cloud, color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
||||
{ label: '节点总数', value: totals.node_count || 0, icon: Cpu, color: 'text-emerald-400', bg: 'bg-emerald-500/10' },
|
||||
{ label: 'Pod 总数', value: totals.pod_count || 0, icon: Container, color: 'text-purple-400', bg: 'bg-purple-500/10' },
|
||||
{ label: 'Deployment 总数', value: totals.deployment_count || 0, icon: Boxes, color: 'text-amber-400', bg: 'bg-amber-500/10' },
|
||||
{ label: 'Service 总数', value: totals.service_count || 0, icon: Network, color: 'text-cyan-400', bg: 'bg-cyan-500/10' },
|
||||
].map((s, i) => (
|
||||
<Card key={i} className="bg-slate-900/60 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">{s.label}</p>
|
||||
<p className="text-2xl font-bold">{s.value}</p>
|
||||
</div>
|
||||
<div className={`p-2.5 rounded-lg ${s.bg}`}>
|
||||
<s.icon className={`h-5 w-5 ${s.color}`} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<StatStrip
|
||||
items={[
|
||||
{ label: '集群', value: totals.cluster_count || 0, color: 'blue' },
|
||||
{ label: '节点', value: totals.node_count || 0, color: 'emerald' },
|
||||
{ label: 'Pod', value: totals.pod_count || 0, color: 'purple' },
|
||||
{ label: 'Deploy', value: totals.deployment_count || 0, color: 'amber' },
|
||||
{ label: 'Service', value: totals.service_count || 0, color: 'cyan' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{clusters.length === 0 && !loading && (
|
||||
<Card className="bg-slate-900/60 border-slate-800">
|
||||
|
||||
@@ -185,68 +185,6 @@ export function KubernetesClusters() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">集群总数</p>
|
||||
<p className="text-2xl font-bold text-slate-100">{clusters.length}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
|
||||
<Cloud className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">健康集群</p>
|
||||
<p className="text-2xl font-bold text-emerald-400">
|
||||
{clusters.filter((c) => c.status === 'healthy').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">总节点数</p>
|
||||
<p className="text-2xl font-bold text-slate-100">
|
||||
{clusters.reduce((acc, c) => acc + (c.node_count || 0), 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-purple-500/10 text-purple-400">
|
||||
<Server className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">总Pod数</p>
|
||||
<p className="text-2xl font-bold text-slate-100">
|
||||
{clusters.reduce((acc, c) => acc + (c.pod_count || 0), 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-cyan-500/10 text-cyan-400">
|
||||
<Database className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-80">
|
||||
|
||||
@@ -81,22 +81,6 @@ export function KubernetesNodes() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: '总节点', value: nodes.length, color: 'text-blue-400' },
|
||||
{ label: 'Master', value: nodes.filter((n) => n.role === 'master').length, color: 'text-purple-400' },
|
||||
{ label: 'Worker', value: nodes.filter((n) => n.role === 'worker').length, color: 'text-emerald-400' },
|
||||
{ label: '就绪', value: nodes.filter((n) => n.status === 'Ready').length, color: 'text-cyan-400' },
|
||||
].map((s) => (
|
||||
<Card key={s.label} className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm text-slate-500">{s.label}</p>
|
||||
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-56">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
|
||||
@@ -48,6 +48,9 @@ const ROUTE_NAME_MAP: Record<string, string> = {
|
||||
'host-monitor': '主机监控',
|
||||
'alerts': '监控告警',
|
||||
'approval': '资源申请',
|
||||
'approval-resource': '资源申请',
|
||||
'approval-app': '应用申请',
|
||||
'approval-permission': '权限审批',
|
||||
'scripts': '脚本库',
|
||||
'ansible': 'Ansible',
|
||||
'ai-assistant': '智能助手',
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useThemeStore, useNotificationStore, useUserStore } from '@/store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Search,
|
||||
Bell,
|
||||
Menu,
|
||||
Settings,
|
||||
@@ -30,30 +28,18 @@ import {
|
||||
import { formatDistanceToNow } from '@/lib/utils';
|
||||
import { useTheme, THEME_OPTIONS } from '@/hooks/useTheme';
|
||||
|
||||
// Keyboard shortcut handler
|
||||
// Keyboard shortcut handler — Cmd/Ctrl+B 切换侧边栏
|
||||
function useKeyboardShortcuts() {
|
||||
const _navigate = useNavigate();
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
// Cmd/Ctrl + K to open search
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
setSearchOpen((prev) => !prev);
|
||||
}
|
||||
// Cmd/Ctrl + B to toggle sidebar
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'b') {
|
||||
e.preventDefault();
|
||||
// Dispatch custom event for sidebar toggle
|
||||
window.dispatchEvent(new CustomEvent('toggle-sidebar'));
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, []);
|
||||
|
||||
return { searchOpen, setSearchOpen };
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
@@ -62,9 +48,6 @@ export function Header() {
|
||||
const { user, logout } = useUserStore();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [searchFocused, setSearchFocused] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
useKeyboardShortcuts();
|
||||
|
||||
const handleLogout = () => {
|
||||
@@ -87,18 +70,6 @@ export function Header() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Quick nav items for search
|
||||
const quickNavItems = [
|
||||
{ name: '仪表盘', path: '/', icon: 'LayoutDashboard' },
|
||||
{ name: '服务器管理', path: '/servers', icon: 'Server' },
|
||||
{ name: 'Kubernetes 集群', path: '/kubernetes', icon: 'Cloud' },
|
||||
{ name: '监控告警', path: '/alerts', icon: 'Bell' },
|
||||
{ name: '告警中心', path: '/alert-center', icon: 'AlertTriangle' },
|
||||
{ name: '流水线', path: '/pipelines', icon: 'GitBranch' },
|
||||
{ name: '智能助手', path: '/ai-assistant', icon: 'Brain' },
|
||||
{ name: '脚本库', path: '/scripts', icon: 'FileCode' },
|
||||
];
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
@@ -121,79 +92,6 @@ export function Header() {
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Global Search */}
|
||||
<div className={cn(
|
||||
'relative transition-all duration-300',
|
||||
searchFocused ? 'w-[28rem] max-w-full' : 'w-80'
|
||||
)}>
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-muted)] pointer-events-none" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="搜索服务器、应用、工单、文档..."
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
onFocus={() => setSearchFocused(true)}
|
||||
onBlur={() => setTimeout(() => setSearchFocused(false), 200)}
|
||||
className={cn(
|
||||
'pl-9 h-9 text-sm transition-all duration-300',
|
||||
'bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]',
|
||||
'text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]',
|
||||
'focus:border-[var(--dg-blue)] focus:ring-1 focus:ring-[var(--dg-blue)]/30',
|
||||
'hover:border-[var(--dg-border-hover)]',
|
||||
)}
|
||||
/>
|
||||
<kbd className="absolute right-3 top-1/2 -translate-y-1/2 px-1.5 py-0.5 text-[10px] font-mono text-[var(--dg-text-muted)] bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded hidden sm:inline-block">
|
||||
⌘K
|
||||
</kbd>
|
||||
|
||||
{/* Search Dropdown */}
|
||||
{searchFocused && searchValue && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-full left-0 right-0 mt-2 py-2 rounded-lg',
|
||||
'bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]',
|
||||
'shadow-lg z-50 animate-scale-in',
|
||||
)}
|
||||
style={{ boxShadow: 'var(--shadow-dropdown)' }}
|
||||
>
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-[var(--dg-text-muted)] uppercase tracking-wider">
|
||||
快速导航
|
||||
</div>
|
||||
{quickNavItems
|
||||
.filter((item) =>
|
||||
item.name.toLowerCase().includes(searchValue.toLowerCase())
|
||||
)
|
||||
.map((item) => (
|
||||
<button
|
||||
key={item.path}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2.5 px-3 py-2 text-sm',
|
||||
'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]',
|
||||
'hover:bg-[var(--dg-bg-hover)] transition-colors text-left',
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
navigate(item.path);
|
||||
setSearchValue('');
|
||||
setSearchFocused(false);
|
||||
}}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
<span>{item.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{quickNavItems.filter((item) =>
|
||||
item.name.toLowerCase().includes(searchValue.toLowerCase())
|
||||
).length === 0 && (
|
||||
<div className="px-3 py-4 text-center text-sm text-[var(--dg-text-muted)]">
|
||||
未找到匹配的结果
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
|
||||
@@ -35,6 +35,9 @@ import {
|
||||
Boxes,
|
||||
Clock,
|
||||
Rocket,
|
||||
ShieldCheck,
|
||||
FileSearch,
|
||||
LogIn,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -132,14 +135,26 @@ const menuItems: MenuItem[] = [
|
||||
{ id: 'ai-predictive', name: '容量预测', icon: BarChart3, path: '/ai-predictive' },
|
||||
],
|
||||
},
|
||||
{ id: 'approval', name: '资源申请', icon: CheckSquare, path: '/approval' },
|
||||
{ id: 'approval', name: '资源申请', icon: CheckSquare,
|
||||
children: [
|
||||
{ id: 'approval-resource', name: '资源申请', icon: Server, path: '/approval/resource' },
|
||||
{ id: 'approval-app', name: '应用申请', icon: Rocket, path: '/approval/app' },
|
||||
{ id: 'approval-permission', name: '权限审批', icon: ShieldCheck, path: '/approval/permission' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'audit', name: '安全审计', icon: FileSearch,
|
||||
children: [
|
||||
{ id: 'audit-terminal', name: '终端审计', icon: Terminal, path: '/audit/terminal' },
|
||||
{ id: 'audit-operations', name: '操作审计', icon: FileCode, path: '/audit/operations' },
|
||||
{ id: 'audit-login', name: '登录日志', icon: LogIn, path: '/audit/login-logs' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'system', name: '系统管理', icon: Settings,
|
||||
children: [
|
||||
{ id: 'users', name: '用户管理', icon: Users, path: '/users' },
|
||||
{ id: 'roles', name: '角色权限', icon: Shield, path: '/roles' },
|
||||
{ id: 'logs-audit', name: '操作审计', icon: FileCode, path: '/logs-audit' },
|
||||
{ id: 'terminal-audit', name: '终端审计', icon: Terminal, path: '/terminal-audit' },
|
||||
{ id: 'credentials', name: '凭证配置', icon: Shield, path: '/credentials' },
|
||||
{ id: 'settings', name: '系统设置', icon: Settings, path: '/settings' },
|
||||
],
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { StatStrip } from '@/components/ui/stat-strip';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
@@ -24,6 +25,7 @@ import { AlertDetailDialog, AlertSilenceDialog } from './AlertCenterDetail';
|
||||
import { AlertRuleDialog } from '@/components/monitor/AlertRuleDialog';
|
||||
import { ContactDialog } from './ContactDialog';
|
||||
import { ContactGroupDialog } from './ContactGroupDialog';
|
||||
import SilenceRuleTab from './SilenceRuleTab';
|
||||
import type { Contact, ContactGroup } from './alert-center-helpers';
|
||||
|
||||
const AlertCenter: React.FC = () => {
|
||||
@@ -151,8 +153,21 @@ const AlertCenter: React.FC = () => {
|
||||
|
||||
const handleSilence = async (id: string, duration: string) => {
|
||||
try {
|
||||
await alertCenterApi.silence(id, duration);
|
||||
toast.success('告警已静默');
|
||||
const alert = alerts.find(a => a.id === id);
|
||||
const now = new Date();
|
||||
const match = duration.match(/^(\d+)([hd])$/);
|
||||
const num = match ? parseInt(match[1]) : 1;
|
||||
const unit = match?.[2] || 'h';
|
||||
const hours = unit === 'd' ? num * 24 : num;
|
||||
const endTime = new Date(now.getTime() + hours * 3600_000);
|
||||
await alertCenterApi.createSilence({
|
||||
name: `静默: ${alert?.title || id} (${duration})`,
|
||||
description: `从告警列表快速静默 — ${alert?.source || ''}`,
|
||||
silence_type: 'onetime',
|
||||
start_time: now.toISOString(),
|
||||
end_time: endTime.toISOString(),
|
||||
});
|
||||
toast.success(`告警已静默 ${duration}`);
|
||||
setSilenceOpen(false);
|
||||
fetchAlerts();
|
||||
} catch {
|
||||
@@ -205,66 +220,21 @@ const AlertCenter: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 统计卡片 — 紧凑 */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<Card className={stats.firing > 0 ? 'border-red-500 border-2' : ''}>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">触发中</p>
|
||||
<p className={`text-xl font-bold ${stats.firing > 0 ? 'text-red-600' : ''}`}>{stats.firing}</p>
|
||||
</div>
|
||||
<div className={`p-2 rounded-md ${stats.firing > 0 ? 'bg-red-100' : 'bg-gray-100'}`}>
|
||||
<Zap className={`w-4 h-4 ${stats.firing > 0 ? 'text-red-600' : 'text-gray-600'}`} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">严重告警</p>
|
||||
<p className="text-xl font-bold text-red-600">{stats.critical}</p>
|
||||
</div>
|
||||
<div className="p-2 bg-red-100 rounded-md">
|
||||
<AlertCircle className="w-4 h-4 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">警告告警</p>
|
||||
<p className="text-xl font-bold text-orange-600">{stats.warning}</p>
|
||||
</div>
|
||||
<div className="p-2 bg-orange-100 rounded-md">
|
||||
<AlertTriangle className="w-4 h-4 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">今日已解决</p>
|
||||
<p className="text-xl font-bold text-green-600">{stats.resolved}</p>
|
||||
</div>
|
||||
<div className="p-2 bg-green-100 rounded-md">
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{/* 统计 — 紧凑条 */}
|
||||
<StatStrip
|
||||
items={[
|
||||
{ label: '触发中', value: stats.firing, color: stats.firing > 0 ? 'red' : 'muted' },
|
||||
{ label: '严重', value: stats.critical, color: 'red' },
|
||||
{ label: '警告', value: stats.warning, color: 'amber' },
|
||||
{ label: '已解决', value: stats.resolved, color: 'emerald' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-4 lg:w-[480px] h-9">
|
||||
<TabsList className="grid w-full grid-cols-5 lg:w-[600px] h-9">
|
||||
<TabsTrigger value="alerts" className="text-xs">告警列表</TabsTrigger>
|
||||
<TabsTrigger value="rules" className="text-xs">告警规则</TabsTrigger>
|
||||
<TabsTrigger value="silences" className="text-xs">静默规则</TabsTrigger>
|
||||
<TabsTrigger value="contacts" className="text-xs">报警联系人</TabsTrigger>
|
||||
<TabsTrigger value="groups" className="text-xs">报警联系组</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -501,6 +471,11 @@ const AlertCenter: React.FC = () => {
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* 静默规则 Tab */}
|
||||
<TabsContent value="silences">
|
||||
<SilenceRuleTab />
|
||||
</TabsContent>
|
||||
|
||||
{/* 报警联系人 Tab */}
|
||||
<TabsContent value="contacts" className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
// 告警静默规则创建/编辑弹框 // Logan.Li
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
import { alertCenterApi } from '@/services/api';
|
||||
|
||||
// 静默类型中文标签
|
||||
const SILENCE_TYPE_LABELS: Record<string, string> = {
|
||||
onetime: '一次性',
|
||||
daily: '每天循环',
|
||||
weekly: '每周循环',
|
||||
};
|
||||
|
||||
const WEEKDAY_LABELS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||
|
||||
export interface SilenceData {
|
||||
id?: number;
|
||||
name: string;
|
||||
description: string;
|
||||
silence_type: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
weekdays: number[];
|
||||
}
|
||||
|
||||
function toLocalDateTimeInput(d: Date): string {
|
||||
const off = d.getTimezoneOffset();
|
||||
const local = new Date(d.getTime() - off * 60000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function addHours(d: Date, hours: number): Date {
|
||||
return new Date(d.getTime() + hours * 3600_000);
|
||||
}
|
||||
|
||||
export function SilenceDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
editingSilence,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editingSilence: any;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [silenceType, setSilenceType] = useState('onetime');
|
||||
const [startTime, setStartTime] = useState('');
|
||||
const [endTime, setEndTime] = useState('');
|
||||
const [weekdays, setWeekdays] = useState<number[]>([]);
|
||||
|
||||
// 编辑时回填
|
||||
useEffect(() => {
|
||||
if (editingSilence) {
|
||||
setName(editingSilence.name || '');
|
||||
setDescription(editingSilence.description || '');
|
||||
setSilenceType(editingSilence.silence_type || 'onetime');
|
||||
// 解析时间
|
||||
const st = editingSilence.start_time ? new Date(editingSilence.start_time) : new Date();
|
||||
const et = editingSilence.end_time ? new Date(editingSilence.end_time) : addHours(new Date(), 1);
|
||||
setStartTime(toLocalDateTimeInput(st));
|
||||
setEndTime(toLocalDateTimeInput(et));
|
||||
// 解析 weekdays
|
||||
let wds: number[] = [];
|
||||
if (editingSilence.weekday_list) {
|
||||
wds = editingSilence.weekday_list;
|
||||
} else if (editingSilence.weekdays) {
|
||||
try { wds = JSON.parse(editingSilence.weekdays); } catch { /* ignore */ }
|
||||
}
|
||||
setWeekdays(wds);
|
||||
} else {
|
||||
setName('');
|
||||
setDescription('');
|
||||
setSilenceType('onetime');
|
||||
const now = new Date();
|
||||
setStartTime(toLocalDateTimeInput(now));
|
||||
setEndTime(toLocalDateTimeInput(addHours(now, 2)));
|
||||
setWeekdays([]);
|
||||
}
|
||||
}, [editingSilence, open]);
|
||||
|
||||
const toggleWeekday = (day: number) => {
|
||||
setWeekdays(prev =>
|
||||
prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error('请填写规则名称');
|
||||
return;
|
||||
}
|
||||
if (!endTime) {
|
||||
toast.error('请设置结束时间');
|
||||
return;
|
||||
}
|
||||
if (silenceType === 'weekly' && weekdays.length === 0) {
|
||||
toast.error('每周静默需要选择至少一个星期');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
silence_type: silenceType,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
weekdays: silenceType === 'weekly' ? weekdays : [],
|
||||
};
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingSilence) {
|
||||
await alertCenterApi.updateSilence(editingSilence.id, payload);
|
||||
toast.success('静默规则已更新');
|
||||
} else {
|
||||
await alertCenterApi.createSilence(payload);
|
||||
toast.success('静默规则已创建');
|
||||
}
|
||||
onOpenChange(false);
|
||||
onSaved();
|
||||
} catch {
|
||||
toast.error('保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent showOverlay={false} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)]">
|
||||
{editingSilence ? '编辑静默规则' : '创建静默规则'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-secondary)]">
|
||||
静默期间匹配的告警将不会触发通知
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-1">
|
||||
{/* 名称 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-primary)]">规则名称 *</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="如:维护窗口静默"
|
||||
className="bg-[var(--dg-bg-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-primary)]">描述</Label>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="可选备注"
|
||||
className="bg-[var(--dg-bg-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 静默类型 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-primary)]">静默类型</Label>
|
||||
<Select value={silenceType} onValueChange={setSilenceType}>
|
||||
<SelectTrigger className="bg-[var(--dg-bg-primary)]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(SILENCE_TYPE_LABELS).map(([val, label]) => (
|
||||
<SelectItem key={val} value={val}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 时间窗口 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-primary)]">
|
||||
{silenceType === 'onetime' ? '开始时间' : '每日开始'}
|
||||
</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
className="bg-[var(--dg-bg-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-primary)]">
|
||||
{silenceType === 'onetime' ? '结束时间' : '每日结束'}
|
||||
</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
className="bg-[var(--dg-bg-primary)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 星期选择(仅 weekly) */}
|
||||
{silenceType === 'weekly' && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-primary)]">生效星期</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{WEEKDAY_LABELS.map((label, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => toggleWeekday(idx)}
|
||||
className={`px-3 py-1.5 rounded-md text-xs transition-colors ${
|
||||
weekdays.includes(idx)
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] text-[var(--dg-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提示 */}
|
||||
<div className="text-xs text-muted-foreground bg-muted/50 rounded-md p-2">
|
||||
{silenceType === 'onetime' && '在指定时间窗口内,所有匹配条件的告警将被静默。窗口结束后自动恢复正常。'}
|
||||
{silenceType === 'daily' && '每天在指定时段内静默告警,适合非工作时间免打扰。'}
|
||||
{silenceType === 'weekly' && '仅在选定星期的指定时段内静默,适合周末维护窗口。'}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// 告警静默规则管理 Tab — 列表 + 创建/编辑/删除/启停 // Logan.Li
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { VolumeX, Plus, RefreshCw, Clock, Calendar, Repeat, Edit, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { alertCenterApi } from '@/services/api';
|
||||
import { SilenceDialog } from './SilenceDialog';
|
||||
|
||||
const SILENCE_TYPE_LABELS: Record<string, string> = {
|
||||
onetime: '一次性',
|
||||
daily: '每天',
|
||||
weekly: '每周',
|
||||
};
|
||||
|
||||
const WEEKDAY_LABELS = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
|
||||
function formatTime(t: string | Date): string {
|
||||
if (!t) return '—';
|
||||
const d = typeof t === 'string' ? new Date(t) : t;
|
||||
if (isNaN(d.getTime())) return '—';
|
||||
return d.toLocaleString('zh-CN', {
|
||||
month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
const SilenceRuleTab: React.FC = () => {
|
||||
const confirm = useConfirm();
|
||||
const [silences, setSilences] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
|
||||
const fetchSilences = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res: Record<string, any> = await alertCenterApi.getSilences();
|
||||
const list = res?.list || res?.data?.list || [];
|
||||
setSilences(list);
|
||||
} catch {
|
||||
toast.error('获取静默规则失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSilences();
|
||||
}, [fetchSilences]);
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditing(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (silence: any) => {
|
||||
setEditing(silence);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (silence: any) => {
|
||||
const ok = await confirm({
|
||||
title: '删除静默规则',
|
||||
description: `确定删除「${silence.name}」?删除后该规则匹配的告警将恢复通知。`,
|
||||
confirmText: '删除',
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await alertCenterApi.deleteSilence(silence.id);
|
||||
toast.success('已删除');
|
||||
fetchSilences();
|
||||
} catch {
|
||||
toast.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (silence: any) => {
|
||||
try {
|
||||
await alertCenterApi.toggleSilence(silence.id);
|
||||
toast.success(silence.status === 1 ? '已禁用' : '已启用');
|
||||
fetchSilences();
|
||||
} catch {
|
||||
toast.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const isCurrentlyActive = (s: any): boolean => {
|
||||
if (s.is_active !== undefined) return s.is_active;
|
||||
if (s.status !== 1) return false;
|
||||
const now = new Date();
|
||||
const st = new Date(s.start_time);
|
||||
const et = new Date(s.end_time);
|
||||
return now >= st && now <= et;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 操作栏 */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<VolumeX className="w-4 h-4" />
|
||||
<span>共 {silences.length} 条规则</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={fetchSilences} disabled={loading}>
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-1" />
|
||||
刷新
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleAdd}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||||
添加静默规则
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 规则列表 */}
|
||||
{loading && silences.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p className="text-sm">加载中...</p>
|
||||
</div>
|
||||
) : silences.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground border border-dashed rounded-lg">
|
||||
<VolumeX className="w-10 h-10 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">暂无静默规则</p>
|
||||
<p className="text-xs mt-1">点击「添加静默规则」创建维护窗口或免打扰时段</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{silences.map((s) => {
|
||||
const active = isCurrentlyActive(s);
|
||||
const enabled = s.status === 1;
|
||||
const sType = s.silence_type || 'onetime';
|
||||
const weekdayList: number[] = s.weekday_list || (() => {
|
||||
try { return JSON.parse(s.weekdays || '[]'); } catch { return []; }
|
||||
})();
|
||||
return (
|
||||
<Card key={s.id} className={active ? 'border-primary/50' : ''}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{/* 左侧信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||
<span className="font-medium text-sm">{s.name}</span>
|
||||
{/* 类型 Badge */}
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{sType === 'onetime' && <Clock className="w-3 h-3 mr-1" />}
|
||||
{sType === 'daily' && <Repeat className="w-3 h-3 mr-1" />}
|
||||
{sType === 'weekly' && <Calendar className="w-3 h-3 mr-1" />}
|
||||
{SILENCE_TYPE_LABELS[sType] || sType}
|
||||
</Badge>
|
||||
{/* 活跃状态 */}
|
||||
{active && (
|
||||
<Badge className="bg-green-500 text-xs">生效中</Badge>
|
||||
)}
|
||||
{!enabled && (
|
||||
<Badge variant="secondary" className="text-xs">已禁用</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{s.description && (
|
||||
<p className="text-xs text-muted-foreground mb-1.5">{s.description}</p>
|
||||
)}
|
||||
|
||||
{/* 时间窗口 */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{sType === 'onetime'
|
||||
? `${formatTime(s.start_time)} — ${formatTime(s.end_time)}`
|
||||
: `每日 ${formatTime(s.start_time)} — ${formatTime(s.end_time)}`
|
||||
}
|
||||
</span>
|
||||
{sType === 'weekly' && weekdayList.length > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
{weekdayList.sort((a,b)=>a-b).map(d => (
|
||||
<span key={d} className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary text-[10px]">
|
||||
{WEEKDAY_LABELS[d]}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 创建时间 */}
|
||||
<div className="text-[10px] text-muted-foreground/60 mt-1">
|
||||
创建于 {formatTime(s.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧操作 */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={() => handleToggle(s)}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => handleEdit(s)}>
|
||||
<Edit className="w-3 h-3 mr-1" />编辑
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs text-red-400" onClick={() => handleDelete(s)}>
|
||||
<Trash2 className="w-3 h-3 mr-1" />删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 创建/编辑弹框 */}
|
||||
<SilenceDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
editingSilence={editing}
|
||||
onSaved={fetchSilences}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SilenceRuleTab;
|
||||
@@ -4,6 +4,7 @@ import { Server, Loader2, Activity, Cpu, RefreshCw, AlertTriangle, Container, Za
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { StatStrip } from '@/components/ui/stat-strip';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { http } from '@/services/http';
|
||||
|
||||
@@ -47,12 +48,14 @@ const Box = ( p: any) => <svg xmlns="http://www.w3.org/2000/svg" width="16" heig
|
||||
|
||||
const K8sPanel: React.FC<{ d: Record<string, any>}> = ({ d }) => (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]"><CardContent className="p-3 text-center"><div className="text-2xl font-bold text-purple-400">{d.podCount?.[0]?.value || 0}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Pods</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]"><CardContent className="p-3 text-center"><div className="text-2xl font-bold text-blue-400">{d.deployCount?.[0]?.value || 0}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Deployments</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]"><CardContent className="p-3 text-center"><div className="text-2xl font-bold text-emerald-400">{d.nodeCount?.[0]?.value || 0}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Nodes</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]"><CardContent className="p-3 text-center"><div className="text-2xl font-bold text-amber-400">{d.nsPods?.length || 0}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Namespaces</div></CardContent></Card>
|
||||
</div>
|
||||
<StatStrip
|
||||
items={[
|
||||
{ label: 'Pods', value: d.podCount?.[0]?.value || 0, color: 'purple' },
|
||||
{ label: 'Deployments', value: d.deployCount?.[0]?.value || 0, color: 'blue' },
|
||||
{ label: 'Nodes', value: d.nodeCount?.[0]?.value || 0, color: 'emerald' },
|
||||
{ label: 'Namespaces', value: d.nsPods?.length || 0, color: 'amber' },
|
||||
]}
|
||||
/>
|
||||
<div className="rounded-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] p-3 max-h-60 overflow-y-auto">
|
||||
<div className="text-xs font-medium mb-2 flex items-center gap-1"><Cpu className="w-3 h-3 text-purple-400" />节点 CPU / 内存利用率</div>
|
||||
{(d.nodeCPU || []).map(( n: any, i: number) => {
|
||||
@@ -93,10 +96,12 @@ const NodePanel: React.FC<{ d: Record<string, any>}> = ({ d }) => (
|
||||
|
||||
const PodPanel: React.FC<{ d: Record<string, any>}> = ({ d }) => (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><div className="text-xl font-bold text-emerald-400">{d.running?.[0]?.value||0}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Running</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><div className="text-xl font-bold text-amber-400">{d.pending?.[0]?.value||0}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Pending</div></CardContent></Card>
|
||||
</div>
|
||||
<StatStrip
|
||||
items={[
|
||||
{ label: 'Running', value: d.running?.[0]?.value || 0, color: 'emerald' },
|
||||
{ label: 'Pending', value: d.pending?.[0]?.value || 0, color: 'amber' },
|
||||
]}
|
||||
/>
|
||||
<div className="rounded-lg bg-[var(--dg-bg-primary)] p-3 max-h-40 overflow-y-auto"><div className="text-xs font-medium mb-2">CPU Top 10</div>{(d.cpuTop||[]).map(( p: any,i:number)=><div key={i} className="flex items-center gap-2 text-[11px] mb-1"><span className="w-10 text-right text-[var(--dg-text-muted)]">{i+1}</span><span className="w-32 truncate" title={p.pod}>{p.pod}</span><MiniBar val={Number(p.value||0)*100} label={`${Number(p.value||0).toFixed(2)}c`} color="bg-purple-500" /></div>)}</div>
|
||||
<div className="rounded-lg bg-[var(--dg-bg-primary)] p-3 max-h-40 overflow-y-auto"><div className="text-xs font-medium mb-2">内存 Top 10</div>{(d.memTop||[]).map(( p: any,i:number)=><div key={i} className="flex items-center gap-2 text-[11px] mb-1"><span className="w-10 text-right text-[var(--dg-text-muted)]">{i+1}</span><span className="w-32 truncate" title={p.pod}>{p.pod}</span><span className="ml-auto">{fmtBytes(Number(p.value||0))}</span></div>)}</div>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import { StatStrip } from '@/components/ui/stat-strip';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription
|
||||
} from '@/components/ui/dialog';
|
||||
@@ -260,64 +261,14 @@ const TraceManager: React.FC = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">总请求数</p>
|
||||
<p className="text-2xl font-bold">{services.reduce((sum, s) => sum + s.requestCount, 0).toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-100 rounded-lg">
|
||||
<Activity className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">平均错误率</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{(services.reduce((sum, s) => sum + s.errorRate, 0) / (services.length || 1)).toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-red-100 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">平均延迟</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{formatDuration(services.reduce((sum, s) => sum + s.avgDuration, 0) / (services.length || 1))}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-green-100 rounded-lg">
|
||||
<Clock className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">服务数量</p>
|
||||
<p className="text-2xl font-bold">{services.length}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-purple-100 rounded-lg">
|
||||
<Layers className="w-5 h-5 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<StatStrip
|
||||
items={[
|
||||
{ label: '总请求', value: services.reduce((sum, s) => sum + s.requestCount, 0).toLocaleString(), color: 'blue' },
|
||||
{ label: '错误率', value: `${(services.reduce((sum, s) => sum + s.errorRate, 0) / (services.length || 1)).toFixed(2)}%`, color: 'red' },
|
||||
{ label: '平均延迟', value: formatDuration(services.reduce((sum, s) => sum + s.avgDuration, 0) / (services.length || 1)), color: 'amber' },
|
||||
{ label: '服务数', value: services.length, color: 'purple' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-3 lg:w-[400px]">
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
// 服务器文件浏览器 — SFTP 浏览/上传/下载(参考 deployment FileBrowserDialog 设计)
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Folder,
|
||||
FolderOpen,
|
||||
File,
|
||||
Upload,
|
||||
Download,
|
||||
Trash2,
|
||||
ArrowLeft,
|
||||
Home,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
FolderPlus,
|
||||
ChevronRight,
|
||||
X,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { serverApi } from '@/services/api';
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
is_dir: boolean;
|
||||
mod_time: string;
|
||||
mode: string;
|
||||
}
|
||||
|
||||
interface Breadcrumb {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1048576).toFixed(2)} MB`;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
serverId: number | null;
|
||||
serverName: string;
|
||||
}
|
||||
|
||||
export function ServerFileBrowser({ open, onOpenChange, serverId, serverName }: Props) {
|
||||
const [items, setItems] = useState<FileItem[]>([]);
|
||||
const [breadcrumbs, setBreadcrumbs] = useState<Breadcrumb[]>([]);
|
||||
const [currentPath, setCurrentPath] = useState('/');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const fetchDir = useCallback(async (path: string) => {
|
||||
if (!serverId) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const resp = await serverApi.listFiles(serverId, path) as any;
|
||||
const data = resp?.data || resp;
|
||||
setItems(data.items || []);
|
||||
setBreadcrumbs(data.breadcrumbs || []);
|
||||
setCurrentPath(data.path || path);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '加载目录失败');
|
||||
setItems([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && serverId) {
|
||||
setFullscreen(false);
|
||||
fetchDir('/');
|
||||
}
|
||||
}, [open, serverId, fetchDir]);
|
||||
|
||||
const handleDownload = async (filePath: string) => {
|
||||
if (!serverId) return;
|
||||
try {
|
||||
await serverApi.downloadFile(serverId, filePath);
|
||||
} catch (err) {
|
||||
setError('下载失败: ' + ((err as any)?.message || ''));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !serverId) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
await serverApi.uploadFile(serverId, currentPath, file);
|
||||
await fetchDir(currentPath);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (filePath: string, isDir: boolean) => {
|
||||
if (!serverId || !confirm(`确认删除 ${isDir ? '目录' : '文件'} "${filePath}"?`)) return;
|
||||
try {
|
||||
await serverApi.deleteFile(serverId, filePath);
|
||||
await fetchDir(currentPath);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleMkdir = async () => {
|
||||
const name = prompt('输入目录名:');
|
||||
if (!name || !serverId) return;
|
||||
const newPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
|
||||
try {
|
||||
await serverApi.mkdir(serverId, newPath);
|
||||
await fetchDir(currentPath);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
const sortedItems = [...items].sort((a, b) => {
|
||||
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
onInteractOutside={(e) => {
|
||||
// 阻止点击外部关闭,上传中避免误关
|
||||
if (uploading) e.preventDefault();
|
||||
}}
|
||||
className={cn(
|
||||
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
|
||||
'!duration-0 !transition-none',
|
||||
fullscreen
|
||||
? '!max-w-none !w-screen !h-screen !rounded-none'
|
||||
: ''
|
||||
)}
|
||||
style={fullscreen
|
||||
? { maxWidth: '100vw', width: '100vw', height: '100vh' }
|
||||
: { maxWidth: '95vw', width: '1100px', maxHeight: '92vh' }
|
||||
}
|
||||
>
|
||||
<DialogTitle className="sr-only">文件浏览 — {serverName}</DialogTitle>
|
||||
<DialogDescription className="sr-only">SFTP 文件管理 · 上传 · 下载 · 删除</DialogDescription>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-[var(--dg-border)] shrink-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FolderOpen className="h-4 w-4 text-[var(--dg-blue)] shrink-0" />
|
||||
<span className="text-[var(--dg-text-primary)] font-medium text-sm truncate">
|
||||
{serverName}
|
||||
</span>
|
||||
<span className="text-[var(--dg-text-muted)] text-xs">· SFTP 文件管理</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setFullscreen(!fullscreen)}
|
||||
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
|
||||
title={fullscreen ? '退出全屏' : '全屏'}
|
||||
>
|
||||
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-primary)] shrink-0">
|
||||
<Button variant="outline" size="sm" className="border-[var(--dg-border)] h-8"
|
||||
onClick={() => fetchDir(currentPath)}>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1" />刷新
|
||||
</Button>
|
||||
<label>
|
||||
<Button variant="outline" size="sm" className="border-[var(--dg-border)] h-8 cursor-pointer"
|
||||
onClick={() => inputRef.current?.click()} disabled={uploading}>
|
||||
{uploading ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Upload className="h-3.5 w-3.5 mr-1" />}
|
||||
上传
|
||||
</Button>
|
||||
<input ref={inputRef} type="file" className="hidden" onChange={handleUpload} />
|
||||
</label>
|
||||
<Button variant="outline" size="sm" className="border-[var(--dg-border)] h-8"
|
||||
onClick={handleMkdir}>
|
||||
<FolderPlus className="h-3.5 w-3.5 mr-1" />新建目录
|
||||
</Button>
|
||||
{error && (
|
||||
<Badge variant="outline" className="border-red-500/30 text-red-400 ml-auto">
|
||||
<X className="h-3 w-3 mr-1 cursor-pointer" onClick={() => setError('')} />
|
||||
{error}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Breadcrumbs */}
|
||||
<div className="flex items-center gap-0.5 px-4 py-2 text-xs border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] overflow-x-auto shrink-0">
|
||||
{breadcrumbs.map((crumb, i) => (
|
||||
<span key={crumb.path} className="flex items-center gap-0.5">
|
||||
{i === 0 ? (
|
||||
<button
|
||||
onClick={() => fetchDir('/')}
|
||||
className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] p-0.5"
|
||||
>
|
||||
<Home className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => fetchDir(crumb.path)}
|
||||
className="text-[var(--dg-blue)] hover:underline truncate max-w-[150px]"
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
)}
|
||||
{i < breadcrumbs.length - 1 && (
|
||||
<ChevronRight className="h-3 w-3 text-[var(--dg-text-muted)] shrink-0" />
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* File List */}
|
||||
<div className="overflow-y-auto flex-1" style={{
|
||||
height: fullscreen ? 'calc(100vh - 110px)' : 'min(55vh, 600px)'
|
||||
}}>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full py-16">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-[var(--dg-blue)]" />
|
||||
</div>
|
||||
) : sortedItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-[var(--dg-text-muted)]">
|
||||
<Folder className="h-10 w-10 mb-2 opacity-30" />
|
||||
<p className="text-sm">空目录</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-[var(--dg-bg-primary)] border-b border-[var(--dg-border)] z-10">
|
||||
<tr className="text-left text-[var(--dg-text-muted)] text-xs">
|
||||
<th className="py-2 pl-4 w-8 font-medium"></th>
|
||||
<th className="py-2 font-medium">名称</th>
|
||||
<th className="py-2 px-2 font-medium w-28 text-right">大小</th>
|
||||
<th className="py-2 px-2 font-medium w-44 hidden sm:table-cell">修改时间</th>
|
||||
<th className="py-2 px-2 font-medium w-24 hidden md:table-cell">权限</th>
|
||||
<th className="py-2 pr-4 pl-2 font-medium w-24 text-center">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentPath !== '/' && (
|
||||
<tr className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)] cursor-pointer"
|
||||
onClick={() => {
|
||||
const parent = currentPath.split('/').slice(0, -1).join('/') || '/';
|
||||
fetchDir(parent);
|
||||
}}>
|
||||
<td className="py-2 pl-4"><ArrowLeft className="h-4 w-4 text-[var(--dg-text-muted)]" /></td>
|
||||
<td className="py-2 text-[var(--dg-text-secondary)]" colSpan={5}>..</td>
|
||||
</tr>
|
||||
)}
|
||||
{sortedItems.map((item) => (
|
||||
<tr key={item.path}
|
||||
className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]"
|
||||
>
|
||||
<td className="py-2 pl-4">
|
||||
{item.is_dir ? (
|
||||
<Folder className="h-4 w-4 text-amber-400" />
|
||||
) : (
|
||||
<File className="h-4 w-4 text-sky-400" />
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
{item.is_dir ? (
|
||||
<button
|
||||
onClick={() => fetchDir(item.path)}
|
||||
className="text-[var(--dg-text-primary)] hover:text-[var(--dg-blue)] hover:underline font-medium cursor-pointer"
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-[var(--dg-text-primary)]">{item.name}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 px-2 text-right text-[var(--dg-text-muted)]">
|
||||
{item.is_dir ? '-' : formatSize(item.size)}
|
||||
</td>
|
||||
<td className="py-2 px-2 text-[var(--dg-text-muted)] hidden sm:table-cell">{item.mod_time}</td>
|
||||
<td className="py-2 px-2 text-[var(--dg-text-muted)] font-mono text-xs hidden md:table-cell">{item.mode}</td>
|
||||
<td className="py-2 pr-4 pl-2">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{!item.is_dir && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDownload(item.path); }}
|
||||
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] hover:text-white"
|
||||
title="下载"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(item.path, item.is_dir); }}
|
||||
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-red-400 hover:text-red-300"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -40,12 +40,13 @@ import {
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { serverApi } from '@/services/api';
|
||||
import { ServerStatsCards } from './ServerStatsCards';
|
||||
import { ServerStatsStrip } from './ServerStatsStrip';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { useUserStore } from '@/store';
|
||||
import { CloudServerImport } from './CloudServerImport';
|
||||
import { TerminalDialog } from './TerminalDialog';
|
||||
import { ServerFileBrowser } from './ServerFileBrowser';
|
||||
import { ExcelServerImport } from './ExcelServerImport';
|
||||
import { ServerForm, EMPTY_FORM, type ServerFormData } from './ServerForm';
|
||||
import { getTags } from './serverHelpers';
|
||||
@@ -72,6 +73,10 @@ export function ServerManager() {
|
||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||
const [terminalServer, setTerminalServer] = useState<{ id: number; name: string; ip: string; hasAuth?: boolean } | null>(null);
|
||||
|
||||
// 文件浏览 Dialog 状态
|
||||
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
|
||||
const [fileBrowserServer, setFileBrowserServer] = useState<{ id: number; name: string } | null>(null);
|
||||
|
||||
// 分页状态
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
@@ -82,6 +87,11 @@ export function ServerManager() {
|
||||
setTerminalOpen(true);
|
||||
};
|
||||
|
||||
const openFileBrowser = ( server: any) => {
|
||||
setFileBrowserServer({ id: server.id, name: server.hostname });
|
||||
setFileBrowserOpen(true);
|
||||
};
|
||||
|
||||
const loadData = async (kw?: string, st?: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -421,7 +431,7 @@ export function ServerManager() {
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<ServerStatsCards servers={servers} />
|
||||
<ServerStatsStrip servers={servers} />
|
||||
|
||||
{/* Filters & Table */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
@@ -512,6 +522,7 @@ export function ServerManager() {
|
||||
selected={selectedServers.includes(server.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
onOpenTerminal={openTerminal}
|
||||
onOpenFiles={openFileBrowser}
|
||||
onOpenEdit={openEditForm}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
@@ -603,6 +614,14 @@ export function ServerManager() {
|
||||
serverIp={terminalServer?.ip ?? ''}
|
||||
hasAuth={terminalServer?.hasAuth ?? true}
|
||||
/>
|
||||
|
||||
{/* 文件浏览 Dialog */}
|
||||
<ServerFileBrowser
|
||||
open={fileBrowserOpen}
|
||||
onOpenChange={setFileBrowserOpen}
|
||||
serverId={fileBrowserServer?.id ?? null}
|
||||
serverName={fileBrowserServer?.name ?? ''}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
// 服务器统计卡片组件 — 从 ServerManager 提取 // Logan.Li
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Server, Activity, RotateCw, Power } from 'lucide-react';
|
||||
|
||||
interface ServerStatsCardsProps {
|
||||
servers: any[];
|
||||
}
|
||||
|
||||
export function ServerStatsCards({ servers }: ServerStatsCardsProps) {
|
||||
const runningCount = servers.filter((s) => s.status === 'running').length;
|
||||
const maintenanceCount = servers.filter((s) => s.status === 'maintenance').length;
|
||||
const stoppedCount = servers.filter((s) => s.status === 'stopped').length;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">服务器总数</p>
|
||||
<p className="text-2xl font-bold text-[var(--dg-text-primary)]">{servers.length}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-[var(--dg-blue)]/10 text-[var(--dg-blue)]">
|
||||
<Server className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">运行中</p>
|
||||
<p className="text-2xl font-bold text-emerald-400">{runningCount}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
|
||||
<Activity className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">维护中</p>
|
||||
<p className="text-2xl font-bold text-amber-400">{maintenanceCount}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-amber-500/10 text-amber-400">
|
||||
<RotateCw className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">已停止</p>
|
||||
<p className="text-2xl font-bold text-[var(--dg-text-muted)]">{stoppedCount}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)]">
|
||||
<Power className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// 服务器统计条 — 紧凑型替代 4 张大卡片
|
||||
import { StatStrip } from '@/components/ui/stat-strip';
|
||||
|
||||
interface ServerStatsStripProps {
|
||||
servers: any[];
|
||||
}
|
||||
|
||||
export function ServerStatsStrip({ servers }: ServerStatsStripProps) {
|
||||
const runningCount = servers.filter((s) => s.status === 'running').length;
|
||||
const maintenanceCount = servers.filter((s) => s.status === 'maintenance').length;
|
||||
const stoppedCount = servers.filter((s) => s.status === 'stopped').length;
|
||||
|
||||
return (
|
||||
<StatStrip
|
||||
items={[
|
||||
{ label: '服务器', value: servers.length, color: 'blue' },
|
||||
{ label: '运行中', value: runningCount, color: 'emerald' },
|
||||
{ label: '维护中', value: maintenanceCount, color: 'amber' },
|
||||
{ label: '已停止', value: stoppedCount, color: 'muted' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Edit, MoreHorizontal, Server as ServerIcon, Terminal, Trash2, Network, HardDrive } from 'lucide-react';
|
||||
import { Edit, MoreHorizontal, Server as ServerIcon, Terminal, Trash2, Network, HardDrive, FolderOpen } from 'lucide-react';
|
||||
import { getStatusBadge, getTags, getUsageColor } from './serverHelpers';
|
||||
|
||||
interface ServerTableRowProps {
|
||||
@@ -21,6 +21,7 @@ interface ServerTableRowProps {
|
||||
onToggleSelect: (id: string) => void;
|
||||
onOpenTerminal: ( server: any) => void;
|
||||
onOpenEdit: ( server: any) => void;
|
||||
onOpenFiles?: ( server: any) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
@@ -30,6 +31,7 @@ export function ServerTableRow({
|
||||
onToggleSelect,
|
||||
onOpenTerminal,
|
||||
onOpenEdit,
|
||||
onOpenFiles,
|
||||
onDelete,
|
||||
}: ServerTableRowProps) {
|
||||
const cpuUsage = Number(server.cpu_usage || 0);
|
||||
@@ -145,7 +147,7 @@ export function ServerTableRow({
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" onClick={() => onOpenTerminal(server)}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" onClick={() => onOpenTerminal(server)} title="终端登录">
|
||||
<Terminal className="h-4 w-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
@@ -171,6 +173,13 @@ export function ServerTableRow({
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
终端
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
|
||||
onClick={() => onOpenFiles?.(server)}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 mr-2" />
|
||||
文件
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
|
||||
<DropdownMenuItem
|
||||
className="text-red-400 focus:bg-[var(--dg-bg-hover)] focus:text-red-400 cursor-pointer"
|
||||
|
||||
@@ -239,7 +239,7 @@ export function WebShell() {
|
||||
}
|
||||
};
|
||||
|
||||
// 注册容器并首次挂载终端
|
||||
// 注册容器并首次挂载终端 + ResizeObserver 自适应
|
||||
const registerContainer = (sessionId: string, el: HTMLDivElement | null) => {
|
||||
if (el) {
|
||||
containerRef.current.set(sessionId, el);
|
||||
@@ -248,19 +248,25 @@ export function WebShell() {
|
||||
try {
|
||||
entry.term.open(el);
|
||||
entry.opened = true;
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
entry.fit.fit();
|
||||
entry.term.focus();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
// 延迟适配:等 DOM 布局完全确定
|
||||
setTimeout(() => {
|
||||
try { entry.fit.fit(); entry.term.focus(); } catch { /* noop */ }
|
||||
}, 100);
|
||||
// ResizeObserver 持续监听容器大小变化
|
||||
const observer = new ResizeObserver(() => {
|
||||
try { entry.fit.fit(); } catch { /* noop */ }
|
||||
});
|
||||
observer.observe(el);
|
||||
(entry as any)._observer = observer;
|
||||
} catch (e) {
|
||||
console.error('打开终端失败:', e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const entry = termsRef.current.get(sessionId);
|
||||
if (entry && (entry as any)._observer) {
|
||||
(entry as any)._observer.disconnect();
|
||||
}
|
||||
containerRef.current.delete(sessionId);
|
||||
}
|
||||
};
|
||||
@@ -399,9 +405,10 @@ export function WebShell() {
|
||||
key={session.id}
|
||||
ref={(el) => registerContainer(session.id, el)}
|
||||
className={cn(
|
||||
'absolute inset-0 p-1',
|
||||
'absolute inset-0',
|
||||
activeSession === session.id ? 'block' : 'hidden'
|
||||
)}
|
||||
style={{ padding: 0 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
// 终端审计实时监控 Dialog — xterm.js 实时查看在线会话 // Logan.Li
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Terminal as XTerm } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import 'xterm/css/xterm.css';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2, Monitor, Server, User as UserIcon, Clock } from 'lucide-react';
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
interface SessionInfo {
|
||||
session_id: number;
|
||||
username: string;
|
||||
server_name: string;
|
||||
server_ip: string;
|
||||
start_time: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface LiveMonitorProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
sessionId: number | null;
|
||||
sessionInfo?: SessionInfo | null;
|
||||
}
|
||||
|
||||
export function LiveMonitorDialog({ open, onOpenChange, sessionId, sessionInfo }: LiveMonitorProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<XTerm | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [connectError, setConnectError] = useState('');
|
||||
const [meta, setMeta] = useState<SessionInfo | null>(sessionInfo || null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !sessionId) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// Init xterm
|
||||
const term = new XTerm({
|
||||
cursorBlink: false,
|
||||
fontSize: 13,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
disableStdin: true,
|
||||
theme: {
|
||||
background: '#0a0e14',
|
||||
foreground: '#d6d6d6',
|
||||
cursor: '#d6d6d6',
|
||||
selectionBackground: '#264f78',
|
||||
black: '#1a1a2e',
|
||||
red: '#ff6b6b',
|
||||
green: '#69db7c',
|
||||
yellow: '#ffd43b',
|
||||
blue: '#4dabf7',
|
||||
magenta: '#da77f2',
|
||||
cyan: '#63e6be',
|
||||
white: '#d6d6d6',
|
||||
brightBlack: '#4a4a4a',
|
||||
brightRed: '#ff8787',
|
||||
brightGreen: '#8ce99a',
|
||||
brightYellow: '#ffe066',
|
||||
brightBlue: '#74c0fc',
|
||||
brightMagenta: '#e599f7',
|
||||
brightCyan: '#7ae5d5',
|
||||
brightWhite: '#f8f9fa',
|
||||
},
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(container);
|
||||
fitAddon.fit();
|
||||
termRef.current = term;
|
||||
|
||||
// Connect WebSocket — try zustand store first, fallback to localStorage
|
||||
let token = useUserStore.getState().token;
|
||||
if (!token) {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem('user-storage') || '');
|
||||
token = stored?.state?.token || '';
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/audit/${sessionId}?token=${encodeURIComponent(token || '')}`;
|
||||
console.log('[LiveMonitor] Connecting to:', wsUrl.replace(/token=[^&]+/, 'token=***'));
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
let didOpen = false;
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[LiveMonitor] WS connected for session:', sessionId);
|
||||
didOpen = true;
|
||||
setConnected(true);
|
||||
setConnectError('');
|
||||
term.writeln('\x1b[36m[审计监控] 已连接到会话...\x1b[0m');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
switch (msg.type) {
|
||||
case 'session_info':
|
||||
setMeta(msg.payload);
|
||||
term.writeln(`\x1b[36m用户: ${msg.payload.username} 服务器: ${msg.payload.server_name}\x1b[0m`);
|
||||
break;
|
||||
case 'history':
|
||||
if (msg.data) {
|
||||
term.write(msg.data);
|
||||
}
|
||||
break;
|
||||
case 'output':
|
||||
if (msg.data) {
|
||||
term.write(msg.data);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (e) => {
|
||||
console.log('[LiveMonitor] WS error:', e);
|
||||
setConnectError('WebSocket 连接失败');
|
||||
};
|
||||
|
||||
ws.onclose = (e) => {
|
||||
console.log('[LiveMonitor] WS closed code:', e.code, 'reason:', e.reason);
|
||||
if (!didOpen) {
|
||||
setConnectError(`连接失败 (code=${e.code}): ${e.reason || '认证失败或网络问题'}`);
|
||||
}
|
||||
setConnected(false);
|
||||
term.writeln('\r\n\x1b[33m[审计监控] 连接已断开\x1b[0m');
|
||||
};
|
||||
|
||||
// 5s 超时:如果还没连上,显示错误
|
||||
const timeout = setTimeout(() => {
|
||||
if (!didOpen) {
|
||||
setConnectError('连接超时 — 请确认已登录且有活跃会话');
|
||||
ws.close();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
ws.close();
|
||||
term.dispose();
|
||||
termRef.current = null;
|
||||
wsRef.current = null;
|
||||
setConnected(false);
|
||||
};
|
||||
}, [open, sessionId]);
|
||||
|
||||
const formatTime = (t: string) => {
|
||||
if (!t) return '--';
|
||||
return new Date(t).toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent showOverlay={false} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-[95vw] max-h-[90vh] w-[1200px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5 text-emerald-400" />
|
||||
终端实时监控
|
||||
{connected && (
|
||||
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10 ml-2 text-xs">
|
||||
● 在线
|
||||
</Badge>
|
||||
)}
|
||||
{!connected && !connectError && (
|
||||
<Badge variant="outline" className="border-yellow-500/30 text-yellow-400 bg-yellow-500/10 ml-2 text-xs">
|
||||
<Loader2 className="h-3 w-3 animate-spin mr-1" />
|
||||
连接中
|
||||
</Badge>
|
||||
)}
|
||||
{connectError && (
|
||||
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10 ml-2 text-xs">
|
||||
连接失败
|
||||
</Badge>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-secondary)]">
|
||||
{meta && (
|
||||
<div className="flex items-center gap-4 text-xs mt-1 flex-wrap">
|
||||
<span className="flex items-center gap-1">
|
||||
<UserIcon className="h-3 w-3" />
|
||||
{meta.username}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Server className="h-3 w-3" />
|
||||
{meta.server_name} ({meta.server_ip})
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatTime(meta.start_time)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full rounded-md overflow-hidden border border-[var(--dg-border)]"
|
||||
style={{ height: '55vh' }}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -18,13 +18,22 @@ interface ReplayDialogProps {
|
||||
sessionId: number | null;
|
||||
}
|
||||
|
||||
interface AsciicastRow {
|
||||
time: number;
|
||||
eventType: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 终端录制回放 Dialog — 用 xterm.js 重放录制的终端输出 // Logan.Li
|
||||
* 终端录制回放 Dialog — asciicast v2 定时回放 (KoKo-compatible)
|
||||
*/
|
||||
export function ReplayDialog({ open, onOpenChange, sessionId }: ReplayDialogProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<XTerm | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !sessionId) return;
|
||||
@@ -34,7 +43,8 @@ export function ReplayDialog({ open, onOpenChange, sessionId }: ReplayDialogProp
|
||||
fontSize: 13,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
disableStdin: true,
|
||||
allowProposedApi: true,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
theme: {
|
||||
background: '#0a0e14',
|
||||
foreground: '#d6d6d6',
|
||||
@@ -48,48 +58,108 @@ export function ReplayDialog({ open, onOpenChange, sessionId }: ReplayDialogProp
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setProgress(0);
|
||||
|
||||
terminalSessionApi
|
||||
.getRecord(sessionId)
|
||||
.then(( data: any) => {
|
||||
.then((raw: any) => {
|
||||
if (cancelled) return;
|
||||
const data = typeof raw === 'string' ? raw : (raw?.data || raw?.record_data || '');
|
||||
|
||||
if (!data || data.trim() === '') {
|
||||
setError('No recording data available for this session');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (containerRef.current) {
|
||||
term.open(containerRef.current);
|
||||
try { fit.fit(); } catch { /* noop */ }
|
||||
}
|
||||
|
||||
// Parse asciicast v2 format
|
||||
const lines = data.split('\n').filter((l: string) => l.trim());
|
||||
const rows: AsciicastRow[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
fit.fit();
|
||||
const parsed = JSON.parse(line);
|
||||
if (Array.isArray(parsed) && parsed.length >= 3) {
|
||||
// asciicast v2 row: [time, "o", "data"]
|
||||
rows.push({ time: parsed[0], eventType: parsed[1], data: parsed[2] });
|
||||
}
|
||||
// Header is skipped (JSON object, not array)
|
||||
} catch {
|
||||
/* noop */
|
||||
// Skip lines that can't be parsed
|
||||
}
|
||||
const output = typeof data === 'string' ? data : '';
|
||||
term.write(output);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled && containerRef.current) {
|
||||
term.open(containerRef.current);
|
||||
term.write('\x1b[31m无录制数据\x1b[0m\r\n');
|
||||
|
||||
if (rows.length === 0) {
|
||||
// Fallback: just write raw data (old format)
|
||||
term.write(data);
|
||||
setProgress(100);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Timed playback
|
||||
setLoading(false);
|
||||
const totalTime = rows[rows.length - 1].time;
|
||||
let rowIndex = 0;
|
||||
let lastTime = 0;
|
||||
|
||||
const playNext = () => {
|
||||
if (cancelled || rowIndex >= rows.length) {
|
||||
setProgress(100);
|
||||
return;
|
||||
}
|
||||
|
||||
const row = rows[rowIndex];
|
||||
const delay = (row.time - lastTime) * 1000; // convert to ms
|
||||
lastTime = row.time;
|
||||
rowIndex++;
|
||||
|
||||
// Clamp delay to avoid extremely long waits (max 2s between chunks)
|
||||
const clampedDelay = Math.min(delay, 2000);
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
term.write(row.data);
|
||||
setProgress(Math.round((row.time / totalTime) * 100));
|
||||
playNext();
|
||||
}, clampedDelay);
|
||||
};
|
||||
|
||||
playNext();
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
.catch((err: any) => {
|
||||
if (!cancelled) {
|
||||
console.error('[Replay] Load failed:', err);
|
||||
setError('Failed to load recording data');
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
try {
|
||||
term.dispose();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
try { term.dispose(); } catch { /* noop */ }
|
||||
termRef.current = null;
|
||||
};
|
||||
}, [open, sessionId]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-slate-900 border-slate-800 p-0 gap-0" style={{ maxWidth: '900px', width: '90vw' }}>
|
||||
<DialogContent showOverlay={false} className="bg-slate-900 border-slate-800 p-0 gap-0" style={{ maxWidth: '900px', width: '90vw' }}>
|
||||
<DialogHeader className="px-4 py-3 border-b border-slate-800">
|
||||
<DialogTitle className="text-slate-100 text-base">终端录制回放 #{sessionId ?? ''}</DialogTitle>
|
||||
<DialogDescription className="text-slate-500 sr-only">重放该会话录制的终端输出</DialogDescription>
|
||||
<DialogTitle className="text-slate-100 text-base flex items-center gap-2">
|
||||
Terminal Replay #{sessionId ?? ''}
|
||||
{progress > 0 && progress < 100 && (
|
||||
<span className="text-xs text-slate-500 font-normal ml-2">{progress}%</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500 sr-only">Replay recorded terminal output</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="relative bg-[#0a0e14]" style={{ height: '460px' }}>
|
||||
{loading && (
|
||||
@@ -97,6 +167,19 @@ export function ReplayDialog({ open, onOpenChange, sessionId }: ReplayDialogProp
|
||||
<Loader2 className="h-6 w-6 animate-spin text-blue-400" />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
|
||||
<p className="text-red-400 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{progress > 0 && progress < 100 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-slate-800 z-10">
|
||||
<div
|
||||
className="h-full bg-blue-500 transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div ref={containerRef} className="absolute inset-0 p-1" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -3,14 +3,12 @@ import { useLocation } from 'react-router-dom';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { CheckCircle, XCircle, Users, Shield, FileCode, Settings, TerminalSquare } from 'lucide-react';
|
||||
import { CheckCircle, XCircle, Users, Shield, Settings } from 'lucide-react';
|
||||
import { userApi } from '@/services/api';
|
||||
import type { Role } from '@/types';
|
||||
import { SystemUserTab } from './SystemUserTab';
|
||||
import { SystemRolesTab } from './SystemRolesTab';
|
||||
import { SystemLogsTab } from './SystemLogsTab';
|
||||
import { SystemSettingsTab } from './SystemSettingsTab';
|
||||
import { TerminalSessionsTab } from './TerminalSessionsTab';
|
||||
import { useLdapManagement } from './useLdapManagement';
|
||||
import type { UserFormData } from './UserFormDialog';
|
||||
|
||||
@@ -43,8 +41,6 @@ export function SystemManager() {
|
||||
const pathToTab: Record<string, string> = {
|
||||
'/users': 'users',
|
||||
'/roles': 'roles',
|
||||
'/logs-audit': 'logs',
|
||||
'/terminal-audit': 'terminal',
|
||||
'/settings': 'settings',
|
||||
};
|
||||
const [activeTab, setActiveTab] = useState(pathToTab[location.pathname] || 'users');
|
||||
@@ -253,14 +249,6 @@ export function SystemManager() {
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
角色权限
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="logs" className="data-[state=active]:bg-slate-700">
|
||||
<FileCode className="h-4 w-4 mr-2" />
|
||||
操作审计
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="terminal" className="data-[state=active]:bg-slate-700">
|
||||
<TerminalSquare className="h-4 w-4 mr-2" />
|
||||
终端审计
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="data-[state=active]:bg-slate-700">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
系统设置
|
||||
@@ -315,17 +303,6 @@ export function SystemManager() {
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="logs" className="mt-4 space-y-4">
|
||||
<SystemLogsTab
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="terminal" className="mt-4 space-y-4">
|
||||
<TerminalSessionsTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" className="mt-4">
|
||||
<SystemSettingsTab {...ldap.settingsTabProps} />
|
||||
</TabsContent>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// 紧凑型统计条 — 替代 4~5 个大卡片的笨重布局
|
||||
// 一行显示所有统计项,带颜色区分
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface StatItem {
|
||||
label: string;
|
||||
value: number | string;
|
||||
color?: string; // tailwind text class or hex
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface StatStripProps {
|
||||
items: StatItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const colorMap: Record<string, { text: string; bg: string }> = {
|
||||
blue: { text: 'text-blue-400', bg: 'bg-blue-500/20' },
|
||||
emerald: { text: 'text-emerald-400', bg: 'bg-emerald-500/20' },
|
||||
amber: { text: 'text-amber-400', bg: 'bg-amber-500/20' },
|
||||
red: { text: 'text-red-400', bg: 'bg-red-500/20' },
|
||||
purple: { text: 'text-purple-400', bg: 'bg-purple-500/20' },
|
||||
cyan: { text: 'text-cyan-400', bg: 'bg-cyan-500/20' },
|
||||
muted: { text: 'text-[var(--dg-text-secondary)]', bg: 'bg-[var(--dg-bg-tertiary)]' },
|
||||
};
|
||||
|
||||
export function StatStrip({ items, className }: StatStripProps) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-2 rounded-lg',
|
||||
'bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]',
|
||||
'overflow-x-auto',
|
||||
className,
|
||||
)}>
|
||||
{items.map((item, i) => {
|
||||
const clr = colorMap[item.color || 'muted'] || colorMap.muted;
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-1.5 shrink-0">
|
||||
{i > 0 && (
|
||||
<span className="w-px h-5 bg-[var(--dg-border)] mx-1 shrink-0" />
|
||||
)}
|
||||
<span className={cn(
|
||||
'w-1.5 h-1.5 rounded-full shrink-0',
|
||||
clr.bg.replace('/20', '').replace('bg-', 'bg-') || 'bg-slate-500',
|
||||
)}
|
||||
style={{ backgroundColor: item.color?.startsWith('#') ? item.color : undefined }}
|
||||
/>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">{item.label}</span>
|
||||
<span className={cn('text-sm font-semibold', clr.text)}>{item.value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user