Add Container Flattener (export|import squash with job tray push)
Release rolling / release (push) Successful in 7m49s

Squash a container into a single-layer image from Manage → Flatten, with
repo/tag, pause, config re-apply, vault credentials, and optional registry push.
This commit is contained in:
Raven Scott
2026-07-18 07:31:58 -04:00
parent a6fea51eaf
commit e9fb26153a
16 changed files with 1382 additions and 3 deletions
+19
View File
@@ -25,6 +25,7 @@ import {
} from './libs/templateDeploy.js';
import { initAddContainerPage } from './libs/addContainer.js';
import { initRegistryManager, openPushImageModal, pullImageWithAuth } from './libs/registryManager.js';
import { initContainerFlattener, openContainerFlattenerModal } from './libs/containerFlattener.js';
import { showContainerSkeleton, createProgressBar, updateProgressBar, removeProgressBar } from './libs/loadingStates.js';
import {
closeAllModals,
@@ -9480,6 +9481,14 @@ function handleRpcMessage(response, conn) {
break;
}
case 'flattenProgress': {
// Job tray listens via manager message; keep status indicator lightly in sync
if (response?.message) {
updateStatusIndicator(String(response.message).slice(0, 140));
}
break;
}
case 'buildProgress': {
const line = (response.stream || response.status || response.error || '').trim();
if (line) {
@@ -10142,6 +10151,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Images registry manager (vault, push/pull auth)
initRegistryManager();
initContainerFlattener();
document.getElementById('registry-refresh-btn')?.addEventListener('click', () => {
if (typeof window.refreshRegistryPanel === 'function') window.refreshRegistryPanel();
});
@@ -12098,6 +12108,15 @@ function bindContainerActionsModalOnce() {
window.peardockOps?.openResourceEditor?.(container.Id, { name });
break;
}
case 'flatten': {
hide();
if (typeof openContainerFlattenerModal === 'function') {
openContainerFlattenerModal(container);
} else {
showAlert('info', 'Container Flattener is unavailable in this build.');
}
break;
}
case 'remove': {
hide();
const deleteModalEl = document.getElementById('deleteModal');
+17
View File
@@ -85,6 +85,23 @@ export const api = {
return connOrActive(connection).request(Methods.renameContainer, { id, name })
},
/**
* Squash container FS into a single-layer image (host-side export|import).
* @param {string|object} idOrArgs
* @param {object} [opts]
*/
flattenContainer(idOrArgs, opts = {}, connection) {
if (opts && typeof opts.request === 'function') {
connection = opts
opts = {}
}
const body =
typeof idOrArgs === 'object' && idOrArgs
? { ...idOrArgs }
: { id: idOrArgs, ...opts }
return connOrActive(connection).request(Methods.flattenContainer, body)
},
updateContainer(id, update, connection) {
return connOrActive(connection).request(Methods.updateContainer, { id, ...update })
},
+190
View File
@@ -453,6 +453,195 @@ export async function buildImageJob(args) {
], { peerId: manager.active?.id, icon: 'fa-hammer', subtitle: tag })
}
/**
* Flatten (squash) a container into a single-layer image, optionally push.
* @param {{
* id: string,
* name?: string,
* repo: string,
* tag?: string,
* message?: string,
* pause?: boolean,
* cmd?: string,
* entrypoint?: string,
* workdir?: string,
* user?: string,
* env?: string,
* push?: boolean,
* credentialId?: string,
* }} args
*/
export async function flattenContainerJob(args) {
const id = String(args.id || '').trim()
const name = String(args.name || shortId(id))
const repo = String(args.repo || '').trim()
const tag = String(args.tag || 'latest').trim() || 'latest'
if (!id) throw new Error('Container id required')
if (!repo) throw new Error('Repository name required')
const ref = `${repo}:${tag}`
const doPush = Boolean(args.push)
const steps = [
{
id: 'plan',
label: 'Plan flatten',
run: async ({ log }) => {
log(`Source container: ${name} (${shortId(id)})`)
log(`Target image: ${ref}`)
log(`Mode: docker export | docker import (single layer)`)
log(`Pause during export: ${args.pause !== false ? 'yes' : 'no'}`)
if (args.message) log(`Message: ${args.message}`)
if (args.cmd) log(`CMD: ${args.cmd}`)
if (args.entrypoint) log(`ENTRYPOINT: ${args.entrypoint}`)
if (args.workdir) log(`WORKDIR: ${args.workdir}`)
if (args.user) log(`USER: ${args.user}`)
if (args.env) {
const lines = String(args.env)
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('#'))
log(`ENV: ${lines.length} variable(s)`)
}
log(`Push after flatten: ${doPush ? 'yes' : 'no'}`)
if (doPush && args.credentialId) log(`Credential: ${args.credentialId}`)
},
},
{
id: 'flatten',
label: 'Export → import',
run: async ({ job, log }) => {
log(`Flattening ${name}${ref}`)
setStep(job.id, 'flatten', 'active', { detail: 'streaming filesystem' })
// Live progress from server flattenProgress pushes
const onMsg = (msg) => {
if (msg?.type !== 'flattenProgress' && msg?.type !== 'push:flattenProgress') return
if (msg.containerId && msg.containerId !== id && !String(id).startsWith(String(msg.containerId).slice(0, 12))) {
// still accept if ref matches
if (msg.ref !== ref) return
}
const phase = msg.phase || ''
const bytes = Number(msg.bytes) || 0
const detail =
bytes > 0
? `${phase || 'work'} · ${formatBytesJob(bytes)}`
: phase || msg.message || ''
if (detail) setStep(job.id, 'flatten', 'active', { detail })
if (msg.message) {
// Avoid spamming identical lines
const last = job.log?.[job.log.length - 1]?.line
if (last !== msg.message) log(msg.message)
}
if (bytes > 0) {
setJobProgress(
job.id,
{
kind: 'flatten',
phase,
bytes,
label: msg.message || `Streaming ${formatBytesJob(bytes)}`,
percent: null,
},
{ stepId: 'flatten' }
)
}
}
manager.on?.('message', onMsg)
try {
const body = {
id,
repo,
tag,
pause: args.pause !== false,
}
if (args.message) body.message = args.message
if (args.cmd) body.cmd = args.cmd
if (args.entrypoint) body.entrypoint = args.entrypoint
if (args.workdir) body.workdir = args.workdir
if (args.user) body.user = args.user
if (args.env) body.env = args.env
const res = await manager.request(Methods.flattenContainer, body)
if (res?.exportBytes != null) {
log(`Streamed ${formatBytesJob(res.exportBytes)} of filesystem tar`)
}
if (res?.imageId) log(`Image id: ${shortId(res.imageId)}`)
if (res?.paused) log('Container was paused during export and resumed')
if (res?.elapsedMs != null) log(`Elapsed: ${(res.elapsedMs / 1000).toFixed(1)}s`)
log(res?.message || `Created ${ref}`)
setJobProgress(job.id, null)
return res
} catch (err) {
setJobProgress(job.id, null)
rethrowJobError(err, 'flattenContainer')
} finally {
try {
manager.off?.('message', onMsg)
manager.removeListener?.('message', onMsg)
} catch {
// ignore
}
}
},
},
]
if (doPush) {
steps.push({
id: 'push',
label: 'Push to registry',
run: async ({ job, log }) => {
const trackKey = ref
const tracker = beginPullProgress(trackKey, job.id, 'push', {
kind: 'image-push',
})
setJobProgress(job.id, tracker.snapshot(), { stepId: 'push' })
log(`Pushing ${ref}`)
try {
const body = {
image: ref,
id: ref,
autoVault: true,
}
if (args.credentialId) body.credentialId = args.credentialId
const res = await manager.request(Methods.pushImage, body)
const final = finalizePullProgress(trackKey, { ok: true })
if (final) {
setJobProgress(final.jobId, final.snapshot, { stepId: 'push' })
if (final.milestoneLine) appendJobLog(final.jobId, final.milestoneLine)
}
log(res?.message || `Pushed ${ref}`)
return res
} catch (err) {
finalizePullProgress(trackKey, {
ok: false,
message: err?.message || String(err),
})
endPullProgress(trackKey, job.id)
rethrowJobError(err, 'pushImage')
}
},
})
}
steps.push({
id: 'done',
label: 'Finish',
run: async ({ log }) => {
log(`Flatten complete: ${ref}`)
if (doPush) log('Image is available locally and on the registry.')
else log('Image is available on the local Docker engine.')
},
})
return runJob(`Flatten ${name}`, steps, {
peerId: manager.active?.id,
icon: 'fa-compress',
subtitle: ref,
})
}
/**
* Push image with hybrid progress card (same UX as pull).
* @param {{ image?: string, id?: string, repo?: string, tag?: string, credentialId?: string, body?: object }} args
@@ -566,6 +755,7 @@ export default {
removeStackJob,
containerActionJob,
buildImageJob,
flattenContainerJob,
pushImageJob,
createVolumeJob,
}
+1
View File
@@ -64,6 +64,7 @@ const METHOD_TIMEOUT_MS = {
deployContainer: Math.max(OP_TIMEOUT_MS, 300000),
pullImage: 600000,
pushImage: 600000,
flattenContainer: 600000,
checkImageUpdates: 120000,
buildImage: 600000,
deployStack: 600000,
+4 -1
View File
@@ -9,6 +9,7 @@ What PearDock can do today, mapped to code and protocol surfaces.
| Area | Status | Default | Primary code |
|------|--------|---------|--------------|
| Container lifecycle | shipped | on | `handlers/containers.js` |
| Container Flattener (export\|import) | shipped | on | `handlers/containers.js`, `libs/containerFlattener.js` |
| Image pull/build/load/save | shipped | on | `handlers/images.js` |
| Networks / volumes | shipped | on | `handlers/networks.js`, `volumes.js` |
| Compose stacks + GitOps | shipped | on | `handlers/stacks.js`, `utils/gitops.js` |
@@ -43,7 +44,9 @@ What PearDock can do today, mapped to code and protocol surfaces.
## Containers
**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/archive/duplicate/prune.
**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.
**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`.
+3 -1
View File
@@ -129,7 +129,9 @@ Below is a **catalog** of `Methods` names. Minimum roles are in `MethodRoles` (s
|--------|----------|
| `listContainers`, `inspectContainer`, `checkImageUpdates`, `containerTop`, `containerStats`, `getStatsHistory`, `getContainerLogs` | viewer |
| `startContainer`, `stopContainer`, `restartContainer`, `killContainer`, `pauseContainer`, `unpauseContainer`, `renameContainer`, `updateContainer`, `createContainer`, `deployContainer`, `bulkContainerOperation`, `waitContainer`, `attachContainer`, `attachInput`, `execContainer`, `execInput` | operator |
| `removeContainer`, `recreateContainer`, `commitContainer`, `exportContainer`, `duplicateContainer`, `pruneContainers`, `archiveContainerGet`, `archiveContainerPut` | admin |
| `removeContainer`, `recreateContainer`, `commitContainer`, `exportContainer`, `flattenContainer`, `duplicateContainer`, `pruneContainers`, `archiveContainerGet`, `archiveContainerPut` | admin |
`flattenContainer` runs host-side `export | import` (single-layer image). Optional `pause` (default true), `message`, and Dockerfile-style changes via `cmd` / `entrypoint` / `workdir` / `user` / `env`. Streams progress on `push:flattenProgress`.
`checkImageUpdates` compares local image digests to remote registry manifests (vault credentials for private hosts). `removeContainer` stops gracefully (short grace + kill fallback) before delete so force-remove does not hang.
+140
View File
@@ -5434,6 +5434,13 @@ services:
<small>CPU &amp; memory limits</small>
</span>
</button>
<button type="button" class="cam-action" data-cam-action="flatten" data-min-role="admin">
<span class="cam-action-icon cam-action-icon-flatten"><i class="fas fa-compress"></i></span>
<span class="cam-action-text">
<strong>Flatten</strong>
<small>Squash into single-layer image</small>
</span>
</button>
</div>
<div class="cam-section-label mt-3">Danger zone</div>
@@ -5510,6 +5517,139 @@ services:
</div>
</div>
<!-- Container Flattener Modal (export | import → single-layer image) -->
<div class="modal fade" id="containerFlattenerModal" tabindex="-1" aria-labelledby="containerFlattenerModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content flatten-modal bg-dark text-white">
<div class="modal-header border-0 pb-0">
<div>
<h5 class="modal-title mb-1" id="containerFlattenerModalLabel">
<i class="fas fa-compress me-2"></i>Container Flattener
</h5>
<div id="flatten-modal-subtitle" class="small text-muted">Squash a container into a single-layer image</div>
</div>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body pt-3">
<div class="flatten-callout mb-3">
<div class="flatten-callout-icon"><i class="fas fa-layer-group"></i></div>
<div>
<strong>What this does</strong>
<p class="mb-0 small text-muted">
Runs <code class="flatten-code">docker export | docker import</code> on the host —
collapsing every image layer and container write into <em>one</em> new layer.
History and parent layers are discarded (unlike Commit). Ideal for baking runtime
state into a slim, pushable artifact.
</p>
</div>
</div>
<div id="flatten-source-card" class="flatten-source-card mb-3"></div>
<div class="flatten-section-label">Destination image</div>
<div class="row g-2 mb-2">
<div class="col-md-8">
<label for="flatten-repo" class="form-label">Repository</label>
<input type="text" class="form-control bg-dark text-white font-monospace" id="flatten-repo"
placeholder="192.168.0.12:5555/myapp" autocomplete="off" spellcheck="false" required>
<small class="text-muted">Include registry host/port when you plan to push (e.g. <code class="flatten-code">host:5555/name</code>).</small>
</div>
<div class="col-md-4">
<label for="flatten-tag" class="form-label">Tag</label>
<input type="text" class="form-control bg-dark text-white font-monospace" id="flatten-tag"
value="flat" autocomplete="off" spellcheck="false">
</div>
</div>
<div class="flatten-ref-bar mb-3">
<span class="text-muted small me-2">Full reference</span>
<code id="flatten-ref-preview" class="flatten-ref-preview text-muted">repository:tag</code>
</div>
<div class="mb-3">
<label for="flatten-message" class="form-label">Message <span class="text-muted fw-normal">(optional)</span></label>
<input type="text" class="form-control bg-dark text-white" id="flatten-message"
placeholder="Why this flat image was created" maxlength="500" autocomplete="off">
</div>
<div class="flatten-options mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="flatten-pause" checked>
<label class="form-check-label" for="flatten-pause">
Pause container during export
<small class="d-block text-muted">Best-effort FS consistency; auto-resumes when done</small>
</label>
</div>
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="flatten-preserve-config" checked>
<label class="form-check-label" for="flatten-preserve-config">
Re-apply runtime config (CMD, ENTRYPOINT, …)
<small class="d-block text-muted">Import drops image config — we restore it from the container</small>
</label>
</div>
</div>
<div class="flatten-section-label d-flex align-items-center justify-content-between">
<span>Push to registry</span>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="flatten-push" role="switch">
<label class="form-check-label small" for="flatten-push">Enable</label>
</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>
<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">Uses vault credentials from <strong>Registry</strong>. Required for private registries.</small>
</div>
<div class="flatten-section-label d-flex align-items-center justify-content-between mt-1">
<span>Advanced changes</span>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="flatten-advanced-toggle" role="switch">
<label class="form-check-label small" for="flatten-advanced-toggle">Edit</label>
</div>
</div>
<div id="flatten-advanced-fields" class="flatten-advanced-panel" style="display: none;">
<div class="row g-2 mb-2">
<div class="col-md-6">
<label for="flatten-cmd" class="form-label">CMD</label>
<input type="text" class="form-control bg-dark text-white font-monospace form-control-sm" id="flatten-cmd"
placeholder='["/bin/sh"] or nginx -g "daemon off;"' autocomplete="off" spellcheck="false">
</div>
<div class="col-md-6">
<label for="flatten-entrypoint" class="form-label">ENTRYPOINT</label>
<input type="text" class="form-control bg-dark text-white font-monospace form-control-sm" id="flatten-entrypoint"
placeholder='["/docker-entrypoint.sh"]' autocomplete="off" spellcheck="false">
</div>
<div class="col-md-6">
<label for="flatten-workdir" class="form-label">WORKDIR</label>
<input type="text" class="form-control bg-dark text-white font-monospace form-control-sm" id="flatten-workdir"
placeholder="/app" autocomplete="off" spellcheck="false">
</div>
<div class="col-md-6">
<label for="flatten-user" class="form-label">USER</label>
<input type="text" class="form-control bg-dark text-white font-monospace form-control-sm" id="flatten-user"
placeholder="1000:1000" autocomplete="off" spellcheck="false">
</div>
</div>
<div class="mb-0">
<label for="flatten-env" class="form-label">ENV <span class="text-muted fw-normal">(one KEY=value per line)</span></label>
<textarea class="form-control bg-dark text-white font-monospace form-control-sm" id="flatten-env" rows="3"
placeholder="PATH=/usr/local/bin&#10;NODE_ENV=production" spellcheck="false"></textarea>
</div>
</div>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="confirm-flatten-btn" data-min-role="admin">
<i class="fas fa-compress me-2"></i>Flatten image
</button>
</div>
</div>
</div>
</div>
<!-- Exec Container Modal -->
<div class="modal fade" id="execContainerModal" tabindex="-1" aria-labelledby="execContainerModalLabel" aria-hidden="true">
<div class="modal-dialog">
+419
View File
@@ -0,0 +1,419 @@
/**
* Container Flattener squash a container into a single-layer image
* (docker export | docker import), with optional registry push.
*/
import { manager, Methods } from '../client/manager.js'
import { presentError } from '../client/errors.js'
import {
showAlert,
showStatusIndicator,
hideStatusIndicator,
} from './uiUtils.js'
import {
loadVaultCredentials,
loadAuthStatus,
fillCredentialSelect,
credentialIdFromSelect,
} from './registryManager.js'
/** @type {{ container: object|null, inspect: object|null }} */
const state = {
container: null,
inspect: null,
}
/**
* @param {string} s
*/
function escapeHtml(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
/**
* @param {number|null|undefined} n
*/
function formatBytes(n) {
const v = Number(n)
if (!Number.isFinite(v) || v < 0) return '—'
if (v < 1024) return `${Math.round(v)} B`
if (v < 1024 ** 2) return `${(v / 1024).toFixed(1)} KiB`
if (v < 1024 ** 3) return `${(v / 1024 ** 2).toFixed(1)} MiB`
return `${(v / 1024 ** 3).toFixed(2)} GiB`
}
/**
* Suggest repo/tag from container name + image.
* @param {object} container
*/
function suggestRepoTag(container) {
const name = (container.Names?.[0] || '').replace(/^\//, '') || 'container'
const image = String(container.Image || '')
let repo = name.toLowerCase().replace(/[^a-z0-9._/-]+/g, '-').replace(/^-+|-+$/g, '')
if (!repo) repo = 'flattened'
// Prefer a sensible default without registry host (user can add 192.168.x:port/)
let tag = 'flat'
if (image && !image.startsWith('sha256:')) {
const lastSlash = image.lastIndexOf('/')
const base = lastSlash >= 0 ? image.slice(lastSlash + 1) : image
const colon = base.lastIndexOf(':')
if (colon > 0) {
const imgName = base.slice(0, colon)
const imgTag = base.slice(colon + 1)
if (imgName && !repo.includes(imgName)) {
// keep container-name based repo; use image tag as suffix
tag = `flat-${imgTag}`.replace(/[^a-zA-Z0-9_.-]+/g, '-').slice(0, 120)
} else if (imgTag) {
tag = `flat-${imgTag}`.replace(/[^a-zA-Z0-9_.-]+/g, '-').slice(0, 120)
}
}
}
return { repo, tag }
}
/**
* Format Config.Cmd / Entrypoint for Dockerfile change lines.
* @param {unknown} val
*/
function formatConfigArray(val) {
if (val == null) return ''
if (Array.isArray(val)) {
if (!val.length) return ''
try {
return JSON.stringify(val)
} catch {
return val.map(String).join(' ')
}
}
return String(val)
}
function updateRefPreview() {
const repo = document.getElementById('flatten-repo')?.value?.trim() || ''
const tag = document.getElementById('flatten-tag')?.value?.trim() || 'latest'
const el = document.getElementById('flatten-ref-preview')
if (!el) return
if (!repo) {
el.textContent = 'repository:tag'
el.classList.add('text-muted')
return
}
el.textContent = `${repo}:${tag || 'latest'}`
el.classList.remove('text-muted')
}
function togglePushFields() {
const on = document.getElementById('flatten-push')?.checked
const wrap = document.getElementById('flatten-push-fields')
if (wrap) wrap.style.display = on ? '' : 'none'
}
function toggleAdvanced() {
const on = document.getElementById('flatten-advanced-toggle')?.checked
const wrap = document.getElementById('flatten-advanced-fields')
if (wrap) wrap.style.display = on ? '' : 'none'
}
/**
* Fill source summary card.
* @param {object} container
* @param {object|null} inspect
*/
function renderSourceCard(container, inspect) {
const name = (container.Names?.[0] || '').replace(/^\//, '') || container.Id?.slice(0, 12)
const stateStr = container.State || inspect?.State?.Status || 'unknown'
const image = container.Image || inspect?.Config?.Image || '—'
const shortId = (container.Id || '').slice(0, 12)
const sizeRw = container.SizeRw ?? inspect?.SizeRw
const sizeRoot = container.SizeRootFs ?? inspect?.SizeRootFs
const layerHint =
sizeRoot != null || sizeRw != null
? `Writable ${formatBytes(sizeRw)} · RootFS ${formatBytes(sizeRoot)}`
: 'Size unknown until export'
const host = document.getElementById('flatten-source-card')
if (!host) return
host.innerHTML = `
<div class="flatten-source-grid">
<div class="flatten-source-icon" aria-hidden="true"><i class="fas fa-cube"></i></div>
<div class="flatten-source-meta">
<div class="flatten-source-name">${escapeHtml(name)}</div>
<div class="flatten-source-sub">
<span class="mono">${escapeHtml(shortId)}</span>
<span class="flatten-dot">·</span>
<span class="badge ${stateBadgeClass(stateStr)}">${escapeHtml(stateStr)}</span>
<span class="flatten-dot">·</span>
<span class="text-muted text-truncate" title="${escapeHtml(image)}">${escapeHtml(image)}</span>
</div>
<div class="flatten-source-size text-muted small">${escapeHtml(layerHint)}</div>
</div>
</div>
`
}
/**
* @param {string} state
*/
function stateBadgeClass(state) {
const s = String(state || '').toLowerCase()
if (s === 'running') return 'bg-success'
if (s === 'paused') return 'bg-warning text-dark'
if (s === 'exited' || s === 'dead') return 'bg-secondary'
if (s === 'restarting') return 'bg-info text-dark'
return 'bg-secondary'
}
/**
* Prefill runtime changes from inspect Config.
* @param {object|null} inspect
*/
function prefillConfig(inspect) {
const cfg = inspect?.Config || {}
const cmdEl = document.getElementById('flatten-cmd')
const epEl = document.getElementById('flatten-entrypoint')
const wdEl = document.getElementById('flatten-workdir')
const userEl = document.getElementById('flatten-user')
const envEl = document.getElementById('flatten-env')
if (cmdEl) cmdEl.value = formatConfigArray(cfg.Cmd)
if (epEl) epEl.value = formatConfigArray(cfg.Entrypoint)
if (wdEl) wdEl.value = cfg.WorkingDir || ''
if (userEl) userEl.value = cfg.User || ''
if (envEl) {
const env = Array.isArray(cfg.Env) ? cfg.Env : []
envEl.value = env.join('\n')
}
}
/**
* Open the Container Flattener modal for a container.
* @param {object} container
*/
export async function openContainerFlattenerModal(container) {
if (!container?.Id) {
showAlert('danger', 'No container selected')
return
}
const modalEl = document.getElementById('containerFlattenerModal')
if (!modalEl || typeof bootstrap === 'undefined') {
showAlert('danger', 'Container Flattener modal unavailable')
return
}
state.container = container
state.inspect = null
const name = (container.Names?.[0] || '').replace(/^\//, '') || container.Id.slice(0, 12)
const titleEl = document.getElementById('containerFlattenerModalLabel')
if (titleEl) {
titleEl.innerHTML = `<i class="fas fa-compress me-2"></i>Container Flattener`
}
const subtitle = document.getElementById('flatten-modal-subtitle')
if (subtitle) subtitle.textContent = `Squash “${name}” into a single-layer image`
renderSourceCard(container, null)
const { repo, tag } = suggestRepoTag(container)
const repoEl = document.getElementById('flatten-repo')
const tagEl = document.getElementById('flatten-tag')
const msgEl = document.getElementById('flatten-message')
const pauseEl = document.getElementById('flatten-pause')
const pushEl = document.getElementById('flatten-push')
const advEl = document.getElementById('flatten-advanced-toggle')
const preserveEl = document.getElementById('flatten-preserve-config')
if (repoEl) repoEl.value = repo
if (tagEl) tagEl.value = tag
if (msgEl) msgEl.value = `Flattened from ${name} via PearDock`
if (pauseEl) pauseEl.checked = String(container.State || '').toLowerCase() === 'running'
if (pushEl) pushEl.checked = false
if (advEl) advEl.checked = false
if (preserveEl) preserveEl.checked = true
togglePushFields()
toggleAdvanced()
updateRefPreview()
// Credentials for optional push
try {
await Promise.all([loadVaultCredentials(), loadAuthStatus()])
} catch {
// continue without vault
}
fillCredentialSelect(document.getElementById('flatten-credential'))
bootstrap.Modal.getOrCreateInstance(modalEl).show()
// Inspect in background for size + config prefill
try {
const res = await manager.request(Methods.inspectContainer, { id: container.Id })
const data = res?.data || res
if (data && state.container?.Id === container.Id) {
state.inspect = data
renderSourceCard(container, data)
if (preserveEl?.checked) prefillConfig(data)
}
} catch (err) {
console.warn('[flatten] inspect failed', err?.message || err)
}
}
/**
* Collect form job args.
*/
function collectFlattenArgs() {
const container = state.container
if (!container?.Id) throw new Error('No container selected')
const repo = document.getElementById('flatten-repo')?.value?.trim() || ''
const 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)
const args = {
id: container.Id,
name,
repo,
tag,
pause: document.getElementById('flatten-pause')?.checked !== false,
push: Boolean(document.getElementById('flatten-push')?.checked),
}
const message = document.getElementById('flatten-message')?.value?.trim()
if (message) args.message = message
const credVal = document.getElementById('flatten-credential')?.value
const credId = credentialIdFromSelect(credVal)
if (args.push && credId) args.credentialId = credId
const advanced = document.getElementById('flatten-advanced-toggle')?.checked
const preserve = document.getElementById('flatten-preserve-config')?.checked
if (advanced || preserve) {
const cmd = document.getElementById('flatten-cmd')?.value?.trim()
const entrypoint = document.getElementById('flatten-entrypoint')?.value?.trim()
const workdir = document.getElementById('flatten-workdir')?.value?.trim()
const user = document.getElementById('flatten-user')?.value?.trim()
const env = document.getElementById('flatten-env')?.value?.trim()
if (cmd) args.cmd = cmd
if (entrypoint) args.entrypoint = entrypoint
if (workdir) args.workdir = workdir
if (user) args.user = user
if (env) args.env = env
}
return args
}
/**
* Start flatten job from modal.
*/
async function startFlattenFromModal() {
let args
try {
args = collectFlattenArgs()
} catch (err) {
showAlert('danger', err?.message || 'Invalid form')
return
}
const modalEl = document.getElementById('containerFlattenerModal')
if (modalEl && typeof bootstrap !== 'undefined') {
bootstrap.Modal.getInstance(modalEl)?.hide()
}
const run =
typeof window.peardockOps?.flattenContainerJob === 'function'
? window.peardockOps.flattenContainerJob
: null
if (!run) {
// Fallback without job tray
showStatusIndicator(`Flattening → ${args.repo}:${args.tag}`)
try {
const res = await manager.request(Methods.flattenContainer, {
id: args.id,
repo: args.repo,
tag: args.tag,
message: args.message,
pause: args.pause,
cmd: args.cmd,
entrypoint: args.entrypoint,
workdir: args.workdir,
user: args.user,
env: args.env,
})
showAlert('success', res?.message || 'Flatten complete')
if (args.push) {
await manager.request(Methods.pushImage, {
image: `${args.repo}:${args.tag}`,
id: `${args.repo}:${args.tag}`,
credentialId: args.credentialId,
autoVault: true,
})
showAlert('success', `Pushed ${args.repo}:${args.tag}`)
}
if (typeof window.loadImages === 'function') window.loadImages()
} catch (err) {
presentError(err, Methods.flattenContainer, { showAlert })
} finally {
hideStatusIndicator()
}
return
}
try {
await run(args)
showAlert('success', `Flattened as ${args.repo}:${args.tag}${args.push ? ' and pushed' : ''}`)
if (typeof window.loadImages === 'function') window.loadImages()
} catch (err) {
if (!err?.viaJob) {
presentError(err, Methods.flattenContainer, { showAlert })
}
}
}
/**
* Wire modal controls once.
*/
export function initContainerFlattener() {
const modalEl = document.getElementById('containerFlattenerModal')
if (!modalEl || modalEl.dataset.bound === '1') return
modalEl.dataset.bound = '1'
document.getElementById('flatten-repo')?.addEventListener('input', updateRefPreview)
document.getElementById('flatten-tag')?.addEventListener('input', updateRefPreview)
document.getElementById('flatten-push')?.addEventListener('change', togglePushFields)
document.getElementById('flatten-advanced-toggle')?.addEventListener('change', toggleAdvanced)
document.getElementById('flatten-preserve-config')?.addEventListener('change', (e) => {
if (e.target.checked && state.inspect) prefillConfig(state.inspect)
})
document.getElementById('confirm-flatten-btn')?.addEventListener('click', () => {
startFlattenFromModal()
})
// Enter in repo/tag submits
for (const id of ['flatten-repo', 'flatten-tag']) {
document.getElementById(id)?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault()
startFlattenFromModal()
}
})
}
modalEl.addEventListener('hidden.bs.modal', () => {
state.container = null
state.inspect = null
})
window.openContainerFlattenerModal = openContainerFlattenerModal
}
export default {
openContainerFlattenerModal,
initContainerFlattener,
}
+1
View File
@@ -65,6 +65,7 @@ const AUDIT_METHODS = new Set([
'updateContainer',
'duplicateContainer',
'commitContainer',
'flattenContainer',
'swarmInit',
'swarmLeave',
'createService',
+346 -1
View File
@@ -1,7 +1,7 @@
/**
* Container RPC handlers.
*/
import { PassThrough } from 'stream'
import { PassThrough, Transform } from 'stream'
import {
docker,
startContainerNoBody,
@@ -33,6 +33,91 @@ function isNoSuchContainer(err) {
return /no such container/i.test(String(err?.message || err || ''))
}
/**
* Build Dockerfile-style change instructions for `docker import -c`.
* Flatten discards image config; changes re-apply essential runtime metadata.
* @param {object} args
* @returns {string[]}
*/
export function buildFlattenImportChanges(args = {}) {
const out = []
const pushChange = (line) => {
const s = validation.sanitizeString(String(line || '').trim(), 2000)
if (s) out.push(s)
}
if (Array.isArray(args.changes)) {
for (const c of args.changes) pushChange(c)
}
if (args.cmd != null && String(args.cmd).trim()) {
const cmd = String(args.cmd).trim()
pushChange(cmd.toUpperCase().startsWith('CMD') ? cmd : `CMD ${cmd}`)
}
if (args.entrypoint != null && String(args.entrypoint).trim()) {
const ep = String(args.entrypoint).trim()
pushChange(ep.toUpperCase().startsWith('ENTRYPOINT') ? ep : `ENTRYPOINT ${ep}`)
}
if (args.workdir != null && String(args.workdir).trim()) {
const wd = String(args.workdir).trim()
pushChange(wd.toUpperCase().startsWith('WORKDIR') ? wd : `WORKDIR ${wd}`)
}
if (args.user != null && String(args.user).trim()) {
const user = String(args.user).trim()
pushChange(user.toUpperCase().startsWith('USER') ? user : `USER ${user}`)
}
if (args.env != null && String(args.env).trim()) {
for (const line of String(args.env).split(/\r?\n/)) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
if (trimmed.toUpperCase().startsWith('ENV ')) {
pushChange(trimmed)
continue
}
// KEY=value or KEY value
const eq = trimmed.indexOf('=')
if (eq > 0) {
const key = trimmed.slice(0, eq).trim()
const val = trimmed.slice(eq + 1).trim()
if (key) pushChange(`ENV ${key}=${val}`)
} else {
const parts = trimmed.split(/\s+/)
if (parts[0]) pushChange(`ENV ${parts[0]}=${parts.slice(1).join(' ')}`)
}
}
}
// Cap to keep querystring reasonable
return out.slice(0, 48)
}
/**
* Loose repo validation for flatten targets (supports registry:port/path).
* @param {string} repo
* @returns {boolean}
*/
export function isValidFlattenRepo(repo) {
if (!repo || typeof repo !== 'string') return false
if (repo.length > 255 || /\s/.test(repo)) return false
// host[:port]/name(/name)* OR name(/name)*
return /^[a-z0-9][a-z0-9._-]*(?::[0-9]+)?(?:\/[a-z0-9][a-z0-9._-]*)+$|^[a-z0-9][a-z0-9._-]*$/i.test(
repo
)
}
/**
* Format byte count for progress logs.
* @param {number} n
*/
function formatFlattenBytes(n) {
const v = Number(n)
if (!Number.isFinite(v) || v < 0) return String(n)
if (v < 1024) return `${Math.round(v)} B`
if (v < 1024 ** 2) return `${(v / 1024).toFixed(1)} KiB`
if (v < 1024 ** 3) return `${(v / 1024 ** 2).toFixed(1)} MiB`
return `${(v / 1024 ** 3).toFixed(2)} GiB`
}
/**
* Release attachments that can delay Docker remove, stop the container, then delete.
* Uses raw Engine socket calls with hard deadlines so remove never hangs on dockerode.
@@ -380,6 +465,266 @@ export function registerContainerHandlers(session) {
}
})
/**
* Squash container filesystem into a single-layer image (export | import).
* Runs entirely on the host no tar over the wire. Optional pause for FS consistency.
*/
session.respond('flattenContainer', async (args) => {
const id = validation.sanitizeString(args.id, 128)
if (!id) throw new Error('Container id required')
// Docker repository names must be lowercase
const repo = validation.sanitizeString(String(args.repo || '').toLowerCase(), 255)
const tag = validation.sanitizeString(args.tag || 'latest', 128) || 'latest'
if (!repo || !isValidFlattenRepo(repo)) {
throw new Error(
'Invalid repository. Use lowercase name or registry/path (e.g. 192.168.0.12:5555/myapp).'
)
}
if (!/^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$/.test(tag)) {
throw new Error('Invalid tag')
}
const ref = `${repo}:${tag}`
const message = args.message
? validation.sanitizeString(args.message, 500)
: undefined
const changes = buildFlattenImportChanges(args)
const wantPause = args.pause !== false // default true for consistency
const container = docker.getContainer(id)
let inspect
try {
inspect = await container.inspect()
} catch (err) {
if (isNoSuchContainer(err)) throw new Error(`No such container: ${id.slice(0, 12)}`)
throw err
}
const containerName =
(inspect.Name || '').replace(/^\//, '') || id.slice(0, 12)
const wasRunning = String(inspect.State?.Status || '').toLowerCase() === 'running'
const wasPaused = Boolean(inspect.State?.Paused)
let didPause = false
const pushProgress = (phase, extra = {}) => {
try {
session.push(Pushes.flattenProgress, {
type: 'flattenProgress',
containerId: id,
repo,
tag,
ref,
phase,
...extra,
})
} catch (e) {
logger.debug('flatten progress push failed', { error: e?.message })
}
}
pushProgress('prepare', {
containerName,
message: `Flattening ${containerName}${ref}`,
})
if (wantPause && wasRunning && !wasPaused) {
try {
pushProgress('pause', { message: 'Pausing container for consistent snapshot…' })
await container.pause()
didPause = true
} catch (err) {
logger.warn('flattenContainer: pause failed, continuing', {
id: id.slice(0, 12),
error: err?.message,
})
pushProgress('pause', {
message: `Pause skipped: ${err?.message || 'failed'}`,
warning: true,
})
}
}
let exportBytes = 0
let imageId = null
const startedAt = Date.now()
try {
pushProgress('export', { message: 'Exporting container filesystem…', bytes: 0 })
const exportStream = await container.export()
let lastPushAt = 0
const counter = new Transform({
transform(chunk, _enc, cb) {
exportBytes += chunk.length
const now = Date.now()
if (now - lastPushAt > 250) {
lastPushAt = now
pushProgress('export', {
message: `Streaming filesystem… ${formatFlattenBytes(exportBytes)}`,
bytes: exportBytes,
phase: 'export',
})
}
cb(null, chunk)
},
})
const importOpts = {
fromSrc: '-',
repo,
tag,
}
if (message) importOpts.message = message
if (changes.length) importOpts.changes = changes
// dial is async; do NOT await importImage before piping or we deadlock
// (import response only arrives after the full tar body is sent).
const importDone = new Promise((resolve, reject) => {
docker.modem.dial(
{
path: '/images/create?',
method: 'POST',
options: importOpts,
file: counter,
isStream: true,
statusCodes: {
200: true,
500: 'server error',
},
},
(err, importStream) => {
if (err) {
reject(err)
return
}
docker.modem.followProgress(
importStream,
(progressErr, output) => {
if (progressErr) reject(progressErr)
else {
try {
const last = Array.isArray(output)
? output[output.length - 1]
: output
const status = last?.status || last?.stream || ''
const m = String(status).match(/sha256:[a-f0-9]+/i)
if (m) imageId = m[0]
else if (typeof last?.aux?.ID === 'string') imageId = last.aux.ID
} catch {
// ignore parse
}
resolve(output)
}
},
(event) => {
if (event?.error) {
logger.debug('flatten import event error', { error: event.error })
}
if (event?.status || event?.stream) {
pushProgress('import', {
message: String(event.status || event.stream || '')
.trim()
.slice(0, 200),
bytes: exportBytes,
})
}
}
)
}
)
})
exportStream.on('error', (err) => {
try {
counter.destroy(err)
} catch {
// ignore
}
})
counter.on('error', (err) => {
try {
exportStream.destroy(err)
} catch {
// ignore
}
})
// Kick the pipe immediately so dial's counter→req consumer receives data
exportStream.pipe(counter)
pushProgress('import', {
message: 'Piping export → import (single layer)…',
bytes: 0,
})
await importDone
} finally {
if (didPause) {
try {
pushProgress('unpause', { message: 'Resuming container…' })
await container.unpause()
} catch (err) {
logger.warn('flattenContainer: unpause failed', {
id: id.slice(0, 12),
error: err?.message,
})
// Surface but don't mask successful flatten
pushProgress('unpause', {
message: `Warning: unpause failed — ${err?.message || 'unknown'}`,
warning: true,
})
}
}
}
// Resolve image id via inspect if not parsed from stream
if (!imageId) {
try {
const img = await docker.getImage(ref).inspect()
imageId = img.Id
} catch {
// leave null
}
}
let virtualSize = null
let size = null
try {
const img = await docker.getImage(imageId || ref).inspect()
imageId = img.Id || imageId
virtualSize = img.VirtualSize ?? img.Size ?? null
size = img.Size ?? null
} catch {
// optional
}
const elapsedMs = Date.now() - startedAt
pushProgress('done', {
message: `Created ${ref}`,
bytes: exportBytes,
imageId,
elapsedMs,
})
return {
success: true,
type: 'flattenContainer',
message: `Flattened container "${containerName}" as ${ref}`,
id,
containerName,
repo,
tag,
ref,
imageId,
exportBytes,
size,
virtualSize,
changes,
paused: didPause,
elapsedMs,
}
})
session.respond('attachContainer', async (args) => {
const container = docker.getContainer(args.id)
const stream = await container.attach({
+4
View File
@@ -160,6 +160,7 @@ export const MethodRoles = Object.freeze({
fireManualAlert: Roles.admin,
commitContainer: Roles.admin,
exportContainer: Roles.admin,
flattenContainer: Roles.admin,
archiveContainerGet: Roles.admin,
archiveContainerPut: Roles.admin,
duplicateContainer: Roles.admin,
@@ -242,6 +243,7 @@ export const Methods = Object.freeze({
renameContainer: 'renameContainer',
commitContainer: 'commitContainer',
exportContainer: 'exportContainer',
flattenContainer: 'flattenContainer',
execContainer: 'execContainer',
duplicateContainer: 'duplicateContainer',
deployContainer: 'deployContainer',
@@ -448,6 +450,7 @@ export const Pushes = Object.freeze({
pullProgress: 'push:pullProgress',
buildProgress: 'push:buildProgress',
pushProgress: 'push:pushProgress',
flattenProgress: 'push:flattenProgress',
dockerEvent: 'push:dockerEvent',
alert: 'push:alert',
/** Live session metadata (role changes, etc.) — no reconnect required */
@@ -473,6 +476,7 @@ export const PushToType = Object.freeze({
[Pushes.pullProgress]: 'pullProgress',
[Pushes.buildProgress]: 'buildProgress',
[Pushes.pushProgress]: 'pushProgress',
[Pushes.flattenProgress]: 'flattenProgress',
[Pushes.dockerEvent]: 'dockerEvent',
[Pushes.alert]: 'alert',
[Pushes.session]: 'session',
+12
View File
@@ -48,6 +48,18 @@ export const MethodSchemas = Object.freeze({
credentialId: { type: 'string', required: false, maxLen: 64 },
autoVault: { type: 'boolean', required: false },
},
flattenContainer: {
id: { type: 'string', required: true, maxLen: 128 },
repo: { type: 'string', required: true, maxLen: 255 },
tag: { type: 'string', required: false, maxLen: 128 },
message: { type: 'string', required: false, maxLen: 500 },
pause: { type: 'boolean', required: false },
cmd: { type: 'string', required: false, maxLen: 2000 },
entrypoint: { type: 'string', required: false, maxLen: 2000 },
workdir: { type: 'string', required: false, maxLen: 1024 },
user: { type: 'string', required: false, maxLen: 256 },
env: { type: 'string', required: false, maxLen: 8000 },
},
deployStack: {
composeContent: { type: 'string', required: true, maxLen: 2_000_000 },
stackName: { type: 'string', required: true, maxLen: 63 },
+74
View File
@@ -0,0 +1,74 @@
/**
* Unit tests for Container Flattener helpers (import changes + repo validation).
*/
import test from 'brittle'
import {
buildFlattenImportChanges,
isValidFlattenRepo,
} from '../server/handlers/containers.js'
import { Methods, MethodRoles, Pushes, Roles } from '../shared/protocol.js'
import { validateMethodArgs } from '../shared/schema.js'
test('Methods.flattenContainer is registered as admin', (t) => {
t.is(Methods.flattenContainer, 'flattenContainer')
t.is(MethodRoles.flattenContainer, Roles.admin)
})
test('Pushes.flattenProgress exists', (t) => {
t.is(Pushes.flattenProgress, 'push:flattenProgress')
})
test('isValidFlattenRepo accepts registry:port/path', (t) => {
t.ok(isValidFlattenRepo('myapp'))
t.ok(isValidFlattenRepo('org/myapp'))
t.ok(isValidFlattenRepo('192.168.0.12:5555/myapp'))
t.ok(isValidFlattenRepo('ghcr.io/org/app'))
t.ok(isValidFlattenRepo('registry.example.com:5000/ns/name'))
})
test('isValidFlattenRepo rejects junk', (t) => {
t.not(isValidFlattenRepo(''))
t.not(isValidFlattenRepo('has space'))
t.not(isValidFlattenRepo('a'.repeat(256)))
})
test('buildFlattenImportChanges from convenience fields', (t) => {
const changes = buildFlattenImportChanges({
cmd: '["/bin/sh"]',
entrypoint: '["/entry"]',
workdir: '/app',
user: '1000',
env: 'FOO=bar\n# comment\nBAZ=qux\n',
})
t.ok(changes.includes('CMD ["/bin/sh"]'))
t.ok(changes.includes('ENTRYPOINT ["/entry"]'))
t.ok(changes.includes('WORKDIR /app'))
t.ok(changes.includes('USER 1000'))
t.ok(changes.includes('ENV FOO=bar'))
t.ok(changes.includes('ENV BAZ=qux'))
t.not(changes.some((c) => c.includes('comment')))
})
test('buildFlattenImportChanges preserves full instruction lines', (t) => {
const changes = buildFlattenImportChanges({
cmd: 'CMD ["nginx","-g","daemon off;"]',
changes: ['EXPOSE 80', 'VOLUME /data'],
})
t.ok(changes.includes('CMD ["nginx","-g","daemon off;"]'))
t.ok(changes.includes('EXPOSE 80'))
t.ok(changes.includes('VOLUME /data'))
})
test('schema validates flattenContainer args', (t) => {
const ok = validateMethodArgs('flattenContainer', {
id: 'abc123',
repo: '192.168.0.12:5555/myapp',
tag: 'flat',
pause: true,
message: 'test',
})
t.ok(ok.ok)
const missing = validateMethodArgs('flattenContainer', { repo: 'x' })
t.not(missing.ok)
})
+5
View File
@@ -30,6 +30,11 @@ const REQUIRED_IDS = [
'pushImageModal',
'pull-image-credential',
'check-image-updates-btn',
'containerFlattenerModal',
'containerActionsModal',
'flatten-repo',
'flatten-tag',
'confirm-flatten-btn',
'containers-table',
'deploy-view',
'tunnels-view',
+146
View File
@@ -4341,6 +4341,152 @@ table.pd-col-table td.pd-col-hidden {
line-height: 1.3;
}
.cam-action-icon-flatten {
background: rgba(167, 139, 250, 0.14) !important;
color: #a78bfa !important;
}
/* ── Container Flattener modal ── */
.flatten-modal {
border-radius: 16px !important;
}
.flatten-callout {
display: flex;
gap: 0.85rem;
align-items: flex-start;
padding: 0.85rem 1rem;
border-radius: 12px;
border: 1px solid rgba(167, 139, 250, 0.28);
background: linear-gradient(
135deg,
rgba(167, 139, 250, 0.1) 0%,
rgba(56, 189, 248, 0.06) 100%
);
}
.flatten-callout-icon {
flex: 0 0 auto;
width: 2.25rem;
height: 2.25rem;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(167, 139, 250, 0.18);
color: #a78bfa;
font-size: 1rem;
}
.flatten-code {
font-size: 0.78em;
padding: 0.1em 0.35em;
border-radius: 4px;
background: rgba(0, 0, 0, 0.25);
color: var(--accent-info, #38bdf8);
}
.flatten-section-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 0.55rem;
}
.flatten-source-card {
padding: 0.85rem 1rem;
border-radius: 12px;
border: 1px solid var(--border-color);
background: var(--bg-surface, rgba(255, 255, 255, 0.03));
}
.flatten-source-grid {
display: flex;
gap: 0.85rem;
align-items: flex-start;
min-width: 0;
}
.flatten-source-icon {
flex: 0 0 auto;
width: 2.5rem;
height: 2.5rem;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(56, 189, 248, 0.12);
color: var(--accent-info, #38bdf8);
font-size: 1.1rem;
}
.flatten-source-meta {
min-width: 0;
flex: 1;
}
.flatten-source-name {
font-weight: 600;
font-size: 0.95rem;
margin-bottom: 0.2rem;
}
.flatten-source-sub {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem 0.4rem;
font-size: 12px;
min-width: 0;
}
.flatten-source-sub .mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: var(--text-muted);
}
.flatten-dot {
opacity: 0.45;
}
.flatten-source-size {
margin-top: 0.35rem;
}
.flatten-ref-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
padding: 0.55rem 0.75rem;
border-radius: 10px;
border: 1px dashed var(--border-color);
background: rgba(0, 0, 0, 0.15);
}
.flatten-ref-preview {
font-size: 13px;
word-break: break-all;
color: var(--accent-info, #38bdf8) !important;
}
.flatten-options {
padding: 0.75rem 0.9rem;
border-radius: 12px;
border: 1px solid var(--border-color);
background: var(--bg-surface, rgba(255, 255, 255, 0.02));
}
.flatten-push-panel,
.flatten-advanced-panel {
padding: 0.85rem 0.9rem;
border-radius: 12px;
border: 1px solid var(--border-color);
background: var(--bg-surface, rgba(255, 255, 255, 0.02));
}
/* Accordion bodies must stay collapsed when not .show (defensive vs BS CDN lag) */
.accordion-collapse.collapse:not(.show) {
display: none !important;
+1
View File
@@ -3761,6 +3761,7 @@ export function initOpsApp({ navigateToView, sendCommand }) {
removeStackJob: jobActions.removeStackJob,
containerActionJob: jobActions.containerActionJob,
buildImageJob: jobActions.buildImageJob,
flattenContainerJob: jobActions.flattenContainerJob,
pushImageJob: jobActions.pushImageJob,
createVolumeJob: jobActions.createVolumeJob,
openSmartNetworkModal,