Add full registry manager and image pull/push with vault credentials.
Release rolling / release (push) Has been cancelled
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:
@@ -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, '"')}">${short}</span>`;
|
||||
})
|
||||
.join('')
|
||||
: '<span class="badge bg-dark border border-secondary"><none></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
@@ -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, {})
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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">
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function escapeAttr(s) {
|
||||
return escapeHtml(s).replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/**
|
||||
* @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'))
|
||||
}
|
||||
@@ -37,8 +37,11 @@ const AUDIT_METHODS = new Set([
|
||||
'loadImageFinish',
|
||||
'importImage',
|
||||
'registryLogin',
|
||||
'registryLogout',
|
||||
'vaultStoreCredential',
|
||||
'vaultDeleteCredential',
|
||||
'vaultUseCredential',
|
||||
'vaultTestCredential',
|
||||
'invitePeer',
|
||||
'deleteInvite',
|
||||
'revokePeer',
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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' }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user