feat: move proxmox discovery/import/sync into a first-party plugin

This commit is contained in:
LukeGus committed 2026-09-21 19:20:17 -05:00
1 parent 9db66d5780
commit d440ce9073
21 files changed
+290 -36

No files matched your search

+2 -2
View File
@@ -25,7 +25,7 @@ import userPreferencesRoutes from "./routes/user-preferences.js";
import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
import uiPreferencesRoutes from "./routes/ui-preferences.js";
import proxmoxRoutes from "./routes/proxmox.js";
import proxmoxDispatch from "./routes/proxmox-dispatch.js";
import termixIdRoutes from "./routes/termix-id.js";
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import { registerTailscaleRoutes } from "./routes/tailscale-routes.js";
@@ -1768,7 +1768,7 @@ app.use("/user-preferences", userPreferencesRoutes);
app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
app.use("/ui-preferences", uiPreferencesRoutes);
app.use("/proxmox", proxmoxRoutes);
app.use("/proxmox", proxmoxDispatch);
app.use("/termix-id", termixIdRoutes);
registerAuditLogRoutes(app, authenticateJWT);
registerTailscaleRoutes(app, authenticateJWT);
@@ -0,0 +1,48 @@
// Mounted at /proxmox. The Proxmox discovery/import/sync feature ships as a
// first-party in-process plugin (plugins/proxmox) rather than a route file
// imported directly here, so this module exists to keep /proxmox/* working
// at its original path without handing the plugin a reference to the shared
// app instance. The plugin runtime registers its router via
// registerProxmoxRouter when it activates, and clears it on deactivate, so a
// disabled plugin falls back to the 404 below.
//
// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards
// to exactly one first-party router (the Proxmox plugin's own), and every
// route in that router already applies authenticateJWT/requireDataAccess
// itself, the same as every other router mounted alongside it here (/users,
// /host, /snippets, ...). /plugin-api gates at the mount point because it
// forwards to arbitrary third-party plugin code that cannot be assumed to
// gate itself; that reasoning does not apply to a single first-party router
// that already does.
import type { Request, Response, NextFunction, Router } from "express";
import { databaseLogger } from "../../utils/logger.js";
let activeRouter: Router | null = null;
export function registerProxmoxRouter(router: Router): void {
activeRouter = router;
databaseLogger.info("Registered Proxmox plugin router", {
operation: "proxmox_dispatch_register",
});
}
export function unregisterProxmoxRouter(): void {
if (!activeRouter) return;
activeRouter = null;
databaseLogger.info("Unregistered Proxmox plugin router", {
operation: "proxmox_dispatch_register",
});
}
export default function proxmoxDispatch(
req: Request,
res: Response,
next: NextFunction,
): void {
if (!activeRouter) {
res.status(404).json({ error: "Proxmox discovery is not available" });
return;
}
activeRouter(req, res, next);
}
@@ -1,44 +0,0 @@
// Pure decision: which auth settings an imported Proxmox guest inherits.
//
// The frontend carries a parallel copy in
// src/ui/components/proxmox/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,
};
}
@@ -1,17 +0,0 @@
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
+9
View File
@@ -54,12 +54,21 @@
* with no write methods and no snippets/fleets/alerts/automations surface at
* all, and ctx.http.route replies with one postMessage value rather than a
* stream, so none of that fits the worker ctx either.
*
* proxmox is here for the same reason as ai: it does not own a transport,
* but it writes directly across hosts through the host repository during
* discovery and sync, resolves and connects to hosts via resolveHostById and
* createJumpHostChain the same way the terminal and ai's command executor
* do, verifies host keys via SSHHostKeyVerifier, and streams its discovery
* progress as server-sent events. Neither the worker's 13-field read-only
* ctx.hosts nor its single-postMessage-reply ctx.http.route can support that.
*/
export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
"ssh-terminal",
"docker",
"host-metrics",
"ai",
"proxmox",
]);
export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner";
+5 -3
View File
@@ -299,9 +299,11 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
// ssh-terminal, docker and host-metrics plugins start their own servers,
// so disabling any of them stops its WS/HTTP server. See
// plugins/ssh-terminal, plugins/docker and plugins/host-metrics.
// AI is also absent: the ai plugin registers its router with the /ai
// dispatcher in database.ts on activate, so disabling it makes /ai/* 404
// instead of leaving a dead import here. See plugins/ai.
// AI and Proxmox are also absent for the same reason: each plugin
// registers its router with its own dispatcher in database.ts on
// activate (/ai, /proxmox), so disabling either makes its routes 404
// instead of leaving a dead import here. See plugins/ai and
// plugins/proxmox.
await import("./hosts/tunnel/index.js");
await import("./hosts/file-manager/index.js");
await import("./hosts/tmux/index.js");
+18 -2
View File
@@ -39,20 +39,22 @@ function manifest(overrides: Record<string, unknown> = {}) {
}
describe("first-party allowlist", () => {
it("contains only ssh-terminal, docker, host-metrics and ai", () => {
it("contains only ssh-terminal, docker, host-metrics, ai and proxmox", () => {
expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([
"ai",
"docker",
"host-metrics",
"proxmox",
"ssh-terminal",
]);
});
it("recognises ssh-terminal, docker, host-metrics and ai and nothing else", () => {
it("recognises ssh-terminal, docker, host-metrics, ai and proxmox and nothing else", () => {
expect(isFirstParty("ssh-terminal")).toBe(true);
expect(isFirstParty("docker")).toBe(true);
expect(isFirstParty("host-metrics")).toBe(true);
expect(isFirstParty("ai")).toBe(true);
expect(isFirstParty("proxmox")).toBe(true);
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
expect(isFirstParty("community-plugin")).toBe(false);
expect(isFirstParty("")).toBe(false);
@@ -69,6 +71,7 @@ describe("runsInProcess", () => {
true,
);
expect(runsInProcess("ai", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
expect(runsInProcess("proxmox", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
// On the list but not asking for it: stays in a worker, so the manifest
// remains an honest description of what the plugin does.
@@ -76,6 +79,7 @@ describe("runsInProcess", () => {
expect(runsInProcess("docker", ["hosts.read"])).toBe(false);
expect(runsInProcess("host-metrics", ["hosts.read"])).toBe(false);
expect(runsInProcess("ai", ["hosts.read"])).toBe(false);
expect(runsInProcess("proxmox", ["hosts.read"])).toBe(false);
// Asking for it but not on the list.
expect(
@@ -155,6 +159,18 @@ describe("manifest gate on the reserved capability", () => {
expect(parsed?.id).toBe("ai");
});
it("allows it for proxmox", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "proxmox",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("proxmox");
});
it("still accepts an ordinary manifest that never mentions it", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({ permissions: ["hosts.read"] }),
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import { resolveProxmoxImportAuth } from "../../../database/routes/proxmox-import-auth.js";
import { resolveProxmoxImportAuth } from "../../../../../plugins/proxmox/backend/proxmox-import-auth.js";
// The frontend carries its own copy of this decision in
// src/ui/components/proxmox/proxmox-import-auth.ts. The two drifting apart is
// plugins/proxmox/frontend/proxmox-import-auth.ts. The two drifting apart is
// what produced the reported bug, so both are held to the same matrix.
describe("resolveProxmoxImportAuth", () => {
it("uses the default credential for key auth when one is configured", () => {
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import {
parseProxmoxJumpHosts,
serializeProxmoxJumpHosts,
} from "../../../database/routes/proxmox-jump-hosts.js";
} from "../../../../../plugins/proxmox/backend/proxmox-jump-hosts.js";
describe("Proxmox jump-host persistence", () => {
const jumpHosts = [{ hostId: 7 }, { hostId: 9 }];
@@ -6,7 +6,8 @@ vi.mock("../../../hosts/metrics-shared/exec-elevated.js", () => ({
execElevated: (...args: unknown[]) => execElevated(...args),
}));
import { execPveshCommand } from "../../../database/routes/proxmox.js";
const { execPveshCommand } =
await import("../../../../../plugins/proxmox/backend/routes.js");
const client = {} as Client;
@@ -1,426 +0,0 @@
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>
);
}
@@ -1,48 +0,0 @@
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" };
}
+5 -1
View File
@@ -84,6 +84,7 @@ import {
HostDockerTab,
HostWebUiTab,
HostProxmoxTab,
HostProxmoxStatsTab,
HostFilesTab,
} from "./HostEditorFeatureTabs";
import { HostEditorGeneralTab } from "./HostEditorGeneralTab";
@@ -2597,7 +2598,10 @@ export function HostEditor({
)}
{activeTab === "proxmox" && (
<HostProxmoxTab form={form} setField={setField} />
<>
<HostProxmoxTab form={form} setField={setField} />
<HostProxmoxStatsTab form={form} setField={setField} />
</>
)}
{activeTab === "files" && (
+20
View File
@@ -317,6 +317,26 @@ export function HostProxmoxTab({
</SettingRow>
</>
)}
</div>
</SectionCard>
);
}
export function HostProxmoxStatsTab({
form,
setField,
}: {
form: HostEditorForm;
setField: SetHostField;
}) {
const { t } = useTranslation();
return (
<SectionCard
title={t("hosts.enableProxmoxStats")}
icon={<Server className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.enableProxmoxStats")}
description={t("hosts.enableProxmoxStatsDesc")}
+1 -1
View File
@@ -28,7 +28,7 @@ import { HostManager } from "@/sidebar/HostManager";
import { HostShareModal } from "@/sidebar/HostShareModal";
import { HostExportDialog } from "@/sidebar/HostExportDialog";
import { CustomizeSidebarPanel } from "@/sidebar/CustomizeSidebarPanel";
import { ProxmoxDiscoverDialog } from "@/components/proxmox/ProxmoxDiscoverDialog";
import { ProxmoxDiscoverDialog } from "../../../plugins/proxmox/frontend/ProxmoxDiscoverDialog";
import { Button } from "@/components/button";
import {
DropdownMenu,
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveProxmoxImportAuth } from "../../../components/proxmox/proxmox-import-auth";
import { resolveProxmoxImportAuth } from "../../../../../plugins/proxmox/frontend/proxmox-import-auth";
describe("resolveProxmoxImportAuth", () => {
it("inherits an available credential even with the default 'password' authType", () => {