Support multiple standalone Proxmox hosts with shared credentials

Comma-separated host list logs into every host, merges VM lists tagged
by origin host, and routes SPICE connections to the right one. Also
adds a username format hint (user@realm) on the login form.
This commit is contained in:
2026-07-06 10:29:44 +02:00
parent a0e7aa8f52
commit ebb405ab9b
5 changed files with 90 additions and 20 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "proxmox-spice-client",
"version": "1.0.1",
"version": "1.0.2",
"description": "VDI SPICE Client f\u00fcr Proxmox",
"main": "src/main.js",
"scripts": {
+67 -15
View File
@@ -8,7 +8,10 @@ const ProxmoxClient = require('./proxmox');
const store = new Store({ name: 'proxmox-spice-client' });
let mainWindow;
let client; // ProxmoxClient instance (persists across login → VM views)
// One ProxmoxClient per logged-in host — several standalone Proxmox hosts can
// share the same root@pam credentials without being a real PVE cluster, so we
// log into every host the user entered and merge their VM lists.
let clients = [];
// ---------- Window management ----------
@@ -120,34 +123,83 @@ ipcMain.handle('credentials:clear', () => {
// ---------- IPC: Proxmox ----------
ipcMain.handle('proxmox:login', async (_e, { host, username, password }) => {
try {
client = new ProxmoxClient(host);
await client.login(username, password);
createWindow('vms.html', 680, 520, true);
return { success: true };
} catch (err) {
return { success: false, error: err.message };
const hosts = String(host || '')
.split(',')
.map((h) => h.trim())
.filter(Boolean);
if (hosts.length === 0) {
return { success: false, error: 'Bitte mindestens einen Host angeben.' };
}
const attempts = await Promise.all(
hosts.map(async (h) => {
const c = new ProxmoxClient(h);
try {
await c.login(username, password);
return { host: h, client: c, success: true };
} catch (err) {
return { host: h, success: false, error: err.message };
}
})
);
const succeeded = attempts.filter((a) => a.success);
const failed = attempts.filter((a) => !a.success);
if (succeeded.length === 0) {
// Single host → show the real error. Multiple hosts, all failing → the
// per-host errors are usually identical (wrong creds), so the first is enough.
return { success: false, error: attempts[0].error };
}
clients = succeeded;
createWindow('vms.html', 680, 520, true);
if (failed.length > 0) {
setImmediate(() => {
dialog.showMessageBox(mainWindow, {
type: 'warning',
title: 'Nicht alle Hosts erreichbar',
message: `${failed.length} von ${hosts.length} Host(s) konnten nicht angemeldet werden.`,
detail: failed.map((f) => `${f.host}: ${f.error}`).join('\n'),
buttons: ['OK'],
});
});
}
return { success: true };
});
ipcMain.handle('proxmox:getVMs', async () => {
try {
const vms = await client.getSpiceVMs();
return { success: true, vms };
const perHost = await Promise.all(
clients.map(async ({ host, client }) => {
try {
const vms = await client.getSpiceVMs();
return vms.map((vm) => ({ ...vm, host }));
} catch {
return [];
}
})
);
return { success: true, vms: perHost.flat() };
} catch (err) {
return { success: false, error: err.message };
}
});
ipcMain.handle('proxmox:logout', () => {
client = null;
clients = [];
createWindow('login.html', 460, 500, false);
});
ipcMain.handle('proxmox:connect', async (_e, { node, vmid }) => {
ipcMain.handle('proxmox:connect', async (_e, { host, node, vmid }) => {
try {
const params = await client.getSpiceTicket(node, vmid);
const vvPath = buildVVFile(params, vmid);
const entry = clients.find((c) => c.host === host) || clients[0];
if (!entry) throw new Error('Nicht angemeldet.');
const params = await entry.client.getSpiceTicket(node, vmid);
const vvPath = buildVVFile(params, vmid, entry.client);
launchRemoteViewer(vvPath);
return { success: true };
} catch (err) {
@@ -179,7 +231,7 @@ ipcMain.handle('settings:browseViewerPath', async () => {
// ---------- SPICE helpers ----------
function buildVVFile(params, vmid) {
function buildVVFile(params, vmid, client) {
const lines = ['[virt-viewer]', `type=${params.type || 'spice'}`];
const host = params.proxy || params.host || client.host.split(':')[0];
+4 -2
View File
@@ -17,12 +17,14 @@
<form id="loginForm" autocomplete="on">
<div class="field">
<label for="host">Proxmox Host</label>
<label for="host">Proxmox Host(s)</label>
<input type="text" id="host" placeholder="pve.example.com" autocomplete="url" spellcheck="false">
<div class="field-hint">Mehrere eigenständige Hosts mit denselben Zugangsdaten? Mit Komma trennen, z. B. pve1.example.com, pve2.example.com</div>
</div>
<div class="field">
<label for="username">Benutzer</label>
<input type="text" id="username" placeholder="user@pam" autocomplete="username" spellcheck="false">
<input type="text" id="username" placeholder="root@pam" autocomplete="username" spellcheck="false">
<div class="field-hint">Format: Benutzer@Realm — z. B. root@pam</div>
</div>
<div class="field">
<label for="password">Passwort</label>
+7
View File
@@ -76,6 +76,13 @@ body.login-page {
margin-bottom: 0.4rem;
}
.field-hint {
font-size: 0.72rem;
color: var(--muted);
margin-top: 0.35rem;
line-height: 1.4;
}
input[type="text"],
input[type="password"] {
width: 100%;
+11 -2
View File
@@ -91,6 +91,9 @@ loadVMs();
// ── Render ────────────────────────────────────────────────
function renderList(vms) {
// Only show the host when there's more than one — single-host setups don't need the noise.
const multiHost = new Set(vms.map((vm) => vm.host)).size > 1;
listEl.innerHTML = vms
.map(
(vm) => `
@@ -99,12 +102,13 @@ function renderList(vms) {
<div class="vm-name">${esc(vm.name)}</div>
<div class="vm-meta">
<span class="status-dot"></span>
Läuft &nbsp;·&nbsp; Node: ${esc(vm.node)} &nbsp;·&nbsp; ID: ${vm.vmid}
Läuft &nbsp;·&nbsp; ${multiHost ? `Host: ${esc(vm.host)} &nbsp;·&nbsp; ` : ''}Node: ${esc(vm.node)} &nbsp;·&nbsp; ID: ${vm.vmid}
</div>
</div>
<button
class="btn-connect"
data-vmid="${vm.vmid}"
data-host="${esc(vm.host)}"
data-node="${esc(vm.node)}"
data-name="${esc(vm.name)}"
>Verbinden</button>
@@ -115,7 +119,12 @@ function renderList(vms) {
listEl.querySelectorAll('.btn-connect').forEach((btn) => {
btn.addEventListener('click', () =>
connectVM({ vmid: btn.dataset.vmid, node: btn.dataset.node, name: btn.dataset.name })
connectVM({
vmid: btn.dataset.vmid,
host: btn.dataset.host,
node: btn.dataset.node,
name: btn.dataset.name,
})
);
});