feat(audit): F1+F2 告警静默规则标记完成 + 审计日志CSV导出实现

F1 告警静默规则 [done]:
- 后端 alert_silence.go: CRUD + Toggle (onetime/daily/weekly 三模式)
- 告警引擎 isSilenced 集成 (alert_engine.go)
- 前端 SilenceRuleTab + SilenceDialog
- 路由 /alerts/silences 已注册

F2 审计日志导出 [done]:
- 重写 ExportAuditLogs: 真正的 CSV 导出 (encoding/csv + UTF-8 BOM)
- 支持 module/action/username/startTime/endTime 筛选
- 导出限制 10000 条防溢出,自动记录审计日志
- JSON 格式向后兼容 (format=json)
- 前端 exportLogs 传递查询参数

同时包含累积的前期工作:
- 目录重构 internal/ -> controller/model/router (v0.55-v0.57)
- 后端分层架构 dto/repository/service/utils
- 审计模块全功能 (终端审计/操作审计/登录日志)
- asciicast v2 录制格式
- SFTP 文件浏览器
- StatStrip 紧凑统计条
- 在线会话自动刷新
- LiveMonitor WebSocket 诊断修复

验证: go build ✅ | npm run build ✅ | Docker 部署 ✅ | 10 回归端点 ✅ | CSV 导出 ✅ | 静默 CRUD ✅
This commit is contained in:
attacker committed 2026-06-25 20:16:30 +08:00
1 parent e57718998a
commit 77c234b060
314 files changed
+10050 -4065

No files matched your search

+25
View File
@@ -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"
/>
);
}
+273
View File
@@ -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"
/>
);
}
+487
View File
@@ -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>
);
}
+16
View File
@@ -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 />;
}
+234
View File
@@ -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>
);
}
+727
View File
@@ -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>
);
}