Complete Track F optional Docker/Portainer ops items
CI / test (push) Successful in 10m2s

Implement volume browse, stack env file, service scale, create
secret/config, disconnect network, registry Hub search, server
schedules, resource editor, and fleet environment tags. Mark roadmap
Tracks A–F complete.
This commit is contained in:
2026-07-10 23:52:26 -04:00
parent b62bcd697e
commit 2b6cfc644f
13 changed files with 1083 additions and 107 deletions
+170
View File
@@ -0,0 +1,170 @@
/**
* Lightweight scheduled Docker maintenance jobs on the peardock server.
* Not a full webhook platform — interval tasks for prune / health checks.
*/
import { docker } from './docker.js'
import logger from '../utils/logger.js'
import fs from 'fs'
import path from 'path'
import { randomBytes } from 'crypto'
const FILE =
process.env.PEARDOCK_SCHEDULES_PATH ||
path.join(process.cwd(), 'peardock-schedules.json')
/** @type {Map<string, { id: string, kind: string, intervalMs: number, enabled: boolean, lastRun?: string, lastError?: string, options?: object, timer?: NodeJS.Timeout }>} */
const jobs = new Map()
function loadDisk() {
try {
if (!fs.existsSync(FILE)) return []
const raw = JSON.parse(fs.readFileSync(FILE, 'utf8'))
return Array.isArray(raw?.jobs) ? raw.jobs : Array.isArray(raw) ? raw : []
} catch (err) {
logger.warn('Failed to load schedules', { error: err.message })
return []
}
}
function saveDisk() {
try {
const payload = {
version: 1,
updatedAt: new Date().toISOString(),
jobs: [...jobs.values()].map((j) => ({
id: j.id,
kind: j.kind,
intervalMs: j.intervalMs,
enabled: j.enabled,
lastRun: j.lastRun || null,
lastError: j.lastError || null,
options: j.options || {},
})),
}
const tmp = `${FILE}.${process.pid}.tmp`
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), { mode: 0o600 })
fs.renameSync(tmp, FILE)
} catch (err) {
logger.warn('Failed to save schedules', { error: err.message })
}
}
async function runJob(job) {
if (!job.enabled) return
try {
if (job.kind === 'systemPrune') {
const volumes = Boolean(job.options?.volumes)
if (typeof docker.pruneSystem === 'function') {
await docker.pruneSystem({ volumes })
} else {
await docker.pruneContainers({})
await docker.pruneImages({})
await docker.pruneNetworks({})
if (volumes) await docker.pruneVolumes({})
}
} else if (job.kind === 'pruneImages') {
await docker.pruneImages({})
} else if (job.kind === 'pruneContainers') {
await docker.pruneContainers({})
} else if (job.kind === 'dockerPing') {
await docker.ping()
} else {
throw new Error(`Unknown schedule kind: ${job.kind}`)
}
job.lastRun = new Date().toISOString()
job.lastError = null
logger.info('Scheduled job ok', { id: job.id, kind: job.kind })
} catch (err) {
job.lastError = err.message || String(err)
job.lastRun = new Date().toISOString()
logger.warn('Scheduled job failed', { id: job.id, kind: job.kind, error: job.lastError })
}
saveDisk()
}
function arm(job) {
if (job.timer) {
clearInterval(job.timer)
job.timer = null
}
if (!job.enabled || job.intervalMs < 60_000) return
job.timer = setInterval(() => {
runJob(job)
}, job.intervalMs)
if (typeof job.timer.unref === 'function') job.timer.unref()
}
export function listSchedules() {
return [...jobs.values()].map((j) => ({
id: j.id,
kind: j.kind,
intervalMs: j.intervalMs,
enabled: j.enabled,
lastRun: j.lastRun || null,
lastError: j.lastError || null,
options: j.options || {},
}))
}
/**
* @param {{ id?: string, kind: string, intervalMs: number, enabled?: boolean, options?: object }} input
*/
export function upsertSchedule(input) {
const kind = String(input.kind || '').trim()
const allowed = new Set(['systemPrune', 'pruneImages', 'pruneContainers', 'dockerPing'])
if (!allowed.has(kind)) throw Object.assign(new Error('Invalid schedule kind'), { code: 'INVALID_ARGS' })
const intervalMs = Math.max(60_000, Number(input.intervalMs) || 3_600_000)
const id =
input.id && String(input.id).startsWith('sch_')
? String(input.id)
: `sch_${Date.now().toString(36)}_${randomBytes(3).toString('hex')}`
const prev = jobs.get(id)
if (prev?.timer) clearInterval(prev.timer)
const job = {
id,
kind,
intervalMs,
enabled: input.enabled !== false,
lastRun: prev?.lastRun,
lastError: prev?.lastError,
options: input.options && typeof input.options === 'object' ? input.options : {},
}
jobs.set(id, job)
arm(job)
saveDisk()
return listSchedules().find((j) => j.id === id)
}
export function deleteSchedule(id) {
const job = jobs.get(id)
if (!job) return false
if (job.timer) clearInterval(job.timer)
jobs.delete(id)
saveDisk()
return true
}
export function restoreSchedules() {
for (const def of loadDisk()) {
if (!def?.kind || !def?.id) continue
const job = {
id: def.id,
kind: def.kind,
intervalMs: Math.max(60_000, Number(def.intervalMs) || 3_600_000),
enabled: def.enabled !== false,
lastRun: def.lastRun,
lastError: def.lastError,
options: def.options || {},
}
jobs.set(job.id, job)
arm(job)
}
logger.info('Schedules restored', { count: jobs.size })
}
export default {
listSchedules,
upsertSchedule,
deleteSchedule,
restoreSchedules,
}