Release rolling / release (push) Has been cancelled
Replace stock Bootstrap modal chrome with a dark teal panel system, and give Settings Save all a Saving… / Saved state with motion-aware styles.
2547 lines
90 KiB
JavaScript
2547 lines
90 KiB
JavaScript
/**
|
||
* 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 (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]
|
||
*/
|
||
/** 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 peer’s 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 <TOKEN> <MANAGER-IP>: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, '&')
|
||
.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 }
|