主要变更: - RDP 远程桌面:新增 backend/pkg/rdp(RDP 引擎 + credssp/display/rle/trace)、 backend/controller/rdp*(连接/凭证/文件/会话/实时)、frontend RDP 组件与 API - Windows SMB 文件传输:backend/pkg/winsmb - 云账单同步:backend/pkg/billingsync + billing_sync/aliyun_billing,前端账单详情 - Agent CLI Kubernetes 工具:kubernetes_action/get、pod_execute/logs - 服务器导入助手:server_import_helpers、ImportCredentialSettings - 国际化与组件重构(cloud/deploy/monitor/server/system 等多模块) - 清理编译产物二进制:移除 backend/server、backend/agent/bin/aiops-agent-* - .gitignore:新增 backend/agent/ 与 .cache/(避免 334MB go-build 缓存误入库) Co-Authored-By: Claude <noreply@anthropic.com>
262 lines
13 KiB
TypeScript
262 lines
13 KiB
TypeScript
// 远程执行页 — 关联主机后批量执行命令 / 上传文件(Go-native SSH) // Logan.Li
|
||
// 对接后端 /remote-exec/*(controller.RemoteExecHandler + pkg/remoteexec),替代已移除的 Ansible。
|
||
import { useState, useEffect } from 'react';
|
||
import {
|
||
Terminal, Upload, RefreshCw, Loader2, Server as ServerIcon, CheckCircle2, XCircle, FileUp,
|
||
} from 'lucide-react';
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Checkbox } from '@/components/ui/checkbox';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { toast } from 'sonner';
|
||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||
import { serverApi, remoteExecApi, type RemoteExecResult, type RemoteUploadResult } from '@/api';
|
||
import { PageShell } from '@/components/layout/PageShell';
|
||
import { PageEmpty } from '@/components/layout/PageEmpty';
|
||
import type { JsonData } from '@/types/common';
|
||
import i18n from '@/i18n';
|
||
|
||
interface RemoteServer {
|
||
id: number; hostname: string; ip: string; username: string; port: number;
|
||
auth_type: string; status: string; has_auth?: boolean;
|
||
}
|
||
|
||
export function RemoteExecPage() {
|
||
const _confirm = useConfirm();
|
||
const [servers, setServers] = useState<RemoteServer[]>([]);
|
||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||
const [keyword, setKeyword] = useState('');
|
||
const [loading, setLoading] = useState(true);
|
||
const [tab, setTab] = useState<'exec' | 'upload'>('exec');
|
||
const [command, setCommand] = useState('');
|
||
const [timeoutSec, setTimeoutSec] = useState(60);
|
||
const [file, setFile] = useState<File | null>(null);
|
||
const [remotePath, setRemotePath] = useState('/tmp/');
|
||
const [execResults, setExecResults] = useState<RemoteExecResult[]>([]);
|
||
const [uploadResults, setUploadResults] = useState<RemoteUploadResult[]>([]);
|
||
const [running, setRunning] = useState(false);
|
||
|
||
const load = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await serverApi.getServers({ page: 1, page_size: 500, keyword });
|
||
setServers(((res as JsonData)?.list as RemoteServer[]) || []);
|
||
} catch { toast.error(i18n.t('task.remoteexecpaget.s68755a4d')); }
|
||
finally { setLoading(false); }
|
||
};
|
||
useEffect(() => { load(); }, []);
|
||
|
||
const selIds = [...selected];
|
||
const selCount = selIds.length;
|
||
const toggle = (id: number) => {
|
||
const next = new Set(selected);
|
||
if (next.has(id)) {
|
||
next.delete(id);
|
||
} else {
|
||
next.add(id);
|
||
}
|
||
setSelected(next);
|
||
};
|
||
const toggleAll = () => {
|
||
setSelected(selected.size === servers.length ? new Set() : new Set(servers.map(s => s.id)));
|
||
};
|
||
|
||
const doExec = async () => {
|
||
if (!command.trim()) { toast.error(i18n.t('agent.agentmonitortsx.s9358aada')); return; }
|
||
if (selCount === 0) { toast.error(i18n.t('task.remoteexecpaget.se62f41c4')); return; }
|
||
const ok = await _confirm({
|
||
title: i18n.t('task.remoteexecpaget.s110b7142'),
|
||
description: `将在 ${selCount} 台主机上执行该命令,请确认命令安全:\n\n${command}`,
|
||
confirmText: i18n.t('deploy.label.execute'), variant: 'destructive',
|
||
});
|
||
if (!ok) return;
|
||
setRunning(true); setExecResults([]);
|
||
try {
|
||
const res = await remoteExecApi.execute({ server_ids: selIds, command, timeout: timeoutSec });
|
||
setExecResults(res || []);
|
||
const succ = (res || []).filter(r => r.success).length;
|
||
toast.success(`执行完成:${succ}/${selCount} 成功`);
|
||
} catch { toast.error(i18n.t('automation.systemTaskFailed')); }
|
||
finally { setRunning(false); }
|
||
};
|
||
|
||
const doUpload = async () => {
|
||
if (!file) { toast.error(i18n.t('task.remoteexecpaget.s68ea13ee')); return; }
|
||
if (!remotePath.trim()) { toast.error(i18n.t('task.remoteexecpaget.sb68231b8')); return; }
|
||
if (selCount === 0) { toast.error(i18n.t('task.remoteexecpaget.se62f41c4')); return; }
|
||
const dst = remotePath.trim().endsWith('/') ? remotePath.trim() + file.name : remotePath.trim();
|
||
setRunning(true); setUploadResults([]);
|
||
try {
|
||
const res = await remoteExecApi.uploadFile({ server_ids: selIds, remote_path: dst, file });
|
||
setUploadResults(res || []);
|
||
const succ = (res || []).filter(r => r.success).length;
|
||
toast.success(`上传完成:${succ}/${selCount} 成功`);
|
||
} catch { toast.error(i18n.t('common.uploadFailed')); }
|
||
finally { setRunning(false); }
|
||
};
|
||
|
||
return (
|
||
<PageShell
|
||
variant="workspace"
|
||
title={i18n.t('automation.remoteExec')}
|
||
subtitle={i18n.t('task.remoteexecpaget.s123ad5f6')}
|
||
icon={<Terminal className="h-5 w-5" />}
|
||
contentClassName="flex-1 min-h-0"
|
||
loading={loading}
|
||
actions={(
|
||
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={load}>
|
||
<RefreshCw className="w-3.5 h-3.5 mr-1" />
|
||
刷新
|
||
</Button>
|
||
)}
|
||
>
|
||
<div className="grid grid-cols-12 gap-4 flex-1 min-h-0">
|
||
{/* 左侧:主机选择 */}
|
||
<Card className="col-span-5 dg-card">
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-sm flex items-center justify-between text-[var(--dg-text-primary)]">
|
||
<span className="flex items-center gap-2"><ServerIcon className="h-4 w-4 dg-text-blue" /> {i18n.t('automation.remoteSelectHost')} </span>
|
||
<Input
|
||
placeholder={i18n.t('task.remoteexecpaget.s253acd21')}
|
||
value={keyword}
|
||
onChange={(e) => setKeyword(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && load()}
|
||
className="h-7 w-40 text-xs dg-input"
|
||
/>
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="max-h-[520px] overflow-y-auto space-y-1">
|
||
{servers.length === 0 ? (
|
||
<PageEmpty
|
||
title={i18n.t('dashboard.serversStatus.empty')}
|
||
description={i18n.t('task.remoteexecpaget.s0afbf1fc')}
|
||
className="py-10"
|
||
/>
|
||
) : (
|
||
<>
|
||
<div className="flex items-center gap-2 px-2 py-1.5 text-xs dg-text-secondary border-b border-[var(--dg-border)]">
|
||
<Checkbox checked={selected.size === servers.length && servers.length > 0} onCheckedChange={toggleAll} />
|
||
全选({servers.length} 台)
|
||
{selCount > 0 && <Badge className="ml-auto bg-blue-500/20 dg-text-blue">已选 {selCount}</Badge>}
|
||
</div>
|
||
{servers.map(s => (
|
||
<label key={s.id} className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-[var(--dg-bg-tertiary)] cursor-pointer">
|
||
<Checkbox checked={selected.has(s.id)} onCheckedChange={() => toggle(s.id)} />
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-xs font-medium text-[var(--dg-text-primary)] truncate">{s.hostname}</div>
|
||
<div className="text-[10px] text-[var(--dg-text-muted)] font-mono">{s.ip} · {s.username || 'root'}</div>
|
||
</div>
|
||
{s.has_auth === false
|
||
? <Badge className="bg-amber-500/20 dg-text-yellow text-[10px]"> {i18n.t('task.remoteexecpaget.s152f5300')} </Badge>
|
||
: <Badge className="bg-emerald-500/20 dg-text-green text-[10px]">{s.auth_type === 'key' ? i18n.t('system.credentialKey') : i18n.t('system.passwordLabel')}</Badge>}
|
||
</label>
|
||
))}
|
||
</>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 右侧:操作 + 结果(内部子 Tab 保留按钮切换) // Logan.Li */}
|
||
<div className="col-span-7 space-y-4">
|
||
<div className="flex gap-2">
|
||
<Button
|
||
variant={tab === 'exec' ? 'default' : 'outline'}
|
||
size="sm"
|
||
onClick={() => setTab('exec')}
|
||
className={tab === 'exec' ? 'dg-btn-primary h-8 text-xs gap-1' : 'dg-btn-secondary h-8 text-xs gap-1'}
|
||
>
|
||
<Terminal className="w-3.5 h-3.5" />执行命令
|
||
</Button>
|
||
<Button
|
||
variant={tab === 'upload' ? 'default' : 'outline'}
|
||
size="sm"
|
||
onClick={() => setTab('upload')}
|
||
className={tab === 'upload' ? 'dg-btn-primary h-8 text-xs gap-1' : 'dg-btn-secondary h-8 text-xs gap-1'}
|
||
>
|
||
<Upload className="w-3.5 h-3.5" />上传文件
|
||
</Button>
|
||
</div>
|
||
|
||
{tab === 'exec' ? (
|
||
<Card className="dg-card">
|
||
<CardHeader className="pb-3"><CardTitle className="text-sm text-[var(--dg-text-primary)]"> {i18n.t('task.remoteexecpaget.seb6e67f1')} </CardTitle></CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<textarea
|
||
value={command}
|
||
onChange={(e) => setCommand(e.target.value)}
|
||
rows={5}
|
||
placeholder={i18n.t('task.remoteexecpaget.s76628808')}
|
||
className="w-full bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md px-3 py-2 text-xs font-mono text-[var(--dg-text-primary)] resize-none"
|
||
/>
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-xs dg-text-secondary"> {i18n.t('automation.remoteTimeout')} </span>
|
||
<Input type="number" min={1} value={timeoutSec} onChange={(e) => setTimeoutSec(Number(e.target.value) || 60)}
|
||
className="h-8 w-24 text-xs dg-input" />
|
||
<Button onClick={doExec} disabled={running} className="ml-auto dg-btn-primary h-8 text-xs">
|
||
{running ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Terminal className="w-3.5 h-3.5 mr-1" />}
|
||
执行({selCount} 台)
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
<Card className="dg-card">
|
||
<CardHeader className="pb-3"><CardTitle className="text-sm text-[var(--dg-text-primary)]"> {i18n.t('task.remoteexecpaget.s30048caa')} </CardTitle></CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<div className="space-y-1.5">
|
||
<span className="text-xs dg-text-secondary"> {i18n.t('task.remoteexecpaget.sb7e21a1e')} </span>
|
||
<Input type="file" onChange={(e) => setFile(e.target.files?.[0] || null)}
|
||
className="h-8 text-xs dg-input" />
|
||
{file && <span className="text-[10px] text-[var(--dg-text-muted)]">{file.name} ({(file.size / 1024).toFixed(1)} KB)</span>}
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<span className="text-xs dg-text-secondary"> {i18n.t('task.remoteexecpaget.sb47f0323')} </span>
|
||
<Input value={remotePath} onChange={(e) => setRemotePath(e.target.value)} placeholder={i18n.t('task.remoteexecpaget.sd4b44a36')}
|
||
className="h-8 text-xs font-mono dg-input" />
|
||
</div>
|
||
<Button onClick={doUpload} disabled={running} className="dg-btn-primary h-8 text-xs">
|
||
{running ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <FileUp className="w-3.5 h-3.5 mr-1" />}
|
||
上传({selCount} 台)
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{(execResults.length > 0 || uploadResults.length > 0) && (
|
||
<Card className="dg-card">
|
||
<CardHeader className="pb-3"><CardTitle className="text-sm text-[var(--dg-text-primary)]"> {i18n.t('automation.remoteExecResult')} </CardTitle></CardHeader>
|
||
<CardContent className="space-y-2 max-h-[300px] overflow-y-auto">
|
||
{execResults.map((r, i) => (
|
||
<div key={i} className="p-2 rounded border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
{r.success ? <CheckCircle2 className="w-3.5 h-3.5 dg-text-green" /> : <XCircle className="w-3.5 h-3.5 dg-text-red" />}
|
||
<span className="text-xs font-mono text-[var(--dg-text-primary)]">{r.host}</span>
|
||
<Badge className={r.success ? 'bg-emerald-500/20 dg-text-green text-[10px]' : 'bg-red-500/20 dg-text-red text-[10px]'}>
|
||
exit={r.exit_code} · {r.duration}ms
|
||
</Badge>
|
||
</div>
|
||
<pre className="text-[10px] font-mono dg-text-secondary whitespace-pre-wrap break-all max-h-32 overflow-y-auto">
|
||
{r.error || r.output || i18n.t('task.remoteexecpaget.s1a63831c')}
|
||
</pre>
|
||
</div>
|
||
))}
|
||
{uploadResults.map((r, i) => (
|
||
<div key={i} className="p-2 rounded border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||
<div className="flex items-center gap-2">
|
||
{r.success ? <CheckCircle2 className="w-3.5 h-3.5 dg-text-green" /> : <XCircle className="w-3.5 h-3.5 dg-text-red" />}
|
||
<span className="text-xs font-mono text-[var(--dg-text-primary)]">{r.host}</span>
|
||
<span className="text-[10px] text-[var(--dg-text-muted)] truncate">{r.remote_path}</span>
|
||
{r.error && <span className="text-[10px] dg-text-red ml-auto">{r.error}</span>}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</PageShell>
|
||
);
|
||
}
|