Add ttl.sh as ephemeral anonymous push target
Release rolling / release (push) Successful in 7m32s

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:
Raven Scott
2026-07-18 07:49:33 -04:00
parent 8235a8a6c8
commit 7b5da2da86
8 changed files with 404 additions and 45 deletions
+10 -3
View File
@@ -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
View File
@@ -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 containers 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+47
View File
@@ -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')
})
+2
View File
@@ -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',
+17
View File
@@ -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;