Files
peardock/ui/track-f-extras.js
T
Raven Scott 5b1acdf324
Release rolling / release (push) Has been cancelled
Use tray spinner for pending UI loading states
Replace em-dash and ad-hoc loaders with the shared job-tray spinner
for deploy templates, host/swarm/tunnels/events, health latency,
container stats/logs/processes, and registry/volume browsers.
2026-07-16 14:30:59 -04:00

430 lines
16 KiB
JavaScript

/**
* 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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
/** @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 = `
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content bg-dark text-white">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-folder-open me-2"></i>Volume files</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="d-flex justify-content-between align-items-center mb-2 gap-2 flex-wrap">
<code id="volume-browse-path" class="small">/</code>
<button type="button" class="btn btn-sm btn-outline-secondary" id="volume-browse-up">Up</button>
</div>
<div id="volume-browse-list" class="list-group list-group-flush"></div>
</div>
</div>
</div>`
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 = `<i class="fas fa-folder-open me-2"></i>${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 = `<div class="list-group-item text-muted">${jobSpinnerHtml('Loading…')}</div>`
}
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 = '<div class="list-group-item text-muted">Empty directory</div>'
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 `<button type="button" class="list-group-item list-group-item-action list-group-item-dark volume-browse-entry" data-type="${escape(e.type)}" data-name="${escape(e.name)}">
<i class="fas ${icon} me-2"></i>${escape(e.name)}<span class="text-muted small">${size}</span>
</button>`
})
.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 = `<div class="list-group-item text-danger">${escape(err.message || 'Browse failed')}</div>`
}
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 = `
<div class="modal-dialog">
<div class="modal-content bg-dark text-white">
<div class="modal-header">
<h5 class="modal-title">Update resources</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="res-edit-id">
<div class="mb-2">
<label class="form-label" for="res-edit-mem">Memory (MB, 0 = no limit)</label>
<input type="number" id="res-edit-mem" class="form-control bg-dark text-white" min="0" step="64" value="0">
</div>
<div class="mb-2">
<label class="form-label" for="res-edit-cpu">CPUs (e.g. 0.5, 1, 2; 0 = no limit)</label>
<input type="number" id="res-edit-cpu" class="form-control bg-dark text-white" min="0" step="0.1" value="0">
</div>
<div class="mb-2">
<label class="form-label" for="res-edit-restart">Restart policy</label>
<select id="res-edit-restart" class="form-select bg-dark text-white">
<option value="">(leave unchanged)</option>
<option value="no">no</option>
<option value="always">always</option>
<option value="unless-stopped">unless-stopped</option>
<option value="on-failure">on-failure</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="res-edit-apply">Apply</button>
</div>
</div>
</div>`
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 = '<div class="text-muted small">No scheduled maintenance jobs on this peer.</div>'
return
}
el.innerHTML = jobs
.map(
(j) => `
<div class="d-flex justify-content-between align-items-start border-bottom border-secondary py-2 gap-2">
<div class="small">
<strong>${escape(j.kind)}</strong>
<span class="text-muted"> · every ${Math.round((j.intervalMs || 0) / 60000)}m</span>
<div class="text-muted">${j.enabled ? 'enabled' : 'disabled'}
${j.lastRun ? ` · last ${escape(new Date(j.lastRun).toLocaleString())}` : ''}
${j.lastError ? ` · <span class="text-warning">${escape(j.lastError)}</span>` : ''}
</div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger schedule-del" data-id="${escape(j.id)}" data-min-role="admin">Delete</button>
</div>`
)
.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 = `<div class="text-muted small">${escape(err.message || 'Schedules unavailable')}</div>`
}
}
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,
}