Release rolling / release (push) Has been cancelled
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.
430 lines
16 KiB
JavaScript
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, '<')
|
|
.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 = `
|
|
<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,
|
|
}
|