/** * Registry manager — vault, Hub search, remote V2 catalog/tags/manifest/delete. * Lives in the top-level Registry view (not under Images). */ import { manager, Methods } from '../client/manager.js' import { presentError } from '../client/errors.js' import { showAlert, showStatusIndicator, hideStatusIndicator, jobSpinnerHtml, } from './uiUtils.js' import { createProgressBar, updateProgressBar, removeProgressBar, } from './loadingStates.js' import { runJob, setJobProgress, appendJobLog } from '../client/jobs.js' import { beginPullProgress, endPullProgress, finalizePullProgress, } from '../client/pullProgress.js' const progress = { create: createProgressBar, update: updateProgressBar, remove: removeProgressBar, } /** * Apply a finalized pull snapshot onto the job (after RPC returns). * @param {ReturnType} final */ function applyFinalPullToJob(final) { if (!final) return const hasMilestone = Boolean(final.milestoneLine) setJobProgress(final.jobId, final.snapshot, { stepId: final.stepId, emit: !hasMilestone, }) if (hasMilestone) { appendJobLog( final.jobId, final.milestoneLine, final.snapshot.phase === 'error' ? 'error' : 'info' ) } } /** @type {Array} */ let cachedCredentials = [] /** @type {{ authenticated?: boolean, username?: string|null, serveraddress?: string|null, credentialId?: string|null, label?: string|null }|null} */ let cachedAuthStatus = null /** Browser state */ const browser = { /** @type {string[]} */ repositories: [], catalogSupported: null, catalogError: null, nextLast: null, activeRepo: null, /** @type {string[]} */ tags: [], /** @type {Map} */ tagMeta: new Map(), serveraddress: '', } function escapeHtml(s) { return String(s ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') } function escapeAttr(s) { return escapeHtml(s).replace(/'/g, ''') } function formatBytes(n) { if (n == null || !Number.isFinite(Number(n))) return '—' const v = Number(n) if (v < 1024) return `${v} B` if (v < 1024 ** 2) return `${(v / 1024).toFixed(1)} KB` if (v < 1024 ** 3) return `${(v / 1024 ** 2).toFixed(1)} MB` return `${(v / 1024 ** 3).toFixed(2)} GB` } /** * Credential id / server for remote browser RPCs. */ function browserAuthArgs() { const credSel = document.getElementById('registry-active-credential') const credVal = credSel?.value || '' const serverOverride = document.getElementById('registry-server-override')?.value?.trim() const args = {} const id = credentialIdFromSelect(credVal) if (id) args.credentialId = id if (serverOverride) args.serveraddress = serverOverride else if (credVal && credVal !== '__session__') { const c = cachedCredentials.find((x) => x.id === credVal) if (c?.serveraddress) args.serveraddress = c.serveraddress } else if (cachedAuthStatus?.serveraddress) { args.serveraddress = cachedAuthStatus.serveraddress } return args } /** * @returns {Promise} */ 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} */ 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 ${escapeHtml(tag)} ${dig} ${escapeHtml(formatBytes(meta.sizeBytes))}
` }) .join('') body.querySelectorAll('.reg-tag-manifest').forEach((btn) => { btn.addEventListener('click', () => inspectManifest(btn.dataset.tag)) }) body.querySelectorAll('.reg-tag-pull').forEach((btn) => { btn.addEventListener('click', () => pullTag(btn.dataset.tag)) }) body.querySelectorAll('.reg-tag-del').forEach((btn) => { btn.addEventListener('click', () => deleteTags([btn.dataset.tag])) }) const updateDel = () => { const n = body.querySelectorAll('.reg-tag-check:checked').length if (delBtn) delBtn.disabled = n === 0 } body.querySelectorAll('.reg-tag-check').forEach((cb) => { cb.addEventListener('change', updateDel) }) updateDel() if (typeof window.applyRoleUI === 'function') window.applyRoleUI() } export async function openRepository(repo) { const name = String(repo || '').trim() if (!name) return browser.activeRepo = name browser.tags = [] browser.tagMeta = new Map() document.getElementById('registry-manifest-panel')?.classList.add('d-none') renderCatalogList() renderTagsTable() const empty = document.getElementById('registry-tags-empty') if (empty) { empty.classList.remove('d-none') empty.innerHTML = jobSpinnerHtml(`Loading tags for ${name}…`) } try { showStatusIndicator(`Listing tags for ${name}…`) const res = await manager.request(Methods.registryListTags, { repository: name, ...browserAuthArgs(), }) browser.activeRepo = res.repository || name browser.tags = res.tags || [] browser.serveraddress = res.serveraddress || browser.serveraddress if (res.tagsDetail) { for (const t of res.tagsDetail) { browser.tagMeta.set(t.tag, t) } } renderTagsTable() showAlert('success', `${browser.tags.length} tag(s) in ${browser.activeRepo}`) } catch (err) { presentError(err, 'registryListTags', { showAlert }) if (empty) { empty.classList.remove('d-none') empty.textContent = err.message || 'Failed to list tags' } } finally { hideStatusIndicator() } } export async function enrichVisibleTags() { if (!browser.activeRepo || !browser.tags.length) return try { showStatusIndicator('Fetching digests…') const res = await manager.request(Methods.registryListTags, { repository: browser.activeRepo, enrich: true, enrichLimit: 50, ...browserAuthArgs(), }) if (res.tagsDetail) { for (const t of res.tagsDetail) { browser.tagMeta.set(t.tag, t) } } renderTagsTable() } catch (err) { presentError(err, 'registryListTags', { showAlert }) } finally { hideStatusIndicator() } } async function inspectManifest(tag) { if (!browser.activeRepo || !tag) return const panel = document.getElementById('registry-manifest-panel') const pre = document.getElementById('registry-manifest-json') try { showStatusIndicator(`Loading manifest ${tag}…`) const res = await manager.request(Methods.registryGetManifest, { repository: browser.activeRepo, reference: tag, ...browserAuthArgs(), }) if (res.digest) { const prev = browser.tagMeta.get(tag) || {} browser.tagMeta.set(tag, { ...prev, digest: res.digest, sizeBytes: res.sizeBytes ?? prev.sizeBytes, mediaType: res.mediaType || prev.mediaType, }) renderTagsTable() } if (panel) panel.classList.remove('d-none') if (pre) { pre.textContent = JSON.stringify( { repository: res.repository, reference: res.reference, digest: res.digest, mediaType: res.mediaType, sizeBytes: res.sizeBytes, architecture: res.architecture, os: res.os, platformCount: res.platformCount, manifest: res.manifest, }, null, 2 ) } } catch (err) { presentError(err, 'registryGetManifest', { showAlert }) } finally { hideStatusIndicator() } } async function pullTag(tag) { if (!browser.activeRepo || !tag) return const host = browser.serveraddress || browserAuthArgs().serveraddress || '' let image = `${browser.activeRepo}:${tag}` // Prefer fully qualified name for non-Hub when we know host if (host && !host.includes('docker.io') && !host.includes('index.docker.io')) { try { const u = host.includes('://') ? new URL(host) : new URL(`https://${host}`) const h = u.host if (h && !browser.activeRepo.startsWith(h + '/')) { image = `${h}/${browser.activeRepo}:${tag}` } } catch { // keep short form } } const credId = credentialIdFromSelect( document.getElementById('registry-active-credential')?.value ) await pullImageWithAuth({ image, credentialId: credId }).catch(() => {}) } async function deleteTags(tags) { if (!browser.activeRepo || !tags?.length) return const ok = window.peardockOps?.confirmDestructive ? await window.peardockOps.confirmDestructive( 'Delete remote tags', `Permanently delete ${tags.length} tag(s) from ${browser.activeRepo} on the registry? This cannot be undone. Some registries (Docker Hub) disallow remote delete.` ) : typeof confirm === 'function' ? confirm(`Delete ${tags.length} remote tag(s)?`) : true if (!ok) return try { showStatusIndicator(`Deleting ${tags.length} tag(s)…`) if (tags.length === 1) { await manager.request(Methods.registryDeleteTag, { repository: browser.activeRepo, reference: tags[0], ...browserAuthArgs(), }) showAlert('success', `Deleted ${tags[0]}`) } else { const res = await manager.request(Methods.registryDeleteTags, { repository: browser.activeRepo, tags, ...browserAuthArgs(), }) showAlert( 'success', `Deleted ${res.deleted || 0}, failed ${res.failed || 0}` ) } await openRepository(browser.activeRepo) } catch (err) { presentError(err, 'registryDeleteTag', { showAlert }) } finally { hideStatusIndicator() } } /** * Pull image with optional vault credential. * Uses the job tray hybrid pull UI (overall bar + active layers). * @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 // Remove any legacy in-page progress strip try { document.getElementById('progress-pull-image')?.remove() } catch { // ignore } try { const job = await runJob( `Pull ${image}`, [ { id: 'pull', label: 'Download layers', run: async ({ job: j, log }) => { const tracker = beginPullProgress(image, j.id, 'pull') const initial = tracker.snapshot() setJobProgress(j.id, initial, { stepId: 'pull', }) log(`Pulling ${image}…`) try { const res = await manager.request(Methods.pullImage, body) applyFinalPullToJob(finalizePullProgress(image, { ok: true })) log(res?.message || 'Pull complete') return res } catch (err) { applyFinalPullToJob( finalizePullProgress(image, { ok: false, message: err?.message || String(err), }) ) throw err } }, }, ], { icon: 'fa-download', subtitle: image } ) // Job tray is source of truth — light success toast only if drawer inactive const msg = job?.result?.message || `Pulled ${image}` if (!window.peardockOps?.isJobDrawerActive?.()) { showAlert('success', msg) } if (typeof window.loadImages === 'function') window.loadImages() else manager.request(Methods.listImages, {}).catch(() => {}) return job?.result || { success: true, message: msg, image } } catch (err) { endPullProgress(image) // runJob already logged error into the tray if (!err?.viaJob) { 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 // Prefer hybrid job-tray push progress (same UX as pull) if (typeof window.peardockOps?.pushImageJob === 'function') { try { document.getElementById('progress-push-image')?.remove() } catch { // ignore } try { const res = await window.peardockOps.pushImageJob({ ...args, image, body, }) if (typeof window.loadImages === 'function') window.loadImages() return res } catch (err) { if (!err?.viaJob) presentError(err, 'pushImage', { showAlert }) throw err } } const host = document.getElementById('registry-view') || 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 !== ':') 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 !== ':') for (const t of imgTags) { options.push({ id: img.Id, ref: t }) } } if (refSelect) { if (!options.length) { refSelect.innerHTML = '' } else { refSelect.innerHTML = options .map( (o) => `` ) .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) => ``) .join('') if (opts.defaultRef && tags.includes(opts.defaultRef)) refSelect.value = opts.defaultRef } else { refSelect.innerHTML = `` } } 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() bootstrap.Modal.getOrCreateInstance(modalEl).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' } /** * Wire DOM once. */ export function initRegistryManager() { document.querySelectorAll('[data-registry-tab]').forEach((btn) => { btn.addEventListener('click', () => { switchRegistryTab(btn.getAttribute('data-registry-tab') || 'browser') }) }) document.getElementById('registry-refresh-btn')?.addEventListener('click', () => { refreshRegistryPanel() }) document.getElementById('registry-catalog-btn')?.addEventListener('click', () => { loadRegistryCatalog({ reset: true }) }) document.getElementById('registry-catalog-more-btn')?.addEventListener('click', () => { loadRegistryCatalog({ reset: false }) }) document.getElementById('registry-open-repo-btn')?.addEventListener('click', () => { const name = document.getElementById('registry-open-repo')?.value?.trim() if (name) openRepository(name) }) document.getElementById('registry-open-repo')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault() const name = e.target.value?.trim() if (name) openRepository(name) } }) document.getElementById('registry-repo-filter')?.addEventListener('input', () => { renderCatalogList() }) document.getElementById('registry-tag-filter')?.addEventListener('input', () => { renderTagsTable() }) document.getElementById('registry-tags-refresh-btn')?.addEventListener('click', () => { if (browser.activeRepo) openRepository(browser.activeRepo) }) document.getElementById('registry-tags-enrich-btn')?.addEventListener('click', () => { enrichVisibleTags() }) document.getElementById('registry-tags-delete-selected-btn')?.addEventListener('click', () => { const checks = [ ...document.querySelectorAll('#registry-tags-body .reg-tag-check:checked'), ] const tags = checks.map((c) => c.value).filter(Boolean) deleteTags(tags) }) document.getElementById('registry-tags-select-all')?.addEventListener('change', (e) => { const on = e.target.checked document.querySelectorAll('#registry-tags-body .reg-tag-check').forEach((cb) => { cb.checked = on }) const delBtn = document.getElementById('registry-tags-delete-selected-btn') if (delBtn) { delBtn.disabled = !on || !document.querySelectorAll('#registry-tags-body .reg-tag-check').length } }) // 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 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 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 = '
Searching…
' try { const res = await manager.request(Methods.searchImages, { term, limit: 25 }) const data = res?.data || [] if (!data.length) { out.innerHTML = '
No results
' 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 `
${escapeHtml(name)}
${escapeHtml(desc)}
${stars} ★
` }) .join('') out.querySelectorAll('.hub-pull').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() } }) }) out.querySelectorAll('.hub-open').forEach((btn) => { btn.addEventListener('click', () => { switchRegistryTab('browser') const open = document.getElementById('registry-open-repo') if (open) open.value = btn.dataset.name openRepository(btn.dataset.name) }) }) } catch (err) { out.innerHTML = `
${escapeHtml(err.message)}
` } } hubBtn.addEventListener('click', runSearch) hubTerm?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault() runSearch() } }) } // Pull modal 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(() => {}) }) } window.openPushImageModal = openPushImageModal window.pullImageWithAuth = pullImageWithAuth window.pushImageWithAuth = pushImageWithAuth window.refreshRegistryPanel = refreshRegistryPanel window.loadRegistryView = loadRegistryView window.switchImagesTab = switchImagesTab window.switchRegistryTab = switchRegistryTab window.preparePullModal = preparePullModal window.openRegistryRepository = openRepository } /** * Refresh credential dropdown when pull modal opens. */ export async function preparePullModal() { try { await loadVaultCredentials() await loadAuthStatus() } catch { // ignore } fillCredentialSelect(document.getElementById('pull-image-credential')) }