mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
* Add Helm and GitOps deployment setup * fix: build better-sqlite3 from source in Docker (#1267) * fix: preserve runtime SSL settings (#1268) * fix: support forwarding from the memory SSH agent (#1269) * fix: support forwarding from the memory agent * style: format memory agent test * fix: prompt for encrypted SFTP key passphrases (#1270) * fix: prompt for SFTP key passphrases * style: format SSH key utility test * fix: include host context in automation notifications (#1271) * fix: include host context in automation notifications * style: format automation notification changes * fix: reserve sidebar height for host tags (#1272) * fix: keep host action rows stable at large font sizes (#1273) * fix: honor certificate setting during server probe (#1274) * fix: package standard Linux icon sizes (#1275) * fix: avoid duplicate Docker HTTPS listener (#1276) * Fix host status without metrics collection (#1277) * fix: allow eight-digit secure auth codes (#1263) Allow TOTP prompts to accept secure auth codes longer than six digits without blocking valid authentication attempts. Generated with Codebuff 🤖 Co-authored-by: Chetan <chetan.development@gmail.com> Co-authored-by: Codebuff <noreply@codebuff.com> * Harden Helm deployment defaults * Update Helm workflow action * Exclude Helm templates from Prettier * Fix browser RDP file drops (#1279) * Fix Proxmox guest credential usernames (#1280) * Add WSL local terminal option (#1281) * refactor: split the transfer engine into focused modules (#1282) * refactor: extract SFTP promisify helpers into sftp-promisify module * refactor: extract transfer timing and rate stats into transfer-stats module * refactor: extract transfer error classes and recovery checks into transfer-errors module * refactor: extract host/path utility helpers into transfer-host-utils module * refactor: extract SFTP directory tree helpers into transfer-sftp-dir module * refactor: extract segment copy job builder into transfer-segment-copy module * refactor: extract file scan and sample helpers into transfer-scan module * refactor: move throttled progress helper into transfer-stats module * style: format transfer modules * perf: optimize tmux monitor aggregation (#1283) * fix: reserve credential tag row height (#1284) * feat: edit AI provider model settings (#1285) * fix: clarify click-to-expand host setting (#1286) * fix: allow portable imports on remote databases (#1287) * fix: allow HTTPS to share the configured port (#1288) * fix: resolve synced jump hosts on the server (#1289) * fix: make terminal clipboard shortcuts layout independent (#1290) * fix: use compatible fetch dispatcher for Tailscale (#1291) * fix: add OIDC environment recovery override (#1292) * fix: coalesce rapid mobile terminal input (#1293) * fix: coalesce rapid mobile terminal input * fix: support clean xterm patch installs * fix: resolve synced remote desktop host IDs (#1295) * feat: make the SFTP file manager path bar editable (#1294) Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * feat: add passkey sign in to the login screen * fix: remove rounded corners from the host list search bar * fix: stop image storage settings text wrapping to one word per line * fix: prevent malformed websocket messages from crashing the server * chore: increment version * fix: remove gaps between host rows in the sidebar list Keep sub-pixel row measurements and stop wiping the size cache on hover. * fix: Failed to connect through jump hosts (#1180) https://github.com/Termix-SSH/Support/issues/1180 * feat: Progress bar for file downloads in the file manager (#1158) https://github.com/Termix-SSH/Support/issues/1158 * feat: Allow setting Silent OIDC Login via ENV var (#1174) https://github.com/Termix-SSH/Support/issues/1174 * feat: `IdentityFile` to limit the number of attempts by agents (#1165) https://github.com/Termix-SSH/Support/issues/1165 * feat: Credentials clone (#1159) https://github.com/Termix-SSH/Support/issues/1159 * chore: update release notes * docs: move helm setup guide to the docs site * fix: type errors in FilteredAgent agent identity handling * fix: remove stale better-sqlite3 prebuilds so the source build is used * fix: actually build better-sqlite3 from source so arm64 docker images work * fix: credential edit pencil in host editor and add clone action to credential list * fix: clear editingHost so the credential pencil actually opens the editor * chore: run format and lint * fix: folder drag and drop upload failing in the file manager * chore: sync Crowdin translations for 2.7.1 --------- Co-authored-by: alex-ctms <alex-ctms@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Chetan Kumar <74929596+ckloop@users.noreply.github.com> Co-authored-by: Chetan <chetan.development@gmail.com> Co-authored-by: Codebuff <noreply@codebuff.com> Co-authored-by: ZacharyZcR <payasonorahc@protonmail.com> Co-authored-by: dropafterfree <maxime.bonillo@gmail.com> Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com>
440 lines
15 KiB
TypeScript
440 lines
15 KiB
TypeScript
import { useRef, useState } from "react";
|
|
import type { HostData } from "@/types/index";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Server, RefreshCw, CheckSquare, Square, Download } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { Button } from "@/components/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from "@/components/dialog";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/select";
|
|
import {
|
|
discoverProxmoxGuestsStream,
|
|
bulkImportSSHHosts,
|
|
getSSHHosts,
|
|
} from "@/main-axios";
|
|
import type { SSHHostWithStatus } from "@/main-axios";
|
|
import type { ProxmoxGuest } from "@/types/proxmox";
|
|
import { resolveProxmoxImportAuth } from "./proxmox-import-auth";
|
|
|
|
interface ProxmoxDiscoverDialogProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
hosts: SSHHostWithStatus[];
|
|
onHostsChanged: (hosts: SSHHostWithStatus[]) => void;
|
|
/** Pre-select a specific host and skip the host picker */
|
|
preselectedHostId?: number;
|
|
/** Credential to use for imported hosts */
|
|
defaultCredentialId?: number | null;
|
|
/** Auth type to use for imported hosts */
|
|
defaultAuthType?: string;
|
|
/** Username from the default credential */
|
|
defaultUsername?: string;
|
|
}
|
|
|
|
export function ProxmoxDiscoverDialog({
|
|
open,
|
|
onClose,
|
|
hosts,
|
|
onHostsChanged,
|
|
preselectedHostId,
|
|
defaultCredentialId,
|
|
defaultAuthType,
|
|
defaultUsername,
|
|
}: ProxmoxDiscoverDialogProps) {
|
|
const { t } = useTranslation();
|
|
const [selectedHostId, setSelectedHostId] = useState<string>(
|
|
preselectedHostId ? String(preselectedHostId) : "",
|
|
);
|
|
const [discovering, setDiscovering] = useState(false);
|
|
const [progress, setProgress] = useState<{
|
|
done: number;
|
|
total: number;
|
|
} | null>(null);
|
|
const streamCloseRef = useRef<(() => void) | null>(null);
|
|
const [guests, setGuests] = useState<ProxmoxGuest[] | null>(null);
|
|
const [discoveredCredentialId, setDiscoveredCredentialId] = useState<
|
|
number | null
|
|
>(null);
|
|
const [discoveredJumpHosts, setDiscoveredJumpHosts] = useState<
|
|
unknown[] | null
|
|
>(null);
|
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
|
const [importing, setImporting] = useState(false);
|
|
|
|
// When opened from the dropdown (no preselectedHostId), only show Proxmox-enabled hosts
|
|
const sshHosts = hosts.filter(
|
|
(h) => !("isFolder" in h) && h.enableProxmox === true,
|
|
);
|
|
|
|
// The Proxmox host the discovery runs against — imported guests are grouped
|
|
// into a folder named after it. Use the same id resolution as discovery so
|
|
// it also works when launched directly from a host action (preselectedHostId).
|
|
const effectiveHostId =
|
|
preselectedHostId != null ? String(preselectedHostId) : selectedHostId;
|
|
const sourceHost = hosts.find(
|
|
(h) => !("isFolder" in h) && String(h.id) === effectiveHostId,
|
|
);
|
|
const importFolder = sourceHost?.name || "Proxmox";
|
|
|
|
function reset() {
|
|
if (!preselectedHostId) setSelectedHostId("");
|
|
setGuests(null);
|
|
setDiscoveredCredentialId(null);
|
|
setDiscoveredJumpHosts(null);
|
|
setSelected(new Set());
|
|
streamCloseRef.current?.();
|
|
streamCloseRef.current = null;
|
|
setProgress(null);
|
|
setDiscovering(false);
|
|
setImporting(false);
|
|
}
|
|
|
|
function handleDiscover() {
|
|
const hostId =
|
|
preselectedHostId ?? (selectedHostId ? Number(selectedHostId) : null);
|
|
if (!hostId) return;
|
|
setDiscovering(true);
|
|
setGuests(null);
|
|
setDiscoveredCredentialId(null);
|
|
setDiscoveredJumpHosts(null);
|
|
setSelected(new Set());
|
|
setProgress(null);
|
|
streamCloseRef.current?.();
|
|
streamCloseRef.current = discoverProxmoxGuestsStream(hostId, {
|
|
onProgress: (done, total) => setProgress({ done, total }),
|
|
onResult: (result) => {
|
|
setGuests(result.guests);
|
|
setDiscoveredCredentialId(result.credentialId ?? null);
|
|
setDiscoveredJumpHosts(result.jumpHosts ?? null);
|
|
setSelected(
|
|
new Set(
|
|
result.guests
|
|
.filter((g) => g.status === "running")
|
|
.map((g) => g.vmid),
|
|
),
|
|
);
|
|
setDiscovering(false);
|
|
setProgress(null);
|
|
streamCloseRef.current = null;
|
|
},
|
|
onError: (message) => {
|
|
toast.error(message ?? t("hosts.proxmoxDiscoveryFailed"));
|
|
setDiscovering(false);
|
|
setProgress(null);
|
|
streamCloseRef.current = null;
|
|
},
|
|
});
|
|
}
|
|
|
|
async function handleImport() {
|
|
if (!guests || selected.size === 0) return;
|
|
setImporting(true);
|
|
try {
|
|
// Prefer explicitly configured credential, then fall back to the host's own credential
|
|
const credId = defaultCredentialId ?? discoveredCredentialId;
|
|
const importAuth = resolveProxmoxImportAuth(defaultAuthType, credId);
|
|
|
|
const selectedGuests = guests.filter((g) => selected.has(g.vmid));
|
|
|
|
const toImport = selectedGuests.map((g) => ({
|
|
name: g.name,
|
|
// No IP discovered (e.g. QEMU without a running guest agent): import
|
|
// with a placeholder so the host is created and the user can fill in
|
|
// the real IP. Re-sync keeps the manual value (guest.ip || existing.ip).
|
|
ip: g.ip || "0.0.0.0",
|
|
port: g.connectionType === "rdp" ? 3389 : 22,
|
|
username:
|
|
importAuth.authType === "credential"
|
|
? ""
|
|
: (defaultUsername ?? "root"),
|
|
folder: importFolder,
|
|
// Inherit the jump-host chain from the scanned Proxmox host so the
|
|
// imported guests are reachable the same way; user can override.
|
|
jumpHosts: discoveredJumpHosts ?? undefined,
|
|
...importAuth,
|
|
enableTerminal: g.connectionType !== "rdp",
|
|
enableFileManager: g.connectionType !== "rdp",
|
|
enableTunnel: g.connectionType !== "rdp",
|
|
enableSsh: g.connectionType !== "rdp",
|
|
enableRdp: g.connectionType === "rdp",
|
|
enableDocker: g.enableDocker,
|
|
connectionType: g.connectionType,
|
|
tags: [
|
|
"proxmox",
|
|
g.type,
|
|
g.node,
|
|
g.type === "lxc" ? `ct-${g.vmid}` : `vm-${g.vmid}`,
|
|
...(g.enableDocker ? ["docker"] : []),
|
|
],
|
|
proxmoxConfig: {
|
|
source: {
|
|
source: "proxmox",
|
|
sourceHostId: Number(effectiveHostId),
|
|
node: g.node,
|
|
vmid: g.vmid,
|
|
type: g.type,
|
|
lastSeenAt: new Date().toISOString(),
|
|
lastStatus: g.status,
|
|
missingSince: null,
|
|
},
|
|
},
|
|
}));
|
|
|
|
const result = toImport.length
|
|
? await bulkImportSSHHosts(toImport as unknown as HostData[], false)
|
|
: { success: 0, updated: 0, skipped: 0, failed: 0 };
|
|
|
|
if (toImport.length) {
|
|
const updated = await getSSHHosts();
|
|
onHostsChanged(updated);
|
|
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
|
}
|
|
|
|
const msg = [
|
|
result.success
|
|
? t("hosts.proxmoxResultImported", { count: result.success })
|
|
: null,
|
|
result.updated
|
|
? t("hosts.proxmoxResultUpdated", { count: result.updated })
|
|
: null,
|
|
result.failed
|
|
? t("hosts.proxmoxResultFailed", { count: result.failed })
|
|
: null,
|
|
]
|
|
.filter(Boolean)
|
|
.join(", ");
|
|
toast.success(t("hosts.proxmoxImportComplete", { summary: msg }));
|
|
|
|
if (result.failed === 0) {
|
|
reset();
|
|
onClose();
|
|
}
|
|
} catch (err: any) {
|
|
toast.error(err?.message ?? t("hosts.proxmoxImportFailed"));
|
|
} finally {
|
|
setImporting(false);
|
|
}
|
|
}
|
|
|
|
const nodeGroups = guests
|
|
? guests.reduce(
|
|
(acc, g) => {
|
|
if (!acc[g.node]) acc[g.node] = [];
|
|
acc[g.node].push(g);
|
|
return acc;
|
|
},
|
|
{} as Record<string, ProxmoxGuest[]>,
|
|
)
|
|
: {};
|
|
|
|
const canDiscover = preselectedHostId != null || !!selectedHostId;
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(v) => {
|
|
if (!v) {
|
|
reset();
|
|
onClose();
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Server className="size-4" />
|
|
{t("hosts.proxmoxImportTitle")}
|
|
</DialogTitle>
|
|
<a
|
|
href="https://docs.termix.site/features/files-and-hosts/proxmox-import"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-[10px] text-accent-brand hover:underline"
|
|
>
|
|
{t("hosts.docsLink")}
|
|
</a>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-3">
|
|
{/* Host selector — hidden when launched from a specific host */}
|
|
{!preselectedHostId && (
|
|
<div className="flex gap-2">
|
|
<Select
|
|
value={selectedHostId}
|
|
onValueChange={setSelectedHostId}
|
|
disabled={discovering}
|
|
>
|
|
<SelectTrigger className="flex-1 text-xs h-8">
|
|
<SelectValue placeholder={t("hosts.proxmoxSelectHost")} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{sshHosts.map((h) => (
|
|
<SelectItem
|
|
key={h.id}
|
|
value={String(h.id)}
|
|
className="text-xs"
|
|
>
|
|
{h.name || h.ip}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={!canDiscover || discovering}
|
|
onClick={handleDiscover}
|
|
className="shrink-0"
|
|
>
|
|
<RefreshCw
|
|
className={`size-3.5 mr-1.5 ${discovering ? "animate-spin" : ""}`}
|
|
/>
|
|
{t("hosts.proxmoxDiscover")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* When launched from a specific host, show Discover directly */}
|
|
{preselectedHostId && !guests && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={discovering}
|
|
onClick={handleDiscover}
|
|
className="w-full"
|
|
>
|
|
<RefreshCw
|
|
className={`size-3.5 mr-1.5 ${discovering ? "animate-spin" : ""}`}
|
|
/>
|
|
{discovering
|
|
? progress
|
|
? `${t("hosts.proxmoxDiscovering")} ${progress.done}/${progress.total}`
|
|
: t("hosts.proxmoxDiscovering")
|
|
: t("hosts.proxmoxDiscoverGuests")}
|
|
</Button>
|
|
)}
|
|
|
|
{/* Guest list */}
|
|
{guests !== null && (
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
<span>
|
|
{t("hosts.proxmoxGuestsSelected", {
|
|
count: guests.length,
|
|
selected: selected.size,
|
|
})}
|
|
</span>
|
|
<div className="flex gap-2">
|
|
<button
|
|
className="hover:text-foreground transition-colors"
|
|
onClick={() =>
|
|
setSelected(new Set(guests.map((g) => g.vmid)))
|
|
}
|
|
>
|
|
{t("hosts.proxmoxSelectAll")}
|
|
</button>
|
|
<span>·</span>
|
|
<button
|
|
className="hover:text-foreground transition-colors"
|
|
onClick={() => setSelected(new Set())}
|
|
>
|
|
{t("hosts.proxmoxDeselectAll")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{guests.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground py-4 text-center">
|
|
{t("hosts.proxmoxNoGuests")}
|
|
</p>
|
|
) : (
|
|
<div className="max-h-64 overflow-y-auto rounded-md border border-border divide-y divide-border">
|
|
{Object.entries(nodeGroups).map(([node, nodeGuests]) => (
|
|
<div key={node}>
|
|
<div className="px-2 py-1 text-[10px] font-medium text-muted-foreground bg-muted/50 uppercase tracking-wider">
|
|
{node}
|
|
</div>
|
|
{nodeGuests.map((g) => (
|
|
<button
|
|
key={g.vmid}
|
|
className="w-full flex items-center gap-2 px-2 py-1.5 text-xs hover:bg-accent transition-colors text-left"
|
|
onClick={() => {
|
|
setSelected((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(g.vmid)) next.delete(g.vmid);
|
|
else next.add(g.vmid);
|
|
return next;
|
|
});
|
|
}}
|
|
>
|
|
{selected.has(g.vmid) ? (
|
|
<CheckSquare className="size-3.5 text-primary shrink-0" />
|
|
) : (
|
|
<Square className="size-3.5 text-muted-foreground shrink-0" />
|
|
)}
|
|
<span className="flex-1 truncate">{g.name}</span>
|
|
<span className="text-muted-foreground text-[10px] shrink-0">
|
|
{g.type.toUpperCase()} {g.vmid}
|
|
</span>
|
|
<span
|
|
className={`shrink-0 text-[10px] ${g.status === "running" ? "text-green-400" : "text-muted-foreground"}`}
|
|
>
|
|
{g.status}
|
|
</span>
|
|
<span
|
|
className={`shrink-0 text-[10px] font-mono ${
|
|
g.ip
|
|
? "text-muted-foreground"
|
|
: "text-amber-400/80 italic"
|
|
}`}
|
|
>
|
|
{g.ip || "no IP"}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
reset();
|
|
onClose();
|
|
}}
|
|
>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
{guests !== null && selected.size > 0 && (
|
|
<Button size="sm" disabled={importing} onClick={handleImport}>
|
|
{importing ? (
|
|
<RefreshCw className="size-3.5 animate-spin mr-1.5" />
|
|
) : (
|
|
<Download className="size-3.5 mr-1.5" />
|
|
)}
|
|
{t("hosts.proxmoxImportButton", { count: selected.size })}
|
|
</Button>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|