Files
aiops/frontend/src/components/task/RemoteExecPage.tsx
T
LoganandClaude 76ec9387f0 feat(rdp): 新增 RDP 远程桌面/SMB/账单同步能力 + 清理编译产物
主要变更:
- 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>
2026-07-04 01:50:36 +08:00

262 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 远程执行页 — 关联主机后批量执行命令 / 上传文件(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>
);
}