feat: 完善堡垒机与 Agent 智能运维能力
- 支持 Windows Agent 构建、安装脚本和统一上报协议 - 增强 RDP 剪贴板、虚拟盘和文件传输能力 - 新增 AIOps 上下文工具、告警 RCA trace 落库和回放接口 - 重构 docs 模块文档并补充终端、堡垒机、监控、AI Agent 设计
This commit is contained in:
1 parent
a9513c2056
commit
e0d652d211
169 files changed
+12192
-9116
No files matched your search
@@ -1,10 +1,9 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -12,7 +11,7 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -20,14 +19,14 @@ import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
@@ -39,34 +38,32 @@ import {
|
||||
RefreshCw,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { useServers, useServerMutations, queryKeys } from '@/hooks/api';
|
||||
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 { RdpDialog } from './RdpDialog';
|
||||
import { ServerFileBrowser } from './ServerFileBrowser';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import { ExcelServerImport } from './ExcelServerImport';
|
||||
import { ServerForm, EMPTY_FORM, type ServerFormData } from './ServerForm';
|
||||
import { getTags } from './serverHelpers';
|
||||
import { ServerTableRow } from './ServerTableRow';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { PageShell } from '@/components/layout/PageShell';
|
||||
import { ContentPanel } from '@/components/layout/ContentPanel';
|
||||
import { Server } from 'lucide-react';
|
||||
} from "lucide-react";
|
||||
import { useServers, useServerMutations } from "@/hooks/api";
|
||||
import { ServerStatsStrip } from "./ServerStatsStrip";
|
||||
import { useConfirm } from "@/components/ui/confirm-dialog";
|
||||
import { toast } from "sonner";
|
||||
import { CloudServerImport } from "./CloudServerImport";
|
||||
import { TerminalDialog } from "./TerminalDialog";
|
||||
import { RdpDialog } from "./RdpDialog";
|
||||
import { ServerFileBrowser } from "./ServerFileBrowser";
|
||||
import type { JsonData } from "@/types/common";
|
||||
import { ExcelServerImport } from "./ExcelServerImport";
|
||||
import { ServerForm, EMPTY_FORM, type ServerFormData } from "./ServerForm";
|
||||
import { getTags } from "./serverHelpers";
|
||||
import { ServerTableRow } from "./ServerTableRow";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import { PageShell } from "@/components/layout/PageShell";
|
||||
import { ContentPanel } from "@/components/layout/ContentPanel";
|
||||
import { Server } from "lucide-react";
|
||||
|
||||
export function ServerManager() {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const queryClient = useQueryClient();
|
||||
const { hasPermission } = usePermission();
|
||||
const [selectedServers, setSelectedServers] = useState<string[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [cloudImportOpen, setCloudImportOpen] = useState(false);
|
||||
const [excelImportOpen, setExcelImportOpen] = useState(false);
|
||||
|
||||
@@ -79,13 +76,25 @@ export function ServerManager() {
|
||||
|
||||
// 终端 Dialog 状态
|
||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||
const [terminalServer, setTerminalServer] = useState<{ id: number; name: string; ip: string; hasAuth?: boolean } | null>(null);
|
||||
const [terminalServer, setTerminalServer] = useState<{
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
hasAuth?: boolean;
|
||||
} | null>(null);
|
||||
const [rdpOpen, setRdpOpen] = useState(false);
|
||||
const [rdpServer, setRdpServer] = useState<{ id: number; name: string; ip: string } | null>(null);
|
||||
const [rdpServer, setRdpServer] = useState<{
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
} | null>(null);
|
||||
|
||||
// 文件浏览 Dialog 状态
|
||||
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
|
||||
const [fileBrowserServer, setFileBrowserServer] = useState<{ id: number; name: string } | null>(null);
|
||||
const [fileBrowserServer, setFileBrowserServer] = useState<{
|
||||
id: number;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
|
||||
// 分页状态
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -94,17 +103,32 @@ export function ServerManager() {
|
||||
const queryParams = useMemo(() => {
|
||||
const params: Record<string, unknown> = { page, page_size: pageSize };
|
||||
if (searchQuery) params.keyword = searchQuery;
|
||||
if (statusFilter && statusFilter !== 'all') params.status = statusFilter;
|
||||
if (statusFilter && statusFilter !== "all") params.status = statusFilter;
|
||||
return params;
|
||||
}, [page, pageSize, searchQuery, statusFilter]);
|
||||
|
||||
const { data, isLoading: loading, isFetching, refetch } = useServers(queryParams);
|
||||
const { createMutation, updateMutation, deleteMutation, batchDeleteMutation } = useServerMutations();
|
||||
const {
|
||||
data,
|
||||
isLoading: loading,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useServers(queryParams);
|
||||
const {
|
||||
createMutation,
|
||||
updateMutation,
|
||||
deleteMutation,
|
||||
batchDeleteMutation,
|
||||
} = useServerMutations();
|
||||
const servers = data?.list ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
|
||||
const openTerminal = (server: JsonData) => {
|
||||
setTerminalServer({ id: server.id, name: server.hostname, ip: server.ip, hasAuth: server.has_auth ?? true });
|
||||
setTerminalServer({
|
||||
id: server.id,
|
||||
name: server.hostname,
|
||||
ip: server.ip,
|
||||
hasAuth: server.has_auth ?? true,
|
||||
});
|
||||
setTerminalOpen(true);
|
||||
};
|
||||
|
||||
@@ -128,85 +152,6 @@ export function ServerManager() {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
// WebSocket 实时接收 Agent 指标推送 // Logan.Li
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const token = useUserStore.getState().token;
|
||||
if (!token) return;
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/metrics?token=${token}`;
|
||||
|
||||
const connect = () => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
// WebSocket 已连接,静默接收指标推送
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'metrics_update') {
|
||||
queryClient.setQueryData(queryKeys.servers(queryParams), (old: typeof data) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
list: old.list.map((s) => {
|
||||
if (s.hostname === msg.hostname) {
|
||||
return {
|
||||
...s,
|
||||
cpu_usage: msg.cpu_usage,
|
||||
memory_usage: msg.memory_usage,
|
||||
disk_usage: msg.disk_usage,
|
||||
status: msg.status,
|
||||
tcp_established: msg.tcp_established,
|
||||
tcp_time_wait: msg.tcp_time_wait,
|
||||
net_rx_bps: msg.net_rx_bps,
|
||||
net_tx_bps: msg.net_tx_bps,
|
||||
disk_read_bps: msg.disk_read_bps,
|
||||
disk_write_bps: msg.disk_write_bps,
|
||||
};
|
||||
}
|
||||
return s;
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 忽略非 JSON 消息
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
// 连接断开,3 秒后重连
|
||||
wsRef.current = null;
|
||||
reconnectTimerRef.current = setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
console.error('[MetricsWS] 连接错误:', err);
|
||||
ws.close();
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null; // 阻止自动重连
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [queryClient, queryParams]);
|
||||
|
||||
// 导出 CSV
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedServers.length === servers.length) {
|
||||
@@ -227,18 +172,18 @@ export function ServerManager() {
|
||||
const handleDelete = async (id: string) => {
|
||||
const server = servers.find((s) => String(s.id) === String(id));
|
||||
const ok = await confirm({
|
||||
title: t('server.deleteTitle'),
|
||||
title: t("server.deleteTitle"),
|
||||
description: `${server?.hostname || id}`,
|
||||
confirmText: t('common.delete'),
|
||||
variant: 'destructive',
|
||||
confirmText: t("common.delete"),
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
toast.success(t('server.deleted'));
|
||||
toast.success(t("server.deleted"));
|
||||
} catch (e) {
|
||||
console.error('[ServerManager] delete failed:', e);
|
||||
toast.error(t('server.deleteFailed'));
|
||||
console.error("[ServerManager] delete failed:", e);
|
||||
toast.error(t("server.deleteFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -246,19 +191,23 @@ export function ServerManager() {
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedServers.length === 0) return;
|
||||
const ok = await confirm({
|
||||
title: t('server.batchDeleteTitle'),
|
||||
title: t("server.batchDeleteTitle"),
|
||||
description: `${selectedServers.length}`,
|
||||
confirmText: t('server.batchDelete'),
|
||||
variant: 'destructive',
|
||||
confirmText: t("server.batchDelete"),
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await batchDeleteMutation.mutateAsync(selectedServers.map((id) => Number(id)));
|
||||
toast.success(t('server.deletedCount', { count: selectedServers.length }));
|
||||
await batchDeleteMutation.mutateAsync(
|
||||
selectedServers.map((id) => Number(id)),
|
||||
);
|
||||
toast.success(
|
||||
t("server.deletedCount", { count: selectedServers.length }),
|
||||
);
|
||||
setSelectedServers([]);
|
||||
} catch (e) {
|
||||
console.error('[ServerManager] batch delete failed:', e);
|
||||
toast.error(t('server.batchDeleteFailed'));
|
||||
console.error("[ServerManager] batch delete failed:", e);
|
||||
toast.error(t("server.batchDeleteFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -274,25 +223,27 @@ export function ServerManager() {
|
||||
const openEditForm = (server: JsonData) => {
|
||||
setEditingId(server.id);
|
||||
setFormData({
|
||||
hostname: server.hostname || '',
|
||||
ip: server.ip || '',
|
||||
public_ip: server.public_ip || '',
|
||||
hostname: server.hostname || "",
|
||||
ip: server.ip || "",
|
||||
public_ip: server.public_ip || "",
|
||||
port: String(server.port || 22),
|
||||
username: server.username || 'root',
|
||||
auth_type: server.auth_type || 'password',
|
||||
os_type: server.os_type || ((server.protocol === 'rdp') ? 'windows' : 'linux'),
|
||||
protocol: server.protocol || ((server.os_type === 'windows') ? 'rdp' : 'ssh'),
|
||||
username: server.username || "root",
|
||||
auth_type: server.auth_type || "password",
|
||||
os_type:
|
||||
server.os_type || (server.protocol === "rdp" ? "windows" : "linux"),
|
||||
protocol:
|
||||
server.protocol || (server.os_type === "windows" ? "rdp" : "ssh"),
|
||||
rdp_port: String(server.rdp_port || 3389),
|
||||
password: '',
|
||||
ssh_key: '',
|
||||
password: "",
|
||||
ssh_key: "",
|
||||
credential_id: null as number | null,
|
||||
os: server.os || '',
|
||||
cpu: String(server.cpu || ''),
|
||||
memory: server.memory ? String(Math.round(server.memory / 1024)) : '', // MB → GB
|
||||
disk: String(server.disk || ''),
|
||||
status: server.status || 'running',
|
||||
tags: server.tags || '',
|
||||
description: server.description || '',
|
||||
os: server.os || "",
|
||||
cpu: String(server.cpu || ""),
|
||||
memory: server.memory ? String(Math.round(server.memory / 1024)) : "", // MB → GB
|
||||
disk: String(server.disk || ""),
|
||||
status: server.status || "running",
|
||||
tags: server.tags || "",
|
||||
description: server.description || "",
|
||||
});
|
||||
setDetailsOpen(false);
|
||||
setFormOpen(true);
|
||||
@@ -308,11 +259,11 @@ export function ServerManager() {
|
||||
ip: formData.ip,
|
||||
public_ip: formData.public_ip || undefined,
|
||||
port: Number(formData.port) || 22,
|
||||
os_type: formData.os_type || 'linux',
|
||||
protocol: formData.protocol || 'ssh',
|
||||
os_type: formData.os_type || "linux",
|
||||
protocol: formData.protocol || "ssh",
|
||||
rdp_port: Number(formData.rdp_port) || 3389,
|
||||
username: formData.username || 'root',
|
||||
auth_type: formData.auth_type || 'password',
|
||||
username: formData.username || "root",
|
||||
auth_type: formData.auth_type || "password",
|
||||
os: formData.os,
|
||||
cpu: Number(formData.cpu) || 0,
|
||||
memory: (Number(formData.memory) || 0) * 1024, // GB → MB
|
||||
@@ -338,11 +289,11 @@ export function ServerManager() {
|
||||
} else {
|
||||
await createMutation.mutateAsync(payload);
|
||||
}
|
||||
toast.success(editingId ? t('server.updated') : t('server.created'));
|
||||
toast.success(editingId ? t("server.updated") : t("server.created"));
|
||||
setFormOpen(false);
|
||||
} catch (e) {
|
||||
console.error('[ServerManager] save failed:', e);
|
||||
toast.error(t('server.saveFailed'));
|
||||
console.error("[ServerManager] save failed:", e);
|
||||
toast.error(t("server.saveFailed"));
|
||||
} finally {
|
||||
setFormSaving(false);
|
||||
}
|
||||
@@ -351,34 +302,55 @@ export function ServerManager() {
|
||||
// 导出 CSV — 将当前服务器列表导出为 CSV 文件 // Logan.Li
|
||||
const handleExport = () => {
|
||||
if (servers.length === 0) {
|
||||
toast.error(t('server.noExportData'));
|
||||
toast.error(t("server.noExportData"));
|
||||
return;
|
||||
}
|
||||
const headers = [
|
||||
t('server.csv.hostname'), t('server.csv.ip'), t('server.csv.publicIp'), t('server.csv.port'),
|
||||
t('server.csv.os'), t('server.csv.cpu'), t('server.csv.memory'), t('server.csv.disk'),
|
||||
t('server.csv.status'), t('server.csv.tags'),
|
||||
t("server.csv.hostname"),
|
||||
t("server.csv.ip"),
|
||||
t("server.csv.publicIp"),
|
||||
t("server.csv.port"),
|
||||
t("server.csv.os"),
|
||||
t("server.csv.cpu"),
|
||||
t("server.csv.memory"),
|
||||
t("server.csv.disk"),
|
||||
t("server.csv.status"),
|
||||
t("server.csv.tags"),
|
||||
];
|
||||
const rows = servers.map((s) => {
|
||||
const memGB = s.memory ? Math.round(Number(s.memory) / 1024) : 0;
|
||||
const tags = getTags(s).join(';');
|
||||
return [s.hostname || '', s.ip || '', s.public_ip || '', s.port || 22, s.os || '',
|
||||
s.cpu || 0, memGB, s.disk || 0, s.status || '', tags];
|
||||
const tags = getTags(s).join(";");
|
||||
return [
|
||||
s.hostname || "",
|
||||
s.ip || "",
|
||||
s.public_ip || "",
|
||||
s.port || 22,
|
||||
s.os || "",
|
||||
s.cpu || 0,
|
||||
memGB,
|
||||
s.disk || 0,
|
||||
s.status || "",
|
||||
tags,
|
||||
];
|
||||
});
|
||||
const csv = [headers, ...rows]
|
||||
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
||||
.join('\n');
|
||||
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||
.map((row) =>
|
||||
row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","),
|
||||
)
|
||||
.join("\n");
|
||||
const blob = new Blob(["\ufeff" + csv], {
|
||||
type: "text/csv;charset=utf-8;",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `servers_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(t('server.exported', { count: servers.length }));
|
||||
toast.success(t("server.exported", { count: servers.length }));
|
||||
};
|
||||
|
||||
const colSpanCount = 9;
|
||||
const colSpanCount = 8;
|
||||
|
||||
const tableToolbar = (
|
||||
<>
|
||||
@@ -386,7 +358,7 @@ export function ServerManager() {
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 dg-text-muted" />
|
||||
<Input
|
||||
placeholder={t('server.searchPlaceholder')}
|
||||
placeholder={t("server.searchPlaceholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="pl-9 h-9 dg-input"
|
||||
@@ -394,24 +366,33 @@ export function ServerManager() {
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={handleStatusFilter}>
|
||||
<SelectTrigger className="w-32 h-9 dg-input">
|
||||
<SelectValue placeholder={t('common.status')} />
|
||||
<SelectValue placeholder={t("common.status")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('server.allStatus')}</SelectItem>
|
||||
<SelectItem value="running">{t('common.running')}</SelectItem>
|
||||
<SelectItem value="stopped">{t('common.stopped')}</SelectItem>
|
||||
<SelectItem value="maintenance">{t('common.maintenance')}</SelectItem>
|
||||
<SelectItem value="offline">{t('common.offline')}</SelectItem>
|
||||
<SelectItem value="all">{t("server.allStatus")}</SelectItem>
|
||||
<SelectItem value="running">{t("common.running")}</SelectItem>
|
||||
<SelectItem value="stopped">{t("common.stopped")}</SelectItem>
|
||||
<SelectItem value="maintenance">
|
||||
{t("common.maintenance")}
|
||||
</SelectItem>
|
||||
<SelectItem value="offline">{t("common.offline")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{selectedServers.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm dg-text-secondary">{t('server.selected', { count: selectedServers.length })}</span>
|
||||
{hasPermission('server:delete') && (
|
||||
<Button variant="outline" size="sm" className="dg-btn-danger dg-btn-sm" onClick={handleBatchDelete}>
|
||||
<span className="text-sm dg-text-secondary">
|
||||
{t("server.selected", { count: selectedServers.length })}
|
||||
</span>
|
||||
{hasPermission("server:delete") && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="dg-btn-danger dg-btn-sm"
|
||||
onClick={handleBatchDelete}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t('server.batchDelete')}
|
||||
{t("server.batchDelete")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -422,150 +403,195 @@ export function ServerManager() {
|
||||
return (
|
||||
<PageShell
|
||||
variant="list"
|
||||
title={t('server.title')}
|
||||
subtitle={t('server.subtitle')}
|
||||
title={t("server.title")}
|
||||
subtitle={t("server.subtitle")}
|
||||
icon={<Server className="h-5 w-5" />}
|
||||
metrics={<ServerStatsStrip servers={servers} />}
|
||||
actions={(
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" className="dg-btn-secondary" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
|
||||
{t('common.refresh')}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="dg-btn-secondary"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("h-4 w-4 mr-2", isFetching && "animate-spin")}
|
||||
/>
|
||||
{t("common.refresh")}
|
||||
</Button>
|
||||
<Button variant="outline" className="dg-btn-secondary" onClick={handleExport}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="dg-btn-secondary"
|
||||
onClick={handleExport}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
{t('common.export')}
|
||||
{t("common.export")}
|
||||
</Button>
|
||||
{hasPermission('server:create') && (
|
||||
{hasPermission("server:create") && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="dg-btn-secondary">
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
{t('server.batchImport')}
|
||||
{t("server.batchImport")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>{t('server.importMethod')}</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>
|
||||
{t("server.importMethod")}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setCloudImportOpen(true)}>
|
||||
<Cloud className="h-4 w-4 mr-2 dg-text-blue" />
|
||||
{t('server.cloudImport')}
|
||||
<span className="ml-2 text-xs dg-text-muted">{t('server.cloudImportHint')}</span>
|
||||
{t("server.cloudImport")}
|
||||
<span className="ml-2 text-xs dg-text-muted">
|
||||
{t("server.cloudImportHint")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setExcelImportOpen(true)}>
|
||||
<FileSpreadsheet className="h-4 w-4 mr-2 dg-text-green" />
|
||||
{t('server.csvImport')}
|
||||
<span className="ml-2 text-xs dg-text-muted">{t('server.csvImportHint')}</span>
|
||||
{t("server.csvImport")}
|
||||
<span className="ml-2 text-xs dg-text-muted">
|
||||
{t("server.csvImportHint")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{hasPermission('server:create') && (
|
||||
{hasPermission("server:create") && (
|
||||
<Button className="dg-btn-primary" onClick={openCreateForm}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t('server.addServer')}
|
||||
{t("server.addServer")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
}
|
||||
>
|
||||
<ContentPanel toolbar={tableToolbar}>
|
||||
<div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedServers.length === servers.length && servers.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">{t('server.hostname')}</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">{t('server.ipAddress')}</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">{t('server.publicIp')}</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">{t('common.status')}</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">{t('server.config')}</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">{t('server.usage')}</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">{t('server.tags')}</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)] text-right">{t('common.actions')}</TableHead>
|
||||
<div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedServers.length === servers.length &&
|
||||
servers.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">
|
||||
{t("server.hostname")}
|
||||
</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">
|
||||
{t("server.ipAddress")}
|
||||
</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">
|
||||
{t("server.publicIp")}
|
||||
</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">
|
||||
{t("common.status")}
|
||||
</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">
|
||||
{t("server.config")}
|
||||
</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">
|
||||
{t("server.tags")}
|
||||
</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)] text-right">
|
||||
{t("common.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableCell
|
||||
colSpan={colSpanCount}
|
||||
className="text-center text-[var(--dg-text-muted)] py-12"
|
||||
>
|
||||
<RefreshCw className="h-5 w-5 animate-spin mx-auto mb-2" />
|
||||
{t("common.loading")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableCell colSpan={colSpanCount} className="text-center text-[var(--dg-text-muted)] py-12">
|
||||
<RefreshCw className="h-5 w-5 animate-spin mx-auto mb-2" />
|
||||
{t('common.loading')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : servers.length === 0 ? (
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableCell colSpan={colSpanCount} className="text-center text-[var(--dg-text-muted)] py-12">
|
||||
{t('server.noData')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
servers.map((server) => (
|
||||
<ServerTableRow
|
||||
key={server.id}
|
||||
server={server}
|
||||
selected={selectedServers.includes(String(server.id))}
|
||||
onToggleSelect={toggleSelect}
|
||||
onOpenTerminal={openTerminal}
|
||||
onOpenRdp={openRdp}
|
||||
onOpenFiles={openFileBrowser}
|
||||
onOpenEdit={openEditForm}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableCell
|
||||
colSpan={colSpanCount}
|
||||
className="text-center text-[var(--dg-text-muted)] py-12"
|
||||
>
|
||||
{t("server.noData")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
servers.map((server) => (
|
||||
<ServerTableRow
|
||||
key={server.id}
|
||||
server={server}
|
||||
selected={selectedServers.includes(String(server.id))}
|
||||
onToggleSelect={toggleSelect}
|
||||
onOpenTerminal={openTerminal}
|
||||
onOpenRdp={openRdp}
|
||||
onOpenFiles={openFileBrowser}
|
||||
onOpenEdit={openEditForm}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--dg-text-muted)]">
|
||||
<span>{t('server.perPage')}</span>
|
||||
<Select value={String(pageSize)} onValueChange={(v) => { setPageSize(Number(v)); setPage(1); }}>
|
||||
<SelectTrigger className="w-16 h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="20">20</SelectItem>
|
||||
<SelectItem value="30">30</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span>{t('server.items')}</span>
|
||||
<span className="ml-4">{t('server.totalItems', { count: total })}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm text-[var(--dg-text-secondary)] px-2">
|
||||
{page} / {Math.max(1, Math.ceil(total / pageSize))}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
|
||||
disabled={page >= Math.ceil(total / pageSize)}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* 分页 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--dg-text-muted)]">
|
||||
<span>{t("server.perPage")}</span>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(v) => {
|
||||
setPageSize(Number(v));
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-16 h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="20">20</SelectItem>
|
||||
<SelectItem value="30">30</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span>{t("server.items")}</span>
|
||||
<span className="ml-4">
|
||||
{t("server.totalItems", { count: total })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm text-[var(--dg-text-secondary)] px-2">
|
||||
{page} / {Math.max(1, Math.ceil(total / pageSize))}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
|
||||
disabled={page >= Math.ceil(total / pageSize)}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ContentPanel>
|
||||
|
||||
{/* 创建/编辑服务器 Dialog */}
|
||||
@@ -584,8 +610,8 @@ export function ServerManager() {
|
||||
open={rdpOpen}
|
||||
onOpenChange={setRdpOpen}
|
||||
serverId={rdpServer?.id ?? null}
|
||||
serverName={rdpServer?.name || ''}
|
||||
serverIp={rdpServer?.ip || ''}
|
||||
serverName={rdpServer?.name || ""}
|
||||
serverIp={rdpServer?.ip || ""}
|
||||
/>
|
||||
|
||||
{/* Import Dialogs */}
|
||||
@@ -609,8 +635,8 @@ export function ServerManager() {
|
||||
open={terminalOpen}
|
||||
onOpenChange={setTerminalOpen}
|
||||
serverId={terminalServer?.id ?? null}
|
||||
serverName={terminalServer?.name ?? ''}
|
||||
serverIp={terminalServer?.ip ?? ''}
|
||||
serverName={terminalServer?.name ?? ""}
|
||||
serverIp={terminalServer?.ip ?? ""}
|
||||
hasAuth={terminalServer?.hasAuth ?? true}
|
||||
/>
|
||||
|
||||
@@ -619,7 +645,7 @@ export function ServerManager() {
|
||||
open={fileBrowserOpen}
|
||||
onOpenChange={setFileBrowserOpen}
|
||||
serverId={fileBrowserServer?.id ?? null}
|
||||
serverName={fileBrowserServer?.name ?? ''}
|
||||
serverName={fileBrowserServer?.name ?? ""}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user