mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: move proxmox discovery/import/sync into a first-party plugin
This commit is contained in:
1 parent
9db66d5780
commit
d440ce9073
21 files changed
+290
-36
No files matched your search
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Proxmox discovery/import/sync - first-party, in-process plugin.
|
||||
*
|
||||
* This plugin runs on the main thread rather than in a worker for the same
|
||||
* reason ai does (see src/backend/plugins/first-party.ts): it writes
|
||||
* directly across hosts through the host repository, resolves and connects
|
||||
* to hosts over the shared SSH pool via resolveHostById and
|
||||
* createJumpHostChain, verifies host keys via SSHHostKeyVerifier, and
|
||||
* streams its discovery progress as server-sent events. None of that fits
|
||||
* the worker ctx -- ctx.hosts is a 13-field read-only view with no write
|
||||
* methods, and ctx.http.route replies with a single postMessage value
|
||||
* rather than a stream.
|
||||
*
|
||||
* Like ai, this plugin does not own a live transport (no WebSocket server,
|
||||
* no dedicated port), so it registers its Express router with the
|
||||
* always-mounted /proxmox dispatcher in
|
||||
* src/backend/database/routes/proxmox-dispatch.ts instead of getting its
|
||||
* own port.
|
||||
*
|
||||
* routes.ts is what used to be src/backend/database/routes/proxmox.ts,
|
||||
* moved here with its sibling modules (proxmox-import-auth.ts,
|
||||
* proxmox-jump-hosts.ts) and rewritten from `export default router` into
|
||||
* startProxmoxService()/stopProxmoxService(), called from
|
||||
* activate()/deactivate() below. Those two functions also start and stop
|
||||
* the background auto-sync interval that used to run at module scope in
|
||||
* proxmox.ts -- keeping it there would mean it fires even when this plugin
|
||||
* is disabled and can never be torn down.
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* Loads a module by its path relative to this file's own directory.
|
||||
*
|
||||
* The plugin ships beside dist/backend, so in a built server the compiled
|
||||
* .js for the moved Proxmox files (routes.js, ...) sits right next to this
|
||||
* .mjs, and core modules resolve via ../../../backend/backend/... from
|
||||
* there (dist/plugins/proxmox/backend -> dist/backend/backend). Under
|
||||
* vitest/dev nothing imports this plugin directly (the loader loads
|
||||
* in-process plugins with a plain dynamic import, no tsx loader involved),
|
||||
* so the only layout that needs to resolve here is the built one.
|
||||
*/
|
||||
async function loadRelative(relativePath) {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const target = path.join(here, `${relativePath}.js`);
|
||||
return import(new URL(`file://${target.replace(/\\/g, "/")}`).href);
|
||||
}
|
||||
|
||||
let routesModule = null;
|
||||
|
||||
export async function activate(ctx) {
|
||||
routesModule = await loadRelative("routes");
|
||||
routesModule.startProxmoxService();
|
||||
ctx.log.info("Proxmox router registered at /proxmox");
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
if (routesModule) {
|
||||
routesModule.stopProxmoxService();
|
||||
routesModule = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Pure decision: which auth settings an imported Proxmox guest inherits.
|
||||
//
|
||||
// The frontend carries a parallel copy in
|
||||
// plugins/proxmox/frontend/proxmox-import-auth.ts. The two drifting apart is
|
||||
// what produced the reported import bug, so both are kept behaviourally
|
||||
// identical and each is unit-tested against the same matrix.
|
||||
export function resolveProxmoxImportAuth(
|
||||
defaultAuthType: string | undefined,
|
||||
credentialId: number | null | undefined,
|
||||
): {
|
||||
authType: string;
|
||||
credentialId: number | null;
|
||||
overrideCredentialUsername: number;
|
||||
} {
|
||||
// An explicit special auth type (none/opkssh/tailscale/vault/…) wins.
|
||||
if (
|
||||
defaultAuthType &&
|
||||
defaultAuthType !== "credential" &&
|
||||
!["password", "key"].includes(defaultAuthType)
|
||||
) {
|
||||
return {
|
||||
authType: defaultAuthType,
|
||||
credentialId: null,
|
||||
overrideCredentialUsername: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// A credential (configured default OR inherited from the source host) is a
|
||||
// concrete auth source -> use it, even when defaultAuthType is the
|
||||
// "password"/"key" default.
|
||||
if (credentialId) {
|
||||
return {
|
||||
authType: "credential",
|
||||
credentialId,
|
||||
overrideCredentialUsername: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authType: "none",
|
||||
credentialId: null,
|
||||
overrideCredentialUsername: 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export function parseProxmoxJumpHosts(raw: unknown): unknown[] | null {
|
||||
if (!raw) return null;
|
||||
if (Array.isArray(raw)) return raw;
|
||||
if (typeof raw !== "string") return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeProxmoxJumpHosts(raw: unknown): string | null {
|
||||
const parsed = parseProxmoxJumpHosts(raw);
|
||||
return parsed ? JSON.stringify(parsed) : null;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,426 @@
|
||||
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 { Select2 } from "@/components/select2";
|
||||
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">
|
||||
<Select2
|
||||
value={selectedHostId}
|
||||
onChange={(event) => setSelectedHostId(event.target.value)}
|
||||
disabled={discovering}
|
||||
placeholder={t("hosts.proxmoxSelectHost")}
|
||||
className="flex-1 text-xs h-8"
|
||||
>
|
||||
{sshHosts.map((h) => (
|
||||
<option key={h.id} value={String(h.id)}>
|
||||
{h.name || h.ip}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Frontend half of the proxmox plugin.
|
||||
*
|
||||
* Unlike ssh-terminal/docker, proxmox has no rail tab or standalone view —
|
||||
* only a discovery dialog (opened imperatively from HostsPanel.tsx, not
|
||||
* through a tab seam) and a host-editor settings tab. Registers the
|
||||
* host-editor tab through the same registerHostEditorTab seam docker uses.
|
||||
*
|
||||
* HostProxmoxTab itself stays in src/ui/sidebar/HostEditorFeatureTabs.tsx
|
||||
* rather than moving into this plugin: it takes the same {form, setField}
|
||||
* pair every other host editor tab does, and those types (HostEditorForm,
|
||||
* the setField signature) are core types HostEditor.tsx owns, the same
|
||||
* reason ssh-terminal leaves session-manager.ts in core rather than copying
|
||||
* it here.
|
||||
*
|
||||
* register() is called when the plugin is enabled, unregister() when it is
|
||||
* disabled. As of this change neither is actually invoked anywhere yet --
|
||||
* see plugins/ssh-terminal/frontend/index.mjs and
|
||||
* src/ui/tests/sidebar/plugin-extension-seam.test.tsx, which says outright
|
||||
* "there is no plugin loader yet" for the frontend half. This file follows
|
||||
* the same shape so it is ready the moment that loader exists. The dialog
|
||||
* is reached today through a direct import in HostsPanel.tsx, not through
|
||||
* this seam.
|
||||
*/
|
||||
|
||||
export const id = "proxmox";
|
||||
export const hostEditorTabId = "proxmox";
|
||||
|
||||
export async function register({ registerHostEditorTab, icons }) {
|
||||
if (registerHostEditorTab) {
|
||||
const { HostProxmoxTab } =
|
||||
await import("../../../src/ui/sidebar/HostEditorFeatureTabs.tsx");
|
||||
registerHostEditorTab({
|
||||
id: hostEditorTabId,
|
||||
labelKey: "hosts.tabProxmox",
|
||||
icon: icons.Server,
|
||||
component: HostProxmoxTab,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function unregister({ unregisterHostEditorTab }) {
|
||||
unregisterHostEditorTab?.(hostEditorTabId);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
const SECRET_BACKED_AUTH_TYPES = new Set(["password", "key"]);
|
||||
const SECRETLESS_AUTH_TYPES = new Set([
|
||||
"none",
|
||||
"agent",
|
||||
"opkssh",
|
||||
"stepca",
|
||||
"tailscale",
|
||||
"vault",
|
||||
]);
|
||||
|
||||
export type ProxmoxImportAuth = {
|
||||
authType: string;
|
||||
credentialId?: number;
|
||||
overrideCredentialUsername?: boolean;
|
||||
};
|
||||
|
||||
export function resolveProxmoxImportAuth(
|
||||
defaultAuthType: string | undefined,
|
||||
credentialId: number | null | undefined,
|
||||
): ProxmoxImportAuth {
|
||||
// An explicit secretless auth choice (none/opkssh/tailscale/vault) wins.
|
||||
if (defaultAuthType && SECRETLESS_AUTH_TYPES.has(defaultAuthType)) {
|
||||
return { authType: defaultAuthType };
|
||||
}
|
||||
|
||||
// A credential (configured default OR inherited from the source Proxmox host)
|
||||
// is a concrete auth source -> use it, even when defaultAuthType is the
|
||||
// "password"/"key" default. Otherwise imported guests end up as authType
|
||||
// "none" although the host authenticates via a credential.
|
||||
if (credentialId) {
|
||||
return {
|
||||
authType: "credential",
|
||||
credentialId,
|
||||
overrideCredentialUsername: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Explicit non secret-backed special type without a credential.
|
||||
if (
|
||||
defaultAuthType &&
|
||||
defaultAuthType !== "credential" &&
|
||||
!SECRET_BACKED_AUTH_TYPES.has(defaultAuthType)
|
||||
) {
|
||||
return { authType: defaultAuthType };
|
||||
}
|
||||
|
||||
return { authType: "none" };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"id": "proxmox",
|
||||
"name": "Proxmox",
|
||||
"version": "1.0.0",
|
||||
"description": "Discover Proxmox VE guests over an existing SSH host, import them as Termix hosts, and keep them in sync.",
|
||||
"author": { "name": "Termix" },
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/Termix-SSH/Termix",
|
||||
"category": "Infrastructure",
|
||||
"icon": "Server",
|
||||
"engine": { "termix": ">=2.9.0", "api": "1" },
|
||||
"capabilities": {
|
||||
"backend": true,
|
||||
"frontend": true,
|
||||
"electron": true,
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
},
|
||||
"permissions": ["process:transport-owner"],
|
||||
"contributes": {
|
||||
"hostCapability": {
|
||||
"key": "enableProxmox",
|
||||
"labelKey": "hosts.proxmoxIntegration",
|
||||
"editorTab": "proxmox"
|
||||
}
|
||||
},
|
||||
"sidecars": []
|
||||
}
|
||||
Reference in new issue
Block a user