/** * Track F UI: volume browser, resource editor, service scale, secrets/configs * create, schedules, registry hub search, fleet env filter, stack env file. */ import { manager, Methods } from '../client/manager.js' import { showAlert, jobSpinnerHtml } from '../libs/uiUtils.js' import { presentError } from '../client/errors.js' import { confirmDialog } from './components.js' function escape(s) { return String(s ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') } /** @type {{ name: string, path: string } | null} */ let volumeBrowseState = null /** * Open volume file browser modal. * @param {string} volumeName */ export async function openVolumeBrowser(volumeName) { const name = String(volumeName || '').trim() if (!name) return volumeBrowseState = { name, path: '/' } let modal = document.getElementById('volume-browse-modal') if (!modal) { modal = document.createElement('div') modal.id = 'volume-browse-modal' modal.className = 'modal fade' modal.tabIndex = -1 modal.innerHTML = ` ` document.body.appendChild(modal) document.getElementById('volume-browse-up')?.addEventListener('click', () => { if (!volumeBrowseState || volumeBrowseState.path === '/') return const parts = volumeBrowseState.path.split('/').filter(Boolean) parts.pop() volumeBrowseState.path = '/' + parts.join('/') if (volumeBrowseState.path !== '/') { // keep } else { volumeBrowseState.path = '/' } refreshVolumeBrowser() }) } const title = modal.querySelector('.modal-title') if (title) title.innerHTML = `${escape(name)}` const bs = window.bootstrap?.Modal if (bs) new bs(modal).show() await refreshVolumeBrowser() } async function refreshVolumeBrowser() { if (!volumeBrowseState) return const list = document.getElementById('volume-browse-list') const pathEl = document.getElementById('volume-browse-path') if (pathEl) pathEl.textContent = volumeBrowseState.path if (list) { list.innerHTML = `
${jobSpinnerHtml('Loading…')}
` } try { const res = await manager.request(Methods.browseVolume, { name: volumeBrowseState.name, path: volumeBrowseState.path, }) const entries = res?.entries || [] if (!list) return if (!entries.length) { list.innerHTML = '
Empty directory
' return } list.innerHTML = entries .map((e) => { const icon = e.type === 'dir' ? 'fa-folder' : e.type === 'link' ? 'fa-link' : 'fa-file' const size = e.type === 'file' && e.size != null ? ` · ${formatBytes(e.size)}` : '' return `` }) .join('') list.querySelectorAll('.volume-browse-entry').forEach((btn) => { btn.addEventListener('click', () => { if (btn.getAttribute('data-type') !== 'dir') return const n = btn.getAttribute('data-name') || '' const base = volumeBrowseState.path === '/' ? '' : volumeBrowseState.path volumeBrowseState.path = `${base}/${n}`.replace(/\/+/g, '/') refreshVolumeBrowser() }) }) } catch (err) { if (list) { list.innerHTML = `
${escape(err.message || 'Browse failed')}
` } presentError(err, 'browseVolume', { showAlert }) } } function formatBytes(n) { if (n < 1024) return `${n} B` if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB` return `${(n / (1024 * 1024)).toFixed(1)} MB` } /** * Live update CPU/memory/restart policy for a running container. * @param {string} containerId * @param {{ name?: string, memoryMb?: number, nanoCpus?: number, restart?: string }} [seed] */ export async function openResourceEditor(containerId, seed = {}) { const id = containerId if (!id) return const name = seed.name || id.slice(0, 12) const ok = await confirmDialog({ title: `Update resources: ${name}`, body: 'Set memory (MB), CPUs (fraction), and restart policy. Applied live via Docker update.', confirmLabel: 'Apply', info: true, icon: 'fa-sliders', }) // confirmDialog doesn't collect fields — use prompt-style modal built here void ok let modal = document.getElementById('resource-editor-modal') if (!modal) { modal = document.createElement('div') modal.id = 'resource-editor-modal' modal.className = 'modal fade' modal.innerHTML = ` ` document.body.appendChild(modal) document.getElementById('res-edit-apply')?.addEventListener('click', async () => { const cid = document.getElementById('res-edit-id')?.value const mem = Number(document.getElementById('res-edit-mem')?.value) || 0 const cpu = Number(document.getElementById('res-edit-cpu')?.value) || 0 const restart = document.getElementById('res-edit-restart')?.value || '' const body = { id: cid } if (mem > 0) body.Memory = mem if (cpu > 0) body.NanoCpus = Math.round(cpu * 1e9) if (restart) body.RestartPolicy = restart try { const res = await manager.request(Methods.updateContainer, body) showAlert('success', res?.message || 'Resources updated') window.bootstrap?.Modal.getInstance(modal)?.hide() } catch (err) { presentError(err, 'updateContainer', { showAlert }) } }) } document.getElementById('res-edit-id').value = id if (seed.memoryMb != null) document.getElementById('res-edit-mem').value = seed.memoryMb if (seed.nanoCpus != null) { document.getElementById('res-edit-cpu').value = (seed.nanoCpus / 1e9).toFixed(2) } if (seed.restart) document.getElementById('res-edit-restart').value = seed.restart if (window.bootstrap?.Modal) new window.bootstrap.Modal(modal).show() } /** * Scale a swarm service. * @param {string} serviceId * @param {string} [serviceName] * @param {number} [current] */ export async function scaleServiceUi(serviceId, serviceName, current = 1) { const id = serviceId if (!id) return const input = window.prompt( `Scale service ${serviceName || id.slice(0, 12)} to how many replicas?`, String(current ?? 1) ) if (input == null) return const replicas = Number(input) if (!Number.isFinite(replicas) || replicas < 0) { showAlert('warning', 'Replicas must be a number ≥ 0') return } try { const res = await manager.request(Methods.scaleService, { id, replicas }) showAlert('success', res?.message || `Scaled to ${replicas}`) if (typeof window.peardockOps?.loadSwarmView === 'function') { window.peardockOps.loadSwarmView() } } catch (err) { presentError(err, 'scaleService', { showAlert }) } } /** * Create swarm secret from form fields. */ export async function createSecretFromForm() { const name = document.getElementById('swarm-secret-name')?.value?.trim() const data = document.getElementById('swarm-secret-data')?.value if (!name || data == null || data === '') { showAlert('warning', 'Secret name and data required') return } try { const res = await manager.request(Methods.createSecret, { name, data }) showAlert('success', res?.message || 'Secret created') if (document.getElementById('swarm-secret-name')) { document.getElementById('swarm-secret-name').value = '' } if (document.getElementById('swarm-secret-data')) { document.getElementById('swarm-secret-data').value = '' } window.peardockOps?.loadSwarmView?.() } catch (err) { presentError(err, 'createSecret', { showAlert }) } } export async function createConfigFromForm() { const name = document.getElementById('swarm-config-name')?.value?.trim() const data = document.getElementById('swarm-config-data')?.value if (!name || data == null || data === '') { showAlert('warning', 'Config name and data required') return } try { const res = await manager.request(Methods.createConfig, { name, data }) showAlert('success', res?.message || 'Config created') if (document.getElementById('swarm-config-name')) { document.getElementById('swarm-config-name').value = '' } if (document.getElementById('swarm-config-data')) { document.getElementById('swarm-config-data').value = '' } window.peardockOps?.loadSwarmView?.() } catch (err) { presentError(err, 'createConfig', { showAlert }) } } /** * Disconnect container from a network. */ export async function disconnectContainerNetwork(containerId, networkId, networkName) { const ok = await confirmDialog({ title: 'Disconnect network?', body: `Disconnect this container from network “${networkName || networkId}”?`, danger: true, confirmLabel: 'Disconnect', }) if (!ok) return try { await manager.request(Methods.disconnectNetwork, { containerId, networkId, force: false, }) showAlert('success', 'Disconnected from network') if (typeof window.refreshContainerNetworking === 'function') { window.refreshContainerNetworking(containerId) } else if (typeof window.sendCommand === 'function') { window.sendCommand('inspectContainer', { id: containerId }) } } catch (err) { presentError(err, 'disconnectNetwork', { showAlert }) } } /** * Load maintenance schedules into host panel if present. */ export async function loadSchedulesPanel() { const el = document.getElementById('schedules-list') if (!el || !manager.active?.connected) return try { const res = await manager.request(Methods.listSchedules, {}) const jobs = res?.schedules || [] if (!jobs.length) { el.innerHTML = '
No scheduled maintenance jobs on this peer.
' return } el.innerHTML = jobs .map( (j) => `
${escape(j.kind)} · every ${Math.round((j.intervalMs || 0) / 60000)}m
${j.enabled ? 'enabled' : 'disabled'} ${j.lastRun ? ` · last ${escape(new Date(j.lastRun).toLocaleString())}` : ''} ${j.lastError ? ` · ${escape(j.lastError)}` : ''}
` ) .join('') el.querySelectorAll('.schedule-del').forEach((btn) => { btn.addEventListener('click', async () => { try { await manager.request(Methods.deleteSchedule, { id: btn.getAttribute('data-id') }) showAlert('success', 'Schedule deleted') loadSchedulesPanel() } catch (err) { presentError(err, 'deleteSchedule', { showAlert }) } }) }) } catch (err) { el.innerHTML = `
${escape(err.message || 'Schedules unavailable')}
` } } export async function addScheduleFromForm() { const kind = document.getElementById('schedule-kind')?.value || 'dockerPing' const minutes = Number(document.getElementById('schedule-minutes')?.value) || 60 const volumes = document.getElementById('schedule-volumes')?.checked === true try { await manager.request(Methods.upsertSchedule, { kind, intervalMs: Math.max(1, minutes) * 60_000, enabled: true, options: kind === 'systemPrune' ? { volumes } : {}, }) showAlert('success', 'Schedule saved') loadSchedulesPanel() } catch (err) { presentError(err, 'upsertSchedule', { showAlert }) } } /** * Peer environment tags for multi-host fleet filtering (client-side). */ export function getPeerEnvironments() { try { return JSON.parse(localStorage.getItem('peardock.peerEnvs.v1') || '{}') } catch { return {} } } export function setPeerEnvironment(peerId, env) { const map = getPeerEnvironments() if (!peerId) return map if (!env) delete map[peerId] else map[peerId] = String(env).slice(0, 32) localStorage.setItem('peardock.peerEnvs.v1', JSON.stringify(map)) return map } export function initTrackFExtras() { document.getElementById('schedule-add-btn')?.addEventListener('click', () => addScheduleFromForm()) document.getElementById('swarm-secret-create')?.addEventListener('click', () => createSecretFromForm()) document.getElementById('swarm-config-create')?.addEventListener('click', () => createConfigFromForm()) document.getElementById('host-schedules-refresh')?.addEventListener('click', () => loadSchedulesPanel()) // Fleet env filter document.getElementById('fleet-env-filter')?.addEventListener('change', () => { const v = document.getElementById('fleet-env-filter')?.value || '' try { const s = JSON.parse(localStorage.getItem('peardock.settings.v1') || '{}') s.fleetEnvFilter = v localStorage.setItem('peardock.settings.v1', JSON.stringify(s)) } catch { // ignore } window.loadFleetView?.() }) } export default { openVolumeBrowser, openResourceEditor, scaleServiceUi, createSecretFromForm, createConfigFromForm, disconnectContainerNetwork, loadSchedulesPanel, addScheduleFromForm, getPeerEnvironments, setPeerEnvironment, initTrackFExtras, }