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:
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user