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:
attacker committed 2026-06-25 20:16:30 +08:00
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">
+9 -25
View File
@@ -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">
+16 -54
View File
@@ -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': '智能助手',
+2 -104
View File
@@ -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 */}
+18 -3
View File
@@ -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"
+16 -9
View File
@@ -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>
);
}
+105 -22
View File
@@ -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>
+55
View File
@@ -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>
);
}