diff --git a/docs/specs/asset/server-detail-sheet.md b/docs/specs/asset/server-detail-sheet.md new file mode 100644 index 0000000..63dfecf --- /dev/null +++ b/docs/specs/asset/server-detail-sheet.md @@ -0,0 +1,129 @@ +# 资产主机详情抽屉与终端窗口壳 + +| 字段 | 内容 | +|------|------| +| 状态 | Implemented | +| 级别 | S2 | +| 负责人 | Logan.Li | +| 创建日期 | 2026-09-14 | +| 最后更新 | 2026-09-14 | +| 关联任务 | 服务器管理页右侧 Sheet 详情;SSH 终端 Dialog 借鉴 lite `/monitor` 窗口壳 | + +## 1. 背景与证据 + +当前 `/servers` 点资产名称或编辑,进入的是 `ServerForm` 编辑弹窗,没有只读详情。Agent 监控有居中 `HostDetailDialog`,主机监控页是整页图表,都不适合在资产列表里扫一眼。 + +opsx-web-lite `/monitor` 右侧 560px `HostDetailDrawer`(规格网格 + 2×2 迷你曲线 + 进程 TOP 进度条)以及 macOS 风格终端壳(红黄绿 + `user@host:~` + 深蓝底)已在本地 `localhost:5184/monitor` 对照过。lite 曲线/进程是演示数据,本次只用其布局,数据走现有 `GET /servers` 列表字段与 `GET /agent/history/:hostname`。 + +## 2. 目标与非目标 + +### 目标 + +- 资产列表可打开右侧只读详情抽屉,不离开列表。 +- 抽屉展示真实资产规格、最近 30 分钟迷你曲线、进程 TOP5;无 Agent 时给空态。 +- SSH 终端弹窗采用 lite 窗口壳,保留真实 xterm / WebSocket / 全屏 / 凭证选择。 + +### 非目标 + +- 不新增 API、表字段或权限码。 +- 不移植 lite 假终端命令模拟器。 +- 不改服务器编辑表单、不改主机监控整页。 +- 不把窗口壳扩到 Kubectl / Pod exec / WebShell 整页(审计实时监控弹窗与 SSH 弹窗共用壳,因其注释已要求布局一致)。 + +## 3. 用户场景 + +- 当运维在服务器列表点击资产名称或菜单「详情」时,系统必须从右侧滑出该主机只读详情,列表仍可见。 +- 当该主机有 Agent 上报时,系统必须展示 CPU/内存/磁盘/网络迷你曲线与进程 TOP5。 +- 当无 Agent 上报时,系统必须展示规格信息,并在曲线/进程区给出空态,不得伪造内核、CPU 型号或进程。 +- 当运维从抽屉点终端(或列表原入口)打开 SSH 时,系统必须显示红黄绿标题栏与 `username@展示名:~`,红点关闭、绿点全屏。 + +## 4. 需求 + +### 功能需求 + +- `FR-01`:服务器列表资产名称与下拉「详情」打开右侧 Sheet(约 560px),关闭后回到列表。 +- `FR-02`:抽屉标题为展示名(别名 > 云实例名 > hostname > IP)加资产状态徽章;副标题说明只读详情。 +- `FR-03`:规格网格展示 OS、内核、CPU 核数、内存、磁盘、IP、区域、运行时长;有则展示公网 IP、云实例、Agent 状态/版本、标签。缺字段显示 `—`。 +- `FR-04`:有 `hostname` 时请求最近 30 分钟 Agent 历史;用末尾至多 30 个点画 CPU/内存/磁盘/网络下行迷你曲线;网络最后一点用 B/s→KB/s→MB/s 换算,禁止把 bps 当 MB/s。 +- `FR-05`:进程 TOP5 取最新上报 `process_top_info`,按 CPU% 降序,带 CPU/MEM 进度条;无数据给空态。 +- `FR-06`:抽屉底部快捷操作:终端或 RDP、文件、监控(`/monitor-center?host=`)、编辑;受现有 `server:webshell` / `server:update` / `agent:monitor` 控制;编辑仍打开现有表单。 +- `FR-07`:`TerminalDialog` 与 `LiveMonitorDialog` 标题栏改为红黄绿窗口壳 + 会话名,去掉与红点重复的右上角 X;绿点切换全屏,全屏时黄点退出全屏。 + +### 非功能需求 + +- `NFR-01`:抽屉仅在打开时拉历史,失败时规格仍可用,曲线/进程空态。 +- `NFR-02`:中英文文案走 i18n;主题色用 `var(--dg-*)`,终端内容区保持深蓝底。 +- `NFR-03`:不引入规格外重构;新增 React 文件不超过 700 行。 + +## 5. 设计与契约 + +### 模块边界 + +- 新组件:`frontend/src/components/server/ServerDetailSheet.tsx` 及 `server-detail/` 辅助。 +- 窗口壳:`frontend/src/components/common/TerminalWindowChrome.tsx`,SSH 与审计实时监控共用。 +- 复用 `useHostHistory`、`getTags`、`getStatusBadge`、`isRdpServer`。 +- 不改 `ServerForm` 的「更多详情」折叠编辑区。 + +### API / 事件 + +- 无新接口。历史:`GET /api/v1/agent/history/:hostname?minutes=30`(已有 JWT `authorized` 组)。 +- 资产字段来自当前列表行(`GET /servers` 已 Preload Region/Cloud)。 + +### 数据与迁移 + +- 无。 + +### UI / 交互 + +- 入口:资产名称、行菜单「详情」。原终端图标、编辑、删除不变。 +- 抽屉打开时列表可被遮罩;终端 Dialog 叠在抽屉之上,关闭终端后抽屉仍在。 +- 加载:曲线区 Spinner;空:无上报文案;错误:与空态相同(不弹阻塞框)。 + +## 6. 风险与可观测性 + +| 风险/失败模式 | 防护或降级 | 观测信号 | +|---------------|------------|----------| +| 主机无 Agent / hostname 对不上 | 规格照常,曲线与进程空态 | 抽屉空态文案 | +| 历史接口 4xx/5xx | TanStack Query error → 空态 | 浏览器网络错误 | +| 网络单位误用 | helpers 统一按 bps 换算 | AC-04 | +| 终端 ESC 被 Dialog 吃掉 | 保持现有 `onEscapeKeyDown` preventDefault | 现有 SSH 行为 | + +## 7. 验收标准 + +- `AC-01`:给定已登录且列表有主机,当点击资产名称,则右侧出现详情抽屉,可见主机名与状态徽章。 +- `AC-02`:给定主机缺内核/区域等字段,当打开抽屉,则对应值显示 `—`,不出现 Xeon/nginx 等写死演示值。 +- `AC-03`:给定主机近 30 分钟有 Agent 上报,当打开抽屉,则出现 4 张迷你曲线,进程 TOP 来自上报而非固定进程名。 +- `AC-04`:给定 `net_rx_bps` 为字节每秒,当展示网络卡片当前值,则单位为 B/s、KB/s 或 MB/s,数值与换算一致。 +- `AC-05`:给定无 Agent 上报,当打开抽屉,则曲线与进程区为空态,规格区仍在。 +- `AC-06`:给定 `server:webshell`,当点抽屉「终端」,则打开带红黄绿与 `user@host:~` 的 SSH 窗,红点关闭、绿点全屏。 +- `AC-07`:`npx tsc --noEmit` 零错误。 + +## 8. 验证追踪 + +| 验收标准 | 测试或检查 | 结果 | +|----------|------------|------| +| AC-01 | 浏览器:`/servers` 点资产名 | Pending(本机 5173/8080 未启动) | +| AC-02 | 代码审查 helpers + 抽屉渲染;无 KERNELS/PROCESS_NAMES 常量 | Passed | +| AC-03 | 有 Agent 主机打开抽屉 | Pending(本机未起 AIOps) | +| AC-04 | `formatBps(933735.6)` → `911.9 KB/s`(非 MB/s) | Passed | +| AC-05 | 无 Agent 主机打开抽屉 | Pending(本机未起 AIOps) | +| AC-06 | 打开 SSH 终端对照窗口壳 | Pending(本机未起 AIOps) | +| AC-07 | `npm exec -- tsc -p tsconfig.app.json --noEmit` | Passed | + +## 9. 发布与回滚 + +前端静态变更,随版本发布。回滚该提交即可。无迁移。 + +## 10. 决策记录 + +| 日期 | 决策 | 原因与影响 | +|------|------|------------| +| 2026-09-14 | 右侧 Sheet 而非居中 Dialog | 列表上下文不丢,对齐 lite 与云账号详情 | +| 2026-09-14 | 曲线窗口 30 分钟、最多 30 点 | Agent 约 10s 上报,1 分钟点太少 | +| 2026-09-14 | 终端去掉重复 X,红点关闭 | lite 红黄绿与右上 X 冲突 | +| 2026-09-14 | LiveMonitorDialog 同步窗口壳 | 该组件已声明与 WebSSH 弹窗布局一致 | + +## 11. 遗留事项 + +- Kubectl / Pod 终端是否统一窗口壳。 +- 资产列表行内是否展示实时 CPU/内存条(lite 主机表已有,本次不做)。 diff --git a/frontend/src/components/common/TerminalWindowChrome.tsx b/frontend/src/components/common/TerminalWindowChrome.tsx new file mode 100644 index 0000000..346e679 --- /dev/null +++ b/frontend/src/components/common/TerminalWindowChrome.tsx @@ -0,0 +1,83 @@ +// macOS 风格终端标题栏:红黄绿 + user@host:~ // Logan.Li +import type { ReactNode } from 'react'; +import { cn } from '@/lib/utils'; + +export const TERMINAL_BG = '#0a0f1d'; +export const TERMINAL_CHROME_BG = '#0d1526'; +export const TERMINAL_BORDER = '#1e2b42'; + +export const TERMINAL_XTERM_THEME = { + background: TERMINAL_BG, + foreground: '#e2e8f0', + cursor: '#28c840', + selectionBackground: 'rgba(40, 200, 64, 0.22)', +}; + +interface TerminalWindowChromeProps { + sessionTitle: string; + connected?: boolean; + fullscreen: boolean; + onToggleFullscreen: () => void; + onClose: () => void; + trailing?: ReactNode; + closeLabel: string; + fullscreenLabel: string; + restoreLabel: string; +} + +export function TerminalWindowChrome({ + sessionTitle, + connected, + fullscreen, + onToggleFullscreen, + onClose, + trailing, + closeLabel, + fullscreenLabel, + restoreLabel, +}: TerminalWindowChromeProps) { + return ( +
+
+ ); +} diff --git a/frontend/src/components/server/ServerDetailSheet.tsx b/frontend/src/components/server/ServerDetailSheet.tsx new file mode 100644 index 0000000..75e0a74 --- /dev/null +++ b/frontend/src/components/server/ServerDetailSheet.tsx @@ -0,0 +1,320 @@ +// 资产主机右侧详情抽屉 — 规格 + 迷你曲线 + 进程 TOP // Logan.Li +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { FolderOpen, Loader2, Monitor, Pencil, Terminal } from 'lucide-react'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; +import { Button } from '@/components/ui/button'; +import { useHostHistory } from '@/hooks/api/useHostMonitor'; +import { usePermission } from '@/hooks/usePermission'; +import type { HostMonitorTimeRange } from '@/pages/monitor/host-monitor/types'; +import { getStatusBadge, getTags, isRdpServer } from './serverHelpers'; +import type { JsonData } from '@/types/common'; +import type { Server as ServerType } from '@/types'; +import { MiniSparkline } from './server-detail/MiniSparkline'; +import { + dash, + displayServerName, + formatBps, + formatMemGB, + formatUptime, + pickNetRxBps, + pickProcessTop, + regionLabel, + sparklineFromHistory, +} from './server-detail/helpers'; + +const DETAIL_RANGE: HostMonitorTimeRange = { + preset: '30m', + label: '30m', + minutes: 30, + buckets: 36, +}; + +interface ServerDetailSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + server: JsonData | null; + onOpenTerminal: (server: JsonData) => void; + onOpenRdp: (server: JsonData) => void; + onOpenFiles: (server: JsonData) => void; + onOpenEdit: (server: JsonData) => void; +} + +export function ServerDetailSheet({ + open, + onOpenChange, + server, + onOpenTerminal, + onOpenRdp, + onOpenFiles, + onOpenEdit, +}: ServerDetailSheetProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { hasPermission } = usePermission(); + const hostname = server ? String(server.hostname || '') : ''; + const { data: history = [], isLoading: historyLoading } = useHostHistory( + open ? hostname : '', + DETAIL_RANGE, + ); + + const latest = history.length > 0 ? history[history.length - 1] : null; + const charts = useMemo(() => { + const cpu = sparklineFromHistory(history, (r) => Number(r.cpu_usage ?? r.cpu ?? 0)); + const mem = sparklineFromHistory(history, (r) => Number(r.mem_usage ?? r.memory_usage ?? r.mem ?? 0)); + const disk = sparklineFromHistory(history, (r) => Number(r.disk_usage ?? r.disk ?? 0)); + const net = sparklineFromHistory(history, pickNetRxBps); + return { cpu, mem, disk, net }; + }, [history]); + + const processes = useMemo( + () => pickProcessTop(latest?.process_top_info), + [latest], + ); + + const hasLive = history.length > 0; + const tags = server ? getTags(server) : []; + const isRdp = server ? isRdpServer(server) : false; + const title = displayServerName(server); + const cpuCores = server?.cpu || latest?.cpu_cores; + const memLabel = formatMemGB(server?.memory); + const diskGb = Number(server?.disk || latest?.disk_total || 0); + const uptime = formatUptime(server?.uptime ?? latest?.uptime); + const lastCpu = charts.cpu.length ? charts.cpu[charts.cpu.length - 1].v : null; + const lastMem = charts.mem.length ? charts.mem[charts.mem.length - 1].v : null; + const lastDisk = charts.disk.length ? charts.disk[charts.disk.length - 1].v : null; + const lastNet = charts.net.length ? charts.net[charts.net.length - 1].v : null; + + const specItems: [string, string][] = [ + [t('server.os'), dash(server?.os)], + [t('server.detailSheet.kernel'), dash(server?.kernel || latest?.kernel)], + [t('server.cpuCores'), cpuCores ? `${cpuCores} ${t('server.table.cores').trim()}` : '—'], + [t('server.formMemory'), dash(memLabel)], + [t('server.formDisk'), diskGb > 0 ? `${diskGb} GB` : '—'], + [t('server.ipAddress'), dash(server?.ip)], + [t('server.detailSheet.region'), dash(regionLabel(server || {}))], + [t('server.detailSheet.uptime'), dash(uptime)], + ]; + + return ( + + + {server && ( + <> + + + {title} + {getStatusBadge(server.status as ServerType['status'])} + + + {t('server.detailSheet.subtitle')} + {hostname && hostname !== title ? ` · ${hostname}` : ''} + + + +
+
+ {specItems.map(([k, v]) => ( +
+
{k}
+
+ {v} +
+
+ ))} +
+ + {(server.public_ip || server.cloud_instance_name || server.agent_status || tags.length > 0) && ( +
+ {server.public_ip ? ( + + ) : null} + {server.cloud_instance_name ? ( + + ) : null} + {server.agent_status ? ( + + ) : null} + {tags.length > 0 ? ( + + ) : null} +
+ )} + +
+

