/** * Track B ops bootstrap: shell, smart defaults, jobs, settings, host/events. * Imported from app.js — attaches to window for onclick/legacy hooks. */ import { manager, Methods } from '../client/manager.js' import { presentError, explainError } from '../client/errors.js' import { runJob, renderJobPanel, subscribeJobs, listJobs, showActivity as jobsShowActivity, updateActivity as jobsUpdateActivity, completeActivity as jobsCompleteActivity, getActivityJobId as jobsGetActivityJobId, getJob, setJobProgress, appendJobLog, } from '../client/jobs.js' import { beginPullProgress, endPullProgress, finalizePullProgress, applyPullProgressEvent, getActivePull, } from '../client/pullProgress.js' import { parseComposeServices } from '../client/templateResolve.js' import * as jobActions from '../client/jobActions.js' import { showAlert, markFeedbackShown, clearTopToasts, } from '../libs/uiUtils.js' import { warmSnapshot, clearSnapshotCache, getSnapshot, suggestNetwork, suggestName, suggestFromImage, } from '../client/snapshot.js' import { openCommandPalette, confirmDialog, askConfirm, statusBadge } from './components.js' import notificationManager from '../libs/notifications.js' import { DEFAULT_TEMPLATE_LIST_URLS, getTemplateListUrls, setTemplateListUrls, normalizeTemplateListUrls, fetchMergedTemplates, clearMergedTemplateCache, } from '../client/templateLists.js' const SETTINGS_KEY = 'peardock.settings.v1' const SIDEBAR_KEY = 'peardock.sidebar.collapsed' const FIRST_CONNECT_KEY = 'peardock.firstConnect.dismissed' /** Defaults for client-side preferences (localStorage peardock.settings.v1). */ export const DEFAULT_SETTINGS = { density: 'comfortable', // comfortable | compact | spacious confirmDestructive: true, refreshSeconds: 10, sidebarCollapsed: false, jobSuccessDismissMs: 3000, badgeMode: 'alerts', // alerts | all | none forceToast: false, openTunnelBrowser: true, offerLocalTunnelAfterCreate: true, defaultTunnelSecure: true, defaultTunnelProtocol: 'tcp', terminalTheme: 'dark', dockerTerminalTheme: 'dark', showFirstConnectTip: true, settingsTab: 'appearance', // Track G — appearance / motion reduceMotion: false, accent: 'teal', // teal | cyan | violet showRefreshStamp: true, /** * Global hide rules for containers that carry matching labels. * Each entry: { name: string, value?: string } — empty value matches any value for the key. * @type {Array<{ name: string, value?: string }>} */ hiddenContainerLabels: [], } /** * Normalize hidden-container label filters from settings / form. * @param {unknown} raw * @returns {Array<{ name: string, value: string }>} */ export function normalizeHiddenContainerLabels(raw) { if (!Array.isArray(raw)) return [] const out = [] const seen = new Set() for (const item of raw) { if (!item || typeof item !== 'object') continue const name = String(item.name ?? item.key ?? '').trim() if (!name || name.length > 256) continue const value = String(item.value ?? '').trim() const key = `${name}\0${value}` if (seen.has(key)) continue seen.add(key) out.push({ name, value }) } return out } export function loadSettings() { try { const raw = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}') // Migrate sidebar from legacy key if settings has no value yet if (raw.sidebarCollapsed === undefined && typeof localStorage !== 'undefined') { raw.sidebarCollapsed = localStorage.getItem(SIDEBAR_KEY) === '1' } if (raw.showFirstConnectTip === undefined && typeof localStorage !== 'undefined') { raw.showFirstConnectTip = localStorage.getItem(FIRST_CONNECT_KEY) !== '1' } const merged = { ...DEFAULT_SETTINGS, ...raw } merged.hiddenContainerLabels = normalizeHiddenContainerLabels( raw.hiddenContainerLabels ?? DEFAULT_SETTINGS.hiddenContainerLabels ) return merged } catch { return { ...DEFAULT_SETTINGS } } } export function saveSettings(partial) { const next = { ...loadSettings(), ...partial } localStorage.setItem(SETTINGS_KEY, JSON.stringify(next)) applySettings(next) return next } /** @type {ReturnType|null} */ let listRefreshTimer = null export function applySettings(s = loadSettings()) { document.body.dataset.density = s.density || 'comfortable' document.body.dataset.badgeMode = s.badgeMode || 'alerts' document.body.dataset.accent = s.accent || 'teal' document.body.dataset.reduceMotion = s.reduceMotion ? '1' : '0' document.body.dataset.refreshStamp = s.showRefreshStamp === false ? '0' : '1' startListAutoRefresh(s.refreshSeconds) // Sidebar collapsed preference const sidebar = document.getElementById('sidebar') if (sidebar) { const collapsed = Boolean(s.sidebarCollapsed) sidebar.classList.toggle('collapsed', collapsed) try { localStorage.setItem(SIDEBAR_KEY, collapsed ? '1' : '0') } catch { // ignore } } // First-connect tip flag try { if (s.showFirstConnectTip === false) { localStorage.setItem(FIRST_CONNECT_KEY, '1') } else { localStorage.removeItem(FIRST_CONNECT_KEY) } } catch { // ignore } // Terminal theme defaults (if terminals expose hooks) try { if (typeof window !== 'undefined') { window.__peardockSettings = s window.__peardockApplyTerminalThemes?.(s.terminalTheme, s.dockerTerminalTheme) // Let the main app re-filter container lists when hide-label rules change window.__peardockOnSettingsChanged?.(s) } } catch { // ignore } } /** * Whether the first-connect tip should show (settings + legacy key). */ export function shouldShowFirstConnectTip() { return loadSettings().showFirstConnectTip !== false } /** * Poll the active resource list when Settings auto-refresh > 0. * @param {number} [seconds] */ export function startListAutoRefresh(seconds) { if (listRefreshTimer) { clearInterval(listRefreshTimer) listRefreshTimer = null } const sec = Number(seconds ?? loadSettings().refreshSeconds ?? 0) if (!sec || sec < 1) return listRefreshTimer = setInterval(() => { if (!manager.active?.connected) return const view = typeof window !== 'undefined' ? window.currentView : null const send = typeof window !== 'undefined' ? window.sendCommand : null if (!send) return // silent: background polls must not spam "REQUEST_ERROR: Request failed" const quiet = { silent: true } if (view === 'containers' || view === 'dashboard') send('listContainers', {}, quiet) else if (view === 'images') send('listImages', {}, quiet) else if (view === 'networks') send('listNetworks', {}, quiet) else if (view === 'volumes') send('listVolumes', {}, quiet) else if (view === 'stacks') send('listStacks', {}, quiet) else if (view === 'host') loadHostView({ silent: true }) else if (view === 'tunnels') loadTunnelsView({ silent: true }) else if (view === 'events') loadEventsView({ silent: true }) else if (view === 'swarm') loadSwarmView({ silent: true }) try { window.peardockUx?.stampOnPoll?.() } catch { // ignore } }, sec * 1000) } export function setOfflineBanner(visible, text) { const el = document.getElementById('offline-banner') const t = document.getElementById('offline-banner-text') if (!el) return el.classList.toggle('visible', Boolean(visible)) if (t && text) t.textContent = text } export function updateActivePeerChip(conn) { const label = document.getElementById('active-peer-label') const dot = document.getElementById('active-peer-dot') if (!label) return if (!conn) { label.textContent = 'No peer' if (dot) dot.className = 'dot' return } label.textContent = conn.alias || conn.id || 'peer' if (dot) { dot.className = 'dot ' + (conn.connected && conn.dockerHealth?.ok !== false ? 'ok' : 'bad') } } /** @type {ReturnType|null} */ let jobDismissTimer = null /** @type {ReturnType|null} */ let jobRemoveTimer = null const JOB_EXIT_ANIM_MS = 380 function jobSuccessDismissMs() { const n = Number(loadSettings().jobSuccessDismissMs) if (!Number.isFinite(n) || n < 0) return 3000 return n } /** * Show live job panel. On success, auto-hide after 3s with exit animation. * Errors stay until the next job or manual clear. * @param {import('../client/jobs.js').Job|object} job */ export function showJob(job) { const drawer = document.getElementById('job-drawer') if (!drawer || !job) return // Cancel pending dismiss if a new update arrives for a running job if (job.status === 'running') { if (jobDismissTimer) { clearTimeout(jobDismissTimer) jobDismissTimer = null } if (jobRemoveTimer) { clearTimeout(jobRemoveTimer) jobRemoveTimer = null } drawer.classList.remove('job-drawer--leaving') drawer.classList.add('job-drawer--visible') } // Incremental patch inside renderJobPanel — no full wipe on progress ticks renderJobPanel(drawer, job) drawer.classList.add('job-drawer--visible') drawer.setAttribute('data-job-status', job.status || 'running') const logEl = drawer.querySelector('.job-log') if (job.status === 'success') { scheduleJobDrawerDismiss(drawer) } else if (job.status === 'error') { // Keep error visible; ensure not mid-leave if (jobDismissTimer) { clearTimeout(jobDismissTimer) jobDismissTimer = null } drawer.classList.remove('job-drawer--leaving') // Always jump to end on error so the failure line is visible if (logEl) { logEl.dataset.following = '1' requestAnimationFrame(() => { logEl.scrollTop = logEl.scrollHeight }) } } } /** * @param {HTMLElement} drawer */ function scheduleJobDrawerDismiss(drawer) { if (jobDismissTimer) clearTimeout(jobDismissTimer) if (jobRemoveTimer) clearTimeout(jobRemoveTimer) const ms = jobSuccessDismissMs() // 0 = keep success tray until the next job if (!ms || ms <= 0) return jobDismissTimer = setTimeout(() => { jobDismissTimer = null dismissJobDrawer(drawer) }, ms) } /** * @param {HTMLElement} [drawer] */ export function dismissJobDrawer(drawer) { const el = drawer || document.getElementById('job-drawer') if (!el) return if (jobDismissTimer) { clearTimeout(jobDismissTimer) jobDismissTimer = null } if (jobRemoveTimer) { clearTimeout(jobRemoveTimer) jobRemoveTimer = null } el.classList.add('job-drawer--leaving') el.classList.remove('job-drawer--visible') jobRemoveTimer = setTimeout(() => { jobRemoveTimer = null el.innerHTML = '' el.classList.remove('job-drawer--leaving') el.removeAttribute('data-job-status') }, JOB_EXIT_ANIM_MS) } /** * True while a hybrid image pull/push card owns the job tray. * Legacy status-indicator activity must not replace that UI mid-download. */ function hybridPullOwnsTray() { if (getActivePull()) return true for (const job of listJobs(8)) { if ( job.status === 'running' && job.progress && (job.progress.kind === 'image-pull' || job.progress.kind === 'image-push') ) { return true } } return false } /** Activity helpers for uiUtils (replace full-screen spinners) */ export function showActivity(message) { // Never clobber the hybrid pull/push progress card with a minified "Pulling… %" activity job if (hybridPullOwnsTray()) return null const job = jobsShowActivity(message) showJob(job) return job } export function updateActivity(message) { if (hybridPullOwnsTray()) return null const job = jobsUpdateActivity(message) if (job) showJob(job) return job } /** * @param {boolean} [ok=true] * @param {string} [message] */ export function completeActivity(ok = true, message) { const job = jobsCompleteActivity(ok, message) if (job) showJob(job) return job } export function getActivityJobId() { return jobsGetActivityJobId() } /** True when bottom job drawer is currently visible */ export function isJobDrawerActive() { const drawer = document.getElementById('job-drawer') return Boolean(drawer?.classList.contains('job-drawer--visible')) } /** * Deploy container with step visibility (uses deployContainer RPC). */ export async function deployContainerWithSteps(args) { /** @type {{ pulledOk: boolean }} */ const state = { pulledOk: false } const replace = args.replace === true const fromAdd = args.source === 'add-container' const alwaysPull = args.alwaysPull !== false const jobLabel = replace ? `Replace ${args.containerName || 'container'}` : fromAdd ? `Create ${args.containerName || 'container'}` : `Deploy ${args.containerName || 'container'}` return runJob( jobLabel, [ { id: 'validate', label: 'Validate options', run: async ({ log }) => { if (!args.containerName || !args.image) { throw new Error( 'Name and image are required — How to fix: fill in Container name and Image before deploying.' ) } log(`Name=${args.containerName}`) log(`Image=${args.image}`) log(`Always pull image: ${alwaysPull ? 'yes' : 'no (local if present)'}`) if (replace) log('Mode: replace existing container with same name') if (args.publishAllPorts) log('Publish all exposed ports: yes') if (args.ports?.length) log(`Ports: ${args.ports.join(', ')}`) if (args.volumes?.length) log(`Volumes: ${args.volumes.join(', ')}`) if (args.networkMode) log(`Network mode: ${args.networkMode}`) if (args.customNetwork) log(`Network: ${args.customNetwork}`) }, }, { id: 'pull', label: alwaysPull ? 'Pull image' : 'Resolve image (local or pull)', run: async ({ job, log }) => { if (args.skipPull) { log('Skip pull (using local image)') state.pulledOk = true return } // alwaysPull=false: let the server decide (local if present, else pull) if (!alwaysPull) { log('Always pull is off — server will use a local image if available') state.pulledOk = false return } log(`Pulling ${args.image}…`) const tracker = beginPullProgress(args.image, job.id, 'pull') const initial = tracker.snapshot() setJobProgress(job.id, initial, { stepId: 'pull', }) try { await manager.request(Methods.pullImage, { image: args.image }) const final = finalizePullProgress(args.image, { ok: true }) if (final) { const hasMilestone = Boolean(final.milestoneLine) setJobProgress(final.jobId, final.snapshot, { stepId: final.stepId, emit: !hasMilestone, }) if (hasMilestone) appendJobLog(final.jobId, final.milestoneLine) } log('Pull complete') state.pulledOk = true } catch (err) { const final = finalizePullProgress(args.image, { ok: false, message: err?.message || String(err), }) if (final) { setJobProgress(final.jobId, final.snapshot, { stepId: final.stepId, }) } endPullProgress(args.image, job.id) // Continue — deploy RPC will pull if needed and return a full error const info = explainError(err, 'pullImage') log( `Pull warning: ${info.message}${info.recovery ? ` (${info.recovery})` : ''} — will retry during create if needed`, 'warning' ) } }, }, { id: 'create', label: replace ? 'Replace, create & start' : 'Create & start container', run: async ({ log }) => { log( replace ? 'Removing previous container (if present), then creating…' : 'Creating and starting container…' ) // Only skip server pull when client pull already succeeded const payload = { ...args, skipPull: state.pulledOk === true, alwaysPull, replace: replace === true, } try { const res = await manager.request(Methods.deployContainer, payload) log(res?.message || `Container "${args.containerName}" is running`) if (res?.id) log(`ID: ${String(res.id).slice(0, 12)}`) if (replace) log('Previous container was replaced') return res } catch (err) { const info = explainError(err, 'deployContainer') // Keep conflict code so outer deploy can offer Replace if ( err?.code === 'CONTAINER_NAME_CONFLICT' || info.code === 'CONTAINER_NAME_CONFLICT' ) { const e = new Error(err.message || info.message) e.code = 'CONTAINER_NAME_CONFLICT' e.cause = err throw e } // Re-throw with full descriptive text for the job log formatter const full = [info.title, info.message, info.recovery ? `How to fix: ${info.recovery}` : ''] .filter(Boolean) .join(' — ') const e = new Error(full || err.message) e.code = info.code || err.code e.cause = err throw e } }, }, ], { peerId: manager.active?.id, icon: replace ? 'fa-recycle' : 'fa-rocket', subtitle: args.image || args.containerName || null, } ) } /** * Unique image refs from a compose YAML (for tray pull progress). * @param {string} composeContent * @returns {string[]} */ function imagesFromCompose(composeContent) { try { const { services } = parseComposeServices(composeContent) const set = new Set() for (const svc of Object.values(services || {})) { const img = String(svc?.image || '').trim() if (img) set.add(img) } return [...set] } catch { return [] } } /** * Pull one image into the hybrid job-tray progress card. * @param {string} image * @param {object} job * @param {(line: string, level?: string) => void} log * @param {string} [stepId='pull'] */ async function pullImageForJob(image, job, log, stepId = 'pull') { const tracker = beginPullProgress(image, job.id, stepId) setJobProgress(job.id, tracker.snapshot(), { stepId }) try { await manager.request(Methods.pullImage, { image }) const final = finalizePullProgress(image, { ok: true }) if (final) { const hasMilestone = Boolean(final.milestoneLine) setJobProgress(final.jobId, final.snapshot, { stepId: final.stepId, emit: !hasMilestone, }) if (hasMilestone) appendJobLog(final.jobId, final.milestoneLine) } log(`Pull complete: ${image}`) return true } catch (err) { const final = finalizePullProgress(image, { ok: false, message: err?.message || String(err), }) if (final) { setJobProgress(final.jobId, final.snapshot, { stepId: final.stepId }) } endPullProgress(image, job.id) const info = explainError(err, 'pullImage') log( `Pull warning for ${image}: ${info.message}${info.recovery ? ` (${info.recovery})` : ''} — compose up may still pull`, 'warning' ) return false } } /** * Deploy stack with the same job-tray UX as container deploys: * validate → pull images (hybrid progress) → compose up. * @param {{ composeContent: string, stackName: string, envFileContent?: string, build?: boolean, skipPull?: boolean, [key: string]: unknown }} args */ export async function deployStackWithSteps(args) { const composeContent = String(args?.composeContent || '') const stackName = String(args?.stackName || '').trim() const skipPull = args?.skipPull === true const images = imagesFromCompose(composeContent) return runJob( `Deploy stack ${stackName || 'compose'}`, [ { id: 'validate', label: 'Validate compose', run: async ({ log }) => { if (!composeContent.trim()) { throw new Error( 'Compose content required — How to fix: paste a docker-compose.yml or load a stack template.' ) } if (!stackName) { throw new Error( 'Stack name required — How to fix: enter a project name (letters, numbers, dashes).' ) } // Client-side parse for early errors (server validates again) try { const { services } = parseComposeServices(composeContent) const names = Object.keys(services || {}) log(`Stack: ${stackName}`) log(`Services (${names.length}): ${names.join(', ') || '—'}`) log(`Images: ${images.length ? images.join(', ') : 'none declared'}`) if (args.envFileContent) { const lines = String(args.envFileContent) .split('\n') .filter((l) => l.trim() && !l.trim().startsWith('#')) log(`Env file: ${lines.length} variable(s)`) } if (args.build) log('Build: enabled (--build)') } catch (err) { throw new Error( `Invalid compose YAML: ${err?.message || err} — How to fix: check indentation and service definitions.` ) } }, }, { id: 'pull', label: skipPull || images.length === 0 ? 'Resolve images' : `Pull images (${images.length})`, run: async ({ job, log }) => { if (skipPull) { log('Skip pull — compose up will use local images or pull as needed') return } if (images.length === 0) { log('No image: fields in compose — skipping dedicated pull step') return } log(`Pulling ${images.length} image(s) for stack…`) let ok = 0 let warn = 0 for (let i = 0; i < images.length; i++) { const image = images[i] log(`[${i + 1}/${images.length}] ${image}`) const success = await pullImageForJob(image, job, log, 'pull') if (success) ok++ else warn++ } // Clear hybrid card after multi-image pulls so compose step is clean setJobProgress(job.id, null, { stepId: 'pull' }) log(`Image pulls finished: ${ok} ok${warn ? `, ${warn} warning(s)` : ''}`) }, }, { id: 'deploy', label: 'Compose up', run: async ({ log }) => { log(`docker compose up -d — project “${stackName}”…`) try { const res = await manager.request(Methods.deployStack, { composeContent, stackName, envFileContent: args.envFileContent || undefined, build: args.build === true, overrideContent: args.overrideContent || undefined, profiles: args.profiles, env: args.env, rollback: args.rollback, }) log(res?.message || `Stack "${stackName}" deployed`) if (res?.method) log(`Engine: ${res.method}`) if (Array.isArray(res?.services)) { for (const s of res.services) { log(` · ${s.service || s.name || '?'}: ${s.status || 'up'}`) } } if (res?.stdout) { const tail = String(res.stdout).trim().split('\n').slice(-12) for (const line of tail) { if (line.trim()) log(line) } } return res } catch (err) { const info = explainError(err, 'deployStack') const full = [info.title, info.message, info.recovery ? `How to fix: ${info.recovery}` : ''] .filter(Boolean) .join(' — ') const e = new Error(full || err.message) e.code = info.code || err.code e.cause = err throw e } }, }, ], { peerId: manager.active?.id, icon: 'fa-layer-group', subtitle: stackName || 'compose', } ) } /** * GitOps stack sync with job tray steps (clone → validate → deploy). * @param {{ stackName: string, repoUrl: string, ref?: string, composePath?: string, build?: boolean }} args */ export async function syncStackFromGitWithSteps(args) { const stackName = String(args?.stackName || '').trim() const repoUrl = String(args?.repoUrl || '').trim() const ref = String(args?.ref || 'main').trim() || 'main' const composePath = String(args?.composePath || 'docker-compose.yml').trim() || 'docker-compose.yml' return runJob( `GitOps stack ${stackName || 'compose'}`, [ { id: 'validate', label: 'Validate options', run: async ({ log }) => { if (!stackName) throw new Error('Stack name required') if (!repoUrl) throw new Error('Repository URL required') log(`Stack: ${stackName}`) log(`Repo: ${repoUrl}`) log(`Ref: ${ref}`) log(`Compose path: ${composePath}`) }, }, { id: 'sync', label: 'Clone & compose up', run: async ({ log }) => { log('Fetching compose from git and deploying…') try { const res = await manager.request(Methods.syncStackFromGit, { stackName, repoUrl, ref, composePath, build: args?.build === true, }) log(res?.message || `Stack "${stackName}" synced from git`) if (res?.commit) log(`Commit: ${String(res.commit).slice(0, 12)}`) if (res?.composePath) log(`Path: ${res.composePath}`) if (res?.method) log(`Engine: ${res.method}`) return res } catch (err) { const info = explainError(err, 'syncStackFromGit') const full = [info.title, info.message, info.recovery ? `How to fix: ${info.recovery}` : ''] .filter(Boolean) .join(' — ') const e = new Error(full || err.message) e.code = info.code || err.code e.cause = err throw e } }, }, ], { peerId: manager.active?.id, icon: 'fa-code-branch', subtitle: repoUrl.slice(0, 48), } ) } export async function openSmartNetworkModal() { const modalEl = document.getElementById('createNetworkSmartModal') if (!modalEl || !window.bootstrap) { showAlert('warning', 'Network modal unavailable') return } await hydrateNetworkForm() const modal = bootstrap.Modal.getOrCreateInstance(modalEl) modal.show() } async function hydrateNetworkForm() { try { const [nameSug, ipam] = await Promise.all([ suggestName('network', 'net'), suggestNetwork(), ]) const nameEl = document.getElementById('smart-net-name') const subEl = document.getElementById('smart-net-subnet') const gwEl = document.getElementById('smart-net-gateway') const badge = document.getElementById('net-name-badge') if (nameEl && !nameEl.dataset.pinned) { nameEl.value = nameSug.name if (badge) badge.style.display = '' } if (subEl && ipam?.subnet) { subEl.value = ipam.subnet document.getElementById('net-subnet-badge')?.style && (document.getElementById('net-subnet-badge').style.display = '') } if (gwEl && ipam?.gateway) gwEl.value = ipam.gateway } catch (err) { presentError(err, 'suggestNetworkIPAM', { showAlert, notificationManager }) } } export async function createSmartNetwork() { const name = document.getElementById('smart-net-name')?.value?.trim() const driver = document.getElementById('smart-net-driver')?.value || 'bridge' const subnet = document.getElementById('smart-net-subnet')?.value?.trim() const gateway = document.getElementById('smart-net-gateway')?.value?.trim() if (!name) { showAlert('warning', 'Network name required') return } try { const job = await runJob('Create network', [ { id: 'create', label: `Create ${name}`, run: async ({ log }) => { log(`driver=${driver} subnet=${subnet || '(auto)'}`) const res = await manager.request(Methods.createNetwork, { name, driver, subnet: subnet || undefined, gateway: gateway || undefined, }) log(res?.message || 'Created') return res }, }, ]) showJob(job) // Job drawer already shows success — do not also toast/tray markFeedbackShown('success', `Network "${name}" created`) const modalEl = document.getElementById('createNetworkSmartModal') if (modalEl && window.bootstrap) bootstrap.Modal.getInstance(modalEl)?.hide() if (typeof window.sendCommand === 'function') window.sendCommand(Methods.listNetworks) } catch (err) { // Job tray has the error log when runJob failed if (!err?.viaJob) { presentError(err, 'createNetwork', { showAlert, toast: false }) } } } export async function loadHostView(opts = {}) { const silent = opts.silent === true const summary = document.getElementById('host-engine-summary') const metricsEl = document.getElementById('host-metrics-summary') const raw = document.getElementById('host-info-raw') if (!manager.active?.connected) { if (summary) summary.textContent = 'Not connected' return } if (summary && !silent) summary.innerHTML = 'Loading…' try { const [snap, metrics, sys] = await Promise.all([ getSnapshot({ force: true }), manager.request(Methods.getMetrics, {}).catch(() => null), manager.request(Methods.getSystemInfo, {}).catch(() => null), ]) const eng = snap.engine || {} if (summary) { summary.innerHTML = `
${escape(eng.name || 'host')}
OS: ${escape(eng.operatingSystem || '—')} · ${escape(eng.architecture || '')}
CPUs: ${eng.ncpu ?? '—'} · Memory: ${formatBytes(eng.memTotal)}
Swarm: ${escape(eng.swarm || 'inactive')}
API: ${escape(eng.version?.ApiVersion || eng.version?.apiVersion || '—')}
Containers: ${snap.counts?.containers ?? 0} (${snap.counts?.running ?? 0} running) · Images: ${snap.counts?.images ?? 0} · Networks: ${snap.counts?.networks ?? 0} · Volumes: ${snap.counts?.volumes ?? 0}
${snap.networkSuggestion?.subnet ? `
Next free subnet: ${escape(snap.networkSuggestion.subnet)}
` : ''} ` } // Maintenance schedules (Track F) if (!silent) { import('./track-f-extras.js') .then((m) => m.loadSchedulesPanel?.()) .catch(() => {}) } if (metricsEl && metrics) { metricsEl.innerHTML = `
Uptime: ${Math.round((metrics.uptimeMs || 0) / 1000)}s
RPC total: ${metrics.rpc?.total ?? 0} · errors: ${metrics.rpc?.errors ?? 0}
Latency p50/p99: ${metrics.rpc?.latencyMs?.p50 ?? 0} / ${metrics.rpc?.latencyMs?.p99 ?? 0} ms
RSS: ${formatBytes(metrics.process?.rss)}
Features: swarm=${metrics.features?.swarm} plugins=${metrics.features?.plugins} holesail=${metrics.features?.holesail}
` } if (raw) { raw.textContent = JSON.stringify(sys?.data || eng.info || {}, null, 2) } } catch (err) { presentError(err, 'getHostSnapshot', { showAlert, silent }) } } let eventsPaused = false export async function loadEventsView(opts = {}) { const silent = opts.silent === true const host = document.getElementById('events-full-list') if (!host || !manager.active?.connected) return try { const res = await manager.request(Methods.getDockerEvents, { timeoutMs: 1500 }) const filter = (document.getElementById('events-filter')?.value || '').toLowerCase() let events = res?.data || [] if (filter) { events = events.filter((e) => JSON.stringify(e).toLowerCase().includes(filter)) } if (!events.length) { host.innerHTML = '
No recent events
' return } host.innerHTML = events .slice() .reverse() .slice(0, 200) .map((e) => { const t = e.time ? new Date(e.time * 1000).toLocaleString() : '' const line = `${t} ${e.Type || ''} ${e.Action || ''} ${e.Actor?.Attributes?.name || e.Actor?.ID || ''}`.trim() return `
${escape(line)}
` }) .join('') } catch (err) { presentError(err, 'getDockerEvents', { showAlert, silent }) } } export function appendLiveEvent(evt) { if (eventsPaused) return const host = document.getElementById('events-full-list') if (!host) return const t = new Date().toLocaleTimeString() const line = `${t} ${evt?.Type || evt?.type || ''} ${evt?.Action || evt?.action || ''} ${evt?.Actor?.Attributes?.name || ''}`.trim() const row = document.createElement('div') row.className = 'list-group-item list-group-item-dark border-secondary py-1' row.textContent = line host.prepend(row) } /** * Holesail tunnels view — list / create / close hs:// port tunnels. */ export async function loadTunnelsView(opts = {}) { const silent = opts.silent === true const banner = document.getElementById('tunnels-status-banner') const list = document.getElementById('tunnels-list') if (!manager.active?.connected) { if (banner) { banner.className = 'alert alert-warning small mb-3' banner.innerHTML = 'Connect a peer to manage tunnels.' } if (list) list.innerHTML = '
Not connected
' return } try { const statusRes = await manager.request(Methods.getHolesailStatus, {}).catch(() => null) const st = statusRes?.status || {} if (!st.enabled) { if (banner) { banner.className = 'alert alert-secondary small mb-3' banner.innerHTML = 'Holesail is off on this peer (ENABLE_HOLESAIL=0). Remove that env var to re-enable (on by default).' } if (list) { list.innerHTML = '
Feature disabled on server
' } return } if (!st.available) { if (banner) { banner.className = 'alert alert-danger small mb-3' banner.innerHTML = 'Holesail is enabled but the holesail package is not available on the server.' } return } const res = await manager.request(Methods.listTunnels, {}) const tunnels = res?.tunnels || [] if (banner) { banner.className = 'alert alert-success small mb-3' banner.innerHTML = `Holesail ready · ${tunnels.length} active / max ${st.max || '—'} · allowed hosts: ${(st.allowedHosts || []).map(escape).join(', ') || '127.0.0.1'}` } if (!list) return if (!tunnels.length) { const emptyKey = 'tunnels-empty' if (list.dataset.listFp !== emptyKey) { list.dataset.listFp = emptyKey list.innerHTML = '
No active tunnels
' } return } // Skip full rebuild when auto-refresh sees the same tunnels const fp = tunnels .map((t) => `${t.id}|${t.url}|${t.state}|${t.name}|${t.host}|${t.port}|${t.protocol}`) .join('\u0001') if (list.dataset.listFp === fp) return list.dataset.listFp = fp list.innerHTML = tunnels .map((t) => { const target = `${t.protocol || 'tcp'}://${t.host}:${t.port}` const url = t.url || '' const meta = [ t.containerName ? `container ${t.containerName}` : null, t.state || null, t.persist === false ? 'ephemeral' : 'persisted', t.createdAt ? new Date(t.createdAt).toLocaleString() : null, ] .filter(Boolean) .join(' · ') return `
${escape(t.name || t.id)}
${escape(target)}${meta ? ` · ${escape(meta)}` : ''}
${escape(url)}
` }) .join('') } catch (err) { presentError(err, 'listTunnels', { showAlert, silent }) if (banner) { banner.className = 'alert alert-danger small mb-3' banner.textContent = err.message || 'Failed to load tunnels' } } } /** Prevent double-submit while a create is in flight */ let tunnelCreateBusy = false export async function createTunnelFromForm() { const s = loadSettings() const name = document.getElementById('tunnel-name')?.value?.trim() const host = document.getElementById('tunnel-host')?.value?.trim() || '127.0.0.1' const port = Number(document.getElementById('tunnel-port')?.value) const protocol = document.getElementById('tunnel-protocol')?.value || s.defaultTunnelProtocol || 'tcp' const secureEl = document.getElementById('tunnel-secure') const secure = secureEl != null ? secureEl.checked !== false : s.defaultTunnelSecure !== false const btn = document.getElementById('tunnel-create-btn') if (!port || port < 1 || port > 65535) { showAlert('warning', 'Enter a valid port (1–65535)') return } if (tunnelCreateBusy) { showAlert('info', 'Tunnel create already in progress…') return } tunnelCreateBusy = true if (btn) btn.disabled = true try { let createdUrl = '' let wasExisting = false const job = await runJob(`Tunnel ${host}:${port}`, [ { id: 'create', label: 'Start Holesail tunnel', run: async ({ log }) => { log(`Target ${protocol}://${host}:${port} secure=${secure}`) const res = await manager.request(Methods.createTunnel, { name: name || undefined, host, port, protocol, secure, }) const url = res?.tunnel?.url wasExisting = Boolean(res?.existing) if (wasExisting) log('Tunnel already active — reusing existing hs:// URL') if (url) { createdUrl = url log(`URL: ${url}`) } return res }, }, ]) showJob(job) const label = wasExisting ? `Tunnel already exists for ${host}:${port}` : `Tunnel created for ${host}:${port}` markFeedbackShown(wasExisting ? 'info' : 'success', label) if (createdUrl && navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(createdUrl) showAlert( wasExisting ? 'info' : 'success', wasExisting ? 'Tunnel already exists — hs:// URL copied' : 'Tunnel created — hs:// URL copied' ) } catch { showAlert(wasExisting ? 'info' : 'success', label) } } else { showAlert(wasExisting ? 'info' : 'success', label) } await loadTunnelsView() } catch (err) { if (!err?.viaJob) presentError(err, 'createTunnel', { showAlert }) } finally { tunnelCreateBusy = false if (btn) btn.disabled = false } } export async function closeTunnelById(id) { if (!id) return try { await manager.request(Methods.closeTunnel, { id }) markFeedbackShown('success', `Tunnel ${id} closed`) await loadTunnelsView() } catch (err) { presentError(err, 'closeTunnel', { showAlert }) } } /** * Bind a local Holesail client to an hs:// URL and optionally open the browser. * @param {string} url * @param {{ openBrowser?: boolean, localPort?: number }} [opts] */ /** * Swarm services / nodes / tasks (on by default; ENABLE_SWARM=0 to disable). */ export async function loadSwarmView(opts = {}) { const silent = opts.silent === true const banner = document.getElementById('swarm-status-banner') if (!manager.active?.connected) { if (banner) { banner.className = 'alert alert-warning small mb-3' banner.textContent = 'Connect a peer to view Swarm.' } return } try { const inspect = await manager.request(Methods.swarmInspect, {}) if (banner) { banner.className = 'alert alert-success small mb-3' const id = inspect?.data?.ID || inspect?.data?.id || '—' banner.innerHTML = `Swarm active · ID ${escape(String(id).slice(0, 16))}` } const [services, nodes, tasks, secrets, configs] = await Promise.all([ manager.request(Methods.listServices, {}).catch(() => ({ data: [] })), manager.request(Methods.listNodes, {}).catch(() => ({ data: [] })), manager.request(Methods.listTasks, {}).catch(() => ({ data: [] })), manager.request(Methods.listSecrets, {}).catch(() => ({ data: [] })), manager.request(Methods.listConfigs, {}).catch(() => ({ data: [] })), ]) const svcBody = document.getElementById('swarm-services-body') const nodeBody = document.getElementById('swarm-nodes-body') const taskBody = document.getElementById('swarm-tasks-body') const secBody = document.getElementById('swarm-secrets-body') const cfgBody = document.getElementById('swarm-configs-body') const svcs = services?.data || [] const nds = nodes?.data || [] const tks = tasks?.data || [] const secs = secrets?.data || [] const cfgs = configs?.data || [] // Fingerprint whole swarm snapshot — skip DOM wipe on silent auto-refresh const swarmFp = [ svcs.map((s) => `${s.ID}|${s.Spec?.Name}|${s.Spec?.Mode?.Replicated?.Replicas}|${s.Spec?.Mode?.Global ? 'g' : ''}`).join(';'), nds.map((n) => `${n.ID}|${n.Status?.State}|${n.Spec?.Availability}`).join(';'), tks.map((t) => `${t.ID}|${t.DesiredState}|${t.Status?.State}`).join(';'), secs.map((s) => s.ID).join(';'), cfgs.map((c) => c.ID).join(';'), ].join('\u0001') const swarmHost = svcBody?.closest('.view') || document.getElementById('swarm-view') if (swarmHost?.dataset?.swarmFp === swarmFp) { return } if (swarmHost) swarmHost.dataset.swarmFp = swarmFp if (svcBody) { svcBody.innerHTML = svcs.length ? svcs .map((s) => { const name = s.Spec?.Name || s.Spec?.Labels?.['com.docker.stack.namespace'] || '—' const image = s.Spec?.TaskTemplate?.ContainerSpec?.Image || '—' const isGlobal = Boolean(s.Spec?.Mode?.Global) const replicas = isGlobal ? 'global' : s.Spec?.Mode?.Replicated?.Replicas ?? '—' const sid = String(s.ID || '') const scaleBtn = isGlobal ? '' : `` return `${escape(name)}${escape(image)}${escape(String(replicas))}${escape(sid.slice(0, 12))}${scaleBtn}` }) .join('') : 'No services' } document.querySelectorAll('.swarm-scale-btn').forEach((btn) => { btn.addEventListener('click', () => { window.peardockOps?.scaleServiceUi?.( btn.getAttribute('data-id'), btn.getAttribute('data-name'), Number(btn.getAttribute('data-replicas')) || 1 ) }) }) if (nodeBody) { nodeBody.innerHTML = nds.length ? nds .map((n) => { const hostname = n.Description?.Hostname || '—' const role = n.Spec?.Role || '—' const status = n.Status?.State || '—' const avail = n.Spec?.Availability || '—' return `${escape(hostname)}${escape(role)}${escape(status)}${escape(avail)}${escape(String(n.ID || '').slice(0, 12))}` }) .join('') : 'No nodes' } if (taskBody) { taskBody.innerHTML = tks.length ? tks .slice(0, 200) .map((t) => { const svc = t.ServiceID ? String(t.ServiceID).slice(0, 12) : '—' const node = t.NodeID ? String(t.NodeID).slice(0, 12) : '—' return `${escape(svc)}${escape(node)}${escape(t.DesiredState || '—')}${escape(t.Status?.State || '—')}${escape(String(t.ID || '').slice(0, 12))}` }) .join('') : 'No tasks' } if (secBody) { secBody.innerHTML = secs.length ? secs .map((s) => { const name = s.Spec?.Name || '—' const created = s.CreatedAt ? new Date(s.CreatedAt).toLocaleString() : '—' return `${escape(name)}${escape(created)}${escape(String(s.ID || '').slice(0, 12))}` }) .join('') : 'No secrets' } if (cfgBody) { cfgBody.innerHTML = cfgs.length ? cfgs .map((c) => { const name = c.Spec?.Name || '—' const created = c.CreatedAt ? new Date(c.CreatedAt).toLocaleString() : '—' return `${escape(name)}${escape(created)}${escape(String(c.ID || '').slice(0, 12))}` }) .join('') : 'No configs' } } catch (err) { const code = err?.code || '' const msg = err?.message || String(err) if (banner) { if (code === 'FEATURE_DISABLED' || /ENABLE_SWARM|disabled/i.test(msg)) { banner.className = 'alert alert-secondary small mb-3' banner.innerHTML = 'Swarm APIs are off. Remove ENABLE_SWARM=0 (on by default).' } else { banner.className = 'alert alert-danger small mb-3' banner.textContent = msg } } if (!silent) presentError(err, 'swarmInspect', { showAlert, silent: /FEATURE_DISABLED|disabled/i.test(msg) }) } } export async function connectLocalTunnel(url, opts = {}) { if (!url) { showAlert('warning', 'No tunnel URL') return null } try { showActivity(`Connecting Holesail client…`) const { connectLocalHolesail } = await import('../client/holesailLocal.js') const openBrowser = opts.openBrowser !== undefined ? opts.openBrowser !== false : loadSettings().openTunnelBrowser !== false const entry = await connectLocalHolesail(url, { openBrowser, localPort: opts.localPort, }) completeActivity(true, `Local proxy on ${entry.host}:${entry.localPort}`) showAlert( 'success', `Local Holesail proxy listening on ${entry.host}:${entry.localPort}` ) return entry } catch (err) { completeActivity(false, err.message || 'Local Holesail failed') const msg = err?.message || String(err) // Designed modal with copy action (never rely on native confirm / alert alone) try { const copy = await confirmDialog({ title: 'Could not start local Holesail proxy', body: `${msg}\n\n` + `Fully restart peardock so Bare main can start the Holesail control API.\n\n` + `Or connect outside peardock:\n` + `npx holesail '${url}'\n\n` + `Then open http://127.0.0.1: in your browser.`, confirmLabel: 'Copy hs:// URL', cancelLabel: 'Dismiss', info: true, icon: 'fa-plug-circle-xmark', }) if (copy && navigator.clipboard?.writeText) { await navigator.clipboard.writeText(url) showAlert('success', 'Holesail URL copied') } } catch { presentError(err, 'holesailLocal', { showAlert }) } return null } } /** @type {string[]} in-memory editor state for template list URLs */ let templateUrlsDraft = [] /** @type {Array<{ name: string, value: string }>} */ let hiddenLabelFiltersDraft = [] function setSelectValue(id, value) { const el = document.getElementById(id) if (el && value !== undefined && value !== null) el.value = String(value) } function setCheckbox(id, checked) { const el = document.getElementById(id) if (el) el.checked = Boolean(checked) } /** * Switch settings subtab. * @param {string} tab */ export function showSettingsTab(tab) { const name = tab || 'appearance' document.querySelectorAll('#settings-tabs .nav-link').forEach((btn) => { btn.classList.toggle('active', btn.getAttribute('data-settings-tab') === name) }) document.querySelectorAll('[data-settings-panel]').forEach((panel) => { panel.classList.toggle('hidden', panel.getAttribute('data-settings-panel') !== name) }) try { saveSettings({ settingsTab: name }) } catch { // ignore } // Peers list lives under Settings — refresh when opening that tab if (name === 'peers' && typeof window.loadPeersView === 'function') { try { window.loadPeersView() } catch { // ignore } } } /** * Collect form values from settings panels into a partial settings object. */ export function readSettingsForm() { const densityRaw = document.getElementById('settings-density')?.value || 'comfortable' const density = ['comfortable', 'compact', 'spacious'].includes(densityRaw) ? densityRaw : 'comfortable' const accentRaw = document.getElementById('settings-accent')?.value || 'teal' const accent = ['teal', 'cyan', 'violet'].includes(accentRaw) ? accentRaw : 'teal' return { density, accent, reduceMotion: document.getElementById('settings-reduce-motion')?.value === '1', showRefreshStamp: document.getElementById('settings-refresh-stamp')?.value !== '0', sidebarCollapsed: document.getElementById('settings-sidebar')?.value === 'collapsed', confirmDestructive: document.getElementById('settings-confirm')?.value !== '0', refreshSeconds: Number(document.getElementById('settings-refresh')?.value) || 0, jobSuccessDismissMs: Number(document.getElementById('settings-job-dismiss')?.value) || 0, badgeMode: document.getElementById('settings-badge-mode')?.value || 'alerts', forceToast: document.getElementById('settings-force-toast')?.value === '1', openTunnelBrowser: document.getElementById('settings-tunnel-open-browser')?.value !== '0', offerLocalTunnelAfterCreate: document.getElementById('settings-tunnel-offer-local')?.value !== '0', defaultTunnelSecure: document.getElementById('settings-tunnel-secure')?.value !== '0', defaultTunnelProtocol: document.getElementById('settings-tunnel-protocol')?.value === 'udp' ? 'udp' : 'tcp', terminalTheme: document.getElementById('settings-term-theme')?.value || 'dark', dockerTerminalTheme: document.getElementById('settings-docker-term-theme')?.value || 'dark', showFirstConnectTip: document.getElementById('settings-first-connect')?.checked !== false, templateListUrls: getTemplateListUrls(), hiddenContainerLabels: normalizeHiddenContainerLabels(hiddenLabelFiltersDraft), } } /** * @param {string} [forceTab] — open a specific settings subtab (e.g. peers) */ export function loadSettingsView(forceTab) { const s = loadSettings() setSelectValue('settings-density', s.density || 'comfortable') setSelectValue('settings-accent', s.accent || 'teal') setSelectValue('settings-reduce-motion', s.reduceMotion ? '1' : '0') setSelectValue('settings-refresh-stamp', s.showRefreshStamp === false ? '0' : '1') setSelectValue('settings-sidebar', s.sidebarCollapsed ? 'collapsed' : 'expanded') setSelectValue('settings-confirm', s.confirmDestructive === false ? '0' : '1') setSelectValue('settings-refresh', s.refreshSeconds ?? 10) setSelectValue('settings-job-dismiss', s.jobSuccessDismissMs ?? 3000) setSelectValue('settings-badge-mode', s.badgeMode || 'alerts') setSelectValue('settings-force-toast', s.forceToast ? '1' : '0') setSelectValue('settings-tunnel-open-browser', s.openTunnelBrowser === false ? '0' : '1') setSelectValue('settings-tunnel-offer-local', s.offerLocalTunnelAfterCreate === false ? '0' : '1') setSelectValue('settings-tunnel-secure', s.defaultTunnelSecure === false ? '0' : '1') setSelectValue('settings-tunnel-protocol', s.defaultTunnelProtocol || 'tcp') setSelectValue('settings-term-theme', s.terminalTheme || 'dark') setSelectValue('settings-docker-term-theme', s.dockerTerminalTheme || 'dark') setCheckbox('settings-first-connect', s.showFirstConnectTip !== false) hiddenLabelFiltersDraft = normalizeHiddenContainerLabels(s.hiddenContainerLabels) renderHiddenLabelFiltersEditor() showSettingsTab(forceTab || s.settingsTab || 'appearance') const peer = document.getElementById('settings-peer-info') const c = manager.active if (peer) { peer.innerHTML = c ? `
ID: ${escape(c.id)}
Role: ${escape(c.role || '—')}
Protocol: v${escape(String(c.protocolVersion ?? '—'))}
Docker: ${c.dockerHealth?.ok ? 'ok' : '—'}
Latency: ${c.latency != null ? escape(String(c.latency)) + ' ms' : '—'}
` : 'Not connected' } // Server feature flags from metrics if available const feat = document.getElementById('settings-server-features') if (feat) { if (!c?.connected) { feat.textContent = 'Connect a peer to inspect feature flags.' } else { feat.innerHTML = 'Loading…' manager .request(Methods.getMetrics, {}) .then((res) => { const f = res?.features || res?.metrics?.features || {} const rows = [ ['Holesail tunnels', f.holesail], ['Swarm', f.swarm], ['Plugins', f.plugins], ] feat.innerHTML = rows .map( ([label, val]) => `
${escape(label)}: ${val === true ? 'on' : val === false ? 'off' : '—'}
` ) .join('') || '
No feature flags reported
' }) .catch(() => { feat.textContent = 'Could not load metrics (peer may not expose getMetrics).' }) } } const ver = document.getElementById('settings-app-version') if (ver) ver.textContent = '2.0.1' templateUrlsDraft = getTemplateListUrls() renderTemplateUrlsEditor() } function renderTemplateUrlsEditor() { const host = document.getElementById('settings-template-urls') if (!host) return if (!templateUrlsDraft.length) { host.innerHTML = '
  • No lists configured — default will be used on save.
  • ' return } host.innerHTML = templateUrlsDraft .map( (url, idx) => `
  • ${escape(url)}
  • ` ) .join('') host.querySelectorAll('.btn-remove-url').forEach((btn) => { btn.addEventListener('click', () => { const i = Number(btn.dataset.idx) if (!Number.isFinite(i)) return templateUrlsDraft = templateUrlsDraft.filter((_, j) => j !== i) renderTemplateUrlsEditor() }) }) } function renderHiddenLabelFiltersEditor() { const host = document.getElementById('settings-hidden-label-filters') if (!host) return if (!hiddenLabelFiltersDraft.length) { host.innerHTML = '
  • No hide filters — all containers remain visible.
  • ' return } host.innerHTML = hiddenLabelFiltersDraft .map( (f, idx) => `
  • ${escape(f.name)}${f.value ? ` = ${escape(f.value)}` : ' (any value)'}
  • ` ) .join('') host.querySelectorAll('.btn-remove-hidden-label').forEach((btn) => { btn.addEventListener('click', () => { const i = Number(btn.dataset.idx) if (!Number.isFinite(i)) return hiddenLabelFiltersDraft = hiddenLabelFiltersDraft.filter((_, j) => j !== i) renderHiddenLabelFiltersEditor() }) }) } function addHiddenLabelFilterFromInput() { const nameEl = document.getElementById('settings-hidden-label-name') const valueEl = document.getElementById('settings-hidden-label-value') const name = String(nameEl?.value || '').trim() const value = String(valueEl?.value || '').trim() if (!name) { showAlert('warning', 'Enter a label name to hide containers by') return } if (name.length > 256 || value.length > 512) { showAlert('danger', 'Label name or value is too long') return } const next = normalizeHiddenContainerLabels([...hiddenLabelFiltersDraft, { name, value }]) if (next.length === hiddenLabelFiltersDraft.length) { showAlert('info', 'That hide filter is already configured') return } hiddenLabelFiltersDraft = next if (nameEl) nameEl.value = '' if (valueEl) valueEl.value = '' renderHiddenLabelFiltersEditor() } function addTemplateUrlFromInput() { const input = document.getElementById('settings-template-url-input') const raw = input?.value?.trim() || '' if (!raw) { showAlert('warning', 'Enter a template list URL') return } let href try { const u = new URL(raw) if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('http(s) only') href = u.href } catch { showAlert('danger', 'Invalid URL — use http(s)://…/templates.json') return } if (templateUrlsDraft.includes(href)) { showAlert('info', 'That list is already configured') return } templateUrlsDraft = normalizeTemplateListUrls([...templateUrlsDraft, href]) if (input) input.value = '' renderTemplateUrlsEditor() } /** * Persist draft URLs and optionally force template re-fetch. * @param {{ reload?: boolean }} [opts] */ export async function saveTemplateListSettings(opts = {}) { const urls = setTemplateListUrls(templateUrlsDraft) templateUrlsDraft = urls renderTemplateUrlsEditor() // Clear deploy-view cache so next open refetches clearMergedTemplateCache() if (typeof window !== 'undefined') { window.__peardockClearDeployTemplateCache?.() } if (opts.reload) { const status = document.getElementById('settings-template-lists-status') if (status) status.textContent = 'Fetching catalogs…' try { const result = await fetchMergedTemplates(urls, { force: true }) if (typeof window !== 'undefined') { window.__peardockSetDeployTemplates?.(result.templates, result.stats) } const st = result.stats const msg = `Loaded ${st.unique} templates from ${st.sources} list(s)` + (st.duplicates ? ` · removed ${st.duplicates} duplicate(s)` : '') + (st.errors?.length ? ` · ${st.errors.length} list(s) failed` : '') if (status) status.textContent = msg showAlert(st.errors?.length && !st.unique ? 'danger' : 'success', msg) return result } catch (err) { if (status) status.textContent = err.message || 'Reload failed' showAlert('danger', err.message || 'Failed to reload templates') return null } } return urls } export function openPalette(navigateToView) { const send = typeof window !== 'undefined' ? window.sendCommand : null const gItems = typeof window !== 'undefined' && window.__peardockTrackGPalette ? window.__peardockTrackGPalette(navigateToView, send) : [] const items = [ { label: 'Dashboard', icon: 'fa-gauge-high', view: 'dashboard', keywords: 'g d home' }, { label: 'Containers', icon: 'fa-cube', view: 'containers', keywords: 'g c' }, { label: 'Images', icon: 'fa-layer-group', view: 'images', keywords: 'g i local' }, { label: 'Registry', icon: 'fa-warehouse', view: 'registry', keywords: 'g r vault hub catalog tags' }, { label: 'Networks', icon: 'fa-diagram-project', view: 'networks', keywords: 'g n' }, { label: 'Volumes', icon: 'fa-hard-drive', view: 'volumes', keywords: 'g v' }, { label: 'Stacks', icon: 'fa-boxes-stacked', view: 'stacks', keywords: 'g s' }, { label: 'Swarm', icon: 'fa-project-diagram', view: 'swarm', keywords: 'g w services' }, { label: 'Deploy', icon: 'fa-rocket', view: 'deploy', keywords: 'g o create' }, { label: 'Fleet', icon: 'fa-server', view: 'fleet', keywords: 'g f multi' }, { label: 'Peers', icon: 'fa-network-wired', keywords: 'g p connections hosts', action: () => { navigateToView('settings') showSettingsTab('peers') }, }, { label: 'Events', icon: 'fa-bolt', view: 'events', keywords: 'g e' }, { label: 'Host', icon: 'fa-microchip', view: 'host', keywords: 'g h system' }, { label: 'Tunnels', icon: 'fa-satellite-dish', view: 'tunnels', keywords: 'g t holesail' }, { label: 'Access', icon: 'fa-user-shield', view: 'access', keywords: 'g a invites acl' }, { label: 'Settings', icon: 'fa-gear', view: 'settings', keywords: 'g , prefs preferences' }, { label: 'Create network (smart)', icon: 'fa-plus', keywords: 'ipam subnet', action: () => openSmartNetworkModal(), }, { label: 'Refresh host snapshot', icon: 'fa-sync', keywords: 'warm cache', action: () => warmSnapshot(), }, ...gItems, ] openCommandPalette(items, (item) => { if (item.action) item.action() else if (item.view && navigateToView) { navigateToView(item.view) try { window.peardockUx?.animateViewEnter?.(item.view) window.peardockUx?.markListRefreshed?.(item.view) } catch { // ignore } } }) } /** * Hydrate duplicate/create form fields from image suggestions. */ export async function hydrateFormFromImage(image, map) { if (!image) return null try { const sug = await suggestFromImage(image) if (!sug?.success) return sug if (map.name && sug.name) map.name.value = sug.name if (map.cmd && sug.cmd) map.cmd.value = sug.cmd if (map.entrypoint && sug.entrypoint) map.entrypoint.value = sug.entrypoint if (map.workdir && sug.workingDir) map.workdir.value = sug.workingDir showAlert('info', `Defaults loaded from image (${sug.ports?.length || 0} ports, ${sug.env?.length || 0} env)`) return sug } catch (err) { presentError(err, 'suggestFromImage', { showAlert, notificationManager }) return null } } export function shouldConfirmDestructive() { return loadSettings().confirmDestructive !== false } export async function confirmDestructive(title, body, requireText) { if (!shouldConfirmDestructive()) return true return confirmDialog({ title, body, danger: true, requireText, confirmLabel: 'Confirm', icon: 'fa-triangle-exclamation', }) } /** Non-destructive designed modal confirm (replaces window.confirm). */ export async function askUserConfirm(title, body, opts = {}) { return askConfirm(title, body, opts) } export function initOpsApp({ navigateToView, sendCommand }) { applySettings() // Track F extras (volume browser, schedules, scale, secrets, …) import('./track-f-extras.js') .then((mod) => { mod.initTrackFExtras?.() if (typeof window !== 'undefined' && window.peardockOps) { Object.assign(window.peardockOps, { openVolumeBrowser: mod.openVolumeBrowser, openResourceEditor: mod.openResourceEditor, scaleServiceUi: mod.scaleServiceUi, disconnectContainerNetwork: mod.disconnectContainerNetwork, loadSchedulesPanel: mod.loadSchedulesPanel, getPeerEnvironments: mod.getPeerEnvironments, setPeerEnvironment: mod.setPeerEnvironment, }) } }) .catch((err) => console.warn('[ops] track-f-extras failed to load', err)) // Track G UX (shortcuts, go-chords, refresh stamp, scroll-top) import('./track-g-ux.js') .then((mod) => { mod.initTrackGUx?.({ navigateToView, sendCommand }) if (typeof window !== 'undefined') { window.__peardockTrackGPalette = mod.trackGPaletteItems if (window.peardockOps) { Object.assign(window.peardockOps, { openShortcutsModal: mod.openShortcutsModal, focusListSearch: mod.focusListSearch, markListRefreshed: mod.markListRefreshed, saveSettings, }) } } }) .catch((err) => console.warn('[ops] track-g-ux failed to load', err)) document.getElementById('offline-banner-dismiss')?.addEventListener('click', () => { setOfflineBanner(false) }) document.getElementById('smart-net-resuggest')?.addEventListener('click', async () => { try { const ipam = await suggestNetwork() if (ipam?.subnet) { document.getElementById('smart-net-subnet').value = ipam.subnet document.getElementById('smart-net-gateway').value = ipam.gateway || '' } } catch (err) { presentError(err, 'suggestNetworkIPAM', { showAlert, notificationManager }) } }) document.getElementById('smart-net-create-btn')?.addEventListener('click', () => createSmartNetwork()) document.getElementById('tunnels-refresh-btn')?.addEventListener('click', () => loadTunnelsView()) document.getElementById('tunnel-create-btn')?.addEventListener('click', () => createTunnelFromForm()) document.getElementById('swarm-refresh-btn')?.addEventListener('click', () => loadSwarmView()) document.getElementById('swarm-tabs')?.addEventListener('click', (e) => { const btn = e.target?.closest?.('[data-swarm-tab]') if (!btn) return const tab = btn.getAttribute('data-swarm-tab') document.querySelectorAll('#swarm-tabs .nav-link').forEach((el) => { el.classList.toggle('active', el === btn) }) document.querySelectorAll('.swarm-panel').forEach((panel) => { panel.classList.toggle('hidden', panel.id !== `swarm-panel-${tab}`) }) }) document.getElementById('tunnels-list')?.addEventListener('click', async (e) => { const t = e.target const copyBtn = t?.closest?.('.tunnel-copy-btn') if (copyBtn) { const url = copyBtn.getAttribute('data-url') || '' if (url && navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(url) showAlert('success', 'hs:// URL copied') } catch { showAlert('warning', 'Could not copy — select the URL manually') } } return } const localBtn = t?.closest?.('.tunnel-local-btn') if (localBtn) { const url = localBtn.getAttribute('data-url') || '' if (url) await connectLocalTunnel(url, { openBrowser: true }) return } const closeBtn = t?.closest?.('.tunnel-close-btn') if (closeBtn) { const id = closeBtn.getAttribute('data-id') if (id) await closeTunnelById(id) } }) document.getElementById('host-refresh-btn')?.addEventListener('click', () => { loadHostView() window.peardockOps?.loadSchedulesPanel?.() }) document.getElementById('events-refresh-btn')?.addEventListener('click', () => loadEventsView()) document.getElementById('events-pause-btn')?.addEventListener('click', (e) => { eventsPaused = !eventsPaused e.currentTarget.textContent = eventsPaused ? 'Resume' : 'Pause' }) document.getElementById('events-filter')?.addEventListener('input', () => loadEventsView()) document.getElementById('settings-tabs')?.addEventListener('click', (e) => { const btn = e.target?.closest?.('[data-settings-tab]') if (!btn) return showSettingsTab(btn.getAttribute('data-settings-tab')) }) document.getElementById('settings-save-btn')?.addEventListener('click', () => { setTemplateListUrls(templateUrlsDraft) const partial = readSettingsForm() partial.templateListUrls = getTemplateListUrls() const next = saveSettings(partial) startListAutoRefresh(next.refreshSeconds) if (typeof window !== 'undefined') window.__peardockClearDeployTemplateCache?.() showAlert('success', 'Preferences saved', { badge: false }) }) document.getElementById('settings-template-url-add')?.addEventListener('click', () => { addTemplateUrlFromInput() }) document.getElementById('settings-template-url-input')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault() addTemplateUrlFromInput() } }) document.getElementById('settings-template-url-reset')?.addEventListener('click', () => { templateUrlsDraft = [...DEFAULT_TEMPLATE_LIST_URLS] renderTemplateUrlsEditor() showAlert('info', 'Default template list restored (save preferences to apply)') }) document.getElementById('settings-template-reload')?.addEventListener('click', () => { saveTemplateListSettings({ reload: true }) }) document.getElementById('settings-hidden-label-add')?.addEventListener('click', () => { addHiddenLabelFilterFromInput() }) document.getElementById('settings-hidden-label-name')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault() addHiddenLabelFilterFromInput() } }) document.getElementById('settings-hidden-label-value')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault() addHiddenLabelFilterFromInput() } }) document.getElementById('settings-notif-mark-read')?.addEventListener('click', () => { try { notificationManager.markAllAsRead() showAlert('success', 'All notifications marked read', { badge: false }) } catch (err) { showAlert('danger', err.message || 'Failed') } }) document.getElementById('settings-notif-clear')?.addEventListener('click', async () => { const ok = await confirmDestructive( 'Clear notification history?', 'This removes all items from the notification center on this client.' ) if (!ok) return try { notificationManager.clearAll() showAlert('success', 'Notification history cleared', { badge: false }) } catch (err) { showAlert('danger', err.message || 'Failed') } }) // Apply density + refresh on boot applySettings(loadSettings()) templateUrlsDraft = getTemplateListUrls() subscribeJobs((job) => { showJob(job) // Job tray is the source of truth — never also fire top-center toasts clearTopToasts() if (job.status === 'success' || job.status === 'error') { const type = job.status === 'success' ? 'success' : 'danger' const msg = job.result?.message || (job.status === 'success' ? `${job.kind} complete` : `${job.kind} failed`) markFeedbackShown(type, msg) // Also mark step errors so presentError/showAlert won't duplicate for (const s of job.steps || []) { if (s.error) markFeedbackShown('danger', s.error) } } // Keep activity history panel live if open const panel = document.getElementById('activity-panel') if (panel && !panel.classList.contains('hidden') && typeof window.renderActivityPanel === 'function') { window.renderActivityPanel() } }) manager.on('connect', () => { // Role may arrive after handshake setTimeout(() => { if (typeof window.applyRoleUI === 'function') window.applyRoleUI() }, 50) }) manager.on('active', () => { if (typeof window.applyRoleUI === 'function') window.applyRoleUI() }) // Allow clicking the job panel header area to dismiss early document.getElementById('job-drawer')?.addEventListener('click', (e) => { const t = e.target if (t?.closest?.('[data-job-dismiss]')) { dismissJobDrawer() } }) document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') { e.preventDefault() openPalette(navigateToView) } // ? and / and g-chords handled in track-g-ux.js }) // Enhance create-network buttons in the app to use smart modal when present document.querySelectorAll('[data-bs-target="#createNetworkModal"], [data-action="create-network"]').forEach((btn) => { btn.addEventListener('click', (e) => { if (document.getElementById('createNetworkSmartModal')) { e.preventDefault() e.stopPropagation() openSmartNetworkModal() } }) }) manager.on('connect', (conn) => { clearSnapshotCache() warmSnapshot() updateActivePeerChip(conn) setOfflineBanner(false) }) manager.on('disconnect', () => { clearSnapshotCache() updateActivePeerChip(manager.active) if (!manager.active?.connected) { setOfflineBanner(true, 'Connection lost — reconnecting…') } }) manager.on('reconnecting', ({ attempt, delayMs }) => { const secs = Math.max(1, Math.round((delayMs || 5000) / 1000)) setOfflineBanner( true, `Connection lost — retrying every ${secs}s (attempt ${attempt})…` ) }) manager.on('reconnected', () => { setOfflineBanner(false) warmSnapshot() }) manager.on('active', (conn) => { clearSnapshotCache() updateActivePeerChip(conn) if (conn?.connected) warmSnapshot() }) // Patch invite token into connect form if app wires submit separately — expose helper window.peardockOps = { deployContainerWithSteps, deployStackWithSteps, syncStackFromGitWithSteps, ...jobActions, bulkContainerJob: jobActions.bulkContainerJob, recreateContainersJob: jobActions.recreateContainersJob, removeImagesJob: jobActions.removeImagesJob, pruneJob: jobActions.pruneJob, removeStackJob: jobActions.removeStackJob, containerActionJob: jobActions.containerActionJob, buildImageJob: jobActions.buildImageJob, pushImageJob: jobActions.pushImageJob, createVolumeJob: jobActions.createVolumeJob, openSmartNetworkModal, hydrateFormFromImage, confirmDestructive, askUserConfirm, confirmDialog, shouldConfirmDestructive, loadSettings, saveSettings, applySettings, shouldShowFirstConnectTip, showSettingsTab, DEFAULT_SETTINGS, normalizeHiddenContainerLabels, startListAutoRefresh, getTemplateListUrls, setTemplateListUrls, saveTemplateListSettings, fetchMergedTemplates, loadHostView, loadEventsView, loadTunnelsView, loadSwarmView, loadSettingsView, createTunnelFromForm, closeTunnelById, connectLocalTunnel, warmSnapshot, appendLiveEvent, dismissJobDrawer, showJob, showActivity, updateActivity, completeActivity, getActivityJobId, isJobDrawerActive, explainError, presentError: (err, method) => presentError(err, method, { showAlert, toast: false }), statusBadge, listJobs, /** Feed Docker pull/push stream events into the active job tray card */ handlePullProgressEvent, openPalette: () => openPalette(navigateToView), } } /** * Route a pullProgress / pushProgress push into the hybrid job-tray card. * @param {object} event * @returns {boolean} true if an active pull owns this event (even when a UI frame is throttled) */ export function handlePullProgressEvent(event) { // Must claim the event whenever a hybrid pull is registered — otherwise app.js // falls back to updateStatusIndicator → showActivity and replaces the tray with // a minified "Pulling image: 42%" activity job (Image #2). if (!getActivePull(event?.image)) return false const update = applyPullProgressEvent(event) if (!update) { // Throttled / no-op frame — hybrid UI already owns the tray; do not fall back return true } // Progress card carries the status — avoid writing step.detail every tick // (that second line under the step made the tray bounce vertically). const hasMilestone = Boolean(update.milestoneLine) setJobProgress(update.jobId, update.snapshot, { stepId: update.stepId, emit: !hasMilestone, }) if (hasMilestone) { appendJobLog( update.jobId, update.milestoneLine, update.snapshot.phase === 'error' || event?.error ? 'error' : 'info' ) } return true } function isTyping(t) { if (!t) return false const tag = t.tagName return tag === 'INPUT' || tag === 'TEXTAREA' || t.isContentEditable } function escape(s) { return String(s ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') } function formatBytes(n) { if (n == null || Number.isNaN(Number(n))) return '—' const v = Number(n) if (v < 1024) return `${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` } export default { initOpsApp }