Files
peardock/ui/ops-app.js
T
Raven Scott fbe1751468
Release rolling / release (push) Has been cancelled
Polish modals and add settings save button feedback
Replace stock Bootstrap modal chrome with a dark teal panel system, and
give Settings Save all a Saving… / Saved state with motion-aware styles.
2026-07-16 15:38:29 -04:00

2547 lines
90 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
jobSpinnerHtml,
jobSpinnerLoadingRow,
} 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<typeof setInterval>|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<typeof setTimeout>|null} */
let jobDismissTimer = null
/** @type {ReturnType<typeof setTimeout>|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 = jobSpinnerHtml('Loading host…')
if (metricsEl && !silent) metricsEl.innerHTML = jobSpinnerHtml('Loading metrics…')
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 = `
<div><strong>${escape(eng.name || 'host')}</strong></div>
<div>OS: ${escape(eng.operatingSystem || '—')} · ${escape(eng.architecture || '')}</div>
<div>CPUs: ${eng.ncpu ?? '—'} · Memory: ${formatBytes(eng.memTotal)}</div>
<div>Swarm: ${escape(eng.swarm || 'inactive')}</div>
<div>API: ${escape(eng.version?.ApiVersion || eng.version?.apiVersion || '—')}</div>
<div class="mt-2">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}</div>
${snap.networkSuggestion?.subnet ? `<div class="mt-2 form-hint-suggested">Next free subnet: ${escape(snap.networkSuggestion.subnet)}</div>` : ''}
`
}
// Keep sidebar Swarm nav in sync with engine LocalNodeState
refreshSwarmNavState(snap).catch(() => {})
// Maintenance schedules (Track F)
if (!silent) {
import('./track-f-extras.js')
.then((m) => m.loadSchedulesPanel?.())
.catch(() => {})
}
if (metricsEl) {
if (metrics) {
metricsEl.innerHTML = `
<div>Uptime: ${Math.round((metrics.uptimeMs || 0) / 1000)}s</div>
<div>RPC total: ${metrics.rpc?.total ?? 0} · errors: ${metrics.rpc?.errors ?? 0}</div>
<div>Latency p50/p99: ${metrics.rpc?.latencyMs?.p50 ?? 0} / ${metrics.rpc?.latencyMs?.p99 ?? 0} ms</div>
<div>RSS: ${formatBytes(metrics.process?.rss)}</div>
<div>Features: swarm=${metrics.features?.swarm} plugins=${metrics.features?.plugins} holesail=${metrics.features?.holesail}</div>
`
} else {
metricsEl.textContent = 'Metrics unavailable'
}
}
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
if (!silent) host.innerHTML = `<div class="text-muted p-3">${jobSpinnerHtml('Loading events…')}</div>`
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 = '<div class="text-muted p-3">No recent events</div>'
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 `<div class="list-group-item list-group-item-dark border-secondary py-1">${escape(line)}</div>`
})
.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 = '<div class="text-muted small p-2">Not connected</div>'
return
}
if (!silent) {
if (banner) {
banner.className = 'alert alert-secondary small mb-3'
banner.innerHTML = jobSpinnerHtml('Loading tunnels…')
}
if (list) list.innerHTML = `<div class="text-muted small p-2">${jobSpinnerHtml('Loading…')}</div>`
}
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 <strong>off</strong> on this peer (<code>ENABLE_HOLESAIL=0</code>). Remove that env var to re-enable (on by default).'
}
if (list) {
list.innerHTML =
'<div class="text-muted small p-2">Feature disabled on server</div>'
}
return
}
if (!st.available) {
if (banner) {
banner.className = 'alert alert-danger small mb-3'
banner.innerHTML =
'Holesail is enabled but the <code>holesail</code> 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 · <strong>${tunnels.length}</strong> 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 = '<div class="text-muted small p-2">No active tunnels</div>'
}
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 `<div class="list-group-item list-group-item-dark border-secondary" data-tunnel-id="${escape(t.id)}">
<div class="d-flex justify-content-between align-items-start gap-2 flex-wrap">
<div class="min-w-0">
<div class="fw-semibold">${escape(t.name || t.id)}</div>
<div class="small text-muted">${escape(target)}${meta ? ` · ${escape(meta)}` : ''}</div>
<code class="small d-block mt-1 text-break user-select-all">${escape(url)}</code>
</div>
<div class="btn-group btn-group-sm flex-shrink-0">
<button type="button" class="btn btn-outline-success tunnel-local-btn" data-url="${escape(url)}" title="Connect locally (Holesail client + browser)">
<i class="fas fa-plug"></i>
</button>
<button type="button" class="btn btn-outline-primary tunnel-copy-btn" data-url="${escape(url)}" title="Copy hs:// URL">
<i class="fas fa-copy"></i>
</button>
<button type="button" class="btn btn-outline-danger tunnel-close-btn" data-id="${escape(t.id)}" data-min-role="operator" title="Close tunnel">
<i class="fas fa-xmark"></i>
</button>
</div>
</div>
</div>`
})
.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 (165535)')
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]
*/
/** Primary message when Swarm nav is unavailable */
export const SWARM_DISABLED_NAV_TITLE = 'Docker Swarm Mode is Disabled on the Node'
/** @type {HTMLElement|null} */
let swarmNavTipEl = null
/** @type {ReturnType<typeof setTimeout>|null} */
let swarmNavTipHideTimer = null
/** @type {boolean} */
let swarmNavTipWired = false
/**
* Build / return the floating Swarm-disabled help popover.
* @returns {HTMLElement}
*/
function ensureSwarmNavTip() {
if (swarmNavTipEl && document.body.contains(swarmNavTipEl)) return swarmNavTipEl
const tip = document.createElement('div')
tip.id = 'swarm-nav-tip'
tip.className = 'pd-nav-tip'
tip.setAttribute('role', 'tooltip')
tip.hidden = true
tip.innerHTML = `
<div class="pd-nav-tip-arrow" aria-hidden="true"></div>
<div class="pd-nav-tip-head">
<span class="pd-nav-tip-icon"><i class="fas fa-project-diagram" aria-hidden="true"></i></span>
<div class="pd-nav-tip-head-text">
<strong class="pd-nav-tip-title">${SWARM_DISABLED_NAV_TITLE}</strong>
<span class="pd-nav-tip-sub">This peers Docker engine is not running as a swarm node.</span>
</div>
</div>
<div class="pd-nav-tip-body">
<p class="pd-nav-tip-label">How to enable Swarm mode</p>
<ol class="pd-nav-tip-steps">
<li>
<span>Initialize a new swarm on this node (manager):</span>
<code class="pd-nav-tip-code">docker swarm init</code>
</li>
<li>
<span>Or join an existing swarm as a worker/manager:</span>
<code class="pd-nav-tip-code">docker swarm join --token &lt;TOKEN&gt; &lt;MANAGER-IP&gt;:2377</code>
</li>
<li>
<span>Confirm the node is active:</span>
<code class="pd-nav-tip-code">docker info | grep -i swarm</code>
</li>
</ol>
<p class="pd-nav-tip-note">
After Swarm is active, reconnect or refresh the peer — the Swarm nav item will enable automatically.
If the peer sets <code>ENABLE_SWARM=0</code>, remove that flag so PearDock exposes Swarm APIs.
</p>
</div>
`
document.body.appendChild(tip)
swarmNavTipEl = tip
// Keep tip visible while pointer is over it
tip.addEventListener('mouseenter', () => {
if (swarmNavTipHideTimer) {
clearTimeout(swarmNavTipHideTimer)
swarmNavTipHideTimer = null
}
})
tip.addEventListener('mouseleave', () => hideSwarmNavTip(120))
return tip
}
/**
* @param {HTMLElement} link
*/
function positionSwarmNavTip(link) {
const tip = ensureSwarmNavTip()
const rect = link.getBoundingClientRect()
// Measure after unhiding
tip.hidden = false
tip.classList.add('pd-nav-tip--visible')
const tipRect = tip.getBoundingClientRect()
const gap = 10
let top = rect.top + rect.height / 2 - tipRect.height / 2
let left = rect.right + gap
// Flip left if overflowing viewport
if (left + tipRect.width > window.innerWidth - 8) {
left = rect.left - tipRect.width - gap
tip.classList.add('pd-nav-tip--left')
} else {
tip.classList.remove('pd-nav-tip--left')
}
top = Math.max(8, Math.min(top, window.innerHeight - tipRect.height - 8))
tip.style.top = `${Math.round(top)}px`
tip.style.left = `${Math.round(left)}px`
}
function showSwarmNavTip(link) {
if (swarmNavTipHideTimer) {
clearTimeout(swarmNavTipHideTimer)
swarmNavTipHideTimer = null
}
if (!link || link.dataset.swarmAvailable === '1') {
hideSwarmNavTip(0)
return
}
positionSwarmNavTip(link)
}
/**
* @param {number} [delayMs]
*/
function hideSwarmNavTip(delayMs = 0) {
if (swarmNavTipHideTimer) {
clearTimeout(swarmNavTipHideTimer)
swarmNavTipHideTimer = null
}
const run = () => {
if (!swarmNavTipEl) return
swarmNavTipEl.classList.remove('pd-nav-tip--visible')
swarmNavTipEl.hidden = true
}
if (delayMs > 0) swarmNavTipHideTimer = setTimeout(run, delayMs)
else run()
}
/** @type {number} */
let swarmNavRecheckSeq = 0
/**
* On hover/focus of a disabled Swarm nav: re-query the peer.
* If Swarm is now active, enable the link and skip the tip.
* @param {HTMLElement} link
*/
async function recheckSwarmOnHover(link) {
if (!link || link.dataset.swarmAvailable === '1') {
hideSwarmNavTip(0)
return
}
// Show tip immediately while we recheck (avoids empty hover)
showSwarmNavTip(link)
if (!manager.active?.connected) return
const seq = ++swarmNavRecheckSeq
try {
// Force a fresh host snapshot so we pick up docker swarm init/join
clearSnapshotCache()
const enabled = await refreshSwarmNavState()
if (seq !== swarmNavRecheckSeq) return
if (enabled) {
hideSwarmNavTip(0)
// Optional soft feedback once re-enabled
try {
showAlert('success', 'Docker Swarm is active on this node — Swarm nav enabled.', {
toast: true,
tray: false,
badge: false,
})
} catch {
// ignore
}
} else if (link.matches(':hover') || document.activeElement === link) {
// Still disabled — keep tip visible / re-position
showSwarmNavTip(link)
}
} catch {
if (seq !== swarmNavRecheckSeq) return
if (link.dataset.swarmAvailable === '0' && (link.matches(':hover') || document.activeElement === link)) {
showSwarmNavTip(link)
}
}
}
/**
* Wire hover/focus handlers once for the Swarm nav item.
* @param {HTMLElement} link
*/
function wireSwarmNavTip(link) {
if (!link || swarmNavTipWired) return
swarmNavTipWired = true
link.addEventListener('mouseenter', () => {
recheckSwarmOnHover(link)
})
link.addEventListener('mouseleave', () => hideSwarmNavTip(160))
link.addEventListener('focus', () => {
recheckSwarmOnHover(link)
})
link.addEventListener('blur', () => hideSwarmNavTip(100))
// Reposition on scroll/resize while open
window.addEventListener(
'scroll',
() => {
if (swarmNavTipEl && !swarmNavTipEl.hidden) positionSwarmNavTip(link)
},
true
)
window.addEventListener('resize', () => {
if (swarmNavTipEl && !swarmNavTipEl.hidden) positionSwarmNavTip(link)
})
}
/**
* Enable/disable sidebar Swarm nav based on Docker LocalNodeState / feature flag.
* @param {boolean} enabled
* @param {string} [title]
*/
export function setSwarmNavEnabled(enabled, title = SWARM_DISABLED_NAV_TITLE) {
const link =
document.getElementById('nav-swarm-link') ||
document.querySelector('#sidebar .nav-link[data-view="swarm"]')
if (!link) return
wireSwarmNavTip(link)
link.classList.toggle('nav-link-disabled', !enabled)
link.setAttribute('aria-disabled', enabled ? 'false' : 'true')
// Prefer custom popover over native browser title tooltip
link.removeAttribute('title')
if (enabled) {
link.removeAttribute('tabindex')
link.dataset.swarmAvailable = '1'
link.setAttribute('aria-describedby', '')
hideSwarmNavTip(0)
} else {
link.setAttribute('tabindex', '-1')
link.dataset.swarmAvailable = '0'
link.dataset.swarmDisabledReason = title || SWARM_DISABLED_NAV_TITLE
ensureSwarmNavTip()
link.setAttribute('aria-describedby', 'swarm-nav-tip')
}
}
/**
* Whether Docker reports this node as an active swarm member.
* LocalNodeState: inactive | pending | active | locked | error
* @param {string|null|undefined} state
*/
export function isDockerSwarmActive(state) {
const s = String(state || '').toLowerCase()
return s === 'active' || s === 'pending'
}
/**
* Refresh Swarm nav from host snapshot (or disable when offline).
* @param {object} [snap] optional pre-fetched getHostSnapshot payload
* @returns {Promise<boolean>} true if swarm nav is enabled
*/
export async function refreshSwarmNavState(snap = null) {
if (!manager.active?.connected) {
setSwarmNavEnabled(false, SWARM_DISABLED_NAV_TITLE)
return false
}
try {
const data = snap || (await getSnapshot())
const state = data?.engine?.swarm
// Also respect server feature flag when present on metrics snapshot
const featureOff = data?.features?.swarm === false
if (featureOff) {
setSwarmNavEnabled(false, SWARM_DISABLED_NAV_TITLE)
return false
}
const enabled = isDockerSwarmActive(state)
setSwarmNavEnabled(enabled, SWARM_DISABLED_NAV_TITLE)
return enabled
} catch {
// Fallback probe: swarmInspect succeeds only when swarming / feature on
try {
await manager.request(Methods.swarmInspect, {})
setSwarmNavEnabled(true)
return true
} catch {
setSwarmNavEnabled(false, SWARM_DISABLED_NAV_TITLE)
return false
}
}
}
/**
* 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.'
}
setSwarmNavEnabled(false, SWARM_DISABLED_NAV_TITLE)
return
}
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')
if (!silent) {
if (svcBody) svcBody.innerHTML = jobSpinnerLoadingRow(5, 'Loading services…')
if (nodeBody) nodeBody.innerHTML = jobSpinnerLoadingRow(5, 'Loading nodes…')
if (taskBody) taskBody.innerHTML = jobSpinnerLoadingRow(5, 'Loading tasks…')
if (secBody) secBody.innerHTML = jobSpinnerLoadingRow(3, 'Loading secrets…')
if (cfgBody) cfgBody.innerHTML = jobSpinnerLoadingRow(3, 'Loading configs…')
if (banner) {
banner.className = 'alert alert-secondary small mb-3'
banner.innerHTML = jobSpinnerHtml('Loading Swarm…')
}
}
try {
const inspect = await manager.request(Methods.swarmInspect, {})
setSwarmNavEnabled(true)
if (banner) {
banner.className = 'alert alert-success small mb-3'
const id = inspect?.data?.ID || inspect?.data?.id || '—'
banner.innerHTML = `Swarm active · ID <code>${escape(String(id).slice(0, 16))}</code>`
}
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 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
? ''
: `<button type="button" class="btn btn-sm btn-outline-primary swarm-scale-btn" data-id="${escape(sid)}" data-name="${escape(name)}" data-replicas="${escape(String(replicas))}" data-min-role="operator">Scale</button>`
return `<tr data-row-key="svc:${escape(sid)}"><td>${escape(name)}</td><td class="small font-monospace">${escape(image)}</td><td>${escape(String(replicas))}</td><td class="small font-monospace">${escape(sid.slice(0, 12))}</td><td>${scaleBtn}</td></tr>`
})
.join('')
: '<tr><td colspan="5" class="text-muted">No services</td></tr>'
}
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 `<tr data-row-key="node:${escape(String(n.ID || ''))}"><td>${escape(hostname)}</td><td>${escape(role)}</td><td>${escape(status)}</td><td>${escape(avail)}</td><td class="small font-monospace">${escape(String(n.ID || '').slice(0, 12))}</td></tr>`
})
.join('')
: '<tr><td colspan="5" class="text-muted">No nodes</td></tr>'
}
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 `<tr data-row-key="task:${escape(String(t.ID || ''))}"><td class="small font-monospace">${escape(svc)}</td><td class="small font-monospace">${escape(node)}</td><td>${escape(t.DesiredState || '—')}</td><td>${escape(t.Status?.State || '—')}</td><td class="small font-monospace">${escape(String(t.ID || '').slice(0, 12))}</td></tr>`
})
.join('')
: '<tr><td colspan="5" class="text-muted">No tasks</td></tr>'
}
if (secBody) {
secBody.innerHTML = secs.length
? secs
.map((s) => {
const name = s.Spec?.Name || '—'
const created = s.CreatedAt ? new Date(s.CreatedAt).toLocaleString() : '—'
return `<tr data-row-key="sec:${escape(String(s.ID || ''))}"><td>${escape(name)}</td><td class="small">${escape(created)}</td><td class="small font-monospace">${escape(String(s.ID || '').slice(0, 12))}</td></tr>`
})
.join('')
: '<tr><td colspan="3" class="text-muted">No secrets</td></tr>'
}
if (cfgBody) {
cfgBody.innerHTML = cfgs.length
? cfgs
.map((c) => {
const name = c.Spec?.Name || '—'
const created = c.CreatedAt ? new Date(c.CreatedAt).toLocaleString() : '—'
return `<tr data-row-key="cfg:${escape(String(c.ID || ''))}"><td>${escape(name)}</td><td class="small">${escape(created)}</td><td class="small font-monospace">${escape(String(c.ID || '').slice(0, 12))}</td></tr>`
})
.join('')
: '<tr><td colspan="3" class="text-muted">No configs</td></tr>'
}
} catch (err) {
const code = err?.code || ''
const msg = err?.message || String(err)
const notSwarm =
code === 'FEATURE_DISABLED' ||
/ENABLE_SWARM|disabled|not a swarm|this node is not|swarm.*not|inactive/i.test(msg)
if (notSwarm) {
setSwarmNavEnabled(false, SWARM_DISABLED_NAV_TITLE)
}
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 <strong>off</strong>. Remove <code>ENABLE_SWARM=0</code> (on by default).'
} else if (notSwarm) {
banner.className = 'alert alert-secondary small mb-3'
banner.textContent = SWARM_DISABLED_NAV_TITLE
} else {
banner.className = 'alert alert-danger small mb-3'
banner.textContent = msg
}
}
if (!silent) presentError(err, 'swarmInspect', { showAlert, silent: notSwarm })
}
}
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:<port> 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
? `<div>ID: <code>${escape(c.id)}</code></div>
<div>Role: ${escape(c.role || '—')}</div>
<div>Protocol: v${escape(String(c.protocolVersion ?? '—'))}</div>
<div>Docker: ${c.dockerHealth?.ok ? 'ok' : '—'}</div>
<div>Latency: ${c.latency != null ? escape(String(c.latency)) + ' ms' : '—'}</div>`
: '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 = jobSpinnerHtml('Loading feature flags…')
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]) =>
`<div>${escape(label)}: <strong>${val === true ? 'on' : val === false ? 'off' : '—'}</strong></div>`
)
.join('') || '<div class="text-muted">No feature flags reported</div>'
})
.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 =
'<li class="list-group-item text-muted">No lists configured — default will be used on save.</li>'
return
}
host.innerHTML = templateUrlsDraft
.map(
(url, idx) => `
<li class="list-group-item" data-idx="${idx}">
<span class="url-text" title="${escape(url)}">${escape(url)}</span>
<button type="button" class="btn btn-sm btn-outline-danger btn-remove-url" data-idx="${idx}" title="Remove">
<i class="fas fa-xmark"></i>
</button>
</li>`
)
.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 =
'<li class="list-group-item text-muted">No hide filters — all containers remain visible.</li>'
return
}
host.innerHTML = hiddenLabelFiltersDraft
.map(
(f, idx) => `
<li class="list-group-item d-flex align-items-center justify-content-between gap-2" data-idx="${idx}">
<span class="font-monospace small text-truncate" title="${escape(f.name)}${f.value ? '=' + escape(f.value) : ''}">
<strong>${escape(f.name)}</strong>${f.value ? `<span class="text-muted"> = ${escape(f.value)}</span>` : '<span class="text-muted"> (any value)</span>'}
</span>
<button type="button" class="btn btn-sm btn-outline-danger btn-remove-hidden-label" data-idx="${idx}" title="Remove filter">
<i class="fas fa-xmark"></i>
</button>
</li>`
)
.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', (e) => {
const btn = e.currentTarget
if (btn?.classList.contains('is-saving') || btn?.classList.contains('is-saved')) return
const idleHtml =
btn.dataset.idleHtml ||
'<i class="fas fa-floppy-disk me-1" aria-hidden="true"></i><span class="settings-save-label">Save all</span>'
if (btn) btn.dataset.idleHtml = idleHtml
clearTimeout(btn?._saveFeedbackTimer)
if (btn) {
btn.classList.remove('is-saved', 'is-error')
btn.classList.add('is-saving')
btn.disabled = true
btn.setAttribute('aria-busy', 'true')
btn.innerHTML =
'<i class="fas fa-circle-notch fa-spin me-1" aria-hidden="true"></i><span class="settings-save-label">Saving…</span>'
}
// Brief saving state so the control always gives tactile feedback (save is local/sync).
const finish = (ok, errMsg) => {
if (!btn) return
btn.classList.remove('is-saving')
btn.removeAttribute('aria-busy')
if (ok) {
btn.classList.add('is-saved')
btn.innerHTML =
'<i class="fas fa-check me-1 settings-save-check" aria-hidden="true"></i><span class="settings-save-label">Saved</span>'
btn._saveFeedbackTimer = setTimeout(() => {
btn.classList.remove('is-saved')
btn.disabled = false
btn.innerHTML = btn.dataset.idleHtml || idleHtml
}, 2000)
} else {
btn.classList.add('is-error')
btn.disabled = false
btn.innerHTML =
'<i class="fas fa-triangle-exclamation me-1" aria-hidden="true"></i><span class="settings-save-label">Failed</span>'
btn._saveFeedbackTimer = setTimeout(() => {
btn.classList.remove('is-error')
btn.innerHTML = btn.dataset.idleHtml || idleHtml
}, 2400)
if (errMsg) showAlert('danger', errMsg, { badge: false })
}
}
window.setTimeout(() => {
try {
setTemplateListUrls(templateUrlsDraft)
const partial = readSettingsForm()
partial.templateListUrls = getTemplateListUrls()
const next = saveSettings(partial)
startListAutoRefresh(next.refreshSeconds)
if (typeof window !== 'undefined') window.__peardockClearDeployTemplateCache?.()
finish(true)
showAlert('success', 'Preferences saved', { badge: false })
} catch (err) {
finish(false, err?.message || 'Could not save preferences')
}
}, 220)
})
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()
refreshSwarmNavState().catch(() => {})
updateActivePeerChip(conn)
setOfflineBanner(false)
})
manager.on('disconnect', () => {
clearSnapshotCache()
updateActivePeerChip(manager.active)
if (!manager.active?.connected) {
setOfflineBanner(true, 'Connection lost — reconnecting…')
setSwarmNavEnabled(false, SWARM_DISABLED_NAV_TITLE)
}
})
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()
refreshSwarmNavState().catch(() => {})
})
manager.on('active', (conn) => {
clearSnapshotCache()
updateActivePeerChip(conn)
if (conn?.connected) {
warmSnapshot()
refreshSwarmNavState().catch(() => {})
} else {
setSwarmNavEnabled(false, SWARM_DISABLED_NAV_TITLE)
}
})
// Initial state before first snapshot
setSwarmNavEnabled(false, SWARM_DISABLED_NAV_TITLE)
if (manager.active?.connected) {
refreshSwarmNavState().catch(() => {})
}
// Patch invite token into connect form if app wires submit separately — expose helper
window.peardockOps = {
deployContainerWithSteps,
deployStackWithSteps,
syncStackFromGitWithSteps,
setSwarmNavEnabled,
refreshSwarmNavState,
isDockerSwarmActive,
SWARM_DISABLED_NAV_TITLE,
onHostSnapshot: (snap) => {
refreshSwarmNavState(snap).catch(() => {})
},
...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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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 }