feat: 完善告警规则配置页面 — 增删改查弹窗 + 修复白屏bug + UpdateRule notify_channels序列化修复

- AlertManager: 新建/编辑告警规则弹窗(规则名称/描述/类型/数据源/表达式/级别/持续时间/通知渠道Switch)
- AlertManager: 修复白屏bug — getRules返回{list,total}未解包导致rules.map崩溃(Frontend Pitfall #1)
- AlertManager: 修复规则列表字段显示 — 原引用不存在的rule.metric/operator/threshold字段,改为rule.source/expression
- AlertManager: 新增规则删除按钮(带确认)
- alert.go: 修复UpdateRule notify_channels序列化bug — 原直接赋值[]string到text列导致500,改用toJSON()
- 同步提交前序未提交改动(WebShell/K8s/Server/Login等组件改进)
This commit is contained in:
attacker committed 2026-06-14 03:07:44 +08:00
1 parent 170818c141
commit 00d5510366
13 files changed
+2170 -496

No files matched your search

+511 -94
View File
@@ -1,10 +1,18 @@
import { useState, useEffect } from 'react';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import {
DropdownMenu,
DropdownMenuContent,
@@ -28,7 +36,6 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Select,
@@ -49,22 +56,75 @@ import {
Eye,
Download,
Upload,
Filter,
Server,
Cpu,
HardDrive,
Activity,
CheckSquare,
Square,
Cloud,
FileSpreadsheet,
RefreshCw,
ChevronDown,
Loader2,
Save,
} from 'lucide-react';
import { serverApi } from '@/services/api';
import type { Server as ServerType } from '@/types';
import { CloudServerImport } from './CloudServerImport';
import { ExcelServerImport } from './ExcelServerImport';
// 资产类型字典(参考 CMDB 运维平台)
const ASSET_TYPE_OPTIONS = [
{ value: 'physical', label: '物理机', color: 'var(--dg-orange)' },
{ value: 'virtual', label: '虚拟机', color: 'var(--dg-blue)' },
{ value: 'cloud', label: '云主机', color: 'var(--dg-cyan)' },
{ value: 'container', label: '容器', color: 'var(--dg-purple)' },
];
const getAssetTypeLabel = (value: string): string =>
ASSET_TYPE_OPTIONS.find((o) => o.value === value)?.label || value || '-';
const getAssetTypeColor = (value: string): string =>
ASSET_TYPE_OPTIONS.find((o) => o.value === value)?.color || 'var(--dg-text-secondary)';
const getConnectTypeLabel = (value: string): string => {
if (value === 'public') return '公网';
if (value === 'private') return '内网';
return value || '-';
};
// 表单数据结构
interface ServerFormData {
hostname: string;
ip: string;
os: string;
cpu: string;
memory: string;
disk: string;
status: string;
region: string;
asset_type: string;
connect_type: string;
floatingip: string;
instance_id: string;
expire_time: string;
remark: string;
}
const EMPTY_FORM: ServerFormData = {
hostname: '',
ip: '',
os: '',
cpu: '',
memory: '',
disk: '',
status: 'running',
region: '',
asset_type: 'physical',
connect_type: 'private',
floatingip: '',
instance_id: '',
expire_time: '',
remark: '',
};
export function ServerManager() {
const [servers, setServers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
@@ -75,6 +135,13 @@ export function ServerManager() {
const [cloudImportOpen, setCloudImportOpen] = useState(false);
const [excelImportOpen, setExcelImportOpen] = useState(false);
// 创建/编辑表单状态
const [formOpen, setFormOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [formData, setFormData] = useState<ServerFormData>(EMPTY_FORM);
const [formSaving, setFormSaving] = useState(false);
const [detailsOpen, setDetailsOpen] = useState(false);
const loadData = async () => {
try {
setLoading(true);
@@ -150,6 +217,74 @@ export function ServerManager() {
}
};
// 批量删除
const handleBatchDelete = async () => {
if (selectedServers.length === 0) return;
try {
await serverApi.batchDelete(selectedServers.map((id) => Number(id)));
setSelectedServers([]);
loadData();
} catch (e) {
console.error('批量删除失败:', e);
}
};
// 打开创建表单
const openCreateForm = () => {
setEditingId(null);
setFormData(EMPTY_FORM);
setDetailsOpen(false);
setFormOpen(true);
};
// 打开编辑表单
const openEditForm = (server: any) => {
setEditingId(server.id);
setFormData({
hostname: server.hostname || '',
ip: server.ip || '',
os: server.os || '',
cpu: String(server.cpu || ''),
memory: String(server.memory || ''),
disk: String(server.disk || ''),
status: server.status || 'running',
region: getRegionName(server),
asset_type: server.asset_type || 'physical',
connect_type: server.connect_type || 'private',
floatingip: server.floatingip || '',
instance_id: server.instance_id || '',
expire_time: server.expire_time || '',
remark: server.remark || '',
});
setDetailsOpen(false);
setFormOpen(true);
};
// 保存表单
const handleSave = async () => {
if (!formData.hostname || !formData.ip) return;
try {
setFormSaving(true);
const payload = {
...formData,
cpu: Number(formData.cpu) || 0,
memory: Number(formData.memory) || 0,
disk: Number(formData.disk) || 0,
};
if (editingId) {
await serverApi.updateServer(editingId, payload);
} else {
await serverApi.createServer(payload);
}
setFormOpen(false);
loadData();
} catch (e) {
console.error('保存服务器失败:', e);
} finally {
setFormSaving(false);
}
};
const getStatusBadge = (status: ServerType['status']) => {
switch (status) {
case 'running':
@@ -161,7 +296,7 @@ export function ServerManager() {
);
case 'stopped':
return (
<Badge variant="outline" className="border-slate-500/30 text-slate-400 bg-slate-500/10">
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-muted)] bg-[var(--dg-bg-tertiary)]">
已停止
</Badge>
);
@@ -185,56 +320,61 @@ export function ServerManager() {
const regions = Array.from(new Set(servers.map((s) => getRegionName(s)).filter(Boolean)));
const colSpanCount = 11;
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">服务器管理</h1>
<p className="text-sm text-slate-500 mt-1">管理云平台服务器资产</p>
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">服务器管理</h1>
<p className="text-sm text-[var(--dg-text-muted)] mt-1">管理云平台服务器资产</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
onClick={loadData}
>
<RefreshCw className={cn('h-4 w-4 mr-2', loading && 'animate-spin')} />
刷新
</Button>
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]">
<Download className="h-4 w-4 mr-2" />
导出
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]">
<Upload className="h-4 w-4 mr-2" />
批量导入
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-slate-800 border-slate-700 text-slate-100">
<DropdownMenuContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<DropdownMenuLabel>选择导入方式</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
<DropdownMenuItem
className="hover:bg-slate-700 cursor-pointer"
className="hover:bg-[var(--dg-bg-hover)] cursor-pointer focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)]"
onClick={() => setCloudImportOpen(true)}
>
<Cloud className="h-4 w-4 mr-2 text-blue-400" />
<Cloud className="h-4 w-4 mr-2 text-[var(--dg-blue)]" />
云主机导入
<span className="ml-2 text-xs text-slate-500">从云平台同步</span>
<span className="ml-2 text-xs text-[var(--dg-text-muted)]">从云平台同步</span>
</DropdownMenuItem>
<DropdownMenuItem
className="hover:bg-slate-700 cursor-pointer"
className="hover:bg-[var(--dg-bg-hover)] cursor-pointer focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)]"
onClick={() => setExcelImportOpen(true)}
>
<FileSpreadsheet className="h-4 w-4 mr-2 text-emerald-400" />
Excel导入
<span className="ml-2 text-xs text-slate-500">批量导入服务器</span>
<span className="ml-2 text-xs text-[var(--dg-text-muted)]">批量导入服务器</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Button
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
onClick={openCreateForm}
>
<Plus className="h-4 w-4 mr-2" />
添加服务器
</Button>
@@ -243,24 +383,24 @@ export function ServerManager() {
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-slate-900 border-slate-800">
<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-slate-500">服务器总数</p>
<p className="text-2xl font-bold text-slate-100">{servers.length}</p>
<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-blue-500/10 text-blue-400">
<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-slate-900 border-slate-800">
<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-slate-500">运行中</p>
<p className="text-sm text-[var(--dg-text-muted)]">运行中</p>
<p className="text-2xl font-bold text-emerald-400">
{servers.filter((s) => s.status === 'running').length}
</p>
@@ -271,11 +411,11 @@ export function ServerManager() {
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<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-slate-500">维护中</p>
<p className="text-sm text-[var(--dg-text-muted)]">维护中</p>
<p className="text-2xl font-bold text-amber-400">
{servers.filter((s) => s.status === 'maintenance').length}
</p>
@@ -286,16 +426,16 @@ export function ServerManager() {
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<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-slate-500">已停止</p>
<p className="text-2xl font-bold text-slate-400">
<p className="text-sm text-[var(--dg-text-muted)]">已停止</p>
<p className="text-2xl font-bold text-[var(--dg-text-muted)]">
{servers.filter((s) => s.status === 'stopped').length}
</p>
</div>
<div className="p-3 rounded-lg bg-slate-500/10 text-slate-400">
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)]">
<Power className="h-5 w-5" />
</div>
</div>
@@ -304,24 +444,25 @@ export function ServerManager() {
</div>
{/* Filters & Table */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-4">
<div className="flex items-center justify-between flex-wrap gap-4">
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-0">
{/* Toolbar */}
<div className="flex items-center justify-between flex-wrap gap-4 p-4">
<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" />
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-muted)]" />
<Input
placeholder="搜索主机名或IP..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
className="pl-9 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-32 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
<SelectTrigger className="w-32 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="running">运行中</SelectItem>
<SelectItem value="stopped">已停止</SelectItem>
@@ -330,10 +471,10 @@ export function ServerManager() {
</SelectContent>
</Select>
<Select value={regionFilter} onValueChange={setRegionFilter}>
<SelectTrigger className="w-32 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
<SelectTrigger className="w-32 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue placeholder="区域" />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<SelectItem value="all">全部区域</SelectItem>
{regions.map((region) => (
<SelectItem key={region} value={region}>
@@ -345,54 +486,64 @@ export function ServerManager() {
</div>
{selectedServers.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-sm text-slate-400">已选择 {selectedServers.length} 项</span>
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300">
<span className="text-sm text-[var(--dg-text-secondary)]">已选择 {selectedServers.length} 项</span>
<Button variant="outline" size="sm" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]">
<Terminal className="h-4 w-4 mr-2" />
批量命令
</Button>
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300">
<Button variant="outline" size="sm" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]">
<RotateCw className="h-4 w-4 mr-2" />
重启
</Button>
<Button
variant="outline"
size="sm"
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
onClick={handleBatchDelete}
>
<Trash2 className="h-4 w-4 mr-2" />
批量删除
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent className="p-0">
<div className="border-t border-slate-800">
<div className="border-t border-[var(--dg-border)]">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
<TableHead className="w-12">
<Checkbox
checked={
selectedServers.length === filteredServers.length && filteredServers.length > 0
}
onCheckedChange={toggleSelectAll}
className="border-slate-600"
className="border-[var(--dg-border)]"
/>
</TableHead>
<TableHead className="text-slate-400">主机名</TableHead>
<TableHead className="text-slate-400">IP地址</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400">配置</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>
<TableHead className="text-[var(--dg-text-secondary)]">主机名</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">IP地址</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">资产类型</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">连接方式</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">状态</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">配置</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">使用率</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">区域</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">标签</TableHead>
<TableHead className="text-[var(--dg-text-secondary)] text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow className="border-slate-800">
<TableCell colSpan={9} className="text-center text-slate-500 py-12">
<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" />
加载中...
</TableCell>
</TableRow>
) : filteredServers.length === 0 ? (
<TableRow className="border-slate-800">
<TableCell colSpan={9} className="text-center text-slate-500 py-12">
<TableRow className="border-[var(--dg-border)]">
<TableCell colSpan={colSpanCount} className="text-center text-[var(--dg-text-muted)] py-12">
暂无服务器数据
</TableCell>
</TableRow>
@@ -403,43 +554,66 @@ export function ServerManager() {
const tags = getTags(server);
const region = getRegionName(server);
return (
<TableRow key={server.id} className="border-slate-800 hover:bg-slate-800/50">
<TableRow key={server.id} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
<TableCell>
<Checkbox
checked={selectedServers.includes(server.id)}
onCheckedChange={() => toggleSelect(server.id)}
className="border-slate-600"
className="border-[var(--dg-border)]"
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-slate-500" />
<Server className="h-4 w-4 text-[var(--dg-text-muted)]" />
<div>
<p className="font-medium text-slate-200">{server.hostname}</p>
<p className="text-xs text-slate-500">{server.os}</p>
<p className="font-medium text-[var(--dg-text-primary)]">{server.hostname}</p>
<p className="text-xs text-[var(--dg-text-muted)]">{server.os}</p>
</div>
</div>
</TableCell>
<TableCell>
<code className="text-sm text-slate-300 bg-slate-800 px-2 py-0.5 rounded">
<code className="text-sm text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)] px-2 py-0.5 rounded">
{server.ip}
</code>
</TableCell>
{/* 资产类型 */}
<TableCell>
<Badge
variant="outline"
className="text-xs border-[var(--dg-border)]"
style={{ color: getAssetTypeColor(server.asset_type) }}
>
{getAssetTypeLabel(server.asset_type)}
</Badge>
</TableCell>
{/* 连接方式 */}
<TableCell>
<span
className={cn(
'text-xs px-2 py-0.5 rounded',
server.connect_type === 'public'
? 'text-[var(--dg-cyan)] bg-[var(--dg-cyan)]/10'
: 'text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)]'
)}
>
{getConnectTypeLabel(server.connect_type)}
</span>
</TableCell>
<TableCell>{getStatusBadge(server.status)}</TableCell>
<TableCell>
<div className="text-sm text-slate-300">
<span className="text-slate-500">{server.cpu}核</span>
<span className="mx-1 text-slate-600">/</span>
<span className="text-slate-500">{server.memory}GB</span>
<span className="mx-1 text-slate-600">/</span>
<span className="text-slate-500">{server.disk}GB</span>
<div className="text-sm text-[var(--dg-text-secondary)]">
<span className="text-[var(--dg-text-muted)]">{server.cpu}核</span>
<span className="mx-1 text-[var(--dg-text-muted)] opacity-50">/</span>
<span className="text-[var(--dg-text-muted)]">{server.memory}GB</span>
<span className="mx-1 text-[var(--dg-text-muted)] opacity-50">/</span>
<span className="text-[var(--dg-text-muted)]">{server.disk}GB</span>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-8">CPU</span>
<div className="w-16 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<span className="text-xs text-[var(--dg-text-muted)] w-8">CPU</span>
<div className="w-16 h-1.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full',
@@ -448,11 +622,11 @@ export function ServerManager() {
style={{ width: `${cpuUsage}%` }}
/>
</div>
<span className="text-xs text-slate-400">{cpuUsage.toFixed(1)}%</span>
<span className="text-xs text-[var(--dg-text-secondary)]">{cpuUsage.toFixed(1)}%</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-8">内存</span>
<div className="w-16 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<span className="text-xs text-[var(--dg-text-muted)] w-8">内存</span>
<div className="w-16 h-1.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full',
@@ -461,12 +635,12 @@ export function ServerManager() {
style={{ width: `${memoryUsage}%` }}
/>
</div>
<span className="text-xs text-slate-400">{memoryUsage.toFixed(1)}%</span>
<span className="text-xs text-[var(--dg-text-secondary)]">{memoryUsage.toFixed(1)}%</span>
</div>
</div>
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{region || '-'}</span>
<span className="text-sm text-[var(--dg-text-secondary)]">{region || '-'}</span>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
@@ -474,13 +648,13 @@ export function ServerManager() {
<Badge
key={tag}
variant="outline"
className="text-xs border-slate-700 text-slate-400 bg-slate-800/50"
className="text-xs border-[var(--dg-border)] text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)]/50"
>
{tag}
</Badge>
))}
{tags.length > 3 && (
<Badge variant="outline" className="text-xs border-slate-700 text-slate-400">
<Badge variant="outline" className="text-xs border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
+{tags.length - 3}
</Badge>
)}
@@ -488,37 +662,40 @@ export function ServerManager() {
</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-slate-400 hover:text-blue-400">
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]">
<Terminal className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 bg-slate-800 border-slate-700">
<DropdownMenuLabel className="text-slate-300">操作</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<DropdownMenuContent align="end" className="w-48 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<DropdownMenuLabel className="text-[var(--dg-text-secondary)]">操作</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
<DropdownMenuItem className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer">
<Eye className="h-4 w-4 mr-2" />
查看详情
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<DropdownMenuItem
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
onClick={() => openEditForm(server)}
>
<Edit className="h-4 w-4 mr-2" />
编辑
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<DropdownMenuItem className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer">
<Terminal className="h-4 w-4 mr-2" />
WebShell
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
<DropdownMenuItem className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer">
<RotateCw className="h-4 w-4 mr-2" />
重启
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-400 focus:bg-slate-700 focus:text-red-400"
className="text-red-400 focus:bg-[var(--dg-bg-hover)] focus:text-red-400 cursor-pointer"
onClick={() => handleDelete(server.id)}
>
<Trash2 className="h-4 w-4 mr-2" />
@@ -538,6 +715,246 @@ export function ServerManager() {
</CardContent>
</Card>
{/* 创建/编辑服务器 Dialog */}
<Dialog open={formOpen} onOpenChange={setFormOpen}>
<DialogContent className="max-w-2xl bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<Server className="h-5 w-5 text-[var(--dg-blue)]" />
{editingId ? '编辑服务器' : '添加服务器'}
</DialogTitle>
<DialogDescription className="text-[var(--dg-text-muted)]">
{editingId ? '修改服务器资产信息' : '录入新的服务器资产到 CMDB'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* 基础信息 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">
主机名 <span className="text-red-400">*</span>
</Label>
<Input
placeholder="web-server-01"
value={formData.hostname}
onChange={(e) => setFormData({ ...formData, hostname: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">
IP地址 <span className="text-red-400">*</span>
</Label>
<Input
placeholder="10.0.0.1"
value={formData.ip}
onChange={(e) => setFormData({ ...formData, ip: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
</div>
{/* CMDB 资产字段 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">资产类型</Label>
<Select
value={formData.asset_type}
onValueChange={(v) => setFormData({ ...formData, asset_type: v })}
>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue placeholder="选择资产类型" />
</SelectTrigger>
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
{ASSET_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<span style={{ color: opt.color }} className="mr-1">●</span>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">连接方式</Label>
<RadioGroup
value={formData.connect_type}
onValueChange={(v) => setFormData({ ...formData, connect_type: v })}
className="flex items-center gap-6 h-9"
>
<div className="flex items-center gap-2">
<RadioGroupItem value="public" id="connect-public" />
<Label htmlFor="connect-public" className="text-[var(--dg-text-secondary)] cursor-pointer font-normal">
公网
</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="private" id="connect-private" />
<Label htmlFor="connect-private" className="text-[var(--dg-text-secondary)] cursor-pointer font-normal">
内网
</Label>
</div>
</RadioGroup>
</div>
</div>
{/* 云主机扩展字段 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">弹性IP地址</Label>
<Input
placeholder="如 203.0.113.10"
value={formData.floatingip}
onChange={(e) => setFormData({ ...formData, floatingip: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">实例ID</Label>
<Input
placeholder="如 i-bp1abc123def"
value={formData.instance_id}
onChange={(e) => setFormData({ ...formData, instance_id: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">到期时间</Label>
<Input
type="datetime-local"
value={formData.expire_time}
onChange={(e) => setFormData({ ...formData, expire_time: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">所在区域</Label>
<Input
placeholder="如 华东1"
value={formData.region}
onChange={(e) => setFormData({ ...formData, region: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
</div>
{/* 更多详情 - 可折叠区域 */}
<Collapsible open={detailsOpen} onOpenChange={setDetailsOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="w-full justify-between text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]"
>
<span className="flex items-center gap-2">
<Server className="h-4 w-4" />
更多详情(OS版本 / CPU / 内存 / 磁盘 / 状态)
</span>
<ChevronDown className={cn('h-4 w-4 transition-transform', detailsOpen && 'rotate-180')} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">操作系统</Label>
<Input
placeholder="如 CentOS 7.9"
value={formData.os}
onChange={(e) => setFormData({ ...formData, os: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">运行状态</Label>
<Select
value={formData.status}
onValueChange={(v) => setFormData({ ...formData, status: v })}
>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<SelectItem value="running">运行中</SelectItem>
<SelectItem value="stopped">已停止</SelectItem>
<SelectItem value="maintenance">维护中</SelectItem>
<SelectItem value="offline">离线</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">CPU核数</Label>
<Input
type="number"
placeholder="4"
value={formData.cpu}
onChange={(e) => setFormData({ ...formData, cpu: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">内存 (GB)</Label>
<Input
type="number"
placeholder="8"
value={formData.memory}
onChange={(e) => setFormData({ ...formData, memory: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">磁盘 (GB)</Label>
<Input
type="number"
placeholder="100"
value={formData.disk}
onChange={(e) => setFormData({ ...formData, disk: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">备注</Label>
<Textarea
placeholder="补充说明..."
value={formData.remark}
onChange={(e) => setFormData({ ...formData, remark: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] min-h-[60px]"
/>
</div>
</CollapsibleContent>
</Collapsible>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setFormOpen(false)}
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)]"
>
取消
</Button>
<Button
onClick={handleSave}
disabled={!formData.hostname || !formData.ip || formSaving}
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
>
{formSaving ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Save className="h-4 w-4 mr-2" />
)}
{editingId ? '保存修改' : '创建'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Import Dialogs */}
<CloudServerImport
open={cloudImportOpen}
+236 -170
View File
@@ -2,9 +2,6 @@ import { useState, useRef, useEffect } from 'react';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Select,
SelectContent,
@@ -26,18 +23,12 @@ import {
Plus,
X,
Server,
Play,
Pause,
Square,
Download,
Upload,
Copy,
Settings,
Monitor,
RefreshCw,
Maximize2,
Minimize2,
} from 'lucide-react';
import { Terminal as XTerm } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import 'xterm/css/xterm.css';
import { serverApi } from '@/services/api';
interface TerminalSession {
@@ -46,7 +37,13 @@ interface TerminalSession {
serverName: string;
serverIp: string;
connected: boolean;
logs: string[];
}
interface TermEntry {
term: XTerm;
ws: WebSocket;
fit: FitAddon;
opened: boolean;
}
export function WebShell() {
@@ -54,9 +51,13 @@ export function WebShell() {
const [sessions, setSessions] = useState<TerminalSession[]>([]);
const [activeSession, setActiveSession] = useState<string | null>(null);
const [selectedServer, setSelectedServer] = useState<string>('');
const [command, setCommand] = useState('');
const [isFullscreen, setIsFullscreen] = useState(false);
const terminalRef = useRef<HTMLDivElement>(null);
const [dialogOpen, setDialogOpen] = useState(false);
// 每个会话的终端实例:sessionId -> { term, ws, fit, opened }
const termsRef = useRef<Map<string, TermEntry>>(new Map());
// 每个会话的容器 DOM
const containerRef = useRef<Map<string, HTMLDivElement>>(new Map());
const loadServers = async () => {
try {
@@ -72,6 +73,21 @@ export function WebShell() {
loadServers();
}, []);
// 卸载时关闭所有连接
useEffect(() => {
return () => {
termsRef.current.forEach(({ ws, term }) => {
try {
ws.close();
term.dispose();
} catch {
/* noop */
}
});
termsRef.current.clear();
};
}, []);
const runningServers = servers.filter((s) => s.status === 'running');
const createSession = () => {
@@ -79,26 +95,94 @@ export function WebShell() {
const server = servers.find((s) => s.id === selectedServer);
if (!server) return;
const sessionId = `session-${Date.now()}`;
const cols = 80;
const rows = 24;
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)',
},
});
const fit = new FitAddon();
term.loadAddon(fit);
const proto = typeof location !== 'undefined' && location.protocol === 'https:' ? 'wss' : 'ws';
const wsUrl = `${proto}://${location.host}/ws/terminal?id=${server.id}&cols=${cols}&rows=${rows}`;
const ws = new WebSocket(wsUrl);
// 终端输入 -> WebSocket
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ status: 0, data }));
}
});
// WebSocket 输出 -> 终端
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.status === 0 && typeof msg.data === 'string') {
term.write(msg.data);
}
} catch {
// 非 JSON,直接写入原始数据
term.write(typeof event.data === 'string' ? event.data : '');
}
};
ws.onopen = () => {
term.write(`\x1b[32m正在连接 ${server.hostname} (${server.ip})...\x1b[0m\r\n`);
};
ws.onclose = () => {
term.write('\r\n\x1b[31m[连接已关闭]\x1b[0m\r\n');
setSessions((prev) =>
prev.map((s) => (s.id === sessionId ? { ...s, connected: false } : s))
);
};
ws.onerror = () => {
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
};
termsRef.current.set(sessionId, { term, ws, fit, opened: false });
const newSession: TerminalSession = {
id: `session-${Date.now()}`,
id: sessionId,
serverId: server.id,
serverName: server.hostname,
serverIp: server.ip,
connected: true,
logs: [
`Connecting to ${server.hostname} (${server.ip})...`,
`Connection established.`,
`Last login: ${new Date().toLocaleString()} from 192.168.1.100`,
`[root@${server.hostname} ~]# `,
],
};
setSessions([...sessions, newSession]);
setActiveSession(newSession.id);
setSessions((prev) => [...prev, newSession]);
setActiveSession(sessionId);
setSelectedServer('');
setDialogOpen(false);
};
const closeSession = (sessionId: string) => {
const entry = termsRef.current.get(sessionId);
if (entry) {
try {
if (entry.ws.readyState === WebSocket.OPEN || entry.ws.readyState === WebSocket.CONNECTING) {
entry.ws.close();
}
entry.term.dispose();
} catch {
/* noop */
}
termsRef.current.delete(sessionId);
}
containerRef.current.delete(sessionId);
const newSessions = sessions.filter((s) => s.id !== sessionId);
setSessions(newSessions);
if (activeSession === sessionId) {
@@ -106,93 +190,127 @@ export function WebShell() {
}
};
const executeCommand = () => {
if (!command.trim() || !activeSession) return;
const session = sessions.find((s) => s.id === activeSession);
if (!session) return;
const newLogs = [...session.logs, command];
// 模拟命令执行结果
const mockResponses: Record<string, string> = {
ls: 'total 32\ndrwxr-xr-x 5 root root 4096 Mar 30 10:00 .\ndrwxr-xr-x 18 root root 4096 Mar 30 09:00 ..\n-rw-r--r-- 1 root root 220 Mar 30 08:00 .bash_logout\n-rw-r--r-- 1 root root 3771 Mar 30 08:00 .bashrc\n-rw-r--r-- 1 root root 807 Mar 30 08:00 .profile\ndrwxr-xr-x 2 root root 4096 Mar 30 09:00 scripts\ndrwxr-xr-x 3 root root 4096 Mar 30 09:00 logs',
pwd: '/root',
'uname -a': 'Linux web-server-01 5.4.210-1.el7.elrepo.x86_64 #1 SMP x86_64 x86_64 x86_64 GNU/Linux',
'df -h': 'Filesystem Size Used Avail Use% Mounted on\n/dev/sda1 500G 180G 320G 36% /\n/dev/sdb1 1.0T 450G 550G 45% /data',
'free -m': ' total used free shared buff/cache available\nMem: 65536 42000 8000 1024 15536 21000\nSwap: 8192 0 8192',
'ps aux': 'USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND\nroot 1 0.0 0.1 18532 3200 ? Ss Mar30 0:03 /sbin/init\nroot 512 0.1 0.5 45200 12800 ? Ss Mar30 2:15 nginx: master process\nroot 1024 0.5 2.0 256000 51200 ? Sl Mar30 15:30 java -jar app.jar',
top: 'top - 10:30:00 up 30 days, 12:00, 1 user, load average: 0.52, 0.58, 0.59\nTasks: 156 total, 1 running, 155 sleeping, 0 stopped, 0 zombie\n%Cpu(s): 25.0 us, 5.0 sy, 0.0 ni, 68.0 id, 0.0 wa, 0.0 hi, 2.0 si, 0.0 st',
};
const cmd = command.trim().split(' ')[0];
const response = mockResponses[command.trim()] || mockResponses[cmd] || `${cmd}: command executed successfully`;
newLogs.push(response);
newLogs.push(`[${session.serverName} ~]# `);
setSessions(
sessions.map((s) => (s.id === activeSession ? { ...s, logs: newLogs } : s))
);
setCommand('');
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
executeCommand();
}
};
// 自动滚动到底部
// 窗口 resize 时适配当前激活的终端
useEffect(() => {
if (terminalRef.current) {
terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
const handleResize = () => {
if (!activeSession) return;
const entry = termsRef.current.get(activeSession);
if (!entry || !entry.opened) return;
try {
entry.fit.fit();
const { cols, rows } = entry.term;
entry.term.resize(cols, rows);
if (entry.ws.readyState === WebSocket.OPEN) {
entry.ws.send(JSON.stringify({ status: 1, cols, rows }));
}
} catch {
/* noop */
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [activeSession, isFullscreen]);
// 激活会话切换后重新 fit
useEffect(() => {
if (!activeSession) return;
const entry = termsRef.current.get(activeSession);
if (entry && entry.opened) {
requestAnimationFrame(() => {
try {
entry.fit.fit();
entry.term.focus();
} catch {
/* noop */
}
});
}
}, [sessions, activeSession]);
}, [activeSession, isFullscreen]);
// 快捷命令:直接发送到当前激活的 WebSocket
const sendCommand = (cmd: string) => {
if (!activeSession) return;
const entry = termsRef.current.get(activeSession);
if (entry && entry.ws.readyState === WebSocket.OPEN) {
entry.ws.send(JSON.stringify({ status: 0, data: cmd + '\n' }));
entry.term.focus();
}
};
// 注册容器并首次挂载终端
const registerContainer = (sessionId: string, el: HTMLDivElement | null) => {
if (el) {
containerRef.current.set(sessionId, el);
const entry = termsRef.current.get(sessionId);
if (entry && !entry.opened) {
try {
entry.term.open(el);
entry.opened = true;
requestAnimationFrame(() => {
try {
entry.fit.fit();
entry.term.focus();
} catch {
/* noop */
}
});
} catch (e) {
console.error('打开终端失败:', e);
}
}
} else {
containerRef.current.delete(sessionId);
}
};
const activeSessionData = sessions.find((s) => s.id === activeSession);
const terminalHeight = isFullscreen ? 'calc(100vh - 140px)' : '500px';
return (
<div className={cn('space-y-4', isFullscreen && 'fixed inset-0 z-50 bg-slate-950 p-4')}>
<div className={cn('space-y-4', isFullscreen && 'fixed inset-0 z-50 p-4')} style={isFullscreen ? { background: 'var(--dg-bg-primary)' } : undefined}>
{/* Page Header */}
{!isFullscreen && (
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">WebShell</h1>
<p className="text-sm text-slate-500 mt-1">远程服务器终端管理</p>
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">WebShell</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">远程服务器终端管理</p>
</div>
<div className="flex items-center gap-2">
<Dialog>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Button className="bg-[var(--dg-blue)] hover:opacity-90 text-white">
<Plus className="h-4 w-4 mr-2" />
新建连接
</Button>
</DialogTrigger>
<DialogContent className="bg-slate-900 border-slate-800">
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DialogHeader>
<DialogTitle className="text-slate-100">新建终端连接</DialogTitle>
<DialogDescription className="text-slate-500">
<DialogTitle className="text-[var(--dg-text-primary)]">新建终端连接</DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]">
选择要连接的服务器
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300">选择服务器</label>
<label className="text-sm font-medium text-[var(--dg-text-secondary)]">选择服务器</label>
<Select value={selectedServer} onValueChange={setSelectedServer}>
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-200">
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue placeholder="选择服务器" />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
{runningServers.map((server) => (
<SelectItem key={server.id} value={server.id}>
<div className="flex items-center gap-2">
<Server className="h-4 w-4" />
<span>{server.hostname}</span>
<span className="text-slate-500">({server.ip})</span>
</div>
</SelectItem>
))}
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
{runningServers.length === 0 ? (
<SelectItem value="__none" disabled>暂无运行中的服务器</SelectItem>
) : (
runningServers.map((server) => (
<SelectItem key={server.id} value={server.id}>
<div className="flex items-center gap-2">
<Server className="h-4 w-4" />
<span>{server.hostname}</span>
<span className="text-[var(--dg-text-muted)]">({server.ip})</span>
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
@@ -200,8 +318,8 @@ export function WebShell() {
<DialogFooter>
<Button
onClick={createSession}
disabled={!selectedServer}
className="bg-blue-600 hover:bg-blue-700 text-white"
disabled={!selectedServer || selectedServer === '__none'}
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
>
<Terminal className="h-4 w-4 mr-2" />
连接
@@ -214,8 +332,8 @@ export function WebShell() {
)}
{/* Terminal Container */}
<Card className="bg-slate-900 border-slate-800 flex-1">
<CardHeader className="pb-2 border-b border-slate-800">
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] flex-1">
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{sessions.length > 0 ? (
@@ -227,8 +345,8 @@ export function WebShell() {
className={cn(
'flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors',
activeSession === session.id
? 'bg-slate-800 text-slate-100'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
? 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-primary)]'
: 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'
)}
>
<div
@@ -238,43 +356,32 @@ export function WebShell() {
)}
/>
<span className="max-w-[120px] truncate">{session.serverName}</span>
<button
<span
onClick={(e) => {
e.stopPropagation();
closeSession(session.id);
}}
className="ml-1 p-0.5 rounded hover:bg-slate-700"
className="ml-1 p-0.5 rounded hover:bg-[var(--dg-bg-hover)] cursor-pointer"
>
<X className="h-3 w-3" />
</button>
</span>
</button>
))}
</div>
) : (
<span className="text-sm text-slate-500">暂无连接</span>
<span className="text-sm text-[var(--dg-text-secondary)]">暂无连接</span>
)}
</div>
<div className="flex items-center gap-1">
{activeSessionData && (
<>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Copy className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Download className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Upload className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Settings className="h-4 w-4" />
</Button>
</>
<span className="text-xs text-[var(--dg-text-muted)] mr-2">
{activeSessionData.serverIp}
</span>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
onClick={() => setIsFullscreen(!isFullscreen)}
>
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
@@ -283,65 +390,27 @@ export function WebShell() {
</div>
</CardHeader>
<CardContent className="p-0">
{activeSessionData ? (
<div className="flex flex-col" style={{ height: isFullscreen ? 'calc(100vh - 140px)' : '500px' }}>
{/* Terminal Output */}
<div
ref={terminalRef}
className="flex-1 p-4 font-mono text-sm bg-slate-950 overflow-auto"
>
{activeSessionData.logs.map((log, index) => (
<div key={index} className="whitespace-pre-wrap text-slate-300 leading-relaxed">
{log.startsWith('[') && log.endsWith(']# ') ? (
<span>
<span className="text-emerald-400">{log.split('#')[0]}#</span>
<span className="text-slate-100">{log.split('#')[1]}</span>
</span>
) : log.startsWith('total') || log.startsWith('drwx') || log.startsWith('-rw') ? (
<span className="text-slate-400">{log}</span>
) : log.startsWith('Filesystem') || log.startsWith('/dev') ? (
<span className="text-slate-400">{log}</span>
) : log.startsWith('USER') || log.startsWith('root') ? (
<span className="text-slate-400">{log}</span>
) : log.startsWith('Connecting') || log.startsWith('Connection') || log.startsWith('Last login') ? (
<span className="text-slate-500">{log}</span>
) : (
<span className="text-slate-300">{log}</span>
)}
</div>
))}
</div>
{/* Command Input */}
<div className="flex items-center gap-2 p-3 border-t border-slate-800 bg-slate-900">
<span className="text-emerald-400 font-mono text-sm whitespace-nowrap">
[{activeSessionData.serverName} ~]#
</span>
<Input
value={command}
onChange={(e) => setCommand(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入命令..."
className="flex-1 bg-transparent border-0 text-slate-200 font-mono text-sm placeholder:text-slate-600 focus-visible:ring-0 focus-visible:ring-offset-0"
autoFocus
{sessions.length > 0 ? (
<div className="relative bg-[#0a0e14]" style={{ height: terminalHeight }}>
{sessions.map((session) => (
<div
key={session.id}
ref={(el) => registerContainer(session.id, el)}
className={cn(
'absolute inset-0 p-1',
activeSession === session.id ? 'block' : 'hidden'
)}
/>
<Button
size="sm"
onClick={executeCommand}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
<Play className="h-4 w-4" />
</Button>
</div>
))}
</div>
) : (
<div
className="flex flex-col items-center justify-center text-slate-500"
style={{ height: isFullscreen ? 'calc(100vh - 140px)' : '500px' }}
className="flex flex-col items-center justify-center text-[var(--dg-text-secondary)]"
style={{ height: terminalHeight }}
>
<Terminal className="h-16 w-16 mb-4 opacity-50" />
<p className="text-lg font-medium">暂无活动连接</p>
<p className="text-sm mt-1">点击"新建连接"开始远程会话</p>
<p className="text-sm mt-1">点击“新建连接”开始远程会话</p>
</div>
)}
</CardContent>
@@ -349,9 +418,9 @@ export function WebShell() {
{/* Quick Commands */}
{activeSessionData && (
<Card className="bg-slate-900 border-slate-800">
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-slate-400">快捷命令</CardTitle>
<CardTitle className="text-sm text-[var(--dg-text-secondary)]">快捷命令</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
@@ -361,11 +430,8 @@ export function WebShell() {
key={cmd}
variant="outline"
size="sm"
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
onClick={() => {
setCommand(cmd);
setTimeout(executeCommand, 100);
}}
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
onClick={() => sendCommand(cmd)}
>
{cmd}
</Button>