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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user