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,338 @@
|
||||
// 服务器文件浏览器 — SFTP 浏览/上传/下载(参考 deployment FileBrowserDialog 设计)
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Folder,
|
||||
FolderOpen,
|
||||
File,
|
||||
Upload,
|
||||
Download,
|
||||
Trash2,
|
||||
ArrowLeft,
|
||||
Home,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
FolderPlus,
|
||||
ChevronRight,
|
||||
X,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { serverApi } from '@/services/api';
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
is_dir: boolean;
|
||||
mod_time: string;
|
||||
mode: string;
|
||||
}
|
||||
|
||||
interface Breadcrumb {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1048576).toFixed(2)} MB`;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
serverId: number | null;
|
||||
serverName: string;
|
||||
}
|
||||
|
||||
export function ServerFileBrowser({ open, onOpenChange, serverId, serverName }: Props) {
|
||||
const [items, setItems] = useState<FileItem[]>([]);
|
||||
const [breadcrumbs, setBreadcrumbs] = useState<Breadcrumb[]>([]);
|
||||
const [currentPath, setCurrentPath] = useState('/');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const fetchDir = useCallback(async (path: string) => {
|
||||
if (!serverId) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const resp = await serverApi.listFiles(serverId, path) as any;
|
||||
const data = resp?.data || resp;
|
||||
setItems(data.items || []);
|
||||
setBreadcrumbs(data.breadcrumbs || []);
|
||||
setCurrentPath(data.path || path);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '加载目录失败');
|
||||
setItems([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && serverId) {
|
||||
setFullscreen(false);
|
||||
fetchDir('/');
|
||||
}
|
||||
}, [open, serverId, fetchDir]);
|
||||
|
||||
const handleDownload = async (filePath: string) => {
|
||||
if (!serverId) return;
|
||||
try {
|
||||
await serverApi.downloadFile(serverId, filePath);
|
||||
} catch (err) {
|
||||
setError('下载失败: ' + ((err as any)?.message || ''));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !serverId) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
await serverApi.uploadFile(serverId, currentPath, file);
|
||||
await fetchDir(currentPath);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (filePath: string, isDir: boolean) => {
|
||||
if (!serverId || !confirm(`确认删除 ${isDir ? '目录' : '文件'} "${filePath}"?`)) return;
|
||||
try {
|
||||
await serverApi.deleteFile(serverId, filePath);
|
||||
await fetchDir(currentPath);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleMkdir = async () => {
|
||||
const name = prompt('输入目录名:');
|
||||
if (!name || !serverId) return;
|
||||
const newPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
|
||||
try {
|
||||
await serverApi.mkdir(serverId, newPath);
|
||||
await fetchDir(currentPath);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
const sortedItems = [...items].sort((a, b) => {
|
||||
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
onInteractOutside={(e) => {
|
||||
// 阻止点击外部关闭,上传中避免误关
|
||||
if (uploading) e.preventDefault();
|
||||
}}
|
||||
className={cn(
|
||||
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
|
||||
'!duration-0 !transition-none',
|
||||
fullscreen
|
||||
? '!max-w-none !w-screen !h-screen !rounded-none'
|
||||
: ''
|
||||
)}
|
||||
style={fullscreen
|
||||
? { maxWidth: '100vw', width: '100vw', height: '100vh' }
|
||||
: { maxWidth: '95vw', width: '1100px', maxHeight: '92vh' }
|
||||
}
|
||||
>
|
||||
<DialogTitle className="sr-only">文件浏览 — {serverName}</DialogTitle>
|
||||
<DialogDescription className="sr-only">SFTP 文件管理 · 上传 · 下载 · 删除</DialogDescription>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-[var(--dg-border)] shrink-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FolderOpen className="h-4 w-4 text-[var(--dg-blue)] shrink-0" />
|
||||
<span className="text-[var(--dg-text-primary)] font-medium text-sm truncate">
|
||||
{serverName}
|
||||
</span>
|
||||
<span className="text-[var(--dg-text-muted)] text-xs">· SFTP 文件管理</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setFullscreen(!fullscreen)}
|
||||
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
|
||||
title={fullscreen ? '退出全屏' : '全屏'}
|
||||
>
|
||||
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-primary)] shrink-0">
|
||||
<Button variant="outline" size="sm" className="border-[var(--dg-border)] h-8"
|
||||
onClick={() => fetchDir(currentPath)}>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1" />刷新
|
||||
</Button>
|
||||
<label>
|
||||
<Button variant="outline" size="sm" className="border-[var(--dg-border)] h-8 cursor-pointer"
|
||||
onClick={() => inputRef.current?.click()} disabled={uploading}>
|
||||
{uploading ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Upload className="h-3.5 w-3.5 mr-1" />}
|
||||
上传
|
||||
</Button>
|
||||
<input ref={inputRef} type="file" className="hidden" onChange={handleUpload} />
|
||||
</label>
|
||||
<Button variant="outline" size="sm" className="border-[var(--dg-border)] h-8"
|
||||
onClick={handleMkdir}>
|
||||
<FolderPlus className="h-3.5 w-3.5 mr-1" />新建目录
|
||||
</Button>
|
||||
{error && (
|
||||
<Badge variant="outline" className="border-red-500/30 text-red-400 ml-auto">
|
||||
<X className="h-3 w-3 mr-1 cursor-pointer" onClick={() => setError('')} />
|
||||
{error}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Breadcrumbs */}
|
||||
<div className="flex items-center gap-0.5 px-4 py-2 text-xs border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] overflow-x-auto shrink-0">
|
||||
{breadcrumbs.map((crumb, i) => (
|
||||
<span key={crumb.path} className="flex items-center gap-0.5">
|
||||
{i === 0 ? (
|
||||
<button
|
||||
onClick={() => fetchDir('/')}
|
||||
className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] p-0.5"
|
||||
>
|
||||
<Home className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => fetchDir(crumb.path)}
|
||||
className="text-[var(--dg-blue)] hover:underline truncate max-w-[150px]"
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
)}
|
||||
{i < breadcrumbs.length - 1 && (
|
||||
<ChevronRight className="h-3 w-3 text-[var(--dg-text-muted)] shrink-0" />
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* File List */}
|
||||
<div className="overflow-y-auto flex-1" style={{
|
||||
height: fullscreen ? 'calc(100vh - 110px)' : 'min(55vh, 600px)'
|
||||
}}>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full py-16">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-[var(--dg-blue)]" />
|
||||
</div>
|
||||
) : sortedItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-[var(--dg-text-muted)]">
|
||||
<Folder className="h-10 w-10 mb-2 opacity-30" />
|
||||
<p className="text-sm">空目录</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-[var(--dg-bg-primary)] border-b border-[var(--dg-border)] z-10">
|
||||
<tr className="text-left text-[var(--dg-text-muted)] text-xs">
|
||||
<th className="py-2 pl-4 w-8 font-medium"></th>
|
||||
<th className="py-2 font-medium">名称</th>
|
||||
<th className="py-2 px-2 font-medium w-28 text-right">大小</th>
|
||||
<th className="py-2 px-2 font-medium w-44 hidden sm:table-cell">修改时间</th>
|
||||
<th className="py-2 px-2 font-medium w-24 hidden md:table-cell">权限</th>
|
||||
<th className="py-2 pr-4 pl-2 font-medium w-24 text-center">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentPath !== '/' && (
|
||||
<tr className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)] cursor-pointer"
|
||||
onClick={() => {
|
||||
const parent = currentPath.split('/').slice(0, -1).join('/') || '/';
|
||||
fetchDir(parent);
|
||||
}}>
|
||||
<td className="py-2 pl-4"><ArrowLeft className="h-4 w-4 text-[var(--dg-text-muted)]" /></td>
|
||||
<td className="py-2 text-[var(--dg-text-secondary)]" colSpan={5}>..</td>
|
||||
</tr>
|
||||
)}
|
||||
{sortedItems.map((item) => (
|
||||
<tr key={item.path}
|
||||
className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]"
|
||||
>
|
||||
<td className="py-2 pl-4">
|
||||
{item.is_dir ? (
|
||||
<Folder className="h-4 w-4 text-amber-400" />
|
||||
) : (
|
||||
<File className="h-4 w-4 text-sky-400" />
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
{item.is_dir ? (
|
||||
<button
|
||||
onClick={() => fetchDir(item.path)}
|
||||
className="text-[var(--dg-text-primary)] hover:text-[var(--dg-blue)] hover:underline font-medium cursor-pointer"
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-[var(--dg-text-primary)]">{item.name}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 px-2 text-right text-[var(--dg-text-muted)]">
|
||||
{item.is_dir ? '-' : formatSize(item.size)}
|
||||
</td>
|
||||
<td className="py-2 px-2 text-[var(--dg-text-muted)] hidden sm:table-cell">{item.mod_time}</td>
|
||||
<td className="py-2 px-2 text-[var(--dg-text-muted)] font-mono text-xs hidden md:table-cell">{item.mode}</td>
|
||||
<td className="py-2 pr-4 pl-2">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{!item.is_dir && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDownload(item.path); }}
|
||||
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] hover:text-white"
|
||||
title="下载"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(item.path, item.is_dir); }}
|
||||
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-red-400 hover:text-red-300"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -40,12 +40,13 @@ import {
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { serverApi } from '@/services/api';
|
||||
import { ServerStatsCards } from './ServerStatsCards';
|
||||
import { ServerStatsStrip } from './ServerStatsStrip';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { useUserStore } from '@/store';
|
||||
import { CloudServerImport } from './CloudServerImport';
|
||||
import { TerminalDialog } from './TerminalDialog';
|
||||
import { ServerFileBrowser } from './ServerFileBrowser';
|
||||
import { ExcelServerImport } from './ExcelServerImport';
|
||||
import { ServerForm, EMPTY_FORM, type ServerFormData } from './ServerForm';
|
||||
import { getTags } from './serverHelpers';
|
||||
@@ -72,6 +73,10 @@ export function ServerManager() {
|
||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||
const [terminalServer, setTerminalServer] = useState<{ id: number; name: string; ip: string; hasAuth?: boolean } | null>(null);
|
||||
|
||||
// 文件浏览 Dialog 状态
|
||||
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
|
||||
const [fileBrowserServer, setFileBrowserServer] = useState<{ id: number; name: string } | null>(null);
|
||||
|
||||
// 分页状态
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
@@ -82,6 +87,11 @@ export function ServerManager() {
|
||||
setTerminalOpen(true);
|
||||
};
|
||||
|
||||
const openFileBrowser = ( server: any) => {
|
||||
setFileBrowserServer({ id: server.id, name: server.hostname });
|
||||
setFileBrowserOpen(true);
|
||||
};
|
||||
|
||||
const loadData = async (kw?: string, st?: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -421,7 +431,7 @@ export function ServerManager() {
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<ServerStatsCards servers={servers} />
|
||||
<ServerStatsStrip servers={servers} />
|
||||
|
||||
{/* Filters & Table */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
@@ -512,6 +522,7 @@ export function ServerManager() {
|
||||
selected={selectedServers.includes(server.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
onOpenTerminal={openTerminal}
|
||||
onOpenFiles={openFileBrowser}
|
||||
onOpenEdit={openEditForm}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
@@ -603,6 +614,14 @@ export function ServerManager() {
|
||||
serverIp={terminalServer?.ip ?? ''}
|
||||
hasAuth={terminalServer?.hasAuth ?? true}
|
||||
/>
|
||||
|
||||
{/* 文件浏览 Dialog */}
|
||||
<ServerFileBrowser
|
||||
open={fileBrowserOpen}
|
||||
onOpenChange={setFileBrowserOpen}
|
||||
serverId={fileBrowserServer?.id ?? null}
|
||||
serverName={fileBrowserServer?.name ?? ''}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
// 服务器统计卡片组件 — 从 ServerManager 提取 // Logan.Li
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Server, Activity, RotateCw, Power } from 'lucide-react';
|
||||
|
||||
interface ServerStatsCardsProps {
|
||||
servers: any[];
|
||||
}
|
||||
|
||||
export function ServerStatsCards({ servers }: ServerStatsCardsProps) {
|
||||
const runningCount = servers.filter((s) => s.status === 'running').length;
|
||||
const maintenanceCount = servers.filter((s) => s.status === 'maintenance').length;
|
||||
const stoppedCount = servers.filter((s) => s.status === 'stopped').length;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">服务器总数</p>
|
||||
<p className="text-2xl font-bold text-[var(--dg-text-primary)]">{servers.length}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-[var(--dg-blue)]/10 text-[var(--dg-blue)]">
|
||||
<Server className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">运行中</p>
|
||||
<p className="text-2xl font-bold text-emerald-400">{runningCount}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
|
||||
<Activity className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">维护中</p>
|
||||
<p className="text-2xl font-bold text-amber-400">{maintenanceCount}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-amber-500/10 text-amber-400">
|
||||
<RotateCw className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">已停止</p>
|
||||
<p className="text-2xl font-bold text-[var(--dg-text-muted)]">{stoppedCount}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)]">
|
||||
<Power className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// 服务器统计条 — 紧凑型替代 4 张大卡片
|
||||
import { StatStrip } from '@/components/ui/stat-strip';
|
||||
|
||||
interface ServerStatsStripProps {
|
||||
servers: any[];
|
||||
}
|
||||
|
||||
export function ServerStatsStrip({ servers }: ServerStatsStripProps) {
|
||||
const runningCount = servers.filter((s) => s.status === 'running').length;
|
||||
const maintenanceCount = servers.filter((s) => s.status === 'maintenance').length;
|
||||
const stoppedCount = servers.filter((s) => s.status === 'stopped').length;
|
||||
|
||||
return (
|
||||
<StatStrip
|
||||
items={[
|
||||
{ label: '服务器', value: servers.length, color: 'blue' },
|
||||
{ label: '运行中', value: runningCount, color: 'emerald' },
|
||||
{ label: '维护中', value: maintenanceCount, color: 'amber' },
|
||||
{ label: '已停止', value: stoppedCount, color: 'muted' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Edit, MoreHorizontal, Server as ServerIcon, Terminal, Trash2, Network, HardDrive } from 'lucide-react';
|
||||
import { Edit, MoreHorizontal, Server as ServerIcon, Terminal, Trash2, Network, HardDrive, FolderOpen } from 'lucide-react';
|
||||
import { getStatusBadge, getTags, getUsageColor } from './serverHelpers';
|
||||
|
||||
interface ServerTableRowProps {
|
||||
@@ -21,6 +21,7 @@ interface ServerTableRowProps {
|
||||
onToggleSelect: (id: string) => void;
|
||||
onOpenTerminal: ( server: any) => void;
|
||||
onOpenEdit: ( server: any) => void;
|
||||
onOpenFiles?: ( server: any) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
@@ -30,6 +31,7 @@ export function ServerTableRow({
|
||||
onToggleSelect,
|
||||
onOpenTerminal,
|
||||
onOpenEdit,
|
||||
onOpenFiles,
|
||||
onDelete,
|
||||
}: ServerTableRowProps) {
|
||||
const cpuUsage = Number(server.cpu_usage || 0);
|
||||
@@ -145,7 +147,7 @@ export function ServerTableRow({
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" onClick={() => onOpenTerminal(server)}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" onClick={() => onOpenTerminal(server)} title="终端登录">
|
||||
<Terminal className="h-4 w-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
@@ -171,6 +173,13 @@ export function ServerTableRow({
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
终端
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
|
||||
onClick={() => onOpenFiles?.(server)}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 mr-2" />
|
||||
文件
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
|
||||
<DropdownMenuItem
|
||||
className="text-red-400 focus:bg-[var(--dg-bg-hover)] focus:text-red-400 cursor-pointer"
|
||||
|
||||
@@ -239,7 +239,7 @@ export function WebShell() {
|
||||
}
|
||||
};
|
||||
|
||||
// 注册容器并首次挂载终端
|
||||
// 注册容器并首次挂载终端 + ResizeObserver 自适应
|
||||
const registerContainer = (sessionId: string, el: HTMLDivElement | null) => {
|
||||
if (el) {
|
||||
containerRef.current.set(sessionId, el);
|
||||
@@ -248,19 +248,25 @@ export function WebShell() {
|
||||
try {
|
||||
entry.term.open(el);
|
||||
entry.opened = true;
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
entry.fit.fit();
|
||||
entry.term.focus();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
// 延迟适配:等 DOM 布局完全确定
|
||||
setTimeout(() => {
|
||||
try { entry.fit.fit(); entry.term.focus(); } catch { /* noop */ }
|
||||
}, 100);
|
||||
// ResizeObserver 持续监听容器大小变化
|
||||
const observer = new ResizeObserver(() => {
|
||||
try { entry.fit.fit(); } catch { /* noop */ }
|
||||
});
|
||||
observer.observe(el);
|
||||
(entry as any)._observer = observer;
|
||||
} catch (e) {
|
||||
console.error('打开终端失败:', e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const entry = termsRef.current.get(sessionId);
|
||||
if (entry && (entry as any)._observer) {
|
||||
(entry as any)._observer.disconnect();
|
||||
}
|
||||
containerRef.current.delete(sessionId);
|
||||
}
|
||||
};
|
||||
@@ -399,9 +405,10 @@ export function WebShell() {
|
||||
key={session.id}
|
||||
ref={(el) => registerContainer(session.id, el)}
|
||||
className={cn(
|
||||
'absolute inset-0 p-1',
|
||||
'absolute inset-0',
|
||||
activeSession === session.id ? 'block' : 'hidden'
|
||||
)}
|
||||
style={{ padding: 0 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user