Files
peardock/libs/registryManager.js
T
Raven Scott 5b1acdf324
Release rolling / release (push) Has been cancelled
Use tray spinner for pending UI loading states
Replace em-dash and ad-hoc loaders with the shared job-tray spinner
for deploy templates, host/swarm/tunnels/events, health latency,
container stats/logs/processes, and registry/volume browsers.
2026-07-16 14:30:59 -04:00

1239 lines
43 KiB
JavaScript

/**
* 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<typeof finalizePullProgress>} 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<object>} */
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<string, { digest?: string|null, sizeBytes?: number|null, mediaType?: string|null, error?: string }>} */
tagMeta: new Map(),
serveraddress: '',
}
function escapeHtml(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function escapeAttr(s) {
return escapeHtml(s).replace(/'/g, '&#39;')
}
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<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
}
/**
* Switch Registry view subtab.
* @param {'browser'|'hub'|'credentials'} tab
*/
export function switchRegistryTab(tab) {
const name = tab || 'browser'
document.querySelectorAll('[data-registry-tab]').forEach((btn) => {
btn.classList.toggle('active', btn.getAttribute('data-registry-tab') === name)
})
document.querySelectorAll('[data-registry-panel]').forEach((panel) => {
panel.classList.toggle('hidden', panel.getAttribute('data-registry-panel') !== name)
})
if (name === 'credentials' || name === 'browser') {
refreshRegistryPanel().catch(() => {})
}
}
/** @deprecated use switchRegistryTab — kept for any leftover callers */
export function switchImagesTab(tab) {
if (tab === 'registries') {
if (typeof window.navigateToView === 'function') window.navigateToView('registry')
else switchRegistryTab('credentials')
return
}
// local images are their own view now
if (typeof window.navigateToView === 'function') window.navigateToView('images')
}
/**
* Refresh registry manager panel UI (session, vault list, browser credential select).
*/
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 under Credentials for private pulls/pushes and remote delete.'
}
}
if (listEl) {
if (!cachedCredentials.length) {
listEl.innerHTML =
'<p class="text-muted small mb-0">No stored credentials. Add one to pull/push private images and browse protected catalogs.</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-primary reg-browse" data-id="${escapeAttr(c.id)}" title="Browse this registry">
<i class="fas fa-folder-open"></i>
</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-browse').forEach((btn) => {
btn.addEventListener('click', () => {
const c = cachedCredentials.find((x) => x.id === btn.dataset.id)
const sel = document.getElementById('registry-active-credential')
if (sel) sel.value = btn.dataset.id
if (c?.serveraddress) {
const o = document.getElementById('registry-server-override')
if (o) o.value = c.serveraddress
}
switchRegistryTab('browser')
loadRegistryCatalog({ reset: true }).catch(() => {})
})
})
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()
}
export async function loadRegistryView() {
switchRegistryTab(
document.querySelector('[data-registry-tab].active')?.getAttribute('data-registry-tab') ||
'browser'
)
await refreshRegistryPanel()
}
function renderCatalogList() {
const list = document.getElementById('registry-catalog-list')
const status = document.getElementById('registry-catalog-status')
const filter = (document.getElementById('registry-repo-filter')?.value || '').toLowerCase()
if (!list) return
let repos = browser.repositories
if (filter) repos = repos.filter((r) => r.toLowerCase().includes(filter))
if (status) {
if (browser.catalogError) {
status.textContent = browser.catalogError
status.className = 'small text-warning mb-2'
} else if (browser.catalogSupported === false) {
status.textContent =
'Catalog API unavailable for this registry. Open a repository by name (right-hand field).'
status.className = 'small text-muted mb-2'
} else if (browser.repositories.length) {
status.textContent = `${browser.repositories.length} repositor${browser.repositories.length === 1 ? 'y' : 'ies'}${filter ? ` · ${repos.length} shown` : ''}`
status.className = 'small text-muted mb-2'
} else {
status.textContent = 'No catalog loaded yet. Click Catalog or open a repo by name.'
status.className = 'small text-muted mb-2'
}
}
if (!repos.length) {
list.innerHTML =
'<div class="text-muted small p-2">No repositories to show.</div>'
return
}
list.innerHTML = repos
.map(
(r) =>
`<button type="button" class="list-group-item list-group-item-action bg-dark text-white border-secondary reg-repo-pick font-monospace ${
browser.activeRepo === r ? 'active' : ''
}" data-repo="${escapeAttr(r)}">${escapeHtml(r)}</button>`
)
.join('')
list.querySelectorAll('.reg-repo-pick').forEach((btn) => {
btn.addEventListener('click', () => {
openRepository(btn.dataset.repo)
})
})
}
export async function loadRegistryCatalog(opts = {}) {
const status = document.getElementById('registry-catalog-status')
const moreBtn = document.getElementById('registry-catalog-more-btn')
if (!manager.active?.connected) {
if (status) status.textContent = 'Not connected'
return
}
if (status) {
status.className = 'small text-muted mb-2 d-flex align-items-center gap-2'
status.innerHTML = jobSpinnerHtml('Loading catalog…')
}
try {
const args = {
...browserAuthArgs(),
n: 100,
}
if (!opts.reset && browser.nextLast) args.last = browser.nextLast
const res = await manager.request(Methods.registryCatalog, args)
if (opts.reset) browser.repositories = []
const next = res?.repositories || []
for (const r of next) {
if (!browser.repositories.includes(r)) browser.repositories.push(r)
}
browser.catalogSupported = res?.supported !== false
browser.catalogError = res?.error || null
browser.nextLast = res?.nextLast || null
browser.serveraddress = res?.serveraddress || browser.serveraddress
if (moreBtn) moreBtn.disabled = !browser.nextLast
renderCatalogList()
} catch (err) {
browser.catalogError = err.message
browser.catalogSupported = false
if (status) {
status.textContent = err.message
status.className = 'small text-danger mb-2'
}
presentError(err, 'registryCatalog', { showAlert })
}
}
function renderTagsTable() {
const empty = document.getElementById('registry-tags-empty')
const table = document.getElementById('registry-tags-table')
const body = document.getElementById('registry-tags-body')
const toolbar = document.getElementById('registry-tags-toolbar')
const countEl = document.getElementById('registry-tag-count')
const repoLabel = document.getElementById('registry-active-repo')
const delBtn = document.getElementById('registry-tags-delete-selected-btn')
const refreshBtn = document.getElementById('registry-tags-refresh-btn')
const enrichBtn = document.getElementById('registry-tags-enrich-btn')
if (repoLabel) repoLabel.textContent = browser.activeRepo || ''
if (!browser.activeRepo) {
if (empty) empty.classList.remove('d-none')
if (table) table.classList.add('d-none')
if (toolbar) toolbar.classList.add('d-none')
if (delBtn) delBtn.disabled = true
if (refreshBtn) refreshBtn.disabled = true
if (enrichBtn) enrichBtn.disabled = true
return
}
if (empty) empty.classList.add('d-none')
if (toolbar) toolbar.classList.remove('d-none')
if (table) table.classList.remove('d-none')
if (refreshBtn) refreshBtn.disabled = false
if (enrichBtn) enrichBtn.disabled = !browser.tags.length
const filter = (document.getElementById('registry-tag-filter')?.value || '').toLowerCase()
let tags = browser.tags
if (filter) tags = tags.filter((t) => t.toLowerCase().includes(filter))
if (countEl) {
countEl.textContent = `${browser.tags.length} tag(s)${filter ? ` · ${tags.length} shown` : ''}`
}
if (!body) return
if (!tags.length) {
body.innerHTML = `<tr><td colspan="5" class="text-muted small">No tags match.</td></tr>`
if (delBtn) delBtn.disabled = true
return
}
body.innerHTML = tags
.map((tag) => {
const meta = browser.tagMeta.get(tag) || {}
const dig = meta.digest
? `<code class="small" title="${escapeAttr(meta.digest)}">${escapeHtml(meta.digest.slice(0, 19))}…</code>`
: meta.error
? `<span class="text-danger small" title="${escapeAttr(meta.error)}">error</span>`
: '<span class="text-muted">—</span>'
return `<tr data-tag="${escapeAttr(tag)}">
<td><input type="checkbox" class="reg-tag-check" value="${escapeAttr(tag)}"></td>
<td class="font-monospace">${escapeHtml(tag)}</td>
<td>${dig}</td>
<td class="small">${escapeHtml(formatBytes(meta.sizeBytes))}</td>
<td>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-info reg-tag-manifest" data-tag="${escapeAttr(tag)}" title="Inspect manifest">
<i class="fas fa-file-code"></i>
</button>
<button type="button" class="btn btn-outline-success reg-tag-pull" data-tag="${escapeAttr(tag)}" data-min-role="operator" title="Pull to host">
<i class="fas fa-download"></i>
</button>
<button type="button" class="btn btn-outline-danger reg-tag-del" data-tag="${escapeAttr(tag)}" data-min-role="admin" title="Delete remote tag">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>`
})
.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 !== '<none>:<none>')
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()
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 = '<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 `<div class="list-group-item bg-dark text-white border-secondary">
<div class="d-flex justify-content-between align-items-start gap-2">
<div class="min-w-0">
<strong class="font-monospace">${escapeHtml(name)}</strong>
<div class="small text-muted text-truncate">${escapeHtml(desc)}</div>
</div>
<span class="badge bg-secondary flex-shrink-0">${stars} ★</span>
</div>
<div class="btn-group btn-group-sm mt-2">
<button type="button" class="btn btn-outline-success hub-pull" data-name="${escapeAttr(name)}">
<i class="fas fa-download me-1"></i>Pull
</button>
<button type="button" class="btn btn-outline-info hub-open" data-name="${escapeAttr(name)}">
<i class="fas fa-folder-open me-1"></i>Tags
</button>
</div>
</div>`
})
.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 = `<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
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'))
}