refactor(frontend): P1-P2 架构统一 — utils/hooks/api 模块化

P1:
- utils/storage.ts、theme.ts、validate.ts + index 统一出口
- request/store/useTheme 接入 storage;清零 confirm/alert
- PipelineManager、TerminalAuditTab 拆分子模块(<700行红线)

P2:
- api/monitor、api/deploy 子目录化
- hooks/api 扩展:servers/pipelines/alerts/audit/sessionCommands
- PipelineManager、AuditOperationsTab 接入 TanStack Query
This commit is contained in:
Logan.Li committed 2026-06-26 00:18:14 +08:00
1 parent d19a03d427
commit f45e203e21
196 files changed
+3228 -2018

No files matched your search

+10 -2
View File
@@ -8,6 +8,7 @@ import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { approvalWorkflowApi, userApi } from '@/api';
import { useConfirm } from '@/components/ui/confirm-dialog';
/* ------------------------------------------------------------------ */
/* Types */
@@ -439,6 +440,7 @@ function WorkflowEditorModal({ workflow, users, onClose, onSaved }: EditorProps)
/* ------------------------------------------------------------------ */
export default function ApprovalWorkflowPage() {
const confirm = useConfirm();
const [items, setItems] = useState<WorkflowItem[]>([]);
const [users, setUsers] = useState<UserItem[]>([]);
const [loading, setLoading] = useState(true);
@@ -488,13 +490,19 @@ export default function ApprovalWorkflowPage() {
}, []);
const handleDelete = useCallback(async (id: number, name: string) => {
if (!window.confirm(`确定删除审批流程「${name}」?删除后不可恢复。`)) return;
const ok = await confirm({
title: '删除审批流程',
description: `确定删除审批流程「${name}」?删除后不可恢复。`,
confirmText: '删除',
variant: 'destructive',
});
if (!ok) return;
try {
await approvalWorkflowApi.delete(id);
setItems((prev) => prev.filter((w) => w.id !== id));
toast.success('已删除');
} catch { toast.error('删除失败'); }
}, []);
}, [confirm]);
const setDefault = useCallback(async (id: number) => {
try {
+17 -23
View File
@@ -1,5 +1,5 @@
// 操作审计 Tab — 增强版审计日志列表,含筛选/导出/统计/清除
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -21,6 +21,7 @@ import {
X,
} from 'lucide-react';
import { auditApi } from '@/api';
import { useAuditLogs } from '@/hooks/api';
import { useConfirm } from '@/components/ui/confirm-dialog';
import type { OperationLog } from '@/types';
@@ -53,13 +54,23 @@ interface AuditStatistics {
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 auditParams = useMemo(() => ({
page: 1,
page_size: 100,
...(moduleFilter ? { module: moduleFilter } : {}),
...(actionFilter ? { action: actionFilter } : {}),
}), [moduleFilter, actionFilter]);
const { data: auditData, isLoading: loading, refetch: refetchLogs } = useAuditLogs(auditParams);
const logs = useMemo(
() => (auditData?.list || []).map((raw) => normalizeLog(raw)),
[auditData],
);
const [modules, setModules] = useState<string[]>([]);
const [actions, setActions] = useState<string[]>([]);
@@ -71,22 +82,6 @@ export function AuditOperationsTab() {
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()]);
@@ -98,9 +93,8 @@ export function AuditOperationsTab() {
}, []);
useEffect(() => {
fetchLogs();
fetchOptions();
}, [fetchLogs, fetchOptions]);
}, [fetchOptions]);
const filteredLogs = logs.filter((log) => {
const kw = keyword.toLowerCase();
@@ -198,7 +192,7 @@ export function AuditOperationsTab() {
<Button
variant="outline"
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
onClick={fetchLogs}
onClick={() => refetchLogs()}
>
<RefreshCw className="h-4 w-4 mr-2" />
刷新
+16 -692
View File
@@ -1,123 +1,12 @@
// 终端审计 Tab — 3 个子标签页:在线会话 / 历史会话 / 会话命令
import { useState, useEffect, useCallback } from 'react';
// 终端审计 Tab — 子标签编排 // Logan.Li
import { useState } 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 '@/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 ----
import { Activity, Monitor, Search } from 'lucide-react';
import { ActiveSessionsPanel } from './terminal-audit/ActiveSessionsPanel';
import { HistorySessionsPanel } from './terminal-audit/HistorySessionsPanel';
import { SessionCommandsPanel } from './terminal-audit/SessionCommandsPanel';
import type { SubTabId } from './terminal-audit/types';
const subTabs: { id: SubTabId; name: string; icon: React.ElementType }[] = [
{ id: 'active', name: '在线会话', icon: Activity },
@@ -125,181 +14,12 @@ const subTabs: { id: SubTabId; name: string; icon: React.ElementType }[] = [
{ 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 ====================
const [userToggledOff, setUserToggledOff] = useState(false);
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
@@ -318,410 +38,14 @@ export function TerminalAuditTab() {
))}
</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>
</>
<ActiveSessionsPanel
userToggledOff={userToggledOff}
onUserToggledOff={() => setUserToggledOff(true)}
/>
)}
{/* ==================== 历史会话 ==================== */}
{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} />
{subTab === 'history' && <HistorySessionsPanel />}
{subTab === 'commands' && <SessionCommandsPanel />}
</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>
);
}
}
@@ -15,12 +15,15 @@ import {
Clock, Plus, Trash2, RefreshCw, Edit, CheckCircle, XCircle,
} from 'lucide-react';
import { DEFAULT_PROBE_FORM, type ProbeForm } from './types';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
interface HealthProbeSectionProps {
selectedHost: string;
}
export function HealthProbeSection({ selectedHost }: HealthProbeSectionProps) {
const confirm = useConfirm();
const qc = useQueryClient();
const { data: probes = [], refetch } = useHealthProbes(selectedHost);
const [probeDialogOpen, setProbeDialogOpen] = useState(false);
@@ -64,15 +67,20 @@ export function HealthProbeSection({ selectedHost }: HealthProbeSectionProps) {
setProbeDialogOpen(false);
qc.invalidateQueries({ queryKey: queryKeys.hostMonitor.probes(selectedHost) });
} catch {
alert('保存失败');
toast.error('保存失败');
}
};
const handleDeleteProbe = async (p: Record<string, unknown>) => {
if (confirm(`确定删除探针"${p.name}"?`)) {
await agentApi.deleteHealthProbe(p.id as number);
qc.invalidateQueries({ queryKey: queryKeys.hostMonitor.probes(selectedHost) });
}
const ok = await confirm({
title: '删除探针',
description: `确定删除探针「${p.name}」?`,
confirmText: '删除',
variant: 'destructive',
});
if (!ok) return;
await agentApi.deleteHealthProbe(p.id as number);
qc.invalidateQueries({ queryKey: queryKeys.hostMonitor.probes(selectedHost) });
};
return (
@@ -1,5 +1,6 @@
// 端口策略对话框 // Logan.Li
import { Button } from '@/components/ui/button';
import { parsePortList } from '@/utils/validate';
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
@@ -69,8 +70,7 @@ export function PortPolicyDialog({
onChange={e => {
if (portPolicyMode === 'whitelist') return;
const val = e.target.value;
const nums = val.split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n) && n > 0 && n <= 65535);
onWhitelistChange(nums);
onWhitelistChange(parsePortList(val));
}}
placeholder={portPolicyMode === 'whitelist' ? '白名单模式下所有端口自动上报' : '80,443,3306,6379'}
readOnly={portPolicyMode === 'whitelist'}
@@ -0,0 +1,247 @@
// 终端审计 — 在线会话子面板 // Logan.Li
import { useState, useEffect } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Search, RefreshCw, XOctagon, Eye, Activity, Timer, TimerOff } from 'lucide-react';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { LiveMonitorDialog } from '@/components/system/LiveMonitorDialog';
import { useTerminalSessions, useTerminalSessionMutations } from '@/hooks/api';
import type { TerminalSession } from './types';
import { formatDuration, getStatusBadge } from './helpers';
const REFRESH_SEC = 10;
interface ActiveSessionsPanelProps {
userToggledOff: boolean;
onUserToggledOff: () => void;
}
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;
}
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>
);
}
export function ActiveSessionsPanel({ userToggledOff, onUserToggledOff }: ActiveSessionsPanelProps) {
const confirm = useConfirm();
const { killMutation } = useTerminalSessionMutations();
const { data: sessions = [], isLoading, refetch } = useTerminalSessions({
page: 1,
page_size: 100,
status: 'active',
});
const [keyword, setKeyword] = useState('');
const [autoRefresh, setAutoRefresh] = useState(() => !userToggledOff);
const [monitorOpen, setMonitorOpen] = useState(false);
const [monitorSessionId, setMonitorSessionId] = useState<number | null>(null);
// 用户未手动关闭时,回到在线会话自动开启刷新
useEffect(() => {
if (!userToggledOff) setAutoRefresh(true);
}, [userToggledOff]);
useEffect(() => {
if (!autoRefresh) return;
const id = setInterval(() => refetch(), REFRESH_SEC * 1000);
return () => clearInterval(id);
}, [autoRefresh, refetch]);
const filtered = sessions.filter((s) => {
const kw = keyword.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 killMutation.mutateAsync(id);
} catch {
/* noop */
}
};
const openMonitor = (sessionId: number) => {
setMonitorSessionId(sessionId);
setMonitorOpen(true);
};
return (
<>
<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>
<Button
variant="outline"
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
onClick={() => refetch()}
>
<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) onUserToggledOff();
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">
{isLoading ? (
<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>
) : filtered.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>
{filtered.map((s) => (
<ActiveSessionRow key={s.id} session={s} onKill={handleKill} onMonitor={openMonitor} />
))}
</TableBody>
</Table>
)}
</div>
</CardContent>
</Card>
<LiveMonitorDialog open={monitorOpen} onOpenChange={setMonitorOpen} sessionId={monitorSessionId} />
</>
);
}
@@ -0,0 +1,170 @@
// 终端审计 — 历史会话子面板 // Logan.Li
import { useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Search, RefreshCw, Play, Download, Monitor } from 'lucide-react';
import { terminalSessionApi } from '@/api';
import { ReplayDialog } from '@/components/system/ReplayDialog';
import { useTerminalSessions } from '@/hooks/api';
import { formatDuration, getStatusBadge } from './helpers';
export function HistorySessionsPanel() {
const { data: sessions = [], isLoading, refetch } = useTerminalSessions({
page: 1,
page_size: 100,
status: 'closed',
});
const [keyword, setKeyword] = useState('');
const [replayId, setReplayId] = useState<number | null>(null);
const [replayOpen, setReplayOpen] = useState(false);
const filtered = sessions.filter((s) => {
const kw = keyword.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
}
};
return (
<>
<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>
<Button
variant="outline"
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
onClick={() => refetch()}
>
<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">
{isLoading ? (
<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>
) : filtered.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>
{filtered.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>
<ReplayDialog open={replayOpen} onOpenChange={setReplayOpen} sessionId={replayId} />
</>
);
}
@@ -0,0 +1,126 @@
// 终端审计 — 会话命令子面板 // Logan.Li
import { useMemo, useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Search, RefreshCw } from 'lucide-react';
import { useSessionCommands } from '@/hooks/api';
import { formatSize } from './helpers';
export function SessionCommandsPanel() {
const [keyword, setKeyword] = useState('');
const [sessionIdInput, setSessionIdInput] = useState('');
const queryParams = useMemo(
() => ({
page: 1,
page_size: 200,
...(sessionIdInput ? { session_id: Number(sessionIdInput) } : {}),
}),
[sessionIdInput],
);
const { data: commands = [], isLoading, refetch } = useSessionCommands(queryParams);
const filtered = commands.filter((c) => {
const kw = keyword.toLowerCase();
return (
!kw ||
(c.username || '').toLowerCase().includes(kw) ||
(c.command || '').toLowerCase().includes(kw)
);
});
return (
<>
<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>
<Input
placeholder="会话 ID(可选)"
value={sessionIdInput}
onChange={(e) => setSessionIdInput(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={() => refetch()}
>
<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">
{isLoading ? (
<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>
) : filtered.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>
{filtered.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>
</>
);
}
@@ -0,0 +1,44 @@
// 终端审计 — 展示辅助 // Logan.Li
import { Badge } from '@/components/ui/badge';
export 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}分` : ''}`;
}
export 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`;
}
export 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>
);
}
@@ -0,0 +1,29 @@
// 终端审计 — 类型定义 // Logan.Li
export 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;
}
export interface SessionCommand {
id: number;
session_id: number;
username: string;
command: string;
exit_code: number;
output_size: number;
created_at: string;
}
export type SubTabId = 'active' | 'history' | 'commands';