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
+12
-2
@@ -32,8 +32,12 @@ const EventsPage = lazy(() => import('@/components/kubernetes/EventsPage').then(
|
||||
const CapacityPage = lazy(() => import('@/components/kubernetes/CapacityPage').then(m => ({ default: m.default })));
|
||||
const AlertManager = lazy(() => import('@/components/monitor/AlertManager').then(m => ({ default: m.AlertManager })));
|
||||
const ApprovalFlowManager = lazy(() => import('@/components/approval/ApprovalFlow').then(m => ({ default: m.ApprovalFlowManager })));
|
||||
const ApprovalResourcePage = lazy(() => import('@/pages/ApprovalResourcePage').then(m => ({ default: m.ApprovalResourcePage })));
|
||||
const ApprovalAppPage = lazy(() => import('@/pages/ApprovalAppPage').then(m => ({ default: m.ApprovalAppPage })));
|
||||
const ApprovalPermissionPage = lazy(() => import('@/pages/ApprovalPermissionPage').then(m => ({ default: m.ApprovalPermissionPage })));
|
||||
const ScriptManager = lazy(() => import('@/components/automation/ScriptManager').then(m => ({ default: m.ScriptManager })));
|
||||
const SystemManager = lazy(() => import('@/components/system/SystemManager').then(m => ({ default: m.SystemManager })));
|
||||
const AuditPage = lazy(() => import('@/pages/AuditPage').then(m => ({ default: m.AuditPage })));
|
||||
const CredentialManager = lazy(() => import('@/components/deploy/CredentialManager').then(m => ({ default: m.default })));
|
||||
const PipelineManager = lazy(() => import('@/components/deploy/PipelineManager').then(m => ({ default: m.PipelineManager })));
|
||||
const DeployCredentials = lazy(() => import('@/components/deploy/DeployCredentials').then(m => ({ default: m.default })));
|
||||
@@ -173,6 +177,9 @@ function App() {
|
||||
|
||||
{/* Automation */}
|
||||
<Route path="approval" element={<LazyPage><ApprovalFlowManager /></LazyPage>} />
|
||||
<Route path="approval/resource" element={<LazyPage><ApprovalResourcePage /></LazyPage>} />
|
||||
<Route path="approval/app" element={<LazyPage><ApprovalAppPage /></LazyPage>} />
|
||||
<Route path="approval/permission" element={<LazyPage><ApprovalPermissionPage /></LazyPage>} />
|
||||
<Route path="cronjobs" element={<LazyPage><CronJobManager /></LazyPage>} />
|
||||
<Route path="scripts" element={<LazyPage><ScriptManager /></LazyPage>} />
|
||||
|
||||
@@ -182,11 +189,14 @@ function App() {
|
||||
<Route path="ai-alert-analyzer" element={<LazyPage><AIAlertAnalyzer /></LazyPage>} />
|
||||
<Route path="ai-predictive" element={<LazyPage><AIPredictiveAnalytics /></LazyPage>} />
|
||||
|
||||
{/* Audit */}
|
||||
<Route path="audit/terminal" element={<LazyPage><AuditPage /></LazyPage>} />
|
||||
<Route path="audit/operations" element={<LazyPage><AuditPage /></LazyPage>} />
|
||||
<Route path="audit/login-logs" element={<LazyPage><AuditPage /></LazyPage>} />
|
||||
|
||||
{/* System */}
|
||||
<Route path="users" element={<LazyPage><SystemManager /></LazyPage>} />
|
||||
<Route path="roles" element={<LazyPage><SystemManager /></LazyPage>} />
|
||||
<Route path="logs-audit" element={<LazyPage><SystemManager /></LazyPage>} />
|
||||
<Route path="terminal-audit" element={<LazyPage><SystemManager /></LazyPage>} />
|
||||
<Route path="credentials" element={<LazyPage><CredentialManager /></LazyPage>} />
|
||||
<Route path="settings" element={<LazyPage><SystemManager /></LazyPage>} />
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// 应用申请子页面 — 应用部署、版本发布申请
|
||||
import { ApprovalCategoryPage, type FormField } from './ApprovalCategoryPage';
|
||||
|
||||
const APP_FIELDS: FormField[] = [
|
||||
{ key: 'app_name', label: '应用名称', type: 'text', placeholder: '如 user-service', required: true },
|
||||
{ key: 'git_url', label: 'Git 地址', type: 'text', placeholder: '如 https://github.com/org/repo.git', required: true },
|
||||
{ key: 'branch', label: '分支', type: 'text', defaultValue: 'main', required: true },
|
||||
{ key: 'language', label: '开发语言', type: 'select', options: ['Java', 'Go', 'Python', 'Node.js', 'C++'], required: true },
|
||||
{ key: 'http_port', label: 'HTTP 端口', type: 'number', placeholder: '如 8080' },
|
||||
{ key: 'env', label: '部署环境', type: 'select', options: ['dev', 'staging', 'prod'], required: true },
|
||||
{ key: 'replicas', label: '副本数', type: 'number', defaultValue: 2, required: true },
|
||||
{ key: 'remark', label: '备注', type: 'textarea' },
|
||||
];
|
||||
|
||||
export function ApprovalAppPage() {
|
||||
return (
|
||||
<ApprovalCategoryPage
|
||||
category="app"
|
||||
title="应用申请"
|
||||
description="申请应用部署、版本发布、流水线配置"
|
||||
fields={APP_FIELDS}
|
||||
templateCode="app_deploy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
// 审批分类通用页面 — 资源申请/应用申请/权限审批三个子页面共用
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Plus, Search, RefreshCw, Clock, CheckCircle, XCircle, FileText, Loader2 } from 'lucide-react';
|
||||
import { approvalApi } from '@/services/api';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface FormField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'text' | 'number' | 'select' | 'textarea';
|
||||
options?: string[];
|
||||
placeholder?: string;
|
||||
defaultValue?: string | number;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
category: 'resource' | 'app' | 'permission';
|
||||
title: string;
|
||||
description: string;
|
||||
fields: FormField[];
|
||||
templateCode: string;
|
||||
}
|
||||
|
||||
interface ProcessItem {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
form_data: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const statusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'approved': return <Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30"><CheckCircle className="h-3 w-3 mr-1" />已通过</Badge>;
|
||||
case 'rejected': return <Badge className="bg-red-500/20 text-red-400 border-red-500/30"><XCircle className="h-3 w-3 mr-1" />已拒绝</Badge>;
|
||||
default: return <Badge className="bg-amber-500/20 text-amber-400 border-amber-500/30"><Clock className="h-3 w-3 mr-1" />审批中</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
export function ApprovalCategoryPage({ category, title, description, fields, templateCode }: Props) {
|
||||
const [processes, setProcesses] = useState<ProcessItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [templateId, setTemplateId] = useState<number | null>(null);
|
||||
const [formData, setFormData] = useState<Record<string, string>>({});
|
||||
|
||||
const fetchProcesses = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = { page_size: '100' };
|
||||
if (category) params.category = category;
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
const resp = (await approvalApi.getProcesses(params)) as any;
|
||||
setProcesses(resp?.list ?? resp?.data?.list ?? []);
|
||||
} catch {
|
||||
setProcesses([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [category, statusFilter]);
|
||||
|
||||
const fetchTemplateId = useCallback(async () => {
|
||||
try {
|
||||
const resp = (await approvalApi.getTemplates({ type: templateCode })) as any;
|
||||
const list = resp?.list ?? resp?.data?.list ?? [];
|
||||
if (list.length > 0) setTemplateId(list[0].id);
|
||||
} catch { /* ignore */ }
|
||||
}, [templateCode]);
|
||||
|
||||
useEffect(() => { fetchProcesses(); }, [fetchProcesses]);
|
||||
useEffect(() => { fetchTemplateId(); }, [fetchTemplateId]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!templateId) { toast.error('模板未加载'); return; }
|
||||
// Validate required fields
|
||||
for (const f of fields) {
|
||||
if (f.required && !formData[f.key]) {
|
||||
toast.error(`请填写「${f.label}」`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// Build title from first 2 fields
|
||||
const firstVal = formData[fields[0]?.key] || '';
|
||||
const secondVal = fields[1] ? formData[fields[1].key] || '' : '';
|
||||
const autoTitle = `${title} - ${firstVal}${secondVal ? ' / ' + secondVal : ''}`;
|
||||
|
||||
await approvalApi.createProcess({
|
||||
template_id: templateId,
|
||||
title: autoTitle,
|
||||
form_data: JSON.stringify(formData),
|
||||
});
|
||||
toast.success('申请已提交');
|
||||
setDialogOpen(false);
|
||||
setFormData({});
|
||||
fetchProcesses();
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || '提交失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateDialog = () => {
|
||||
const init: Record<string, string> = {};
|
||||
fields.forEach((f) => { init[f.key] = f.defaultValue?.toString() || ''; });
|
||||
setFormData(init);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const filtered = processes.filter((p) => {
|
||||
return !keyword || (p.title || '').toLowerCase().includes(keyword.toLowerCase());
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">{title}</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">{description}</p>
|
||||
</div>
|
||||
<Button onClick={openCreateDialog} className="bg-[var(--dg-blue)] hover:bg-[var(--dg-blue)]/90">
|
||||
<Plus className="h-4 w-4 mr-2" />新建申请
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<Input
|
||||
placeholder="搜索申请..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
className="pl-9 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-32 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
<SelectValue placeholder="全部状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="pending">审批中</SelectItem>
|
||||
<SelectItem value="approved">已通过</SelectItem>
|
||||
<SelectItem value="rejected">已拒绝</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" className="border-[var(--dg-border)]" onClick={fetchProcesses}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-[var(--dg-blue)]" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-[var(--dg-text-muted)]">
|
||||
<FileText className="h-10 w-10 mb-3 opacity-30" />
|
||||
<p className="text-sm">暂无申请记录</p>
|
||||
<Button variant="link" className="mt-2" onClick={openCreateDialog}>立即申请</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">ID</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">申请标题</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">状态</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">提交时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((p) => (
|
||||
<TableRow key={p.id} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
|
||||
<TableCell className="text-[var(--dg-text-muted)] text-xs">#{p.id}</TableCell>
|
||||
<TableCell className="text-[var(--dg-text-primary)] font-medium">{p.title}</TableCell>
|
||||
<TableCell>{statusBadge(p.status)}</TableCell>
|
||||
<TableCell className="text-[var(--dg-text-muted)] text-sm">
|
||||
{p.created_at ? new Date(p.created_at).toLocaleString('zh-CN') : '--'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)]">新建{title}</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-secondary)]">{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
{fields.map((field) => (
|
||||
<div key={field.key} className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-primary)] text-sm">
|
||||
{field.label}
|
||||
{field.required && <span className="text-red-400 ml-0.5">*</span>}
|
||||
</Label>
|
||||
{field.type === 'select' ? (
|
||||
<Select
|
||||
value={formData[field.key] || ''}
|
||||
onValueChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
|
||||
>
|
||||
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
<SelectValue placeholder={field.placeholder || `选择${field.label}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(field.options || []).map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>{opt}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : field.type === 'textarea' ? (
|
||||
<textarea
|
||||
value={formData[field.key] || ''}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={field.placeholder}
|
||||
rows={3}
|
||||
className="w-full rounded-md bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] px-3 py-2 text-sm resize-none focus:outline-none focus:border-[var(--dg-blue)]"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={field.type}
|
||||
value={formData[field.key] || ''}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={field.placeholder}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button variant="outline" className="border-[var(--dg-border)]" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={submitting} className="bg-[var(--dg-blue)] hover:bg-[var(--dg-blue)]/90">
|
||||
{submitting ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />提交中...</> : '提交申请'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// 权限审批子页面 — 堡垒机、VPN、日志查询、数据查询等权限申请
|
||||
import { ApprovalCategoryPage, type FormField } from './ApprovalCategoryPage';
|
||||
|
||||
const PERMISSION_FIELDS: FormField[] = [
|
||||
{ key: 'perm_type', label: '权限类型', type: 'select', options: [
|
||||
'堡垒机权限', '生产VPN权限', '日志查询权限', '数据库查询权限', '服务器SSH权限', 'K8s集群权限',
|
||||
], required: true },
|
||||
{ key: 'account', label: '申请账号', type: 'text', placeholder: '如 logan.li', required: true },
|
||||
{ key: 'reason', label: '申请理由', type: 'textarea', placeholder: '说明为什么需要该权限', required: true },
|
||||
{ key: 'duration', label: '权限时长', type: 'select', options: [
|
||||
'临时(24小时)', '短期(7天)', '中期(30天)', '长期(90天)', '永久',
|
||||
], required: true },
|
||||
];
|
||||
|
||||
export function ApprovalPermissionPage() {
|
||||
return (
|
||||
<ApprovalCategoryPage
|
||||
category="permission"
|
||||
title="权限审批"
|
||||
description="申请堡垒机、VPN、日志查询、数据库查询等运维权限"
|
||||
fields={PERMISSION_FIELDS}
|
||||
templateCode="permission_request"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// 资源申请子页面 — 云服务器、存储、网络等资源申请
|
||||
import { ApprovalCategoryPage, type FormField } from './ApprovalCategoryPage';
|
||||
|
||||
const RESOURCE_FIELDS: FormField[] = [
|
||||
{ key: 'provider', label: '云厂商', type: 'select', options: ['阿里云', '腾讯云', '华为云', 'AWS'], required: true },
|
||||
{ key: 'region', label: '地域', type: 'text', placeholder: '如 cn-hangzhou', required: true },
|
||||
{ key: 'spec', label: '规格', type: 'select', options: ['2C4G', '4C8G', '8C16G', '16C32G', '32C64G'], required: true },
|
||||
{ key: 'disk', label: '系统盘(GB)', type: 'number', defaultValue: 40, required: true },
|
||||
{ key: 'count', label: '数量(台)', type: 'number', defaultValue: 1, required: true },
|
||||
{ key: 'duration', label: '时长', type: 'select', options: ['1个月', '3个月', '6个月', '1年'], required: true },
|
||||
{ key: 'purpose', label: '用途说明', type: 'textarea', required: true },
|
||||
];
|
||||
|
||||
export function ApprovalResourcePage() {
|
||||
return (
|
||||
<ApprovalCategoryPage
|
||||
category="resource"
|
||||
title="资源申请"
|
||||
description="申请云服务器、存储、网络等基础设施资源"
|
||||
fields={RESOURCE_FIELDS}
|
||||
templateCode="server_apply"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
// 操作审计 Tab — 增强版审计日志列表,含筛选/导出/统计/清除
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Search,
|
||||
RefreshCw,
|
||||
Download,
|
||||
BarChart3,
|
||||
Eye,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { auditApi } from '@/services/api';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import type { OperationLog } from '@/types';
|
||||
|
||||
// Normalize backend log to frontend type
|
||||
function normalizeLog(raw: any): OperationLog {
|
||||
return {
|
||||
id: String(raw.id ?? ''),
|
||||
userId: String(raw.user_id ?? raw.userId ?? ''),
|
||||
username: raw.username ?? '',
|
||||
module: raw.module ?? raw.resource ?? '',
|
||||
action: raw.action ?? '',
|
||||
resource: raw.resource ?? '',
|
||||
ip: raw.ip ?? '',
|
||||
userAgent: raw.user_agent ?? raw.userAgent ?? '',
|
||||
result: raw.result === 'failure' ? 'failure' : 'success',
|
||||
message: raw.message ?? raw.detail ?? '',
|
||||
createdAt: raw.createdAt ?? raw.created_at ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
interface AuditStatistics {
|
||||
total_logs?: number;
|
||||
success_count?: number;
|
||||
failure_count?: number;
|
||||
today_count?: number;
|
||||
top_modules?: { module: string; count: number }[];
|
||||
top_actions?: { action: string; count: number }[];
|
||||
top_users?: { username: string; count: number }[];
|
||||
}
|
||||
|
||||
export function AuditOperationsTab() {
|
||||
const confirm = useConfirm();
|
||||
const [logs, setLogs] = useState<OperationLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Filters
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [moduleFilter, setModuleFilter] = useState('');
|
||||
const [actionFilter, setActionFilter] = useState('');
|
||||
const [modules, setModules] = useState<string[]>([]);
|
||||
const [actions, setActions] = useState<string[]>([]);
|
||||
|
||||
// Detail modal
|
||||
const [detailLog, setDetailLog] = useState<OperationLog | null>(null);
|
||||
|
||||
// Statistics modal
|
||||
const [statsOpen, setStatsOpen] = useState(false);
|
||||
const [statistics, setStatistics] = useState<AuditStatistics | null>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = { page: 1, page_size: 100 };
|
||||
if (moduleFilter) params.module = moduleFilter;
|
||||
if (actionFilter) params.action = actionFilter;
|
||||
const resp = await auditApi.getLogs(params);
|
||||
const list = (resp as any)?.list ?? [];
|
||||
setLogs(list.map(normalizeLog));
|
||||
} catch {
|
||||
setLogs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [moduleFilter, actionFilter]);
|
||||
|
||||
const fetchOptions = useCallback(async () => {
|
||||
try {
|
||||
const [modRes, actRes] = await Promise.all([auditApi.getModules(), auditApi.getActions()]);
|
||||
setModules((modRes as any)?.list ?? (modRes as any) ?? []);
|
||||
setActions((actRes as any)?.list ?? (actRes as any) ?? []);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
fetchOptions();
|
||||
}, [fetchLogs, fetchOptions]);
|
||||
|
||||
const filteredLogs = logs.filter((log) => {
|
||||
const kw = keyword.toLowerCase();
|
||||
return (
|
||||
!kw ||
|
||||
log.username.toLowerCase().includes(kw) ||
|
||||
log.module.toLowerCase().includes(kw) ||
|
||||
log.action.toLowerCase().includes(kw) ||
|
||||
log.resource.toLowerCase().includes(kw)
|
||||
);
|
||||
});
|
||||
|
||||
const getLogResultBadge = (result: OperationLog['result']) => {
|
||||
return result === 'success' ? (
|
||||
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
|
||||
成功
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
|
||||
失败
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const params: any = {};
|
||||
if (moduleFilter) params.module = moduleFilter;
|
||||
if (actionFilter) params.action = actionFilter;
|
||||
await auditApi.exportLogs(params);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowStats = async () => {
|
||||
setStatsOpen(true);
|
||||
setStatsLoading(true);
|
||||
try {
|
||||
const data = (await auditApi.getStatistics()) as AuditStatistics;
|
||||
setStatistics(data);
|
||||
} catch {
|
||||
setStatistics(null);
|
||||
} finally {
|
||||
setStatsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const viewDetail = (log: OperationLog) => {
|
||||
setDetailLog(log);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder="搜索操作日志..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Module filter */}
|
||||
<select
|
||||
value={moduleFilter}
|
||||
onChange={(e) => setModuleFilter(e.target.value)}
|
||||
className="h-9 px-3 text-sm rounded-md bg-slate-800/50 border border-slate-700 text-slate-300 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="">全部模块</option>
|
||||
{modules.map((m: any) => (
|
||||
<option key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Action filter */}
|
||||
<select
|
||||
value={actionFilter}
|
||||
onChange={(e) => setActionFilter(e.target.value)}
|
||||
className="h-9 px-3 text-sm rounded-md bg-slate-800/50 border border-slate-700 text-slate-300 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="">全部操作</option>
|
||||
{actions.map((a: any) => (
|
||||
<option key={a.value} value={a.value}>
|
||||
{a.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
|
||||
onClick={fetchLogs}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
|
||||
onClick={handleExport}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
导出
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Logs Table */}
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-0">
|
||||
<div className="border-t border-slate-800">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : filteredLogs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
|
||||
<Search className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm">暂无操作日志</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">时间</TableHead>
|
||||
<TableHead className="text-slate-400">用户</TableHead>
|
||||
<TableHead className="text-slate-400">模块</TableHead>
|
||||
<TableHead className="text-slate-400">操作类型</TableHead>
|
||||
<TableHead className="text-slate-400">资源</TableHead>
|
||||
<TableHead className="text-slate-400">IP</TableHead>
|
||||
<TableHead className="text-slate-400">结果</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">详情</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredLogs.map((log) => (
|
||||
<TableRow key={log.id} className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{log.createdAt ? new Date(log.createdAt).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-200">{log.username || '--'}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-300">{log.module || '--'}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10">
|
||||
{log.action || '--'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs text-slate-400 bg-slate-800 px-2 py-0.5 rounded">
|
||||
{log.resource || '--'}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">{log.ip || '--'}</span>
|
||||
</TableCell>
|
||||
<TableCell>{getLogResultBadge(log.result)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-blue-400 hover:bg-slate-800"
|
||||
onClick={() => viewDetail(log)}
|
||||
title="查看详情"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Detail Modal */}
|
||||
<LogDetailDialog log={detailLog} onClose={() => setDetailLog(null)} />
|
||||
|
||||
{/* Statistics Modal */}
|
||||
<StatisticsDialog
|
||||
open={statsOpen}
|
||||
onClose={() => setStatsOpen(false)}
|
||||
loading={statsLoading}
|
||||
stats={statistics}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Log Detail Dialog
|
||||
// =========================================================================
|
||||
|
||||
function LogDetailDialog({ log, onClose }: { log: OperationLog | null; onClose: () => void }) {
|
||||
if (!log) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||
<div className="relative bg-slate-900 border border-slate-800 rounded-lg p-6 max-w-lg w-full mx-4 shadow-2xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-slate-100">操作日志详情</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-slate-100"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-sm">
|
||||
<DetailRow label="ID" value={log.id} />
|
||||
<DetailRow label="时间" value={log.createdAt ? new Date(log.createdAt).toLocaleString() : '--'} />
|
||||
<DetailRow label="用户" value={log.username || '--'} />
|
||||
<DetailRow label="用户ID" value={log.userId || '--'} />
|
||||
<DetailRow label="模块" value={log.module || '--'} />
|
||||
<DetailRow label="操作类型" value={log.action || '--'} />
|
||||
<DetailRow label="资源" value={log.resource || '--'} />
|
||||
<DetailRow label="IP地址" value={log.ip || '--'} />
|
||||
<DetailRow label="User-Agent" value={log.userAgent || '--'} />
|
||||
<DetailRow
|
||||
label="结果"
|
||||
value={
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
log.result === 'success'
|
||||
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
: 'border-red-500/30 text-red-400 bg-red-500/10'
|
||||
}
|
||||
>
|
||||
{log.result === 'success' ? '成功' : '失败'}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
{log.message && <DetailRow label="详情" value={log.message} mono />}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
onClick={onClose}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="text-slate-500 w-20 flex-shrink-0">{label}</span>
|
||||
<span className={mono ? 'text-slate-300 font-mono text-xs break-all' : 'text-slate-200 break-all'}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Statistics Dialog
|
||||
// =========================================================================
|
||||
|
||||
function StatisticsDialog({
|
||||
open,
|
||||
onClose,
|
||||
loading,
|
||||
stats,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
loading: boolean;
|
||||
stats: AuditStatistics | null;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||
<div className="relative bg-slate-900 border border-slate-800 rounded-lg p-6 max-w-md w-full mx-4 shadow-2xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-slate-100">审计统计</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-slate-100"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : stats ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<StatCard label="总日志数" value={String(stats.total_logs ?? '--')} color="blue" />
|
||||
<StatCard label="今日日志" value={String(stats.today_count ?? '--')} color="emerald" />
|
||||
<StatCard label="成功数" value={String(stats.success_count ?? '--')} color="emerald" />
|
||||
<StatCard label="失败数" value={String(stats.failure_count ?? '--')} color="red" />
|
||||
</div>
|
||||
|
||||
{stats.top_modules && stats.top_modules.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-400 mb-2">热门模块</h4>
|
||||
<div className="space-y-1">
|
||||
{stats.top_modules.slice(0, 5).map((m, i) => (
|
||||
<div key={i} className="flex justify-between text-xs">
|
||||
<span className="text-slate-300">{m.module}</span>
|
||||
<span className="text-slate-500">{m.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats.top_users && stats.top_users.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-400 mb-2">活跃用户</h4>
|
||||
<div className="space-y-1">
|
||||
{stats.top_users.slice(0, 5).map((u, i) => (
|
||||
<div key={i} className="flex justify-between text-xs">
|
||||
<span className="text-slate-300">{u.username}</span>
|
||||
<span className="text-slate-500">{u.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-500 text-center py-10 text-sm">暂无统计数据</p>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
onClick={onClose}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, color }: { label: string; value: string; color: 'blue' | 'emerald' | 'red' }) {
|
||||
const colorMap = {
|
||||
blue: 'border-blue-500/20 bg-blue-500/5 text-blue-400',
|
||||
emerald: 'border-emerald-500/20 bg-emerald-500/5 text-emerald-400',
|
||||
red: 'border-red-500/20 bg-red-500/5 text-red-400',
|
||||
};
|
||||
return (
|
||||
<div className={`rounded-lg border p-3 ${colorMap[color]}`}>
|
||||
<div className="text-xs text-slate-500">{label}</div>
|
||||
<div className="text-xl font-bold mt-0.5">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// 安全审计 — 内容由侧边栏路由分发 | v2 (0603)
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { TerminalAuditTab } from './TerminalAuditTab';
|
||||
import { AuditOperationsTab } from './AuditOperationsTab';
|
||||
import { LoginLogsTab } from './LoginLogsTab';
|
||||
|
||||
export function AuditPage() {
|
||||
const { pathname } = useLocation();
|
||||
console.debug('[AuditPage v2] pathname:', pathname);
|
||||
|
||||
if (pathname.includes('/audit/terminal')) return <TerminalAuditTab />;
|
||||
if (pathname.includes('/audit/operations')) return <AuditOperationsTab />;
|
||||
if (pathname.includes('/audit/login-logs')) return <LoginLogsTab />;
|
||||
|
||||
return <TerminalAuditTab />;
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// 登录日志 Tab — 用户登录记录列表(参考 opsx-web-lite) // Logan.Li
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Search, RefreshCw, LogIn, Monitor, Globe } from 'lucide-react';
|
||||
import { loginLogApi } from '@/services/api';
|
||||
|
||||
interface LoginLog {
|
||||
id: number;
|
||||
username: string;
|
||||
login_type: string;
|
||||
ip: string;
|
||||
location: string;
|
||||
browser: string;
|
||||
os: string;
|
||||
user_agent: string;
|
||||
status: 'success' | 'fail';
|
||||
fail_reason: string;
|
||||
login_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function normalizeLoginLog(raw: any): LoginLog {
|
||||
return {
|
||||
id: raw.id ?? 0,
|
||||
username: raw.username ?? '',
|
||||
login_type: raw.login_type ?? 'password',
|
||||
ip: raw.ip ?? '',
|
||||
location: raw.location ?? '',
|
||||
browser: raw.browser ?? '',
|
||||
os: raw.os ?? '',
|
||||
user_agent: raw.user_agent ?? raw.userAgent ?? '',
|
||||
status: raw.status === 'failed' || raw.status === 'fail' ? 'fail' : 'success',
|
||||
fail_reason: raw.fail_reason ?? raw.message ?? '',
|
||||
login_at: raw.login_at ?? raw.created_at ?? '',
|
||||
created_at: raw.created_at ?? raw.login_at ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export function LoginLogsTab() {
|
||||
const [logs, setLogs] = useState<LoginLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = { page: 1, page_size: 100 };
|
||||
if (statusFilter) params.status = statusFilter === 'fail' ? 'failed' : statusFilter;
|
||||
const resp = (await loginLogApi.list(params)) as any;
|
||||
const list = resp?.list ?? [];
|
||||
setLogs(list.map(normalizeLoginLog));
|
||||
} catch {
|
||||
setLogs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
||||
const filteredLogs = logs.filter((log) => {
|
||||
const kw = keyword.toLowerCase();
|
||||
return (
|
||||
!kw ||
|
||||
(log.username || '').toLowerCase().includes(kw) ||
|
||||
(log.ip || '').toLowerCase().includes(kw) ||
|
||||
(log.location || '').toLowerCase().includes(kw) ||
|
||||
(log.user_agent || '').toLowerCase().includes(kw)
|
||||
);
|
||||
});
|
||||
|
||||
const getStatusBadge = (status: LoginLog['status'], reason?: string) => {
|
||||
return status === 'success' ? (
|
||||
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
|
||||
成功
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-red-500/30 text-red-400 bg-red-500/10"
|
||||
title={reason || undefined}
|
||||
>
|
||||
{reason || '失败'}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getLoginTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case 'password': return '密码登录';
|
||||
case 'ldap': return 'LDAP';
|
||||
case 'token': return 'Token';
|
||||
case 'oauth': return 'OAuth';
|
||||
default: return type || '密码登录';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder="搜索用户/IP/归属地..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{[
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'success', label: '成功' },
|
||||
{ value: 'fail', label: '失败' },
|
||||
].map(({ value, label }) => (
|
||||
<Button
|
||||
key={value || 'all'}
|
||||
variant={statusFilter === value ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={
|
||||
statusFilter === value
|
||||
? 'h-8 bg-blue-600 hover:bg-blue-700 text-white'
|
||||
: 'h-8 border-slate-700 text-slate-400 hover:text-slate-100 hover:bg-slate-800'
|
||||
}
|
||||
onClick={() => setStatusFilter(value)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
|
||||
onClick={fetchLogs}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Logs Table */}
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-0">
|
||||
<div className="border-t border-slate-800">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : filteredLogs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
|
||||
<LogIn className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm">暂无登录日志</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">登录时间</TableHead>
|
||||
<TableHead className="text-slate-400">用户</TableHead>
|
||||
<TableHead className="text-slate-400">登录方式</TableHead>
|
||||
<TableHead className="text-slate-400">IP</TableHead>
|
||||
<TableHead className="text-slate-400">归属地</TableHead>
|
||||
<TableHead className="text-slate-400">浏览器/OS</TableHead>
|
||||
<TableHead className="text-slate-400">状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredLogs.map((log) => (
|
||||
<TableRow key={log.id} className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{log.login_at ? new Date(log.login_at).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-200 font-medium">{log.username || '--'}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10 text-xs">
|
||||
{getLoginTypeLabel(log.login_type)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs text-slate-400 bg-slate-800 px-2 py-0.5 rounded">
|
||||
{log.ip || '--'}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-xs text-slate-500 flex items-center gap-1">
|
||||
<Globe className="h-3 w-3" />
|
||||
{log.location || '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-xs text-slate-500 flex items-center gap-1" title={log.user_agent}>
|
||||
<Monitor className="h-3 w-3 flex-shrink-0" />
|
||||
{log.browser || log.os
|
||||
? [log.browser, log.os].filter(Boolean).join(' / ')
|
||||
: log.user_agent
|
||||
? log.user_agent.length > 40
|
||||
? log.user_agent.slice(0, 40) + '...'
|
||||
: log.user_agent
|
||||
: '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(log.status, log.fail_reason)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
// 终端审计 Tab — 3 个子标签页:在线会话 / 历史会话 / 会话命令
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Search,
|
||||
RefreshCw,
|
||||
Play,
|
||||
Download,
|
||||
XOctagon,
|
||||
Eye,
|
||||
Monitor,
|
||||
Activity,
|
||||
Timer,
|
||||
TimerOff,
|
||||
} from 'lucide-react';
|
||||
import { terminalSessionApi, sessionCommandApi } from '@/services/api';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { ReplayDialog } from '@/components/system/ReplayDialog';
|
||||
import { LiveMonitorDialog } from '@/components/system/LiveMonitorDialog';
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
interface TerminalSession {
|
||||
id: number;
|
||||
user_id?: number;
|
||||
username: string;
|
||||
server_id: number;
|
||||
server_name: string;
|
||||
server_ip: string;
|
||||
server_port: number;
|
||||
client_ip: string;
|
||||
status: string;
|
||||
duration: number;
|
||||
record_size: number;
|
||||
start_time: string;
|
||||
end_time?: string | null;
|
||||
}
|
||||
|
||||
interface SessionCommand {
|
||||
id: number;
|
||||
session_id: number;
|
||||
username: string;
|
||||
command: string;
|
||||
exit_code: number;
|
||||
output_size: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
type SubTabId = 'active' | 'history' | 'commands';
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
function formatDuration(sec: number): string {
|
||||
if (!sec || sec < 0) return '--';
|
||||
if (sec < 60) return `${sec}秒`;
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
if (m < 60) return `${m}分${s > 0 ? `${s}秒` : ''}`;
|
||||
const h = Math.floor(m / 60);
|
||||
const rm = m % 60;
|
||||
return `${h}时${rm > 0 ? `${rm}分` : ''}`;
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes) return '0 B';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
if (status === 'active') {
|
||||
return (
|
||||
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 mr-1.5 inline-block animate-pulse" />
|
||||
活动中
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return (
|
||||
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
|
||||
失败
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="border-slate-500/30 text-slate-400 bg-slate-500/10">
|
||||
已关闭
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// Computes elapsed time for active sessions
|
||||
function useElapsedTimer(startTime: string | undefined): number {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!startTime) return;
|
||||
const start = new Date(startTime).getTime();
|
||||
const tick = () => setElapsed(Math.floor((Date.now() - start) / 1000));
|
||||
tick();
|
||||
const id = setInterval(tick, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [startTime]);
|
||||
return elapsed;
|
||||
}
|
||||
|
||||
// ---- Sub-Tab definitions ----
|
||||
|
||||
const subTabs: { id: SubTabId; name: string; icon: React.ElementType }[] = [
|
||||
{ id: 'active', name: '在线会话', icon: Activity },
|
||||
{ id: 'history', name: '历史会话', icon: Monitor },
|
||||
{ id: 'commands', name: '会话命令', icon: Search },
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// MAIN COMPONENT
|
||||
// =========================================================================
|
||||
|
||||
export function TerminalAuditTab() {
|
||||
const confirm = useConfirm();
|
||||
const [subTab, setSubTab] = useState<SubTabId>('active');
|
||||
|
||||
// ---- Active Sessions State ----
|
||||
const [activeSessions, setActiveSessions] = useState<TerminalSession[]>([]);
|
||||
const [activeLoading, setActiveLoading] = useState(true);
|
||||
const [activeKeyword, setActiveKeyword] = useState('');
|
||||
|
||||
// ---- History Sessions State ----
|
||||
const [historySessions, setHistorySessions] = useState<TerminalSession[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(true);
|
||||
const [historyKeyword, setHistoryKeyword] = useState('');
|
||||
const [replayId, setReplayId] = useState<number | null>(null);
|
||||
const [replayOpen, setReplayOpen] = useState(false);
|
||||
|
||||
// ---- Session Commands State ----
|
||||
const [commands, setCommands] = useState<SessionCommand[]>([]);
|
||||
const [commandsLoading, setCommandsLoading] = useState(true);
|
||||
const [commandsKeyword, setCommandsKeyword] = useState('');
|
||||
const [commandsSessionId, setCommandsSessionId] = useState('');
|
||||
|
||||
// ---- Real-time monitoring dialog ----
|
||||
const [monitorOpen, setMonitorOpen] = useState(false);
|
||||
const [monitorSessionId, setMonitorSessionId] = useState<number | null>(null);
|
||||
|
||||
// ---- Auto-refresh ----
|
||||
const [autoRefresh, setAutoRefresh] = useState(true); // 页面打开即自动刷新
|
||||
const [userToggledOff, setUserToggledOff] = useState(false); // 用户手动关闭后不再自动开启
|
||||
const REFRESH_SEC = 10;
|
||||
|
||||
// 切换到「在线会话」时自动开启刷新(除非用户已手动关闭)
|
||||
useEffect(() => {
|
||||
if (subTab === 'active' && !userToggledOff) {
|
||||
setAutoRefresh(true);
|
||||
} else if (subTab !== 'active') {
|
||||
setAutoRefresh(false);
|
||||
}
|
||||
}, [subTab, userToggledOff]);
|
||||
|
||||
// ==================== Active Sessions ====================
|
||||
|
||||
const fetchActiveSessions = useCallback(async () => {
|
||||
setActiveLoading(true);
|
||||
try {
|
||||
const resp = (await terminalSessionApi.list({ page: 1, page_size: 100, status: 'active' })) as any;
|
||||
setActiveSessions(resp?.list ?? []);
|
||||
} catch {
|
||||
setActiveSessions([]);
|
||||
} finally {
|
||||
setActiveLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchActiveSessions();
|
||||
}, [fetchActiveSessions]);
|
||||
|
||||
// 自动刷新在线会话列表
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const id = setInterval(fetchActiveSessions, REFRESH_SEC * 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [autoRefresh, fetchActiveSessions]);
|
||||
|
||||
const filteredActive = activeSessions.filter((s) => {
|
||||
const kw = activeKeyword.toLowerCase();
|
||||
return (
|
||||
!kw ||
|
||||
(s.username || '').toLowerCase().includes(kw) ||
|
||||
(s.server_name || '').toLowerCase().includes(kw) ||
|
||||
(s.server_ip || '').toLowerCase().includes(kw)
|
||||
);
|
||||
});
|
||||
|
||||
const handleKill = async (id: number, username: string) => {
|
||||
const ok = await confirm({
|
||||
title: '阻断会话',
|
||||
description: `确认强制断开用户「${username}」的当前会话吗?`,
|
||||
confirmText: '阻断',
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await terminalSessionApi.kill(id);
|
||||
await fetchActiveSessions();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
|
||||
const openMonitor = (sessionId: number) => {
|
||||
setMonitorSessionId(sessionId);
|
||||
setMonitorOpen(true);
|
||||
};
|
||||
|
||||
// ==================== History Sessions ====================
|
||||
|
||||
const fetchHistorySessions = useCallback(async () => {
|
||||
setHistoryLoading(true);
|
||||
try {
|
||||
const resp = (await terminalSessionApi.list({ page: 1, page_size: 100, status: 'closed' })) as any;
|
||||
setHistorySessions(resp?.list ?? []);
|
||||
} catch {
|
||||
setHistorySessions([]);
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHistorySessions();
|
||||
}, [fetchHistorySessions]);
|
||||
|
||||
const filteredHistory = historySessions.filter((s) => {
|
||||
const kw = historyKeyword.toLowerCase();
|
||||
return (
|
||||
!kw ||
|
||||
(s.username || '').toLowerCase().includes(kw) ||
|
||||
(s.server_name || '').toLowerCase().includes(kw) ||
|
||||
(s.server_ip || '').toLowerCase().includes(kw)
|
||||
);
|
||||
});
|
||||
|
||||
const openReplay = (id: number) => {
|
||||
setReplayId(id);
|
||||
setReplayOpen(true);
|
||||
};
|
||||
|
||||
const handleDownload = async (id: number) => {
|
||||
try {
|
||||
await terminalSessionApi.download(id);
|
||||
} catch {
|
||||
// download error is handled in http.download
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== Session Commands ====================
|
||||
|
||||
const fetchCommands = useCallback(async () => {
|
||||
setCommandsLoading(true);
|
||||
try {
|
||||
const params: any = { page: 1, page_size: 200 };
|
||||
if (commandsSessionId) params.session_id = Number(commandsSessionId);
|
||||
const resp = (await sessionCommandApi.list(params)) as any;
|
||||
setCommands(resp?.list ?? []);
|
||||
} catch {
|
||||
setCommands([]);
|
||||
} finally {
|
||||
setCommandsLoading(false);
|
||||
}
|
||||
}, [commandsSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCommands();
|
||||
}, [fetchCommands]);
|
||||
|
||||
const filteredCommands = commands.filter((c) => {
|
||||
const kw = commandsKeyword.toLowerCase();
|
||||
return (
|
||||
!kw ||
|
||||
(c.username || '').toLowerCase().includes(kw) ||
|
||||
(c.command || '').toLowerCase().includes(kw)
|
||||
);
|
||||
});
|
||||
|
||||
// ==================== RENDER ====================
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Sub-Tab Bar */}
|
||||
<div className="flex gap-1 bg-[var(--dg-bg-secondary)] p-1 rounded-lg border border-[var(--dg-border)] w-fit">
|
||||
{subTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setSubTab(tab.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-all',
|
||||
subTab === tab.id
|
||||
? 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-primary)] shadow-sm'
|
||||
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]',
|
||||
)}
|
||||
>
|
||||
<tab.icon className="h-4 w-4" />
|
||||
{tab.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ==================== 在线会话 ==================== */}
|
||||
{subTab === 'active' && (
|
||||
<>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder="搜索用户/服务器..."
|
||||
value={activeKeyword}
|
||||
onChange={(e) => setActiveKeyword(e.target.value)}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
|
||||
onClick={fetchActiveSessions}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
variant={autoRefresh ? 'default' : 'outline'}
|
||||
className={autoRefresh
|
||||
? 'bg-emerald-600 hover:bg-emerald-700 text-white'
|
||||
: 'border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800'
|
||||
}
|
||||
onClick={() => {
|
||||
if (autoRefresh) setUserToggledOff(true); // 手动关闭后不再自动开启
|
||||
setAutoRefresh(!autoRefresh);
|
||||
}}
|
||||
title={autoRefresh ? `每 ${REFRESH_SEC}s 自动刷新 · 点击关闭` : '点击开启自动刷新'}
|
||||
>
|
||||
{autoRefresh ? (
|
||||
<><Timer className="h-4 w-4 mr-1.5 animate-pulse" />{REFRESH_SEC}s</>
|
||||
) : (
|
||||
<><TimerOff className="h-4 w-4 mr-2" />自动刷新</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-0">
|
||||
<div className="border-t border-slate-800">
|
||||
{activeLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : filteredActive.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
|
||||
<Activity className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm">暂无在线会话</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">ID</TableHead>
|
||||
<TableHead className="text-slate-400">用户</TableHead>
|
||||
<TableHead className="text-slate-400">目标服务器</TableHead>
|
||||
<TableHead className="text-slate-400">端口</TableHead>
|
||||
<TableHead className="text-slate-400">开始时间</TableHead>
|
||||
<TableHead className="text-slate-400">时长</TableHead>
|
||||
<TableHead className="text-slate-400">状态</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredActive.map((s) => (
|
||||
<ActiveSessionRow
|
||||
key={s.id}
|
||||
session={s}
|
||||
onKill={handleKill}
|
||||
onMonitor={openMonitor}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ==================== 历史会话 ==================== */}
|
||||
{subTab === 'history' && (
|
||||
<>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder="搜索用户/服务器/IP..."
|
||||
value={historyKeyword}
|
||||
onChange={(e) => setHistoryKeyword(e.target.value)}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
|
||||
onClick={fetchHistorySessions}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-0">
|
||||
<div className="border-t border-slate-800">
|
||||
{historyLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : filteredHistory.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
|
||||
<Monitor className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm">暂无历史会话</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">ID</TableHead>
|
||||
<TableHead className="text-slate-400">用户</TableHead>
|
||||
<TableHead className="text-slate-400">目标服务器</TableHead>
|
||||
<TableHead className="text-slate-400">端口</TableHead>
|
||||
<TableHead className="text-slate-400">开始时间</TableHead>
|
||||
<TableHead className="text-slate-400">结束时间</TableHead>
|
||||
<TableHead className="text-slate-400">时长</TableHead>
|
||||
<TableHead className="text-slate-400">状态</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredHistory.map((s) => (
|
||||
<TableRow key={s.id} className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">{s.id}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-200">{s.username || '--'}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-slate-200">{s.server_name || '--'}</span>
|
||||
<span className="text-xs text-slate-500">{s.server_ip}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">{s.server_port}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{s.start_time ? new Date(s.start_time).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{s.end_time ? new Date(s.end_time).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-300">{formatDuration(s.duration)}</span>
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(s.status)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-blue-400 hover:bg-slate-800"
|
||||
onClick={() => openReplay(s.id)}
|
||||
title="回放"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-green-400 hover:bg-slate-800"
|
||||
onClick={() => handleDownload(s.id)}
|
||||
title="下载录制"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ==================== 会话命令 ==================== */}
|
||||
{subTab === 'commands' && (
|
||||
<>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder="搜索用户/命令..."
|
||||
value={commandsKeyword}
|
||||
onChange={(e) => setCommandsKeyword(e.target.value)}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="会话 ID(可选)"
|
||||
value={commandsSessionId}
|
||||
onChange={(e) => setCommandsSessionId(e.target.value)}
|
||||
className="w-32 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
|
||||
onClick={fetchCommands}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-0">
|
||||
<div className="border-t border-slate-800">
|
||||
{commandsLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : filteredCommands.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
|
||||
<Search className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm">暂无会话命令记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">时间</TableHead>
|
||||
<TableHead className="text-slate-400">用户</TableHead>
|
||||
<TableHead className="text-slate-400">命令</TableHead>
|
||||
<TableHead className="text-slate-400">输出大小</TableHead>
|
||||
<TableHead className="text-slate-400">关联会话</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredCommands.map((c) => (
|
||||
<TableRow key={c.id} className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{c.created_at ? new Date(c.created_at).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-200">{c.username || '--'}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs text-slate-300 bg-slate-800 px-2 py-0.5 rounded max-w-[400px] truncate block">
|
||||
{c.command || '--'}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">{formatSize(c.output_size)}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-xs text-slate-500">#{c.session_id}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Replay Dialog */}
|
||||
<ReplayDialog open={replayOpen} onOpenChange={setReplayOpen} sessionId={replayId} />
|
||||
|
||||
{/* Real-time monitor dialog */}
|
||||
<LiveMonitorDialog open={monitorOpen} onOpenChange={setMonitorOpen} sessionId={monitorSessionId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Active Session Row (with live timer)
|
||||
// =========================================================================
|
||||
|
||||
function ActiveSessionRow({
|
||||
session,
|
||||
onKill,
|
||||
onMonitor,
|
||||
}: {
|
||||
session: TerminalSession;
|
||||
onKill: (id: number, username: string) => void;
|
||||
onMonitor: (id: number) => void;
|
||||
}) {
|
||||
const elapsed = useElapsedTimer(session.start_time);
|
||||
|
||||
return (
|
||||
<TableRow className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">{session.id}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-200">{session.username || '--'}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-slate-200">{session.server_name || '--'}</span>
|
||||
<span className="text-xs text-slate-500">{session.server_ip}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">{session.server_port}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{session.start_time ? new Date(session.start_time).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-emerald-400 font-mono">{formatDuration(elapsed)}</span>
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(session.status)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-red-400 hover:bg-slate-800"
|
||||
onClick={() => onKill(session.id, session.username)}
|
||||
title="阻断会话"
|
||||
>
|
||||
<XOctagon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-blue-400 hover:bg-slate-800"
|
||||
onClick={() => onMonitor(session.id)}
|
||||
title="实时查看"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Real-time Monitor Stub Dialog
|
||||
// =========================================================================
|
||||
|
||||
function MonitorStubDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
sessionId,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
sessionId: number | null;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={() => onOpenChange(false)} />
|
||||
<div className="relative bg-slate-900 border border-slate-800 rounded-lg p-6 max-w-md w-full mx-4 shadow-2xl">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<Monitor className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-slate-100">实时查看</h3>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
会话 #{sessionId} 实时监控功能即将上线
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-2 bg-slate-800 rounded-md p-2">
|
||||
WebSocket 流式传输功能开发中,敬请期待。届时支持终端实时镜像查看与会话录制同步回放。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Agent 管理 & 终端会话
|
||||
// Agent 管理
|
||||
// 从 services/api.ts 拆分 — Q3 api.ts 模块化
|
||||
|
||||
import { http, type ListResponse } from '../http';
|
||||
@@ -36,24 +36,4 @@ export const agentApi = {
|
||||
savePortPolicy: (hostname: string, data: { port_filter_mode: string; port_whitelist: number[] }) => http.put(`/agent/port-policies/${hostname}`, data),
|
||||
};
|
||||
|
||||
// ==================== 区域管理 API ====================
|
||||
|
||||
export const terminalSessionApi = {
|
||||
list: async (params?: { page?: number; page_size?: number; server_id?: number; username?: string; status?: string }) => {
|
||||
return http.get<ListResponse>('/terminal-sessions', params);
|
||||
},
|
||||
get: async (id: number) => {
|
||||
return http.get(`/terminal-sessions/${id}`);
|
||||
},
|
||||
getRecord: async (id: number) => {
|
||||
return http.get<string>(`/terminal-sessions/${id}/record`);
|
||||
},
|
||||
delete: async (id: number) => {
|
||||
return http.delete(`/terminal-sessions/${id}`);
|
||||
},
|
||||
batchDelete: async (ids: number[]) => {
|
||||
return http.post('/terminal-sessions/batch-delete', { ids });
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 发布系统 API(仓库/镜像仓库认证) ====================
|
||||
@@ -91,7 +91,22 @@ export const alertCenterApi = {
|
||||
resolve: async (id: number | string) => {
|
||||
return http.post(`/alerts/${id}/ack`);
|
||||
},
|
||||
silence: async (_id: number | string, _duration: string) => ({ success: true }),
|
||||
// 告警静默规则 CRUD
|
||||
getSilences: async (params?: { status?: string }) => {
|
||||
return http.get<ListResponse>('/alerts/silences', params);
|
||||
},
|
||||
createSilence: async (data: Record<string, unknown>) => {
|
||||
return http.post('/alerts/silences', data);
|
||||
},
|
||||
updateSilence: async (id: number | string, data: Record<string, unknown>) => {
|
||||
return http.put(`/alerts/silences/${id}`, data);
|
||||
},
|
||||
deleteSilence: async (id: number | string) => {
|
||||
return http.delete(`/alerts/silences/${id}`);
|
||||
},
|
||||
toggleSilence: async (id: number | string) => {
|
||||
return http.post(`/alerts/silences/${id}/toggle`);
|
||||
},
|
||||
toggleRule: async (id: number | string) => {
|
||||
return http.post(`/alerts/rules/${id}/toggle`);
|
||||
},
|
||||
|
||||
@@ -43,6 +43,25 @@ export const serverApi = {
|
||||
batchInstallAgent: async (ids: number[], token: string) => {
|
||||
return http.post<{ total: number; success: number; results: any[] }>(`/servers/install-agent?token=${encodeURIComponent(token)}`, { server_ids: ids });
|
||||
},
|
||||
|
||||
// SFTP 文件浏览
|
||||
listFiles: async (serverId: number, path: string) => {
|
||||
return http.get(`/servers/${serverId}/files?path=${encodeURIComponent(path)}`);
|
||||
},
|
||||
downloadFile: async (serverId: number, filePath: string) => {
|
||||
return http.download(`/servers/${serverId}/files/download?path=${encodeURIComponent(filePath)}`);
|
||||
},
|
||||
uploadFile: async (serverId: number, dirPath: string, file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return http.upload(`/servers/${serverId}/files/upload?path=${encodeURIComponent(dirPath)}`, formData);
|
||||
},
|
||||
deleteFile: async (serverId: number, filePath: string) => {
|
||||
return http.delete(`/servers/${serverId}/files?path=${encodeURIComponent(filePath)}`);
|
||||
},
|
||||
mkdir: async (serverId: number, dirPath: string) => {
|
||||
return http.post(`/servers/${serverId}/files/mkdir`, { path: dirPath });
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== Kubernetes 集群 API ====================
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
// 从 services/api.ts 拆分 — Q3 api.ts 模块化
|
||||
|
||||
import { http, type ListResponse } from '../http';
|
||||
import { isOnline, timeAgo, asList } from '../../components/agent/agentUtils';
|
||||
|
||||
export const auditApi = {
|
||||
getLogs: async (params?: { page?: number; page_size?: number; module?: string; action?: string }) => {
|
||||
return http.get<ListResponse>('/audit/logs', params);
|
||||
getLogs: async (params?: { page?: number; page_size?: number; module?: string; action?: string; username?: string; start_time?: string; end_time?: string }) => {
|
||||
return http.get<ListResponse>('/audit/logs', params as Record<string, string | number | boolean | undefined>);
|
||||
},
|
||||
getLogDetail: async (id: number | string) => {
|
||||
return http.get(`/audit/logs/${id}`);
|
||||
},
|
||||
getModules: async () => {
|
||||
return http.get('/audit/modules');
|
||||
@@ -17,6 +19,17 @@ export const auditApi = {
|
||||
getStatistics: async () => {
|
||||
return http.get('/audit/statistics');
|
||||
},
|
||||
exportLogs: async (params?: Record<string, string | number | boolean | undefined>) => {
|
||||
// Build URL with query params for server-side CSV export
|
||||
const qs = new URLSearchParams();
|
||||
qs.set('format', 'csv');
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && v !== '') qs.set(k, String(v));
|
||||
});
|
||||
}
|
||||
return http.download(`/audit/logs/export?${qs.toString()}`);
|
||||
},
|
||||
clearLogs: async () => {
|
||||
return http.post('/audit/logs/clear');
|
||||
},
|
||||
@@ -24,9 +37,51 @@ export const auditApi = {
|
||||
|
||||
// ==================== 终端会话审计 API(WebSSH 录制) ==================== // Logan.Li
|
||||
|
||||
export const terminalSessionApi = {
|
||||
list: async (params?: { page?: number; page_size?: number; server_id?: number; username?: string; status?: string }) => {
|
||||
return http.get<ListResponse>('/terminal-sessions', params as Record<string, string | number | boolean | undefined>);
|
||||
},
|
||||
get: async (id: number) => {
|
||||
return http.get(`/terminal-sessions/${id}`);
|
||||
},
|
||||
getRecord: async (id: number) => {
|
||||
return http.get<string>(`/terminal-sessions/${id}/record`);
|
||||
},
|
||||
kill: async (id: number) => {
|
||||
return http.post(`/terminal-sessions/${id}/kill`);
|
||||
},
|
||||
download: async (id: number) => {
|
||||
return http.download(`/terminal-sessions/${id}/download`, `session-${id}.cast`);
|
||||
},
|
||||
delete: async (id: number) => {
|
||||
return http.delete(`/terminal-sessions/${id}`);
|
||||
},
|
||||
batchDelete: async (ids: number[]) => {
|
||||
return http.post('/terminal-sessions/batch-delete', { ids });
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 会话命令审计 API ====================
|
||||
|
||||
export const sessionCommandApi = {
|
||||
list: async (params?: { page?: number; page_size?: number; session_id?: number; username?: string }) => {
|
||||
return http.get<ListResponse>('/session-commands', params as Record<string, string | number | boolean | undefined>);
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 登录日志 API ====================
|
||||
|
||||
export const loginLogApi = {
|
||||
list: async (params?: { page?: number; page_size?: number; username?: string; status?: string }) => {
|
||||
return http.get<ListResponse>('/login-logs', params as Record<string, string | number | boolean | undefined>);
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 审批流程 API ====================
|
||||
|
||||
export const approvalApi = {
|
||||
getTemplates: async () => {
|
||||
return http.get('/approvals/templates');
|
||||
getTemplates: async (params?: { type?: string; category?: string }) => {
|
||||
return http.get('/approvals/templates', params);
|
||||
},
|
||||
getTemplate: async (id: number | string) => {
|
||||
return http.get(`/approvals/templates/${id}`);
|
||||
@@ -40,7 +95,7 @@ export const approvalApi = {
|
||||
deleteTemplate: async (id: number | string) => {
|
||||
return http.delete(`/approvals/templates/${id}`);
|
||||
},
|
||||
getProcesses: async (params?: { page?: number; page_size?: number; status?: string }) => {
|
||||
getProcesses: async (params?: { page?: number; page_size?: number; status?: string; category?: string }) => {
|
||||
return http.get('/approvals/processes', params);
|
||||
},
|
||||
createProcess: async ( data: any) => {
|
||||
|
||||
@@ -15,12 +15,8 @@ export interface ListResponse<T = unknown> {
|
||||
total: number;
|
||||
}
|
||||
|
||||
// 可序列化为 JSON 的请求体类型
|
||||
// 注: 使用 unknown 接受任意 body(前端 API 层会 JSON.stringify)
|
||||
type Json = unknown;
|
||||
|
||||
// 通用请求函数
|
||||
// 注: 默认返回类型为 any 以兼容动态后端响应;调用方应通过泛型参数指定具体类型
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function request<T = any>(
|
||||
method: string,
|
||||
@@ -28,7 +24,6 @@ async function request<T = any>(
|
||||
body?: Json,
|
||||
params?: Record<string, string | number | boolean | undefined>
|
||||
): Promise<T> {
|
||||
// 构建 URL(含 query params)
|
||||
let fullUrl = `${BASE_URL}${url}`;
|
||||
if (params) {
|
||||
const searchParams = new URLSearchParams();
|
||||
@@ -38,17 +33,16 @@ async function request<T = any>(
|
||||
}
|
||||
});
|
||||
const qs = searchParams.toString();
|
||||
if (qs) fullUrl += `?${qs}`;
|
||||
if (qs) fullUrl += '?' + qs;
|
||||
}
|
||||
|
||||
// 获取 token
|
||||
const token = useUserStore.getState().token || localStorage.getItem('token');
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
headers['Authorization'] = 'Bearer ' + token;
|
||||
}
|
||||
|
||||
const response = await fetch(fullUrl, {
|
||||
@@ -69,12 +63,9 @@ async function request<T = any>(
|
||||
throw new Error(json.message || '请求失败');
|
||||
}
|
||||
|
||||
// Return json.data for standard responses, or full json for non-standard ones
|
||||
return (json.data !== undefined ? json.data : json) as T;
|
||||
}
|
||||
|
||||
// 导出便捷方法
|
||||
// 注: 默认泛型为 any 以兼容动态后端响应
|
||||
export const http = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
get: <T = any>(url: string, params?: Record<string, string | number | boolean | undefined>) =>
|
||||
@@ -92,40 +83,54 @@ export const http = {
|
||||
getRaw: async (url: string): Promise<string> => {
|
||||
const tk = useUserStore.getState().token;
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
|
||||
const resp = await fetch(`${baseUrl}/api/v1${url}`, { headers: { Authorization: `Bearer ${tk}` } });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const resp = await fetch(baseUrl + '/api/v1' + url, { headers: { Authorization: 'Bearer ' + tk } });
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
return resp.text();
|
||||
},
|
||||
|
||||
putRaw: async (url: string, body: string, contentType: string): Promise<unknown> => {
|
||||
const tk = useUserStore.getState().token;
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
|
||||
const resp = await fetch(`${baseUrl}/api/v1${url}`, {
|
||||
const resp = await fetch(baseUrl + '/api/v1' + url, {
|
||||
method: 'PUT',
|
||||
headers: { Authorization: `Bearer ${tk}`, 'Content-Type': contentType },
|
||||
headers: { Authorization: 'Bearer ' + tk, 'Content-Type': contentType },
|
||||
body,
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
return resp.json();
|
||||
},
|
||||
|
||||
// 二进制下载(blob)— 用于 tar.gz / 文件下载
|
||||
download: async (url: string, filename?: string): Promise<void> => {
|
||||
const tk = useUserStore.getState().token;
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
|
||||
const resp = await fetch(`${baseUrl}/api/v1${url}`, {
|
||||
headers: { Authorization: `Bearer ${tk}` },
|
||||
const resp = await fetch(baseUrl + '/api/v1' + url, {
|
||||
headers: { Authorization: 'Bearer ' + tk },
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
const blob = await resp.blob();
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename || resp.headers.get('Content-Disposition')?.match(/filename="?(.+?)"?$/)?.[1] || 'download';
|
||||
const disp = resp.headers.get('Content-Disposition') || '';
|
||||
const m = disp.match(/filename="?(.+?)"?$/);
|
||||
a.download = filename || (m ? m[1] : 'download');
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(a.href);
|
||||
},
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
upload: async <T = any>(url: string, formData: FormData): Promise<T> => {
|
||||
const tk = useUserStore.getState().token;
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
|
||||
const resp = await fetch(baseUrl + '/api/v1' + url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + tk },
|
||||
body: formData,
|
||||
});
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
return resp.json();
|
||||
},
|
||||
};
|
||||
|
||||
export default http;
|
||||
Reference in new issue
Block a user