Release rolling / release (push) Has been cancelled
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.
1239 lines
43 KiB
JavaScript
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, '&')
|
|
.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<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'))
|
|
}
|