Expose ttl.sh in Flatten and Push Image target lists with TTL presets, auto-retag to ttl.sh/name:duration, and skip vault auth for public temp pushes.
This commit is contained in:
+10
-3
@@ -503,7 +503,11 @@ export async function flattenContainerJob(args) {
|
||||
log(`ENV: ${lines.length} variable(s)`)
|
||||
}
|
||||
log(`Push after flatten: ${doPush ? 'yes' : 'no'}`)
|
||||
if (doPush && args.credentialId) log(`Credential: ${args.credentialId}`)
|
||||
if (doPush && args.pushTarget === 'ttl.sh') {
|
||||
log(`Push target: ttl.sh (anonymous ephemeral · tag is TTL)`)
|
||||
} else if (doPush && args.credentialId) {
|
||||
log(`Credential: ${args.credentialId}`)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -598,14 +602,17 @@ export async function flattenContainerJob(args) {
|
||||
})
|
||||
setJobProgress(job.id, tracker.snapshot(), { stepId: 'push' })
|
||||
log(`Pushing ${ref}…`)
|
||||
if (args.credentialId) {
|
||||
if (args.pushTarget === 'ttl.sh') {
|
||||
log('Anonymous push to ttl.sh — image is public and will expire with the TTL tag')
|
||||
} else if (args.credentialId) {
|
||||
log(`Using vault credential ${args.credentialId} (auto-retags short names onto that registry)`)
|
||||
}
|
||||
try {
|
||||
const body = {
|
||||
image: ref,
|
||||
id: ref,
|
||||
autoVault: true,
|
||||
// ttl.sh is anonymous; skip vault matching that could attach wrong Hub creds
|
||||
autoVault: args.pushTarget === 'ttl.sh' ? false : true,
|
||||
}
|
||||
if (args.credentialId) body.credentialId = args.credentialId
|
||||
const res = await manager.request(Methods.pushImage, body)
|
||||
|
||||
+3
-1
@@ -46,7 +46,9 @@ What PearDock can do today, mapped to code and protocol surfaces.
|
||||
**UI:** Containers view + detail pane + **Add container** page (header button).
|
||||
**RPC:** list/inspect/start/stop/restart/kill/pause/unpause/remove/recreate/rename/update/deploy/bulk/top/stats/logs/exec/attach/commit/export/flatten/archive/duplicate/prune.
|
||||
|
||||
**Container Flattener** (Manage → Flatten in the container ⋮ actions modal): host-side `docker export | docker import` that squashes the container filesystem into a **single-layer** image. UI sets repo/tag, optional message, pause-during-export, re-applies CMD/ENTRYPOINT/WORKDIR/USER/ENV from inspect, and can **push** to a registry with vault credentials. Progress runs in the **job tray** (`flattenContainer` + optional `pushImage`). Unlike Commit, history/parent layers are discarded.
|
||||
**Container Flattener** (Manage → Flatten in the container ⋮ actions modal): host-side `docker export | docker import` that squashes the container filesystem into a **single-layer** image. UI sets repo/tag, optional message, pause-during-export, re-applies CMD/ENTRYPOINT/WORKDIR/USER/ENV from inspect, and can **push** to a registry with vault credentials — or to **[ttl.sh](https://ttl.sh/)** as an anonymous temporary public target (TTL tag `5m`–`24h`, no auth). Progress runs in the **job tray** (`flattenContainer` + optional `pushImage`). Unlike Commit, history/parent layers are discarded.
|
||||
|
||||
**ttl.sh push target**: available in Flatten and Push Image credential dropdowns as “ttl.sh · temporary”. Forces `ttl.sh/<name>:<ttl>` retag; `autoVault` is off so Hub session credentials are not sent.
|
||||
|
||||
**Image update indicators** (style): **Updates** column compares each container’s local image `RepoDigest` to the remote registry manifest digest for the same tag. Green check = up to date, orange up-arrow = update available (click to pull), grey dash = unknown/skipped. **Check updates** lives in the containers action bar and reports results in the **job tray**. Server caches digests (~5m). Uses vault credentials for private registries. RPC: `checkImageUpdates`.
|
||||
|
||||
|
||||
+35
-5
@@ -5316,12 +5316,27 @@ services:
|
||||
<input type="text" id="push-image-tag" class="form-control bg-dark text-white font-monospace" value="latest" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label for="push-image-credential" class="form-label">Registry credential</label>
|
||||
<div class="mb-3">
|
||||
<label for="push-image-credential" class="form-label">Registry credential / target</label>
|
||||
<select id="push-image-credential" class="form-select bg-dark text-white registry-cred-select">
|
||||
<option value="">No credential (public / session auto)</option>
|
||||
</select>
|
||||
<small class="text-muted">Required for most private registries. Manage credentials under Images → Registries.</small>
|
||||
<small class="text-muted">Required for most private registries. Manage credentials under Registry. Or pick <strong>ttl.sh</strong> for a temporary public push.</small>
|
||||
</div>
|
||||
<div id="push-ttl-sh-fields" class="push-ttl-sh-panel mb-0" style="display: none;">
|
||||
<label for="push-ttl-sh-duration" class="form-label">ttl.sh lifetime</label>
|
||||
<select id="push-ttl-sh-duration" class="form-select bg-dark text-white">
|
||||
<option value="5m">5 minutes</option>
|
||||
<option value="30m">30 minutes</option>
|
||||
<option value="1h" selected>1 hour (default)</option>
|
||||
<option value="6h">6 hours</option>
|
||||
<option value="12h">12 hours</option>
|
||||
<option value="24h">24 hours (max)</option>
|
||||
</select>
|
||||
<small class="text-muted d-block mt-1">
|
||||
Anonymous ephemeral registry at <a href="https://ttl.sh/" target="_blank" rel="noopener noreferrer">ttl.sh</a>.
|
||||
Images are <strong>public</strong> and auto-delete after the TTL. Tag becomes the duration (e.g. <code>1h</code>).
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -5596,11 +5611,26 @@ services:
|
||||
</div>
|
||||
</div>
|
||||
<div id="flatten-push-fields" class="flatten-push-panel mb-3" style="display: none;">
|
||||
<label for="flatten-credential" class="form-label">Registry credential</label>
|
||||
<label for="flatten-credential" class="form-label">Registry credential / target</label>
|
||||
<select id="flatten-credential" class="form-select bg-dark text-white registry-cred-select">
|
||||
<option value="">No credential (public / session auto)</option>
|
||||
</select>
|
||||
<small class="text-muted d-block">Uses vault credentials from <strong>Registry</strong>. Selecting one prefixes the repository with that registry host so the push does not go to Docker Hub.</small>
|
||||
<small class="text-muted d-block">Vault credentials from <strong>Registry</strong>, or <strong>ttl.sh</strong> for a temporary public push (no auth, auto-expire).</small>
|
||||
<div id="flatten-ttl-sh-fields" class="mt-2" style="display: none;">
|
||||
<label for="flatten-ttl-sh-duration" class="form-label">ttl.sh lifetime</label>
|
||||
<select id="flatten-ttl-sh-duration" class="form-select bg-dark text-white form-select-sm">
|
||||
<option value="5m">5 minutes</option>
|
||||
<option value="30m">30 minutes</option>
|
||||
<option value="1h" selected>1 hour (default)</option>
|
||||
<option value="6h">6 hours</option>
|
||||
<option value="12h">12 hours</option>
|
||||
<option value="24h">24 hours (max)</option>
|
||||
</select>
|
||||
<small class="text-muted d-block mt-1">
|
||||
Pushes to <a href="https://ttl.sh/" target="_blank" rel="noopener noreferrer">ttl.sh</a> anonymously.
|
||||
Public image; tag is the TTL (e.g. <code class="flatten-code">ttl.sh/myapp:1h</code>).
|
||||
</small>
|
||||
</div>
|
||||
<div id="flatten-push-hint" class="flatten-push-hint small mt-2" hidden></div>
|
||||
</div>
|
||||
|
||||
|
||||
+128
-24
@@ -15,12 +15,19 @@ import {
|
||||
fillCredentialSelect,
|
||||
credentialIdFromSelect,
|
||||
getCachedCredentials,
|
||||
isTtlShSelectValue,
|
||||
ttlShRepoFromName,
|
||||
stripRegistryPrefix,
|
||||
isValidTtlShDuration,
|
||||
TTL_SH_DEFAULT_TTL,
|
||||
} from './registryManager.js'
|
||||
|
||||
/** @type {{ container: object|null, inspect: object|null }} */
|
||||
/** @type {{ container: object|null, inspect: object|null, preTtlRepo?: string|null, preTtlTag?: string|null }} */
|
||||
const state = {
|
||||
container: null,
|
||||
inspect: null,
|
||||
preTtlRepo: null,
|
||||
preTtlTag: null,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,19 +171,66 @@ function updateRefPreview() {
|
||||
}
|
||||
|
||||
/**
|
||||
* When push is on + vault credential selected, prefix repo with that registry.
|
||||
* Show/hide ttl.sh lifetime controls.
|
||||
* @param {boolean} on
|
||||
*/
|
||||
function toggleTtlShFields(on) {
|
||||
const wrap = document.getElementById('flatten-ttl-sh-fields')
|
||||
if (wrap) wrap.style.display = on ? '' : 'none'
|
||||
const tagLabel = document.querySelector('label[for="flatten-tag"]')
|
||||
if (tagLabel) {
|
||||
tagLabel.textContent = on ? 'TTL tag' : 'Tag'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply selected push target (vault registry host or ttl.sh).
|
||||
* @param {{ force?: boolean }} [opts]
|
||||
*/
|
||||
function applyCredentialRegistryPrefix(opts = {}) {
|
||||
const pushOn = document.getElementById('flatten-push')?.checked
|
||||
if (!pushOn && !opts.force) return
|
||||
const credVal = document.getElementById('flatten-credential')?.value
|
||||
const credId = credentialIdFromSelect(credVal)
|
||||
if (!credId) return
|
||||
const cred = getCachedCredentials().find((c) => c.id === credId)
|
||||
if (!cred?.serveraddress) return
|
||||
const repoEl = document.getElementById('flatten-repo')
|
||||
const tagEl = document.getElementById('flatten-tag')
|
||||
if (!repoEl) return
|
||||
|
||||
if (isTtlShSelectValue(credVal)) {
|
||||
// Remember non-ttl values so we can restore when leaving ttl.sh
|
||||
if (!repoEl.value.trim().startsWith('ttl.sh/')) {
|
||||
state.preTtlRepo = repoEl.value.trim()
|
||||
state.preTtlTag = tagEl?.value?.trim() || null
|
||||
}
|
||||
const base = stripRegistryPrefix(state.preTtlRepo || repoEl.value.trim())
|
||||
repoEl.value = ttlShRepoFromName(base)
|
||||
const ttl =
|
||||
document.getElementById('flatten-ttl-sh-duration')?.value || TTL_SH_DEFAULT_TTL
|
||||
if (tagEl) tagEl.value = ttl
|
||||
toggleTtlShFields(true)
|
||||
updateRefPreview()
|
||||
updatePushHint()
|
||||
return
|
||||
}
|
||||
|
||||
// Leaving ttl.sh — restore prior repo/tag once
|
||||
if (repoEl.value.trim().startsWith('ttl.sh/') && state.preTtlRepo != null) {
|
||||
repoEl.value = state.preTtlRepo
|
||||
if (tagEl && state.preTtlTag != null) tagEl.value = state.preTtlTag
|
||||
state.preTtlRepo = null
|
||||
state.preTtlTag = null
|
||||
}
|
||||
toggleTtlShFields(false)
|
||||
|
||||
const credId = credentialIdFromSelect(credVal)
|
||||
if (!credId) {
|
||||
updatePushHint()
|
||||
return
|
||||
}
|
||||
const cred = getCachedCredentials().find((c) => c.id === credId)
|
||||
if (!cred?.serveraddress) {
|
||||
updatePushHint()
|
||||
return
|
||||
}
|
||||
const next = ensureRepoUnderRegistry(repoEl.value.trim(), cred.serveraddress)
|
||||
if (next && next !== repoEl.value.trim()) {
|
||||
repoEl.value = next
|
||||
@@ -195,19 +249,32 @@ function updatePushHint() {
|
||||
return
|
||||
}
|
||||
const credVal = document.getElementById('flatten-credential')?.value
|
||||
const credId = credentialIdFromSelect(credVal)
|
||||
const repo = document.getElementById('flatten-repo')?.value?.trim() || ''
|
||||
const tag = document.getElementById('flatten-tag')?.value?.trim() || 'latest'
|
||||
const ref = repo ? `${repo}:${tag}` : '…'
|
||||
|
||||
if (isTtlShSelectValue(credVal)) {
|
||||
const ttl = isValidTtlShDuration(tag) ? tag : TTL_SH_DEFAULT_TTL
|
||||
hint.hidden = false
|
||||
hint.innerHTML =
|
||||
`<i class="fas fa-hourglass-half me-1"></i>` +
|
||||
`Temporary public push to <a href="https://ttl.sh/" target="_blank" rel="noopener noreferrer">ttl.sh</a> — ` +
|
||||
`<code class="flatten-code">${escapeHtml(ref.startsWith('ttl.sh/') ? ref : `ttl.sh/…:${ttl}`)}</code>. ` +
|
||||
`No login. Image expires after <strong>${escapeHtml(ttl)}</strong> and is world-readable.`
|
||||
return
|
||||
}
|
||||
|
||||
const credId = credentialIdFromSelect(credVal)
|
||||
if (!credId) {
|
||||
hint.hidden = false
|
||||
hint.innerHTML =
|
||||
'<i class="fas fa-info-circle me-1"></i>No vault credential — push uses session auth or anonymous. ' +
|
||||
'Short names go to <strong>Docker Hub</strong>; include <code class="flatten-code">host:port/name</code> for a private registry.'
|
||||
'Short names go to <strong>Docker Hub</strong>; include <code class="flatten-code">host:port/name</code> for a private registry, ' +
|
||||
'or choose <strong>ttl.sh</strong> for a temporary public target.'
|
||||
return
|
||||
}
|
||||
const cred = getCachedCredentials().find((c) => c.id === credId)
|
||||
const prefix = registryPrefixFromServeraddress(cred?.serveraddress)
|
||||
const ref = repo ? `${repo}:${tag}` : '…'
|
||||
if (prefix && !repoHasRegistryHost(repo)) {
|
||||
hint.hidden = false
|
||||
hint.innerHTML =
|
||||
@@ -229,6 +296,7 @@ function togglePushFields() {
|
||||
const wrap = document.getElementById('flatten-push-fields')
|
||||
if (wrap) wrap.style.display = on ? '' : 'none'
|
||||
if (on) applyCredentialRegistryPrefix()
|
||||
else toggleTtlShFields(false)
|
||||
updatePushHint()
|
||||
}
|
||||
|
||||
@@ -353,6 +421,9 @@ export async function openContainerFlattenerModal(container) {
|
||||
if (pushEl) pushEl.checked = false
|
||||
if (advEl) advEl.checked = false
|
||||
if (preserveEl) preserveEl.checked = true
|
||||
state.preTtlRepo = null
|
||||
state.preTtlTag = null
|
||||
toggleTtlShFields(false)
|
||||
togglePushFields()
|
||||
toggleAdvanced()
|
||||
updateRefPreview()
|
||||
@@ -363,7 +434,9 @@ export async function openContainerFlattenerModal(container) {
|
||||
} catch {
|
||||
// continue without vault
|
||||
}
|
||||
fillCredentialSelect(document.getElementById('flatten-credential'))
|
||||
fillCredentialSelect(document.getElementById('flatten-credential'), {
|
||||
includeTtlSh: true,
|
||||
})
|
||||
|
||||
bootstrap.Modal.getOrCreateInstance(modalEl).show()
|
||||
|
||||
@@ -389,7 +462,7 @@ function collectFlattenArgs() {
|
||||
if (!container?.Id) throw new Error('No container selected')
|
||||
|
||||
let repo = document.getElementById('flatten-repo')?.value?.trim() || ''
|
||||
const tag = document.getElementById('flatten-tag')?.value?.trim() || 'latest'
|
||||
let tag = document.getElementById('flatten-tag')?.value?.trim() || 'latest'
|
||||
if (!repo) throw new Error('Repository name is required')
|
||||
|
||||
const name = (container.Names?.[0] || '').replace(/^\//, '') || container.Id.slice(0, 12)
|
||||
@@ -406,19 +479,40 @@ function collectFlattenArgs() {
|
||||
if (message) args.message = message
|
||||
|
||||
const credVal = document.getElementById('flatten-credential')?.value
|
||||
const credId = credentialIdFromSelect(credVal)
|
||||
if (args.push && credId) {
|
||||
args.credentialId = credId
|
||||
// Tag the flat image under the credential's registry so push does not hit Docker Hub
|
||||
const cred = getCachedCredentials().find((c) => c.id === credId)
|
||||
if (cred?.serveraddress) {
|
||||
const under = ensureRepoUnderRegistry(repo, cred.serveraddress)
|
||||
if (under !== repo) {
|
||||
args.repo = under
|
||||
repo = under
|
||||
const repoEl = document.getElementById('flatten-repo')
|
||||
if (repoEl) repoEl.value = under
|
||||
updateRefPreview()
|
||||
if (args.push && isTtlShSelectValue(credVal)) {
|
||||
// Anonymous ephemeral registry — tag must be the TTL duration
|
||||
const ttl =
|
||||
document.getElementById('flatten-ttl-sh-duration')?.value ||
|
||||
(isValidTtlShDuration(tag) ? tag : TTL_SH_DEFAULT_TTL)
|
||||
if (!isValidTtlShDuration(ttl)) {
|
||||
throw new Error('ttl.sh lifetime must be like 1h or 30m (max 24h)')
|
||||
}
|
||||
repo = ttlShRepoFromName(repo)
|
||||
tag = ttl
|
||||
args.repo = repo
|
||||
args.tag = tag
|
||||
args.pushTarget = 'ttl.sh'
|
||||
// no credentialId — anonymous
|
||||
const repoEl = document.getElementById('flatten-repo')
|
||||
const tagEl = document.getElementById('flatten-tag')
|
||||
if (repoEl) repoEl.value = repo
|
||||
if (tagEl) tagEl.value = tag
|
||||
updateRefPreview()
|
||||
} else {
|
||||
const credId = credentialIdFromSelect(credVal)
|
||||
if (args.push && credId) {
|
||||
args.credentialId = credId
|
||||
// Tag the flat image under the credential's registry so push does not hit Docker Hub
|
||||
const cred = getCachedCredentials().find((c) => c.id === credId)
|
||||
if (cred?.serveraddress) {
|
||||
const under = ensureRepoUnderRegistry(repo, cred.serveraddress)
|
||||
if (under !== repo) {
|
||||
args.repo = under
|
||||
repo = under
|
||||
const repoEl = document.getElementById('flatten-repo')
|
||||
if (repoEl) repoEl.value = under
|
||||
updateRefPreview()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -524,6 +618,14 @@ export function initContainerFlattener() {
|
||||
applyCredentialRegistryPrefix()
|
||||
updatePushHint()
|
||||
})
|
||||
document.getElementById('flatten-ttl-sh-duration')?.addEventListener('change', () => {
|
||||
if (!isTtlShSelectValue(document.getElementById('flatten-credential')?.value)) return
|
||||
const tagEl = document.getElementById('flatten-tag')
|
||||
const ttl =
|
||||
document.getElementById('flatten-ttl-sh-duration')?.value || TTL_SH_DEFAULT_TTL
|
||||
if (tagEl) tagEl.value = ttl
|
||||
updateRefPreview()
|
||||
})
|
||||
document.getElementById('flatten-advanced-toggle')?.addEventListener('change', toggleAdvanced)
|
||||
|
||||
document.getElementById('flatten-preserve-config')?.addEventListener('change', (e) => {
|
||||
@@ -547,6 +649,8 @@ export function initContainerFlattener() {
|
||||
modalEl.addEventListener('hidden.bs.modal', () => {
|
||||
state.container = null
|
||||
state.inspect = null
|
||||
state.preTtlRepo = null
|
||||
state.preTtlTag = null
|
||||
})
|
||||
|
||||
window.openContainerFlattenerModal = openContainerFlattenerModal
|
||||
|
||||
+162
-12
@@ -227,20 +227,104 @@ export function getCachedCredentials() {
|
||||
return cachedCredentials
|
||||
}
|
||||
|
||||
/** Sentinel select value: anonymous ephemeral push to https://ttl.sh */
|
||||
export const TTL_SH_SELECT_VALUE = '__ttl_sh__'
|
||||
|
||||
/** Default image lifetime on ttl.sh when none chosen */
|
||||
export const TTL_SH_DEFAULT_TTL = '1h'
|
||||
|
||||
/** Common TTL presets (max 24h per ttl.sh) */
|
||||
export const TTL_SH_PRESETS = Object.freeze([
|
||||
{ value: '5m', label: '5 minutes' },
|
||||
{ value: '30m', label: '30 minutes' },
|
||||
{ value: '1h', label: '1 hour (default)' },
|
||||
{ value: '6h', label: '6 hours' },
|
||||
{ value: '12h', label: '12 hours' },
|
||||
{ value: '24h', label: '24 hours (max)' },
|
||||
])
|
||||
|
||||
/**
|
||||
* Fill a <select> with vault credentials.
|
||||
* @param {string} [selectValue]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isTtlShSelectValue(selectValue) {
|
||||
return String(selectValue || '') === TTL_SH_SELECT_VALUE
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate ttl.sh duration tag (e.g. 5m, 1h, 24h). Max 24 hours.
|
||||
* @param {string} ttl
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isValidTtlShDuration(ttl) {
|
||||
const s = String(ttl || '').trim().toLowerCase()
|
||||
const m = /^(\d+)(m|h)$/.exec(s)
|
||||
if (!m) return false
|
||||
const n = Number(m[1])
|
||||
if (!Number.isFinite(n) || n < 1) return false
|
||||
if (m[2] === 'm') return n <= 24 * 60
|
||||
return n <= 24
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip registry host from a repo path → short name for retargeting.
|
||||
* @param {string} repo
|
||||
* @returns {string}
|
||||
*/
|
||||
export function stripRegistryPrefix(repo) {
|
||||
let name = String(repo || '').trim()
|
||||
if (!name) return 'image'
|
||||
if (name.startsWith('ttl.sh/')) name = name.slice('ttl.sh/'.length)
|
||||
else {
|
||||
const first = name.split('/')[0] || ''
|
||||
if (first === 'localhost' || first.includes('.') || first.includes(':')) {
|
||||
const parts = name.split('/')
|
||||
name = parts.slice(1).join('/') || parts[0]
|
||||
}
|
||||
}
|
||||
name = name.replace(/^\/+|\/+$/g, '')
|
||||
return name || 'image'
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ttl.sh repository path: ttl.sh/<name>
|
||||
* @param {string} nameOrRepo
|
||||
* @returns {string}
|
||||
*/
|
||||
export function ttlShRepoFromName(nameOrRepo) {
|
||||
const base = stripRegistryPrefix(nameOrRepo)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._/-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
return `ttl.sh/${base || 'image'}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill a <select> with vault credentials (+ optional ephemeral targets).
|
||||
* @param {HTMLSelectElement|null} select
|
||||
* @param {{ includeSession?: boolean, includeNone?: boolean, selectedId?: string|null }} [opts]
|
||||
* @param {{
|
||||
* includeSession?: boolean,
|
||||
* includeNone?: boolean,
|
||||
* includeTtlSh?: boolean,
|
||||
* selectedId?: string|null,
|
||||
* }} [opts]
|
||||
*/
|
||||
export function fillCredentialSelect(select, opts = {}) {
|
||||
if (!select) return
|
||||
const includeSession = opts.includeSession !== false
|
||||
const includeNone = opts.includeNone !== false
|
||||
// Opt-in: only push / flatten targets, not pull credential lists
|
||||
const includeTtlSh = opts.includeTtlSh === true
|
||||
const selectedId = opts.selectedId ?? select.value
|
||||
const parts = []
|
||||
if (includeNone) {
|
||||
parts.push('<option value="">No credential (public / session auto)</option>')
|
||||
}
|
||||
if (includeTtlSh) {
|
||||
parts.push(
|
||||
`<option value="${TTL_SH_SELECT_VALUE}">ttl.sh · temporary (no auth, auto-expire)</option>`
|
||||
)
|
||||
}
|
||||
if (includeSession && cachedAuthStatus?.authenticated) {
|
||||
const label =
|
||||
cachedAuthStatus.label ||
|
||||
@@ -261,12 +345,14 @@ export function fillCredentialSelect(select, opts = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve credentialId for RPC (null = none / session default).
|
||||
* Resolve credentialId for RPC (null = none / session default / ttl.sh).
|
||||
* @param {string} selectValue
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
export function credentialIdFromSelect(selectValue) {
|
||||
if (!selectValue || selectValue === '__session__') return undefined
|
||||
if (!selectValue || selectValue === '__session__' || isTtlShSelectValue(selectValue)) {
|
||||
return undefined
|
||||
}
|
||||
return selectValue
|
||||
}
|
||||
|
||||
@@ -459,7 +545,12 @@ export async function refreshRegistryPanel() {
|
||||
sel.id === 'registry-active-credential' && browserPrefs.credentialId
|
||||
? browserPrefs.credentialId
|
||||
: undefined
|
||||
fillCredentialSelect(sel, prefer ? { selectedId: prefer } : {})
|
||||
const includeTtlSh =
|
||||
sel.id === 'push-image-credential' || sel.id === 'flatten-credential'
|
||||
fillCredentialSelect(sel, {
|
||||
...(prefer ? { selectedId: prefer } : {}),
|
||||
includeTtlSh,
|
||||
})
|
||||
})
|
||||
if (browserPrefs.serveraddress) {
|
||||
const o = document.getElementById('registry-server-override')
|
||||
@@ -1091,14 +1182,22 @@ export async function pullImageWithAuth(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 destRef = args.repo
|
||||
? `${args.repo}:${args.tag || 'latest'}`
|
||||
: image
|
||||
const isTtlSh =
|
||||
/^ttl\.sh\//i.test(String(args.repo || '')) ||
|
||||
/^ttl\.sh\//i.test(destRef) ||
|
||||
args.pushTarget === 'ttl.sh'
|
||||
const body = {
|
||||
image: image || undefined,
|
||||
id: args.id || image || undefined,
|
||||
autoVault: true,
|
||||
// Never attach vault/session auth for anonymous ttl.sh pushes
|
||||
autoVault: isTtlSh ? false : true,
|
||||
}
|
||||
if (args.repo) body.repo = args.repo
|
||||
if (args.tag) body.tag = args.tag
|
||||
if (args.credentialId) body.credentialId = args.credentialId
|
||||
if (args.credentialId && !isTtlSh) body.credentialId = args.credentialId
|
||||
|
||||
const label = args.repo ? `${args.repo}:${args.tag || 'latest'}` : image
|
||||
|
||||
@@ -1199,9 +1298,10 @@ export async function openPushImageModal(opts = {}) {
|
||||
}
|
||||
}
|
||||
}
|
||||
fillCredentialSelect(credSelect)
|
||||
fillCredentialSelect(credSelect, { includeTtlSh: true })
|
||||
if (retagCheck) retagCheck.checked = false
|
||||
togglePushRetagFields()
|
||||
applyPushModalTtlShTarget()
|
||||
bootstrap.Modal.getOrCreateInstance(modalEl).show()
|
||||
return
|
||||
}
|
||||
@@ -1226,8 +1326,9 @@ export async function openPushImageModal(opts = {}) {
|
||||
if (tagInput) tagInput.value = t || 'latest'
|
||||
}
|
||||
if (retagCheck) retagCheck.checked = false
|
||||
fillCredentialSelect(credSelect)
|
||||
fillCredentialSelect(credSelect, { includeTtlSh: true })
|
||||
togglePushRetagFields()
|
||||
applyPushModalTtlShTarget()
|
||||
|
||||
bootstrap.Modal.getOrCreateInstance(modalEl).show()
|
||||
}
|
||||
@@ -1238,6 +1339,33 @@ function togglePushRetagFields() {
|
||||
if (wrap) wrap.style.display = retag ? '' : 'none'
|
||||
}
|
||||
|
||||
/**
|
||||
* When push credential is ttl.sh, force retag under ttl.sh with a TTL tag.
|
||||
*/
|
||||
function applyPushModalTtlShTarget() {
|
||||
const credVal = document.getElementById('push-image-credential')?.value
|
||||
const ttlWrap = document.getElementById('push-ttl-sh-fields')
|
||||
const isTtl = isTtlShSelectValue(credVal)
|
||||
if (ttlWrap) ttlWrap.style.display = isTtl ? '' : 'none'
|
||||
if (!isTtl) return
|
||||
|
||||
const retag = document.getElementById('push-image-retag')
|
||||
if (retag) {
|
||||
retag.checked = true
|
||||
togglePushRetagFields()
|
||||
}
|
||||
const ref = document.getElementById('push-image-ref')?.value || ''
|
||||
const repoEl = document.getElementById('push-image-repo')
|
||||
const tagEl = document.getElementById('push-image-tag')
|
||||
const ttlSel = document.getElementById('push-ttl-sh-duration')
|
||||
const base =
|
||||
stripRegistryPrefix(repoEl?.value || '') ||
|
||||
stripRegistryPrefix(ref.includes(':') ? ref.slice(0, ref.lastIndexOf(':')) : ref)
|
||||
if (repoEl) repoEl.value = ttlShRepoFromName(base || 'image')
|
||||
const ttl = ttlSel?.value || TTL_SH_DEFAULT_TTL
|
||||
if (tagEl) tagEl.value = ttl
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire DOM once.
|
||||
*/
|
||||
@@ -1475,16 +1603,38 @@ export function initRegistryManager() {
|
||||
|
||||
// Push modal
|
||||
document.getElementById('push-image-retag')?.addEventListener('change', togglePushRetagFields)
|
||||
document.getElementById('push-image-credential')?.addEventListener('change', () => {
|
||||
applyPushModalTtlShTarget()
|
||||
})
|
||||
document.getElementById('push-ttl-sh-duration')?.addEventListener('change', () => {
|
||||
if (!isTtlShSelectValue(document.getElementById('push-image-credential')?.value)) return
|
||||
const tagEl = document.getElementById('push-image-tag')
|
||||
const ttl = document.getElementById('push-ttl-sh-duration')?.value || TTL_SH_DEFAULT_TTL
|
||||
if (tagEl) tagEl.value = ttl
|
||||
})
|
||||
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'
|
||||
let retag = document.getElementById('push-image-retag')?.checked
|
||||
let repo = document.getElementById('push-image-repo')?.value?.trim()
|
||||
let tag = document.getElementById('push-image-tag')?.value?.trim() || 'latest'
|
||||
const credVal = document.getElementById('push-image-credential')?.value
|
||||
|
||||
// ttl.sh: force retag to ttl.sh/<name>:<ttl>, no vault credential
|
||||
if (isTtlShSelectValue(credVal)) {
|
||||
applyPushModalTtlShTarget()
|
||||
retag = true
|
||||
repo = document.getElementById('push-image-repo')?.value?.trim()
|
||||
tag = document.getElementById('push-image-tag')?.value?.trim() || TTL_SH_DEFAULT_TTL
|
||||
if (!isValidTtlShDuration(tag)) {
|
||||
showAlert('danger', 'ttl.sh tag must be a duration like 1h or 30m (max 24h)')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (retag && !repo) {
|
||||
showAlert('danger', 'Repository is required when re-tagging for push')
|
||||
return
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* ttl.sh ephemeral registry helpers.
|
||||
*/
|
||||
import test from 'brittle'
|
||||
import {
|
||||
TTL_SH_SELECT_VALUE,
|
||||
TTL_SH_DEFAULT_TTL,
|
||||
isTtlShSelectValue,
|
||||
isValidTtlShDuration,
|
||||
stripRegistryPrefix,
|
||||
ttlShRepoFromName,
|
||||
credentialIdFromSelect,
|
||||
} from '../libs/registryManager.js'
|
||||
|
||||
test('ttl.sh select sentinel', (t) => {
|
||||
t.is(TTL_SH_SELECT_VALUE, '__ttl_sh__')
|
||||
t.ok(isTtlShSelectValue(TTL_SH_SELECT_VALUE))
|
||||
t.absent(isTtlShSelectValue(''))
|
||||
t.absent(isTtlShSelectValue('abc123'))
|
||||
t.is(credentialIdFromSelect(TTL_SH_SELECT_VALUE), undefined)
|
||||
t.is(credentialIdFromSelect(''), undefined)
|
||||
t.is(credentialIdFromSelect('vault-id'), 'vault-id')
|
||||
})
|
||||
|
||||
test('isValidTtlShDuration accepts presets and rejects junk', (t) => {
|
||||
t.ok(isValidTtlShDuration('5m'))
|
||||
t.ok(isValidTtlShDuration('1h'))
|
||||
t.ok(isValidTtlShDuration('24h'))
|
||||
t.ok(isValidTtlShDuration(TTL_SH_DEFAULT_TTL))
|
||||
t.absent(isValidTtlShDuration('latest'))
|
||||
t.absent(isValidTtlShDuration('25h'))
|
||||
t.absent(isValidTtlShDuration(''))
|
||||
t.absent(isValidTtlShDuration('1d'))
|
||||
})
|
||||
|
||||
test('ttlShRepoFromName builds ttl.sh paths', (t) => {
|
||||
t.is(ttlShRepoFromName('apache-httpd'), 'ttl.sh/apache-httpd')
|
||||
t.is(ttlShRepoFromName('ttl.sh/apache-httpd'), 'ttl.sh/apache-httpd')
|
||||
t.is(ttlShRepoFromName('192.168.0.12:5555/myapp'), 'ttl.sh/myapp')
|
||||
t.is(ttlShRepoFromName('ghcr.io/org/app'), 'ttl.sh/org/app')
|
||||
})
|
||||
|
||||
test('stripRegistryPrefix', (t) => {
|
||||
t.is(stripRegistryPrefix('ttl.sh/foo'), 'foo')
|
||||
t.is(stripRegistryPrefix('localhost:5000/bar'), 'bar')
|
||||
t.is(stripRegistryPrefix('simple'), 'simple')
|
||||
})
|
||||
@@ -35,6 +35,8 @@ const REQUIRED_IDS = [
|
||||
'flatten-repo',
|
||||
'flatten-tag',
|
||||
'confirm-flatten-btn',
|
||||
'flatten-ttl-sh-duration',
|
||||
'push-ttl-sh-duration',
|
||||
'containers-table',
|
||||
'deploy-view',
|
||||
'tunnels-view',
|
||||
|
||||
@@ -4496,6 +4496,23 @@ table.pd-col-table td.pd-col-hidden {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.push-ttl-sh-panel,
|
||||
#flatten-ttl-sh-fields {
|
||||
padding: 0.75rem 0.85rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(251, 191, 36, 0.35);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(251, 191, 36, 0.1) 0%,
|
||||
rgba(167, 139, 250, 0.06) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.push-ttl-sh-panel a,
|
||||
#flatten-ttl-sh-fields a {
|
||||
color: var(--accent-info, #38bdf8);
|
||||
}
|
||||
|
||||
/* Accordion bodies must stay collapsed when not .show (defensive vs BS CDN lag) */
|
||||
.accordion-collapse.collapse:not(.show) {
|
||||
display: none !important;
|
||||
|
||||
Reference in New Issue
Block a user