+ {t('server.detailSheet.realtime')} +

+ {historyLoading ? ( +
+ +
+ ) : !hasLive ? ( +

+ {t('server.detailSheet.noAgent')} +

+ ) : ( +
+ + + + +
+ )} +
+ +
+

+ {t('server.detailSheet.processTop')} +

+ {!hasLive || processes.length === 0 ? ( +

+ {t('server.detailSheet.noProcess')} +

+ ) : ( +
+ + + + + + + + + + {processes.map((p, i) => ( + + + + + + ))} + +
{t('server.detailSheet.process')}CPU%MEM%
+ {p.name} + + + + +
+
+ )} +
+
+ + + {hasPermission('server:webshell') && ( + + )} + {hasPermission('server:update') && ( + + )} + {hasPermission('agent:monitor') && hostname && ( + + )} + {hasPermission('server:update') && ( + + )} + + + )} +
+
+ ); +} + +function SpecLine({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function UsageBar({ value }: { value: number }) { + const pct = Math.min(100, Math.max(0, value)); + return ( +
+
+
+
+ + {value.toFixed(1)}% + +
+ ); +} diff --git a/frontend/src/components/server/ServerManager.tsx b/frontend/src/components/server/ServerManager.tsx index e4bf473..d3116c0 100644 --- a/frontend/src/components/server/ServerManager.tsx +++ b/frontend/src/components/server/ServerManager.tsx @@ -52,6 +52,7 @@ import { ExcelServerImport } from "./ExcelServerImport"; import { ServerForm, EMPTY_FORM, type ServerFormData } from "./ServerForm"; import { getTags } from "./serverHelpers"; import { ServerTableRow } from "./ServerTableRow"; +import { ServerDetailSheet } from "./ServerDetailSheet"; import { usePermission } from "@/hooks/usePermission"; import { PageShell } from "@/components/layout/PageShell"; import { ContentPanel } from "@/components/layout/ContentPanel"; @@ -80,6 +81,7 @@ export function ServerManager() { id: number; name: string; ip: string; + username?: string; hasAuth?: boolean; } | null>(null); const [rdpOpen, setRdpOpen] = useState(false); @@ -95,6 +97,7 @@ export function ServerManager() { id: number; name: string; } | null>(null); + const [detailServer, setDetailServer] = useState(null); // 分页状态 const [page, setPage] = useState(1); @@ -130,6 +133,7 @@ export function ServerManager() { id: server.id, name: getServerDisplayName(server), ip: server.ip, + username: server.username || "root", hasAuth: server.has_auth ?? true, }); setTerminalOpen(true); @@ -548,6 +552,7 @@ export function ServerManager() { onOpenRdp={openRdp} onOpenFiles={openFileBrowser} onOpenEdit={openEditForm} + onOpenDetail={setDetailServer} onDelete={handleDelete} /> )) @@ -650,9 +655,29 @@ export function ServerManager() { serverId={terminalServer?.id ?? null} serverName={terminalServer?.name ?? ""} serverIp={terminalServer?.ip ?? ""} + username={terminalServer?.username || "root"} hasAuth={terminalServer?.hasAuth ?? true} /> + { + if (!v) setDetailServer(null); + }} + server={ + detailServer + ? servers.find((s) => String(s.id) === String(detailServer.id)) || detailServer + : null + } + onOpenTerminal={openTerminal} + onOpenRdp={openRdp} + onOpenFiles={openFileBrowser} + onOpenEdit={(s) => { + setDetailServer(null); + openEditForm(s); + }} + /> + {/* 文件浏览 Dialog */} void; onOpenRdp: (server: JsonData) => void; onOpenEdit: (server: JsonData) => void; + onOpenDetail?: (server: JsonData) => void; onOpenFiles?: (server: JsonData) => void; onDelete: (id: string) => void; } @@ -43,6 +44,7 @@ export function ServerTableRow({ onOpenTerminal, onOpenRdp, onOpenEdit, + onOpenDetail, onOpenFiles, onDelete, }: ServerTableRowProps) { @@ -66,9 +68,13 @@ export function ServerTableRow({
-

+

{secondaryName} {server.cloud_instance_id ? ` · ${server.cloud_instance_id}` : ""} @@ -147,10 +153,7 @@ export function ServerTableRow({ )} )} - {(hasPermission("server:update") || - hasPermission("server:delete") || - hasPermission("server:webshell")) && ( - +

diff --git a/frontend/src/components/server/TerminalDialog.tsx b/frontend/src/components/server/TerminalDialog.tsx index a377910..fd88d27 100644 --- a/frontend/src/components/server/TerminalDialog.tsx +++ b/frontend/src/components/server/TerminalDialog.tsx @@ -10,8 +10,14 @@ import { Button } from "@/components/ui/button"; import { Terminal as XTerm } from "xterm"; import { FitAddon } from "xterm-addon-fit"; import "xterm/css/xterm.css"; -import { Maximize2, Minimize2, X, Shield } from "lucide-react"; +import { Shield } from "lucide-react"; import { cn } from "@/lib/utils"; +import { + TerminalWindowChrome, + TERMINAL_BG, + TERMINAL_BORDER, + TERMINAL_XTERM_THEME, +} from "@/components/common/TerminalWindowChrome"; import { attachTerminalResize, fitTerminal, @@ -31,13 +37,14 @@ interface TerminalDialogProps { serverId: number | null; serverName: string; serverIp: string; - /** 服务器是否有可用的认证凭据(密码或SSH密钥) */ + username?: string; + /** ????????????????? SSH ??? // Logan.Li */ hasAuth?: boolean; } /** - * 终端 Dialog — WebSocket SSH 终端 // Logan.Li - * 打开时自动连接,关闭时自动断开 + * ?? Dialog ? WebSocket SSH ?? // Logan.Li + * ??????????????? */ export function TerminalDialog({ open, @@ -45,6 +52,7 @@ export function TerminalDialog({ serverId, serverName, serverIp, + username = "root", hasAuth = true, }: TerminalDialogProps) { const { t } = useTranslation(); @@ -56,13 +64,11 @@ export function TerminalDialog({ const [fullscreen, setFullscreen] = useState(false); const [hasOutput, setHasOutput] = useState(false); - // 凭据选择状态(hasAuth=false 时显示) // Logan.Li const [credentials, setCredentials] = useState([]); const [selectedCredId, setSelectedCredId] = useState(""); const [assigning, setAssigning] = useState(false); - const [authReady, setAuthReady] = useState(hasAuth); // 认证就绪标志 + const [authReady, setAuthReady] = useState(hasAuth); - // 加载凭据列表 useEffect(() => { if (!open || hasAuth) return; credentialApi @@ -71,7 +77,6 @@ export function TerminalDialog({ .catch(() => {}); }, [open, hasAuth]); - // 关联凭据 const handleAssignCredential = async () => { if (!selectedCredId || !serverId) return; try { @@ -80,13 +85,12 @@ export function TerminalDialog({ toast.success(t("common.operationSuccess")); setAuthReady(true); } catch (e) { - toast.error(e?.message || t("common.operationFailed")); + toast.error((e as Error)?.message || t("common.operationFailed")); } finally { setAssigning(false); } }; - // 重置状态 useEffect(() => { if (!open) { setAuthReady(hasAuth); @@ -94,30 +98,22 @@ export function TerminalDialog({ } }, [open, hasAuth]); - // 连接终端(authReady=true 时才执行) useEffect(() => { if (!open || !serverId || !authReady) return; setHasOutput(false); - // 创建终端 const term = new XTerm({ cursorBlink: true, fontSize: 13, fontFamily: 'Menlo, Monaco, "Courier New", monospace', allowProposedApi: true, - theme: { - background: "#0a0e14", - foreground: "#d6d6d6", - cursor: "#d6d6d6", - selectionBackground: "rgba(255,255,255,0.2)", - }, + theme: TERMINAL_XTERM_THEME, }); const fit = new FitAddon(); term.loadAddon(fit); termRef.current = term; fitRef.current = fit; - // WebSocket 连接 const token = useUserStore.getState().token || ""; const wsUrl = `${getWsBase()}/ws/terminal?id=${serverId}&cols=80&rows=24&token=${encodeURIComponent(token)}`; const ws = new WebSocket(wsUrl); @@ -144,19 +140,18 @@ export function TerminalDialog({ ws.onopen = () => { setConnected(true); - // 挂载后 fit + 同步 PTY 尺寸,避免右侧黑边 // Logan.Li mountXterm(term, fit, () => containerRef.current, ws); }; ws.onclose = () => { setConnected(false); setHasOutput(true); - term.write(`\r\n\x1b[31m[${t("server.connectionClosed")}]\r\n`); + term.write(`\r\n\x1b[31m[${t("server.connectionClosed")}]\x1b[0m\r\n`); }; ws.onerror = () => { setHasOutput(true); - term.write(`\r\n\x1b[31m[${t("server.connectionError")}]\r\n`); + term.write(`\r\n\x1b[31m[${t("server.connectionError")}]\x1b[0m\r\n`); }; return () => { @@ -170,9 +165,8 @@ export function TerminalDialog({ wsRef.current = null; setConnected(false); }; - }, [open, serverId, authReady]); + }, [open, serverId, authReady, t]); - // 容器尺寸变化时 fit 并同步远端 PTY // Logan.Li useEffect(() => { if (!open || !authReady) return; @@ -204,66 +198,46 @@ export function TerminalDialog({ e.preventDefault()} className={cn( - "bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0", - // 禁用 CSS 过渡:终端窗口应该即时缩放,不需要动画 + "overflow-hidden p-0 gap-0 text-[#e2e8f0]", "!duration-0 !transition-none", fullscreen ? "!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0" : "", )} - style={ - fullscreen + style={{ + background: TERMINAL_BG, + borderColor: TERMINAL_BORDER, + ...(fullscreen ? { maxWidth: "100vw", width: "100vw", height: "100vh" } - : { maxWidth: "960px", width: "92vw" } - } + : { maxWidth: "960px", width: "92vw" }), + }} > - 终端 - {serverName} - SSH 终端连接 - {/* 终端头部 */} -
-
-
- - {serverName} - - ({serverIp}) -
-
- - -
-
- {/* 凭据选择器(无认证凭据时显示) */} + + {t("server.table.terminal")} - {serverName} + + + {serverName} ({serverIp}) + + setFullscreen(!fullscreen)} + onClose={() => onOpenChange(false)} + closeLabel={t("common.close")} + fullscreenLabel={t("server.detailSheet.fullscreen")} + restoreLabel={t("server.detailSheet.exitFullscreen")} + /> {!authReady && (
-

+

{t("server.noAuthCred")}

@@ -278,12 +252,12 @@ export function TerminalDialog({ @@ -291,17 +265,19 @@ export function TerminalDialog({

)} - {/* 终端容器 */} {authReady && (
diff --git a/frontend/src/components/server/server-detail/MiniSparkline.tsx b/frontend/src/components/server/server-detail/MiniSparkline.tsx new file mode 100644 index 0000000..bca894e --- /dev/null +++ b/frontend/src/components/server/server-detail/MiniSparkline.tsx @@ -0,0 +1,39 @@ +// 迷你实时曲线卡片 // Logan.Li +import { Line, LineChart, ResponsiveContainer } from 'recharts'; +import type { SparkPoint } from './helpers'; + +interface MiniSparklineProps { + title: string; + data: SparkPoint[]; + color: string; + valueLabel: string; +} + +export function MiniSparkline({ title, data, color, valueLabel }: MiniSparklineProps) { + return ( +
+
+ {title} + {valueLabel} +
+
+ {data.length === 0 ? ( +
+ ) : ( + + + + + + )} +
+
+ ); +} diff --git a/frontend/src/components/server/server-detail/helpers.ts b/frontend/src/components/server/server-detail/helpers.ts new file mode 100644 index 0000000..6afdcd2 --- /dev/null +++ b/frontend/src/components/server/server-detail/helpers.ts @@ -0,0 +1,111 @@ +// 资产主机详情抽屉 — 数据整形 // Logan.Li +import type { JsonData } from '@/types/common'; + +export interface SparkPoint { + i: number; + v: number; +} + +export interface ProcessTopItem { + name: string; + cpu: number; + mem: number; +} + +export function displayServerName(server: JsonData | null | undefined): string { + if (!server) return ''; + return String( + server.alias || server.cloud_instance_name || server.hostname || server.ip || '', + ); +} + +export function parseMaybeJSON(raw: unknown): JsonData { + if (!raw) return {}; + if (typeof raw === 'object') return raw as JsonData; + try { + return JSON.parse(String(raw)) as JsonData; + } catch { + return {}; + } +} + +export function asFiniteNumber(value: unknown, fallback = 0): number { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + +/** 网络/磁盘吞吐按字节每秒换算,禁止把 bps 直接当 MB/s。 // Logan.Li */ +export function formatBps(bps: number): string { + const abs = Math.abs(bps); + if (abs >= 1024 * 1024 * 1024) return `${(bps / 1024 / 1024 / 1024).toFixed(1)} GB/s`; + if (abs >= 1024 * 1024) return `${(bps / 1024 / 1024).toFixed(1)} MB/s`; + if (abs >= 1024) return `${(bps / 1024).toFixed(1)} KB/s`; + return `${bps.toFixed(1)} B/s`; +} + +export function formatUptime(seconds: unknown): string { + const sec = Math.max(0, Math.floor(asFiniteNumber(seconds))); + if (sec <= 0) return ''; + const days = Math.floor(sec / 86400); + const hours = Math.floor((sec % 86400) / 3600); + if (days > 0) return `${days}d ${hours}h`; + const mins = Math.floor((sec % 3600) / 60); + if (hours > 0) return `${hours}h ${mins}m`; + return `${mins}m`; +} + +export function formatMemGB(memoryMb: unknown): string { + const mb = asFiniteNumber(memoryMb); + if (mb <= 0) return ''; + const gb = mb / 1024; + if (gb >= 1) { + const shown = gb >= 10 || Math.abs(gb - Math.round(gb)) < 0.05 ? String(Math.round(gb)) : gb.toFixed(1); + return `${shown} GB`; + } + return `${Math.round(mb)} MB`; +} + +export function regionLabel(server: JsonData): string { + const region = server.region; + if (!region) return ''; + if (typeof region === 'string') return region; + const obj = region as JsonData; + return String(obj.name || obj.Name || obj.code || ''); +} + +export function sparklineFromHistory( + history: JsonData[], + pick: (row: JsonData) => number, + maxPoints = 30, +): SparkPoint[] { + const slice = history.slice(-maxPoints); + return slice.map((row, i) => ({ i, v: asFiniteNumber(pick(row)) })); +} + +export function pickNetRxBps(row: JsonData): number { + const net = parseMaybeJSON(row.net_info); + return asFiniteNumber(net.net_rx_bps); +} + +export function pickProcessTop(raw: unknown, limit = 5): ProcessTopItem[] { + let list: JsonData[] = []; + if (Array.isArray(raw)) { + list = raw as JsonData[]; + } else if (typeof raw === 'string') { + const parsed = parseMaybeJSON(raw); + list = Array.isArray(parsed) ? (parsed as unknown as JsonData[]) : []; + } + return list + .map((p) => ({ + name: String(p.cmdline || p.name || '?'), + cpu: asFiniteNumber(p.cpu_pct ?? p.cpu), + mem: asFiniteNumber(p.mem_pct ?? p.mem), + })) + .sort((a, b) => b.cpu - a.cpu) + .slice(0, limit); +} + +export function dash(value: unknown): string { + const s = String(value ?? '').trim(); + return s || '—'; +} diff --git a/frontend/src/components/system/LiveMonitorDialog.tsx b/frontend/src/components/system/LiveMonitorDialog.tsx index e2b9eeb..5bdadac 100644 --- a/frontend/src/components/system/LiveMonitorDialog.tsx +++ b/frontend/src/components/system/LiveMonitorDialog.tsx @@ -10,8 +10,14 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Badge } from '@/components/ui/badge'; -import { Loader2, Maximize2, Minimize2, Monitor, X } from 'lucide-react'; +import { Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { + TerminalWindowChrome, + TERMINAL_BG, + TERMINAL_BORDER, + TERMINAL_XTERM_THEME, +} from '@/components/common/TerminalWindowChrome'; import { attachTerminalResize, mountXterm } from '@/lib/xterm-utils'; import { useUserStore } from '@/store'; import { TerminalLoadingOverlay } from '@/components/common/TerminalLoadingOverlay'; @@ -64,12 +70,7 @@ export function LiveMonitorDialog({ open, onOpenChange, sessionId, sessionInfo } disableStdin: true, allowProposedApi: true, scrollback: 5000, - theme: { - background: '#0a0e14', - foreground: '#d6d6d6', - cursor: '#d6d6d6', - selectionBackground: 'rgba(255,255,255,0.2)', - }, + theme: TERMINAL_XTERM_THEME, }); const fitAddon = new FitAddon(); @@ -183,72 +184,68 @@ export function LiveMonitorDialog({ open, onOpenChange, sessionId, sessionInfo } showCloseButton={false} showOverlay={false} className={cn( - 'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0', + 'overflow-hidden p-0 gap-0 text-[#e2e8f0]', '!duration-0 !transition-none', fullscreen ? '!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0' : '', )} - style={ - fullscreen + style={{ + background: TERMINAL_BG, + borderColor: TERMINAL_BORDER, + ...(fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } - : { maxWidth: '960px', width: '92vw' } - } + : { maxWidth: '960px', width: '92vw' }), + }} > 终端实时监控 审计在线 WebSSH 会话 - - {/* 工具栏 — 与 TerminalDialog 一致 // Logan.Li */} -
-
- - 终端实时监控 - {meta && ( - - {meta.username} · {meta.server_name} ({meta.server_ip}) · {formatTime(meta.start_time)} - - )} - {connected && ( - - ● 在线 - - )} - {!connected && !connectError && ( - - - 连接中 - - )} - {connectError && ( - - 连接失败 - - )} -
-
- - -
-
+ setFullscreen(!fullscreen)} + onClose={() => onOpenChange(false)} + closeLabel="关闭" + fullscreenLabel="全屏" + restoreLabel="退出全屏" + trailing={ + <> + {meta && ( + + {meta.server_ip} · {formatTime(meta.start_time)} + + )} + {connected && ( + + 在线 + + )} + {!connected && !connectError && ( + + + 连接中 + + )} + {connectError && ( + + 连接失败 + + )} + + } + /> {connectError && ( -

{connectError}

+

+ {connectError} +

)} -
+
diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index edddb6e..18344e1 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -1236,6 +1236,24 @@ "clipboardDisabled": "Clipboard is disabled by session policy", "fileUploadDisabled": "File upload is disabled by session policy", "fileDownloadDisabled": "File download is disabled by session policy" + }, + "detailSheet": { + "subtitle": "Host detail · specs and live metrics", + "kernel": "Kernel", + "region": "Region", + "uptime": "Uptime", + "cloudInstance": "Cloud instance", + "realtime": "Live metrics (last 30 min)", + "memory": "Memory", + "disk": "Disk", + "netIn": "Network in", + "processTop": "Process TOP5", + "process": "Process", + "noAgent": "No Agent reports yet. Charts and process TOP are unavailable.", + "noProcess": "No process data", + "monitor": "Monitor", + "fullscreen": "Fullscreen", + "exitFullscreen": "Exit fullscreen" } }, "alert": { @@ -7679,6 +7697,8 @@ "safetyBlockDanger": "Block danger only", "enableReasoning": "Enable reasoning mode", "mimoPlatform": "MiMo Platform", + "mimoTokenPlan": "Xiaomi MiMo Token Plan", + "mimoPlanHint": "Requires a MiMo Token Plan subscription; use the plan-specific key with the monthly endpoint", "getPaygApiKey": "Get pay-as-you-go API Key", "safetyBalanced": "Balanced", "shiftEnterHint": "Shift+Enter for newline · Enter to send", diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index 55f1247..280cb24 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -1236,6 +1236,24 @@ "clipboardDisabled": "会话策略已禁用剪贴板", "fileUploadDisabled": "会话策略已禁用文件上传", "fileDownloadDisabled": "会话策略已禁用文件下载" + }, + "detailSheet": { + "subtitle": "主机详情 · 资产规格与实时监控", + "kernel": "内核", + "region": "区域", + "uptime": "运行时长", + "cloudInstance": "云实例", + "realtime": "实时资源(最近 30 分钟)", + "memory": "内存", + "disk": "磁盘", + "netIn": "网络下行", + "processTop": "进程 TOP5", + "process": "进程", + "noAgent": "暂无 Agent 上报,曲线与进程 TOP 不可用", + "noProcess": "暂无进程数据", + "monitor": "监控", + "fullscreen": "全屏", + "exitFullscreen": "退出全屏" } }, "alert": { @@ -7679,6 +7697,8 @@ "safetyBlockDanger": "仅拦截危险", "enableReasoning": "启用推理模式", "mimoPlatform": "MiMo 开放平台", + "mimoTokenPlan": "小米 MiMo Token Plan(包月)", + "mimoPlanHint": "需在 MiMo 开放平台订阅 Token Plan 套餐,使用套餐专用密钥接入包月端点", "getPaygApiKey": "获取按量 API Key", "safetyBalanced": "均衡", "shiftEnterHint": "Shift+回车换行 · 回车发送",