Add full registry manager and image pull/push with vault credentials.
Release rolling / release (push) Has been cancelled

Wire credential-aware pull/push, session auth, and an Images Registries tab for vault management.
This commit is contained in:
Raven Scott
2026-07-15 14:54:30 -04:00
parent 1a1394a68b
commit e51de4ad1d
16 changed files with 1341 additions and 124 deletions
+102 -18
View File
@@ -19,6 +19,7 @@ import {
filterTemplatesByQuery,
} from './libs/templateDeploy.js';
import { initAddContainerPage } from './libs/addContainer.js';
import { initRegistryManager, openPushImageModal, pullImageWithAuth } from './libs/registryManager.js';
import { showContainerSkeleton, createProgressBar, updateProgressBar, removeProgressBar } from './libs/loadingStates.js';
import { closeAllModals, showStatusIndicator, hideStatusIndicator, updateStatusIndicator, showAlert } from './libs/uiUtils.js';
import notificationManager from './libs/notifications.js';
@@ -1631,6 +1632,7 @@ async function loadAccessView() {
btn.addEventListener('click', async () => {
await manager.request(Methods.vaultUseCredential, { id: btn.dataset.id });
if (typeof showAlert === 'function') showAlert('success', 'Vault credential applied to session');
if (typeof window.refreshRegistryPanel === 'function') window.refreshRegistryPanel();
});
});
vaultEl.querySelectorAll('.vault-del').forEach((btn) => {
@@ -1641,6 +1643,7 @@ async function loadAccessView() {
if (!ok) return;
await manager.request(Methods.vaultDeleteCredential, { id: btn.dataset.id });
loadAccessView();
if (typeof window.refreshRegistryPanel === 'function') window.refreshRegistryPanel();
});
});
}
@@ -1854,10 +1857,12 @@ document.addEventListener('DOMContentLoaded', () => {
username: fd.get('username'),
password: fd.get('password'),
serveraddress: fd.get('serveraddress') || undefined,
label: fd.get('label') || undefined,
});
vaultForm.reset();
if (typeof showAlert === 'function') showAlert('success', 'Credential stored encrypted');
loadAccessView();
if (typeof window.refreshRegistryPanel === 'function') window.refreshRegistryPanel();
} catch (err) {
if (typeof showAlert === 'function') showAlert('danger', err.message);
}
@@ -2931,6 +2936,16 @@ function updateSystemInfo(systemInfo) {
// Images Functions
let allImages = []; // Store all images for filtering
/** Expose for registry push modal catalog */
Object.defineProperty(window, 'allImages', {
get() {
return allImages;
},
set(v) {
allImages = v || [];
},
configurable: true,
});
let currentImageFilter = 'all'; // Current filter: 'all', 'used', 'unused'
function loadImages() {
@@ -3022,21 +3037,35 @@ function renderImages(images) {
if (skipIfUnchangedList(imagesList, fp)) return;
imagesList.innerHTML = filteredImages.map(image => {
const repoTag = image.RepoTags && image.RepoTags[0] ? image.RepoTags[0].split(':') : ['<none>', '<none>'];
const tags = (image.RepoTags || []).filter(Boolean);
const primary = tags[0] || '<none>:<none>';
const repoTag = primary.includes(':')
? [primary.slice(0, primary.lastIndexOf(':')), primary.slice(primary.lastIndexOf(':') + 1)]
: [primary, ''];
const repo = repoTag[0];
const tag = repoTag[1];
const imageId = image.Id.substring(7, 19);
const size = formatBytes(image.Size);
const created = image.Created ? new Date(image.Created * 1000).toLocaleDateString() : 'Unknown';
const usage = image.usage ? image.usage.length : 0;
const tagBadges =
tags.length > 0
? tags
.map((t) => {
const short = t.includes(':') ? t.slice(t.lastIndexOf(':') + 1) : t;
return `<span class="badge bg-secondary me-1 mb-1" title="${String(t).replace(/"/g, '&quot;')}">${short}</span>`;
})
.join('')
: '<span class="badge bg-dark border border-secondary">&lt;none&gt;</span>';
const tagsJson = encodeURIComponent(JSON.stringify(tags));
const canPull = tags.length > 0 && tags[0] !== '<none>:<none>';
return `
<tr>
<td>
<input type="checkbox" class="image-checkbox" data-image-id="${image.Id}" onchange="updateBulkActionsImagesToolbar()">
</td>
<td>${repo}</td>
<td><span class="badge bg-secondary">${tag}</span></td>
<td class="text-break" style="max-width: 14rem;">${repo}</td>
<td style="max-width: 12rem;">${tagBadges}</td>
<td><code>${imageId}</code></td>
<td>${size}</td>
<td>${created}</td>
@@ -3049,6 +3078,16 @@ function renderImages(images) {
<button class="btn btn-outline-success action-tag-image" data-image-id="${image.Id}" title="Tag Image" data-min-role="operator">
<i class="fas fa-tag"></i>
</button>
<button class="btn btn-outline-primary action-push-image" data-image-id="${image.Id}" data-tags="${tagsJson}" data-default-ref="${encodeURIComponent(primary)}" title="Push to registry" data-min-role="operator">
<i class="fas fa-upload"></i>
</button>
${
canPull
? `<button class="btn btn-outline-secondary action-pull-image" data-ref="${encodeURIComponent(primary)}" title="Re-pull ${primary.replace(/"/g, '')}" data-min-role="operator">
<i class="fas fa-download"></i>
</button>`
: ''
}
<button class="btn btn-outline-danger action-remove-image" data-image-id="${image.Id}" title="Remove" data-min-role="admin">
<i class="fas fa-trash"></i>
</button>
@@ -3124,6 +3163,37 @@ function renderImages(images) {
});
});
imagesList.querySelectorAll('.action-push-image').forEach((btn) => {
btn.addEventListener('click', () => {
let repoTags = [];
try {
repoTags = JSON.parse(decodeURIComponent(btn.dataset.tags || '%5B%5D'));
} catch {
repoTags = [];
}
const defaultRef = decodeURIComponent(btn.dataset.defaultRef || '');
if (typeof openPushImageModal === 'function') {
openPushImageModal({
id: btn.dataset.imageId,
defaultRef,
repoTags,
});
}
});
});
imagesList.querySelectorAll('.action-pull-image').forEach((btn) => {
btn.addEventListener('click', () => {
const ref = decodeURIComponent(btn.dataset.ref || '');
if (!ref) return;
if (typeof pullImageWithAuth === 'function') {
pullImageWithAuth({ image: ref }).catch(() => {});
} else {
sendCommand('pullImage', { image: ref });
}
});
});
applyRoleUI();
}
@@ -3728,23 +3798,17 @@ function pullImage() {
showAlert('danger', 'Please enter an image name');
return;
}
const credVal = document.getElementById('pull-image-credential')?.value;
const credentialId =
credVal && credVal !== '__session__' ? credVal : undefined;
const modal = bootstrap.Modal.getInstance(document.getElementById('pullImageModal'));
if (modal) modal.hide();
try {
const host =
document.getElementById('images-view') ||
document.getElementById('alert-container')?.parentElement ||
document.body;
document.getElementById('progress-pull-image')?.remove();
host.prepend(createProgressBar('pull-image', `Pulling ${imageName}`));
} catch {
// ignore
if (typeof pullImageWithAuth === 'function') {
pullImageWithAuth({ image: imageName, credentialId }).catch(() => {});
return;
}
showStatusIndicator(`Pulling image "${imageName}"...`);
sendCommand('pullImage', { image: imageName }).then((response) => {
sendCommand('pullImage', { image: imageName, credentialId }).then((response) => {
removeProgressBar('pull-image');
if (response?.success) {
hideStatusIndicator();
@@ -3756,7 +3820,6 @@ function pullImage() {
const errorMsg = handleErrorResponse(response);
if (errorMsg) showAlert('danger', errorMsg);
} else {
// Error already emitted via manager.send → handleRpcMessage
hideStatusIndicator();
}
});
@@ -7890,6 +7953,21 @@ function handleRpcMessage(response, conn) {
break;
}
case 'pushProgress': {
const pct = pullProgressPercent(response);
const label = response.image || 'image';
const msg = [response.status, response.progress].filter(Boolean).join(' ');
if (response.error) {
updateStatusIndicator(`Push error: ${response.error}`);
} else if (pct != null) {
updateProgressBar('push-image', pct, msg || `Pushing ${label}`);
updateStatusIndicator(`Pushing ${label}: ${pct}%`);
} else if (msg) {
updateStatusIndicator(`Push: ${msg}`);
}
break;
}
case 'buildProgress': {
const line = (response.stream || response.status || response.error || '').trim();
if (line) {
@@ -8486,6 +8564,12 @@ document.addEventListener('DOMContentLoaded', () => {
// Add container page (blank create form)
initAddContainerPage();
// Images registry manager (vault, push/pull auth)
initRegistryManager();
document.getElementById('registry-refresh-btn')?.addEventListener('click', () => {
if (typeof window.refreshRegistryPanel === 'function') window.refreshRegistryPanel();
});
// Prefer smart network modal for create buttons
document.querySelectorAll('[data-bs-target="#createNetworkModal"]').forEach((btn) => {
btn.setAttribute('data-bs-target', '#createNetworkSmartModal');
+36 -11
View File
@@ -147,8 +147,13 @@ export const api = {
return connOrActive(connection).request(Methods.binaryStreamClose, { streamId })
},
pullImage(image, connection) {
return connOrActive(connection).request(Methods.pullImage, { image })
pullImage(image, opts = {}, connection) {
if (opts && typeof opts.request === 'function') {
connection = opts
opts = {}
}
const body = typeof image === 'object' && image ? { ...image } : { image, ...opts }
return connOrActive(connection).request(Methods.pullImage, body)
},
removeImage(id, opts = {}, connection) {
@@ -180,7 +185,15 @@ export const api = {
},
pushImage(image, opts = {}, connection) {
return connOrActive(connection).request(Methods.pushImage, { image, id: image, ...opts })
if (opts && typeof opts.request === 'function') {
connection = opts
opts = {}
}
const body =
typeof image === 'object' && image
? { ...image }
: { image, id: opts.id || image, ...opts }
return connOrActive(connection).request(Methods.pushImage, body)
},
saveImage(id, opts = {}, connection) {
@@ -209,35 +222,35 @@ export const api = {
pruneBuilder(connection) {
return connOrActive(connection).request(Methods.pruneBuilder, {})
}
},
systemPrune(opts = {}, connection) {
return connOrActive(connection).request(Methods.systemPrune, opts)
}
},
recreateContainer(id, opts = {}, connection) {
return connOrActive(connection).request(Methods.recreateContainer, { id, ...opts })
}
},
browseVolume(name, path = '/', connection) {
return connOrActive(connection).request(Methods.browseVolume, { name, path })
}
},
listSchedules(connection) {
return connOrActive(connection).request(Methods.listSchedules, {})
}
},
upsertSchedule(args, connection) {
return connOrActive(connection).request(Methods.upsertSchedule, args)
}
},
deleteSchedule(id, connection) {
return connOrActive(connection).request(Methods.deleteSchedule, { id })
}
},
scaleService(id, replicas, connection) {
return connOrActive(connection).request(Methods.scaleService, { id, replicas })
}
},
updateContainer(args, connection) {
return connOrActive(connection).request(Methods.updateContainer, args)
@@ -327,6 +340,10 @@ export const api = {
return connOrActive(connection).request(Methods.registryLogin, args)
},
registryLogout(connection) {
return connOrActive(connection).request(Methods.registryLogout, {})
},
getAuthStatus(connection) {
return connOrActive(connection).request(Methods.getAuthStatus, {})
},
@@ -384,6 +401,14 @@ export const api = {
return connOrActive(connection).request(Methods.vaultUseCredential, { id })
},
vaultTestCredential(args, connection) {
return connOrActive(connection).request(Methods.vaultTestCredential, args)
},
vaultClearSession(connection) {
return connOrActive(connection).request(Methods.vaultClearSession, {})
},
listPeers(connection) {
return connOrActive(connection).request(Methods.listPeers, {})
},
+1
View File
@@ -48,6 +48,7 @@ const METHOD_TIMEOUT_MS = {
bulkContainerOperation: Math.max(OP_TIMEOUT_MS * 2, 300000),
deployContainer: Math.max(OP_TIMEOUT_MS, 300000),
pullImage: 600000,
pushImage: 600000,
buildImage: 600000,
deployStack: 600000,
removeStack: OP_TIMEOUT_MS,
+1
View File
@@ -79,6 +79,7 @@ Sidebar `data-view="…"` sections, including:
|------|---------|
| containers | Table + details, lifecycle actions; **Add container** header button |
| add-container | Blank create form (always-pull, ports, advanced); not in sidebar |
| images | Local images + Registries tab (vault, pull/push with credentials) |
| images | Images, pull/build |
| networks / volumes | Resource management |
| stacks | Compose projects |
+11 -1
View File
@@ -52,7 +52,17 @@ Deploy templates form remains under the Deploy tab for catalog-driven deploys.
## Images
Pull with progress pushes; build; Hub search; tag; prune; load/save chunked transfer for large artifacts.
**UI:** Images view with **Local images** and **Registries** tabs.
| Capability | Notes |
|------------|--------|
| Local inventory | Filter used/unused, search, multi-tag display |
| Pull | Modal with vault credential select; Hub search; re-pull from row |
| Push | Modal: choose tag, optional re-tag to registry namespace, vault credential; progress push |
| Tag / inspect / remove / build / prune | Existing actions |
| Registry manager | Vault CRUD (encrypted), test auth, session login/logout, auto-match credential by image host |
**RPC:** `pullImage` / `pushImage` accept `credentialId` (and optional retag via `repo`+`tag` on push). Vault: `listVaultCredentials`, `vaultStoreCredential`, `vaultDeleteCredential`, `vaultUseCredential`, `vaultTestCredential`, `vaultClearSession` / `registryLogout`, `getAuthStatus`.
---
+222 -55
View File
@@ -1392,79 +1392,185 @@
<div class="page-header">
<div>
<h2><i class="fas fa-layer-group"></i>Images</h2>
<p class="page-subtitle">Local image inventory and actions</p>
<p class="page-subtitle">Local images, pull/push, and registry vault</p>
</div>
<div class="btn-group">
<div class="btn-group flex-wrap">
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#pullImageModal" data-min-role="operator">
<i class="fas fa-download me-2"></i>Pull Image
<i class="fas fa-download me-2"></i>Pull
</button>
<button class="btn btn-outline-primary" type="button" data-min-role="operator" onclick="window.openPushImageModal && window.openPushImageModal({ id: '', repoTags: [] })">
<i class="fas fa-upload me-2"></i>Push
</button>
<button class="btn btn-success" data-bs-toggle="modal" data-bs-target="#buildImageModal" data-min-role="admin">
<i class="fas fa-hammer me-2"></i>Build Image
<i class="fas fa-hammer me-2"></i>Build
</button>
<button class="btn btn-outline-warning" type="button" data-min-role="admin" onclick="window.pruneResource && window.pruneResource('images')">
<i class="fas fa-broom me-2"></i>Prune
</button>
<button class="btn btn-outline-secondary" type="button" data-min-role="admin" onclick="window.pruneResource && window.pruneResource('builder')" title="Prune build cache">
<i class="fas fa-hammer me-2"></i>Builder cache
<i class="fas fa-database me-2"></i>Builder cache
</button>
</div>
</div>
<!-- Image Filters -->
<div class="view-toolbar mb-3 d-flex flex-wrap gap-2 align-items-center justify-content-between">
<div class="btn-group" role="group" aria-label="Image filters">
<button type="button" class="btn btn-outline-primary image-filter-btn active" data-filter="all" onclick="filterImages('all')">
<i class="fas fa-list me-2"></i>All
<span id="filter-count-all" class="badge bg-secondary ms-2">0</span>
<ul class="nav nav-tabs mb-3" id="images-tabs" role="tablist">
<li class="nav-item">
<button class="nav-link active" type="button" data-images-tab="local">
<i class="fas fa-hard-drive me-1"></i>Local images
</button>
<button type="button" class="btn btn-outline-primary image-filter-btn" data-filter="used" onclick="filterImages('used')">
<i class="fas fa-check-circle me-2"></i>Used
<span id="filter-count-used" class="badge bg-success ms-2">0</span>
</button>
<button type="button" class="btn btn-outline-primary image-filter-btn" data-filter="unused" onclick="filterImages('unused')">
<i class="fas fa-times-circle me-2"></i>Unused
<span id="filter-count-unused" class="badge bg-warning ms-2">0</span>
</li>
<li class="nav-item">
<button class="nav-link" type="button" data-images-tab="registries">
<i class="fas fa-warehouse me-1"></i>Registries
</button>
</li>
</ul>
<div id="images-panel-local">
<!-- Image Filters -->
<div class="view-toolbar mb-3 d-flex flex-wrap gap-2 align-items-center justify-content-between">
<div class="btn-group" role="group" aria-label="Image filters">
<button type="button" class="btn btn-outline-primary image-filter-btn active" data-filter="all" onclick="filterImages('all')">
<i class="fas fa-list me-2"></i>All
<span id="filter-count-all" class="badge bg-secondary ms-2">0</span>
</button>
<button type="button" class="btn btn-outline-primary image-filter-btn" data-filter="used" onclick="filterImages('used')">
<i class="fas fa-check-circle me-2"></i>Used
<span id="filter-count-used" class="badge bg-success ms-2">0</span>
</button>
<button type="button" class="btn btn-outline-primary image-filter-btn" data-filter="unused" onclick="filterImages('unused')">
<i class="fas fa-times-circle me-2"></i>Unused
<span id="filter-count-unused" class="badge bg-warning ms-2">0</span>
</button>
</div>
<input type="search" id="image-search" class="form-control form-control-sm bg-dark text-white list-search-input" placeholder="Filter local images…" spellcheck="false" autocomplete="off">
</div>
<!-- Bulk Actions Toolbar for Images -->
<div id="bulk-actions-images-toolbar" class="view-toolbar mb-3" style="display: none;">
<div class="d-flex align-items-center justify-content-between">
<div>
<span id="selected-images-count" class="text-primary fw-bold">0</span>
<span class="text-secondary"> image(s) selected</span>
</div>
<div class="btn-group">
<button class="btn btn-sm btn-outline-danger" onclick="bulkRemoveImages()" data-min-role="admin" title="Remove Selected">
<i class="fas fa-trash me-1"></i>Remove
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="clearImageSelection()" title="Clear Selection">
<i class="fas fa-times me-1"></i>Clear
</button>
</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-dark table-striped" id="images-table">
<thead>
<tr>
<th style="width: 40px;">
<input type="checkbox" id="select-all-images" onchange="toggleSelectAllImages(this)">
</th>
<th>Repository</th>
<th>Tags</th>
<th>Image ID</th>
<th>Size</th>
<th>Created</th>
<th>Usage</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="images-list"></tbody>
</table>
</div>
<input type="search" id="image-search" class="form-control form-control-sm bg-dark text-white list-search-input" placeholder="Filter local images…" spellcheck="false" autocomplete="off">
</div>
<!-- Bulk Actions Toolbar for Images -->
<div id="bulk-actions-images-toolbar" class="view-toolbar mb-3" style="display: none;">
<div class="d-flex align-items-center justify-content-between">
<div>
<span id="selected-images-count" class="text-primary fw-bold">0</span>
<span class="text-secondary"> image(s) selected</span>
<!-- Registry manager -->
<div id="images-panel-registries" class="hidden">
<div id="registry-session-status" class="alert alert-secondary small mb-3">Loading session…</div>
<div class="row g-4">
<div class="col-lg-6">
<div class="card bg-dark border-secondary h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fas fa-key me-2"></i>Vault credentials</span>
<button type="button" class="btn btn-sm btn-outline-secondary" id="registry-refresh-btn" title="Refresh">
<i class="fas fa-sync"></i>
</button>
</div>
<div class="card-body">
<p class="small text-muted">Encrypted at rest on the server. Use a credential for private pull/push, or activate it for the session.</p>
<div id="registry-cred-list" class="mb-3"></div>
<hr class="border-secondary">
<h6 class="text-muted">Add registry</h6>
<form id="registry-store-form" class="row g-2">
<div class="col-md-6">
<input class="form-control form-control-sm bg-dark text-white" name="label" placeholder="Label (optional)" autocomplete="off">
</div>
<div class="col-md-6">
<input class="form-control form-control-sm bg-dark text-white" name="serveraddress" placeholder="Registry URL (default Docker Hub)" autocomplete="off">
</div>
<div class="col-md-6">
<input class="form-control form-control-sm bg-dark text-white" name="username" placeholder="Username" required autocomplete="username">
</div>
<div class="col-md-6">
<input class="form-control form-control-sm bg-dark text-white" name="password" type="password" placeholder="Password / token" required autocomplete="current-password">
</div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="requireAuth" id="registry-require-auth">
<label class="form-check-label small" for="registry-require-auth">Require successful auth before storing</label>
</div>
</div>
<div class="col-12">
<button class="btn btn-sm btn-primary" type="submit" data-min-role="admin">
<i class="fas fa-lock me-1"></i>Store encrypted
</button>
</div>
</form>
</div>
</div>
</div>
<div class="btn-group">
<button class="btn btn-sm btn-outline-danger" onclick="bulkRemoveImages()" data-min-role="admin" title="Remove Selected">
<i class="fas fa-trash me-1"></i>Remove
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="clearImageSelection()" title="Clear Selection">
<i class="fas fa-times me-1"></i>Clear
</button>
<div class="col-lg-6">
<div class="card bg-dark border-secondary mb-4">
<div class="card-header"><i class="fas fa-sign-in-alt me-2"></i>Session login</div>
<div class="card-body">
<p class="small text-muted">Log in for this session only (does not persist unless you also store).</p>
<form id="registry-login-form" class="row g-2">
<div class="col-12">
<input class="form-control form-control-sm bg-dark text-white" name="serveraddress" placeholder="Registry URL (default Docker Hub)">
</div>
<div class="col-md-6">
<input class="form-control form-control-sm bg-dark text-white" name="username" placeholder="Username" required>
</div>
<div class="col-md-6">
<input class="form-control form-control-sm bg-dark text-white" name="password" type="password" placeholder="Password / token" required>
</div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="alsoStore" id="registry-also-store">
<label class="form-check-label small" for="registry-also-store">Also store in vault</label>
</div>
</div>
<div class="col-12">
<button class="btn btn-sm btn-outline-primary" type="submit" data-min-role="admin">
<i class="fas fa-plug me-1"></i>Log in
</button>
</div>
</form>
</div>
</div>
<div class="card bg-dark border-secondary">
<div class="card-header"><i class="fas fa-magnifying-glass me-2"></i>Docker Hub search</div>
<div class="card-body">
<div class="input-group input-group-sm mb-2">
<input type="search" id="registry-hub-term" class="form-control bg-dark text-white" placeholder="Search Hub…" spellcheck="false" autocomplete="off">
<button type="button" class="btn btn-outline-info" id="registry-hub-search-btn">Search</button>
</div>
<div id="registry-hub-results" class="list-group list-group-flush small" style="max-height: 240px; overflow: auto;"></div>
</div>
</div>
</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-dark table-striped">
<thead>
<tr>
<th style="width: 40px;">
<input type="checkbox" id="select-all-images" onchange="toggleSelectAllImages(this)">
</th>
<th>Repository</th>
<th>Tag</th>
<th>Image ID</th>
<th>Size</th>
<th>Created</th>
<th>Usage</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="images-list"></tbody>
</table>
</div>
</div>
</div>
@@ -2219,19 +2325,24 @@ services:
<div class="card bg-dark border-secondary">
<div class="card-header"><i class="fas fa-key me-2"></i>Registry vault</div>
<div class="card-body">
<p class="small text-muted mb-2">Full registry manager lives under <strong>Images → Registries</strong> (test, session login, Hub search). Quick store here:</p>
<form id="vault-store-form" class="mb-3">
<div class="row g-2">
<div class="col-md-6">
<input class="form-control form-control-sm bg-dark text-white" name="label" placeholder="Label (optional)">
</div>
<div class="col-md-6">
<input class="form-control form-control-sm bg-dark text-white" name="serveraddress" placeholder="Registry (default Docker Hub)">
</div>
<div class="col-md-6">
<input class="form-control form-control-sm bg-dark text-white" name="username" placeholder="Username" required>
</div>
<div class="col-md-6">
<input class="form-control form-control-sm bg-dark text-white" name="password" type="password" placeholder="Password" required>
</div>
<div class="col-12">
<input class="form-control form-control-sm bg-dark text-white" name="serveraddress" placeholder="Registry (default Docker Hub)">
</div>
<div class="col-12">
<button class="btn btn-sm btn-primary" type="submit">Store encrypted</button>
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="navigateToView('images'); window.switchImagesTab && window.switchImagesTab('registries')">Open registry manager</button>
</div>
</div>
</form>
@@ -4353,6 +4464,13 @@ services:
<input type="text" id="pull-image-name" class="form-control bg-dark text-white font-monospace" placeholder="nginx:latest" required autocomplete="off" spellcheck="false">
<small class="text-muted">Full image reference (e.g. nginx:latest, ghcr.io/org/app:1.0)</small>
</div>
<div class="mb-3">
<label for="pull-image-credential" class="form-label">Registry credential</label>
<select id="pull-image-credential" class="form-select bg-dark text-white registry-cred-select">
<option value="">No credential (public / session auto)</option>
</select>
<small class="text-muted">Pick a vault credential for private registries, or leave empty to use session auth / auto-match.</small>
</div>
<div class="mb-2">
<label class="form-label" for="hub-search-term">Search Docker Hub</label>
<div class="input-group input-group-sm">
@@ -4367,7 +4485,7 @@ services:
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="pullImage()">
<button type="button" class="btn btn-primary" id="confirm-pull-image-btn">
<i class="fas fa-download me-2"></i>Pull Image
</button>
</div>
@@ -4375,6 +4493,55 @@ services:
</div>
</div>
<!-- Push Image Modal -->
<div class="modal fade" id="pushImageModal" tabindex="-1" aria-labelledby="pushImageModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content bg-dark text-white">
<div class="modal-header">
<h5 class="modal-title" id="pushImageModalLabel"><i class="fas fa-upload me-2"></i>Push Image</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="hidden" id="push-image-id">
<div class="mb-3">
<label for="push-image-ref" class="form-label">Local reference to push</label>
<select id="push-image-ref" class="form-select bg-dark text-white font-monospace">
<option value="">Select an image tag…</option>
</select>
<small class="text-muted">Choose which local tag to push to the registry.</small>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="push-image-retag">
<label class="form-check-label" for="push-image-retag">Re-tag before push (e.g. to your registry namespace)</label>
</div>
<div id="push-retag-fields" class="row g-2 mb-3" style="display: none;">
<div class="col-md-8">
<label for="push-image-repo" class="form-label">Destination repository</label>
<input type="text" id="push-image-repo" class="form-control bg-dark text-white font-monospace" placeholder="ghcr.io/org/app" autocomplete="off" spellcheck="false">
</div>
<div class="col-md-4">
<label for="push-image-tag" class="form-label">Tag</label>
<input type="text" id="push-image-tag" class="form-control bg-dark text-white font-monospace" value="latest" autocomplete="off" spellcheck="false">
</div>
</div>
<div class="mb-0">
<label for="push-image-credential" class="form-label">Registry credential</label>
<select id="push-image-credential" class="form-select bg-dark text-white registry-cred-select">
<option value="">No credential (public / session auto)</option>
</select>
<small class="text-muted">Required for most private registries. Manage credentials under Images → Registries.</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="confirm-push-image-btn" data-min-role="operator">
<i class="fas fa-upload me-2"></i>Push
</button>
</div>
</div>
</div>
</div>
<!-- Generic Confirmation Modal -->
<div class="modal fade" id="confirmModal" tabindex="-1" aria-labelledby="confirmModalLabel" aria-hidden="true">
<div class="modal-dialog">
+640
View File
@@ -0,0 +1,640 @@
/**
* Registry manager + image push/pull helpers for the Images view.
* Uses the encrypted registry vault and session auth for private registries.
*/
import { manager, Methods } from '../client/manager.js'
import { presentError } from '../client/errors.js'
import { showAlert, showStatusIndicator, hideStatusIndicator } from './uiUtils.js'
import {
createProgressBar,
updateProgressBar,
removeProgressBar,
} from './loadingStates.js'
const progress = {
create: createProgressBar,
update: updateProgressBar,
remove: removeProgressBar,
}
/** @type {Array<object>} */
let cachedCredentials = []
/** @type {{ authenticated?: boolean, username?: string|null, serveraddress?: string|null, credentialId?: string|null, label?: string|null }|null} */
let cachedAuthStatus = null
function escapeHtml(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function escapeAttr(s) {
return escapeHtml(s).replace(/'/g, '&#39;')
}
/**
* @returns {Promise<object[]>}
*/
export async function loadVaultCredentials() {
if (!manager.active?.connected) {
cachedCredentials = []
return []
}
try {
const res = await manager.request(Methods.listVaultCredentials, {})
cachedCredentials = res?.data || []
return cachedCredentials
} catch (err) {
cachedCredentials = []
throw err
}
}
/**
* @returns {Promise<object>}
*/
export async function loadAuthStatus() {
if (!manager.active?.connected) {
cachedAuthStatus = { authenticated: false }
return cachedAuthStatus
}
try {
const res = await manager.request(Methods.getAuthStatus, {})
cachedAuthStatus = res || { authenticated: false }
return cachedAuthStatus
} catch {
cachedAuthStatus = { authenticated: false }
return cachedAuthStatus
}
}
export function getCachedCredentials() {
return cachedCredentials
}
/**
* Fill a <select> with vault credentials.
* @param {HTMLSelectElement|null} select
* @param {{ includeSession?: boolean, includeNone?: boolean, selectedId?: string|null }} [opts]
*/
export function fillCredentialSelect(select, opts = {}) {
if (!select) return
const includeSession = opts.includeSession !== false
const includeNone = opts.includeNone !== false
const selectedId = opts.selectedId ?? select.value
const parts = []
if (includeNone) {
parts.push('<option value="">No credential (public / session auto)</option>')
}
if (includeSession && cachedAuthStatus?.authenticated) {
const label =
cachedAuthStatus.label ||
cachedAuthStatus.username ||
'session'
parts.push(
`<option value="__session__">Active session (${escapeHtml(label)})</option>`
)
}
for (const c of cachedCredentials) {
const text = `${c.label || c.username} · ${c.serveraddress || 'docker.io'}`
parts.push(`<option value="${escapeAttr(c.id)}">${escapeHtml(text)}</option>`)
}
select.innerHTML = parts.join('')
if (selectedId && [...select.options].some((o) => o.value === selectedId)) {
select.value = selectedId
}
}
/**
* Resolve credentialId for RPC (null = none / session default).
* @param {string} selectValue
* @returns {string|undefined}
*/
export function credentialIdFromSelect(selectValue) {
if (!selectValue || selectValue === '__session__') return undefined
return selectValue
}
/**
* Refresh registry manager panel UI.
*/
export async function refreshRegistryPanel() {
const listEl = document.getElementById('registry-cred-list')
const statusEl = document.getElementById('registry-session-status')
if (!manager.active?.connected) {
if (listEl) listEl.innerHTML = '<p class="text-muted small mb-0">Not connected.</p>'
if (statusEl) {
statusEl.className = 'alert alert-secondary small mb-3'
statusEl.textContent = 'Connect to a peer to manage registries.'
}
return
}
try {
await Promise.all([loadVaultCredentials(), loadAuthStatus()])
} catch (err) {
if (listEl) listEl.innerHTML = `<p class="text-danger small mb-0">${escapeHtml(err.message)}</p>`
return
}
if (statusEl) {
if (cachedAuthStatus?.authenticated) {
const who = cachedAuthStatus.label || cachedAuthStatus.username || 'user'
const server = cachedAuthStatus.serveraddress || 'registry'
statusEl.className = 'alert alert-success small mb-3 d-flex flex-wrap justify-content-between align-items-center gap-2'
statusEl.innerHTML = `
<span><i class="fas fa-check-circle me-1"></i>Session auth: <strong>${escapeHtml(who)}</strong>
<span class="text-muted">@ ${escapeHtml(server)}</span></span>
<button type="button" class="btn btn-sm btn-outline-light" id="registry-logout-btn" data-min-role="operator">
<i class="fas fa-sign-out-alt me-1"></i>Clear session
</button>`
statusEl.querySelector('#registry-logout-btn')?.addEventListener('click', async () => {
try {
await manager.request(Methods.registryLogout, {})
showAlert('success', 'Registry session cleared')
refreshRegistryPanel()
} catch (e) {
presentError(e, 'registryLogout', { showAlert })
}
})
} else {
statusEl.className = 'alert alert-secondary small mb-3'
statusEl.innerHTML =
'<i class="fas fa-info-circle me-1"></i>No active registry session. Use a vault credential or log in below for private pulls/pushes.'
}
}
if (listEl) {
if (!cachedCredentials.length) {
listEl.innerHTML =
'<p class="text-muted small mb-0">No stored credentials. Add one to pull/push private images.</p>'
} else {
listEl.innerHTML = cachedCredentials
.map(
(c) => `
<div class="registry-cred-row d-flex flex-wrap justify-content-between align-items-center gap-2 mb-2 p-2 rounded border border-secondary">
<div class="min-w-0">
<div class="fw-semibold text-truncate">${escapeHtml(c.label || c.username)}</div>
<div class="small text-muted text-truncate">
${escapeHtml(c.username)} · ${escapeHtml(c.serveraddress || '')}
</div>
</div>
<div class="btn-group btn-group-sm flex-shrink-0">
<button type="button" class="btn btn-outline-success reg-use" data-id="${escapeAttr(c.id)}" data-min-role="operator" title="Use for this session">
<i class="fas fa-plug"></i><span class="d-none d-md-inline ms-1">Use</span>
</button>
<button type="button" class="btn btn-outline-info reg-test" data-id="${escapeAttr(c.id)}" data-min-role="operator" title="Test login">
<i class="fas fa-vial"></i><span class="d-none d-md-inline ms-1">Test</span>
</button>
<button type="button" class="btn btn-outline-danger reg-del" data-id="${escapeAttr(c.id)}" data-min-role="admin" title="Delete">
<i class="fas fa-trash"></i>
</button>
</div>
</div>`
)
.join('')
listEl.querySelectorAll('.reg-use').forEach((btn) => {
btn.addEventListener('click', async () => {
try {
const res = await manager.request(Methods.vaultUseCredential, { id: btn.dataset.id })
showAlert('success', res?.message || 'Credential applied to session')
refreshRegistryPanel()
} catch (e) {
presentError(e, 'vaultUseCredential', { showAlert })
}
})
})
listEl.querySelectorAll('.reg-test').forEach((btn) => {
btn.addEventListener('click', async () => {
try {
showStatusIndicator('Testing registry auth…')
const res = await manager.request(Methods.vaultTestCredential, { id: btn.dataset.id })
showAlert('success', res?.message || 'Auth OK')
} catch (e) {
presentError(e, 'vaultTestCredential', { showAlert })
} finally {
hideStatusIndicator()
}
})
})
listEl.querySelectorAll('.reg-del').forEach((btn) => {
btn.addEventListener('click', async () => {
const ok = window.peardockOps?.confirmDestructive
? await window.peardockOps.confirmDestructive(
'Delete credential',
'Remove this registry credential from the vault?'
)
: typeof confirm === 'function'
? confirm('Delete this credential?')
: true
if (!ok) return
try {
await manager.request(Methods.vaultDeleteCredential, { id: btn.dataset.id })
showAlert('success', 'Credential deleted')
refreshRegistryPanel()
if (typeof window.loadAccessView === 'function') window.loadAccessView()
} catch (e) {
presentError(e, 'vaultDeleteCredential', { showAlert })
}
})
})
}
}
// Refresh any open credential selects
document.querySelectorAll('select.registry-cred-select').forEach((sel) => {
fillCredentialSelect(sel)
})
if (typeof window.applyRoleUI === 'function') window.applyRoleUI()
}
/**
* Pull image with optional vault credential.
* @param {{ image: string, credentialId?: string }} args
*/
export async function pullImageWithAuth(args) {
const image = String(args.image || '').trim()
if (!image) throw new Error('Image name required')
const body = { image, autoVault: true }
if (args.credentialId) body.credentialId = args.credentialId
const host =
document.getElementById('images-view') ||
document.getElementById('alert-container')?.parentElement ||
document.body
try {
document.getElementById('progress-pull-image')?.remove()
host.prepend(progress.create('pull-image', `Pulling ${image}`))
} catch {
// ignore
}
showStatusIndicator(`Pulling "${image}"…`)
try {
const res = await manager.request(Methods.pullImage, body)
progress.remove('pull-image')
hideStatusIndicator()
showAlert('success', res?.message || `Pulled ${image}`)
if (typeof window.loadImages === 'function') window.loadImages()
else manager.request(Methods.listImages, {}).catch(() => {})
return res
} catch (err) {
progress.remove('pull-image')
hideStatusIndicator()
presentError(err, 'pullImage', { showAlert })
throw err
}
}
/**
* Push image (optionally retag) with vault credential.
* @param {{ image?: string, id?: string, repo?: string, tag?: string, credentialId?: string }} args
*/
export async function pushImageWithAuth(args) {
const image = String(args.image || args.id || '').trim()
if (!image && !args.repo) throw new Error('Image reference required')
const body = {
image: image || undefined,
id: args.id || image || undefined,
autoVault: true,
}
if (args.repo) body.repo = args.repo
if (args.tag) body.tag = args.tag
if (args.credentialId) body.credentialId = args.credentialId
const label = args.repo ? `${args.repo}:${args.tag || 'latest'}` : image
const host =
document.getElementById('images-view') ||
document.getElementById('alert-container')?.parentElement ||
document.body
try {
document.getElementById('progress-push-image')?.remove()
host.prepend(progress.create('push-image', `Pushing ${label}`))
} catch {
// ignore
}
showStatusIndicator(`Pushing "${label}"…`)
try {
const res = await manager.request(Methods.pushImage, body)
progress.remove('push-image')
hideStatusIndicator()
showAlert('success', res?.message || `Pushed ${label}`)
if (typeof window.loadImages === 'function') window.loadImages()
return res
} catch (err) {
progress.remove('push-image')
hideStatusIndicator()
presentError(err, 'pushImage', { showAlert })
throw err
}
}
/**
* Open push modal for an image id / default ref.
* @param {{ id: string, defaultRef?: string, repoTags?: string[] }} opts
*/
export async function openPushImageModal(opts = {}) {
const modalEl = document.getElementById('pushImageModal')
if (!modalEl || typeof bootstrap === 'undefined') {
showAlert('danger', 'Push modal unavailable')
return
}
try {
await loadVaultCredentials()
await loadAuthStatus()
} catch {
// continue
}
const idInput = document.getElementById('push-image-id')
const refSelect = document.getElementById('push-image-ref')
const repoInput = document.getElementById('push-image-repo')
const tagInput = document.getElementById('push-image-tag')
const retagCheck = document.getElementById('push-image-retag')
const credSelect = document.getElementById('push-image-credential')
let tags = (opts.repoTags || []).filter((t) => t && t !== '<none>:<none>')
// Header "Push" with no image: offer all local tagged images
if (!opts.id && !tags.length && Array.isArray(window.allImages)) {
const options = []
for (const img of window.allImages) {
const imgTags = (img.RepoTags || []).filter((t) => t && t !== '<none>:<none>')
for (const t of imgTags) {
options.push({ id: img.Id, ref: t })
}
}
if (refSelect) {
if (!options.length) {
refSelect.innerHTML = '<option value="">No tagged local images</option>'
} else {
refSelect.innerHTML = options
.map(
(o) =>
`<option value="${escapeAttr(o.ref)}" data-id="${escapeAttr(o.id)}">${escapeHtml(o.ref)}</option>`
)
.join('')
if (idInput) idInput.value = options[0].id
refSelect.onchange = () => {
const sel = refSelect.selectedOptions[0]
if (idInput && sel?.dataset?.id) idInput.value = sel.dataset.id
}
}
}
fillCredentialSelect(credSelect)
if (retagCheck) retagCheck.checked = false
togglePushRetagFields()
bootstrap.Modal.getOrCreateInstance(modalEl).show()
return
}
if (idInput) idInput.value = opts.id || ''
if (refSelect) {
if (tags.length) {
refSelect.innerHTML = tags
.map((t) => `<option value="${escapeAttr(t)}">${escapeHtml(t)}</option>`)
.join('')
if (opts.defaultRef && tags.includes(opts.defaultRef)) refSelect.value = opts.defaultRef
} else {
refSelect.innerHTML = `<option value="${escapeAttr(opts.id)}">${escapeHtml((opts.id || '').slice(0, 19))} (id)</option>`
}
}
if (repoInput) {
const def = opts.defaultRef || tags[0] || ''
const [r, t] = def.includes(':') ? [def.slice(0, def.lastIndexOf(':')), def.slice(def.lastIndexOf(':') + 1)] : [def, 'latest']
repoInput.value = r.startsWith('sha256') ? '' : r
if (tagInput) tagInput.value = t || 'latest'
}
if (retagCheck) retagCheck.checked = false
fillCredentialSelect(credSelect)
togglePushRetagFields()
const modal = bootstrap.Modal.getOrCreateInstance(modalEl)
modal.show()
}
function togglePushRetagFields() {
const retag = document.getElementById('push-image-retag')?.checked
const wrap = document.getElementById('push-retag-fields')
if (wrap) wrap.style.display = retag ? '' : 'none'
}
/**
* Switch Images view subtab.
* @param {'local'|'registries'} tab
*/
export function switchImagesTab(tab) {
const local = document.getElementById('images-panel-local')
const registries = document.getElementById('images-panel-registries')
document.querySelectorAll('[data-images-tab]').forEach((btn) => {
btn.classList.toggle('active', btn.getAttribute('data-images-tab') === tab)
})
if (local) local.classList.toggle('hidden', tab !== 'local')
if (registries) registries.classList.toggle('hidden', tab !== 'registries')
if (tab === 'registries') refreshRegistryPanel()
}
/**
* Wire DOM once.
*/
export function initRegistryManager() {
// Subtabs
document.querySelectorAll('[data-images-tab]').forEach((btn) => {
btn.addEventListener('click', () => {
switchImagesTab(btn.getAttribute('data-images-tab') || 'local')
})
})
// Store credential form
const storeForm = document.getElementById('registry-store-form')
if (storeForm && !storeForm.dataset.wired) {
storeForm.dataset.wired = '1'
storeForm.addEventListener('submit', async (e) => {
e.preventDefault()
const fd = new FormData(storeForm)
const requireAuth = storeForm.querySelector('[name="requireAuth"]')?.checked
try {
showStatusIndicator('Storing credential…')
await manager.request(Methods.vaultStoreCredential, {
username: fd.get('username'),
password: fd.get('password'),
serveraddress: fd.get('serveraddress') || undefined,
label: fd.get('label') || undefined,
verify: true,
requireAuth: Boolean(requireAuth),
})
storeForm.reset()
showAlert('success', 'Credential stored encrypted')
refreshRegistryPanel()
if (typeof window.loadAccessView === 'function') window.loadAccessView()
} catch (err) {
presentError(err, 'vaultStoreCredential', { showAlert })
} finally {
hideStatusIndicator()
}
})
}
// Session login (without vault store)
const loginForm = document.getElementById('registry-login-form')
if (loginForm && !loginForm.dataset.wired) {
loginForm.dataset.wired = '1'
loginForm.addEventListener('submit', async (e) => {
e.preventDefault()
const fd = new FormData(loginForm)
try {
showStatusIndicator('Logging in…')
const res = await manager.request(Methods.registryLogin, {
username: fd.get('username'),
password: fd.get('password'),
serveraddress: fd.get('serveraddress') || undefined,
})
showAlert('success', res?.message || 'Logged in')
if (loginForm.querySelector('[name="alsoStore"]')?.checked) {
await manager.request(Methods.vaultStoreCredential, {
username: fd.get('username'),
password: fd.get('password'),
serveraddress: fd.get('serveraddress') || undefined,
label: fd.get('label') || fd.get('username'),
verify: false,
})
}
loginForm.reset()
refreshRegistryPanel()
} catch (err) {
presentError(err, 'registryLogin', { showAlert })
} finally {
hideStatusIndicator()
}
})
}
// Hub search on registries tab
const hubBtn = document.getElementById('registry-hub-search-btn')
const hubTerm = document.getElementById('registry-hub-term')
if (hubBtn && !hubBtn.dataset.wired) {
hubBtn.dataset.wired = '1'
const runSearch = async () => {
const term = hubTerm?.value?.trim()
const out = document.getElementById('registry-hub-results')
if (!term || !out) return
out.innerHTML = '<div class="text-muted small p-2">Searching…</div>'
try {
const res = await manager.request(Methods.searchImages, { term, limit: 25 })
const data = res?.data || []
if (!data.length) {
out.innerHTML = '<div class="text-muted small p-2">No results</div>'
return
}
out.innerHTML = data
.map((r) => {
const name = r.name || r.Name || ''
const stars = r.star_count ?? r.starCount ?? 0
const desc = r.description || r.Description || ''
return `<button type="button" class="list-group-item list-group-item-action bg-dark text-white border-secondary hub-pick" data-name="${escapeAttr(name)}">
<div class="d-flex justify-content-between"><strong class="font-monospace">${escapeHtml(name)}</strong>
<span class="badge bg-secondary">${stars} ★</span></div>
<div class="small text-muted text-truncate">${escapeHtml(desc)}</div>
</button>`
})
.join('')
out.querySelectorAll('.hub-pick').forEach((btn) => {
btn.addEventListener('click', () => {
const name = btn.dataset.name
const pullName = document.getElementById('pull-image-name')
if (pullName) pullName.value = name
const modalEl = document.getElementById('pullImageModal')
if (modalEl && typeof bootstrap !== 'undefined') {
bootstrap.Modal.getOrCreateInstance(modalEl).show()
preparePullModal()
}
})
})
} catch (err) {
out.innerHTML = `<div class="text-danger small p-2">${escapeHtml(err.message)}</div>`
}
}
hubBtn.addEventListener('click', runSearch)
hubTerm?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault()
runSearch()
}
})
}
// Pull modal enhancements
const pullModal = document.getElementById('pullImageModal')
pullModal?.addEventListener('show.bs.modal', () => {
preparePullModal()
})
const pullBtn = document.getElementById('confirm-pull-image-btn')
if (pullBtn && !pullBtn.dataset.regWired) {
pullBtn.dataset.regWired = '1'
pullBtn.addEventListener('click', async () => {
const image = document.getElementById('pull-image-name')?.value?.trim()
const credVal = document.getElementById('pull-image-credential')?.value
if (!image) {
showAlert('danger', 'Please enter an image name')
return
}
const modal = bootstrap.Modal.getInstance(document.getElementById('pullImageModal'))
modal?.hide()
await pullImageWithAuth({
image,
credentialId: credentialIdFromSelect(credVal),
}).catch(() => {})
})
}
// Push modal
document.getElementById('push-image-retag')?.addEventListener('change', togglePushRetagFields)
const pushConfirm = document.getElementById('confirm-push-image-btn')
if (pushConfirm && !pushConfirm.dataset.wired) {
pushConfirm.dataset.wired = '1'
pushConfirm.addEventListener('click', async () => {
const id = document.getElementById('push-image-id')?.value
const ref = document.getElementById('push-image-ref')?.value
const retag = document.getElementById('push-image-retag')?.checked
const repo = document.getElementById('push-image-repo')?.value?.trim()
const tag = document.getElementById('push-image-tag')?.value?.trim() || 'latest'
const credVal = document.getElementById('push-image-credential')?.value
if (retag && !repo) {
showAlert('danger', 'Repository is required when re-tagging for push')
return
}
const modal = bootstrap.Modal.getInstance(document.getElementById('pushImageModal'))
modal?.hide()
await pushImageWithAuth({
id: id || ref,
image: retag ? undefined : ref || id,
repo: retag ? repo : undefined,
tag: retag ? tag : undefined,
credentialId: credentialIdFromSelect(credVal),
}).catch(() => {})
})
}
// Expose for app.js action buttons
window.openPushImageModal = openPushImageModal
window.pullImageWithAuth = pullImageWithAuth
window.pushImageWithAuth = pushImageWithAuth
window.refreshRegistryPanel = refreshRegistryPanel
window.switchImagesTab = switchImagesTab
window.preparePullModal = preparePullModal
}
/**
* Refresh credential dropdown when pull modal opens.
*/
export async function preparePullModal() {
try {
await loadVaultCredentials()
await loadAuthStatus()
} catch {
// ignore
}
fillCredentialSelect(document.getElementById('pull-image-credential'))
}
+3
View File
@@ -37,8 +37,11 @@ const AUDIT_METHODS = new Set([
'loadImageFinish',
'importImage',
'registryLogin',
'registryLogout',
'vaultStoreCredential',
'vaultDeleteCredential',
'vaultUseCredential',
'vaultTestCredential',
'invitePeer',
'deleteInvite',
'revokePeer',
+41
View File
@@ -193,6 +193,47 @@ export function getCredential(id) {
}
}
/**
* Find a vault credential matching a registry host (best-effort).
* @param {string} serverHint e.g. ghcr.io or https://index.docker.io/v1/
* @returns {{ id: string, username: string, password: string, serveraddress: string }|null}
*/
export function findCredentialForServer(serverHint) {
if (!serverHint) return null
const hint = String(serverHint).toLowerCase().replace(/\/+$/, '')
const vault = loadVault()
const entries = Object.values(vault.credentials)
// Prefer exact serveraddress match
for (const c of entries) {
const sa = String(c.serveraddress || '').toLowerCase().replace(/\/+$/, '')
if (sa && (sa === hint || sa.includes(hint) || hint.includes(sa))) {
return {
id: c.id,
username: c.username,
password: c.password,
serveraddress: c.serveraddress,
}
}
}
// Host-only match (strip scheme)
const hostOnly = hint.replace(/^https?:\/\//, '').split('/')[0]
for (const c of entries) {
const sa = String(c.serveraddress || '')
.toLowerCase()
.replace(/^https?:\/\//, '')
.split('/')[0]
if (sa && hostOnly && (sa === hostOnly || sa.endsWith(`.${hostOnly}`) || hostOnly.endsWith(`.${sa}`))) {
return {
id: c.id,
username: c.username,
password: c.password,
serveraddress: c.serveraddress,
}
}
}
return null
}
export function vaultPath() {
return vaultFilePath()
}
+57 -26
View File
@@ -4,9 +4,22 @@
import { docker } from '../services/docker.js'
import * as validation from '../utils/validation.js'
import { Pushes } from '../../shared/protocol.js'
import { getSessionAuthconfig } from './system.js'
import { resolveRegistryAuth } from './vault.js'
import logger from '../utils/logger.js'
/**
* @param {object|null} authconfig
* @returns {object|undefined}
*/
function dockerAuthOpts(authconfig) {
if (!authconfig?.username) return undefined
return {
username: authconfig.username,
password: authconfig.password,
serveraddress: authconfig.serveraddress || 'https://index.docker.io/v1/',
}
}
export function registerImageHandlers(session) {
session.respond('listImages', async (args = {}) => {
const listOpts = { all: args.all !== false }
@@ -63,21 +76,18 @@ export function registerImageHandlers(session) {
throw new Error('Invalid image name')
}
const authconfig = getSessionAuthconfig(session)
const authconfig = resolveRegistryAuth(session, {
credentialId: args.credentialId,
auth: args.auth,
autoVault: args.autoVault,
image: imageName,
})
const auth = dockerAuthOpts(authconfig)
// dockerode: pull(repoTag, opts, callback, auth)
const pullStream = await new Promise((resolve, reject) => {
const onPull = (err, stream) => (err ? reject(err) : resolve(stream))
if (authconfig) {
docker.pull(
imageName,
{},
onPull,
{
username: authconfig.username,
password: authconfig.password,
serveraddress: authconfig.serveraddress,
}
)
if (auth) {
docker.pull(imageName, {}, onPull, auth)
} else {
docker.pull(imageName, onPull)
}
@@ -105,7 +115,12 @@ export function registerImageHandlers(session) {
)
})
return { success: true, message: `Image "${imageName}" pulled successfully`, image: imageName }
return {
success: true,
message: `Image "${imageName}" pulled successfully`,
image: imageName,
usedAuth: Boolean(auth),
}
})
session.respond('removeImage', async (args) => {
@@ -231,17 +246,25 @@ export function registerImageHandlers(session) {
session.respond('pushImage', async (args) => {
const imageName = validation.sanitizeString(args.image || args.id, 255)
if (!imageName) throw new Error('image name/id required')
const authconfig = getSessionAuthconfig(session)
const image = docker.getImage(args.id || imageName)
// Optional: retag before push (repo:tag destination)
let pushRef = imageName
if (args.repo) {
const repo = validation.sanitizeString(args.repo, 255)
const tag = validation.sanitizeString(args.tag || 'latest', 128)
if (!repo) throw new Error('repo required when tagging for push')
await docker.getImage(args.id || imageName).tag({ repo, tag })
pushRef = `${repo}:${tag}`
}
const authconfig = resolveRegistryAuth(session, {
credentialId: args.credentialId,
auth: args.auth,
autoVault: args.autoVault,
image: pushRef,
})
const image = docker.getImage(pushRef)
const stream = await image.push({
tag: args.tag || undefined,
authconfig: authconfig
? {
username: authconfig.username,
password: authconfig.password,
serveraddress: authconfig.serveraddress,
}
: undefined,
tag: args.repo ? undefined : args.tag || undefined,
authconfig: dockerAuthOpts(authconfig),
})
await new Promise((resolve, reject) => {
docker.modem.followProgress(
@@ -249,9 +272,12 @@ export function registerImageHandlers(session) {
(err) => (err ? reject(err) : resolve()),
(event) => {
try {
if (event?.error) {
logger.debug('push layer error event', { error: event.error, image: pushRef })
}
session.push(Pushes.pushProgress, {
type: 'pushProgress',
image: imageName,
image: pushRef,
status: event.status || null,
progress: event.progress || null,
progressDetail: event.progressDetail || null,
@@ -264,7 +290,12 @@ export function registerImageHandlers(session) {
}
)
})
return { success: true, message: `Image "${imageName}" pushed`, image: imageName }
return {
success: true,
message: `Image "${pushRef}" pushed`,
image: pushRef,
usedAuth: Boolean(authconfig),
}
})
session.respond('saveImage', async (args) => {
+34 -3
View File
@@ -206,8 +206,7 @@ export function registerSystemHandlers(session) {
logger.warn('checkAuth soft-fail, storing credentials', { error: err.message })
}
registryAuth.set(session.id, { username, serveraddress, password })
session.state.set('registryAuth', { username, serveraddress, password })
setSessionAuthconfig(session, { username, serveraddress, password })
return {
success: true,
message: `Authenticated as ${username}`,
@@ -216,15 +215,22 @@ export function registerSystemHandlers(session) {
})
session.respond('getAuthStatus', async () => {
const auth = session.state.get('registryAuth') || registryAuth.get(session.id)
const auth = getSessionAuthconfig(session)
return {
success: true,
authenticated: Boolean(auth),
username: auth?.username || null,
serveraddress: auth?.serveraddress || null,
credentialId: auth?.credentialId || null,
label: auth?.label || null,
}
})
session.respond('registryLogout', async () => {
clearSessionAuthconfig(session)
return { success: true, message: 'Registry session credentials cleared' }
})
session.respond('browseDirectory', async (args) => {
const requestedPath = args?.path || '/'
if (!validation.isValidDirectoryPath(requestedPath)) {
@@ -306,3 +312,28 @@ export function registerSystemHandlers(session) {
export function getSessionAuthconfig(session) {
return session.state.get('registryAuth') || registryAuth.get(session.id) || null
}
/**
* @param {import('../rpc/session.js').PeerSession} session
* @param {{ username: string, password: string, serveraddress?: string, credentialId?: string, label?: string }} auth
*/
export function setSessionAuthconfig(session, auth) {
if (!auth?.username || !auth?.password) return
const normalized = {
username: auth.username,
password: auth.password,
serveraddress: auth.serveraddress || 'https://index.docker.io/v1/',
credentialId: auth.credentialId || null,
label: auth.label || null,
}
registryAuth.set(session.id, normalized)
session.state.set('registryAuth', normalized)
}
/**
* @param {import('../rpc/session.js').PeerSession} session
*/
export function clearSessionAuthconfig(session) {
registryAuth.delete(session.id)
session.state.delete('registryAuth')
}
+131 -8
View File
@@ -5,6 +5,81 @@ import * as vault from '../core/registry-vault.js'
import * as validation from '../utils/validation.js'
import { docker } from '../services/docker.js'
import logger from '../utils/logger.js'
import { getSessionAuthconfig, setSessionAuthconfig, clearSessionAuthconfig } from './system.js'
/**
* Resolve Docker authconfig for pull/push from vault id, inline auth, or session.
* @param {import('../rpc/session.js').PeerSession} session
* @param {{ credentialId?: string, auth?: object, autoVault?: boolean, image?: string }} args
* @returns {{ username: string, password: string, serveraddress: string }|null}
*/
export function resolveRegistryAuth(session, args = {}) {
if (args.credentialId) {
const cred = vault.getCredential(String(args.credentialId))
if (!cred) throw Object.assign(new Error('Vault credential not found'), { code: 'VAULT_NOT_FOUND' })
return {
username: cred.username,
password: cred.password,
serveraddress: cred.serveraddress,
}
}
if (args.auth && args.auth.username && args.auth.password) {
return {
username: String(args.auth.username),
password: String(args.auth.password),
serveraddress:
args.auth.serveraddress || 'https://index.docker.io/v1/',
}
}
const sessionAuth = getSessionAuthconfig(session)
if (sessionAuth) {
return {
username: sessionAuth.username,
password: sessionAuth.password,
serveraddress: sessionAuth.serveraddress,
}
}
// Optional: match vault entry from image registry host
if (args.autoVault !== false && args.image) {
const host = registryHostFromImage(args.image)
if (host) {
const found = vault.findCredentialForServer(host)
if (found) {
return {
username: found.username,
password: found.password,
serveraddress: found.serveraddress,
}
}
}
}
return null
}
/**
* @param {string} image
* @returns {string|null}
*/
export function registryHostFromImage(image) {
if (!image || typeof image !== 'string') return null
const ref = image.split('@')[0]
const withoutTag = ref.includes('/')
? ref.replace(/:[^/]+$/, '')
: ref
// docker.io short names: nginx, library/nginx, user/app
const parts = withoutTag.split('/')
if (parts.length === 1) return 'docker.io'
if (parts.length === 2 && !parts[0].includes('.') && !parts[0].includes(':') && parts[0] !== 'localhost') {
return 'docker.io'
}
return parts[0].split(':')[0] || null
}
async function checkDockerAuth(authconfig) {
return new Promise((resolve, reject) => {
docker.checkAuth(authconfig, (err, res) => (err ? reject(err) : resolve(res)))
})
}
export function registerVaultHandlers(session) {
session.respond('listVaultCredentials', async () => {
@@ -22,16 +97,13 @@ export function registerVaultHandlers(session) {
args.serveraddress || 'https://index.docker.io/v1/',
512
)
const label = validation.sanitizeString(args.label || '', 128)
if (!username || !password) throw new Error('username and password required')
// Optional verify against engine
if (args.verify !== false) {
try {
await new Promise((resolve, reject) => {
docker.checkAuth({ username, password, serveraddress }, (err, res) =>
err ? reject(err) : resolve(res)
)
})
await checkDockerAuth({ username, password, serveraddress })
} catch (err) {
if (args.requireAuth) throw new Error(`Auth failed: ${err.message}`)
logger.warn('vault store: checkAuth soft-fail', { error: err.message })
@@ -43,7 +115,7 @@ export function registerVaultHandlers(session) {
username,
password,
serveraddress,
label: args.label || username,
label: label || username,
})
return { success: true, message: 'Credential stored encrypted at rest', data: stored }
})
@@ -55,20 +127,71 @@ export function registerVaultHandlers(session) {
session.respond('vaultUseCredential', async (args) => {
if (!args.id) throw new Error('credential id required')
const listed = vault.listCredentials().find((c) => c.id === args.id)
const cred = vault.getCredential(args.id)
if (!cred) throw new Error('Credential not found')
session.state.set('registryAuth', {
const auth = {
username: cred.username,
password: cred.password,
serveraddress: cred.serveraddress,
})
credentialId: args.id,
label: listed?.label || cred.username,
}
setSessionAuthconfig(session, auth)
return {
success: true,
message: `Using vault credential for ${cred.username}`,
data: {
id: args.id,
username: cred.username,
serveraddress: cred.serveraddress,
label: listed?.label || null,
},
}
})
session.respond('vaultTestCredential', async (args) => {
let authconfig = null
if (args.id) {
const cred = vault.getCredential(String(args.id))
if (!cred) throw new Error('Credential not found')
authconfig = {
username: cred.username,
password: cred.password,
serveraddress: cred.serveraddress,
}
} else {
const username = validation.sanitizeString(args.username, 128)
const password = args.password
const serveraddress = validation.sanitizeString(
args.serveraddress || 'https://index.docker.io/v1/',
512
)
if (!username || !password) throw new Error('id or username+password required')
authconfig = { username, password, serveraddress }
}
try {
const res = await checkDockerAuth(authconfig)
return {
success: true,
ok: true,
message: `Authenticated as ${authconfig.username}`,
data: {
username: authconfig.username,
serveraddress: authconfig.serveraddress,
identityToken: Boolean(res?.IdentityToken),
status: res?.Status || null,
},
}
} catch (err) {
throw Object.assign(new Error(`Auth failed: ${err.message}`), {
code: 'REGISTRY_AUTH_FAILED',
})
}
})
session.respond('vaultClearSession', async () => {
clearSessionAuthconfig(session)
return { success: true, message: 'Registry session credentials cleared' }
})
}
+6
View File
@@ -53,6 +53,7 @@ export const MethodRoles = Object.freeze({
searchImages: Roles.viewer,
getAuthStatus: Roles.viewer,
listVaultCredentials: Roles.viewer,
vaultTestCredential: Roles.operator,
listPeers: Roles.viewer,
/** Invite strings are secrets — admin only (not viewer/operator read-only) */
listInvites: Roles.admin,
@@ -124,6 +125,8 @@ export const MethodRoles = Object.freeze({
waitContainer: Roles.operator,
stackPull: Roles.operator,
vaultUseCredential: Roles.operator,
vaultClearSession: Roles.operator,
registryLogout: Roles.operator,
createService: Roles.operator,
updateService: Roles.operator,
scaleService: Roles.operator,
@@ -291,6 +294,7 @@ export const Methods = Object.freeze({
browseDirectory: 'browseDirectory',
dockerCommand: 'dockerCommand',
registryLogin: 'registryLogin',
registryLogout: 'registryLogout',
getAuthStatus: 'getAuthStatus',
// Registry vault
@@ -299,6 +303,8 @@ export const Methods = Object.freeze({
vaultListCredentials: 'listVaultCredentials',
listVaultCredentials: 'listVaultCredentials',
vaultUseCredential: 'vaultUseCredential',
vaultTestCredential: 'vaultTestCredential',
vaultClearSession: 'vaultClearSession',
// Peer ACL
listPeers: 'listPeers',
+25
View File
@@ -37,10 +37,16 @@ export const MethodSchemas = Object.freeze({
},
pullImage: {
image: { type: 'string', required: true, maxLen: 255 },
credentialId: { type: 'string', required: false, maxLen: 64 },
autoVault: { type: 'boolean', required: false },
},
pushImage: {
image: { type: 'string', required: false, maxLen: 255 },
id: { type: 'string', required: false, maxLen: 255 },
tag: { type: 'string', required: false, maxLen: 128 },
repo: { type: 'string', required: false, maxLen: 255 },
credentialId: { type: 'string', required: false, maxLen: 64 },
autoVault: { type: 'boolean', required: false },
},
deployStack: {
composeContent: { type: 'string', required: true, maxLen: 2_000_000 },
@@ -76,11 +82,30 @@ export const MethodSchemas = Object.freeze({
username: { type: 'string', required: true, maxLen: 128 },
password: { type: 'string', required: true, maxLen: 512 },
serveraddress: { type: 'string', required: false, maxLen: 512 },
label: { type: 'string', required: false, maxLen: 128 },
id: { type: 'string', required: false, maxLen: 64 },
verify: { type: 'boolean', required: false },
requireAuth: { type: 'boolean', required: false },
},
vaultTestCredential: {
id: { type: 'string', required: false, maxLen: 64 },
username: { type: 'string', required: false, maxLen: 128 },
password: { type: 'string', required: false, maxLen: 512 },
serveraddress: { type: 'string', required: false, maxLen: 512 },
},
vaultUseCredential: {
id: { type: 'string', required: true, maxLen: 64 },
},
vaultDeleteCredential: {
id: { type: 'string', required: true, maxLen: 64 },
},
vaultClearSession: {},
registryLogin: {
username: { type: 'string', required: true, maxLen: 128 },
password: { type: 'string', required: true, maxLen: 512 },
serveraddress: { type: 'string', required: false, maxLen: 512 },
},
registryLogout: {},
browseDirectory: {
path: { type: 'string', required: false, maxLen: 4096 },
},
+24
View File
@@ -13,8 +13,11 @@ const {
listCredentials,
getCredential,
deleteCredential,
findCredentialForServer,
} = await import('../server/core/registry-vault.js')
const { registryHostFromImage } = await import('../server/handlers/vault.js')
test('vault encrypts credentials at rest', (t) => {
const stored = storeCredential({
username: 'alice',
@@ -40,3 +43,24 @@ test('vault encrypts credentials at rest', (t) => {
deleteCredential(stored.id)
t.is(listCredentials().length, 0)
})
test('findCredentialForServer matches host', (t) => {
const stored = storeCredential({
username: 'bot',
password: 'tok',
serveraddress: 'ghcr.io',
label: 'ghcr',
})
const found = findCredentialForServer('ghcr.io')
t.ok(found)
t.is(found.id, stored.id)
t.is(found.username, 'bot')
deleteCredential(stored.id)
})
test('registryHostFromImage parses common refs', (t) => {
t.is(registryHostFromImage('nginx:latest'), 'docker.io')
t.is(registryHostFromImage('library/redis'), 'docker.io')
t.is(registryHostFromImage('ghcr.io/org/app:1'), 'ghcr.io')
t.is(registryHostFromImage('registry.example.com:5000/ns/img:v2'), 'registry.example.com')
})
+5
View File
@@ -22,6 +22,11 @@ const REQUIRED_IDS = [
'addc-always-pull',
'duplicate-always-pull',
'duplicate-container-form',
'images-view',
'images-panel-registries',
'registry-store-form',
'pushImageModal',
'pull-image-credential',
'deploy-view',
'tunnels-view',
'swarm-view',