diff --git a/admin/app/controllers/system_controller.ts b/admin/app/controllers/system_controller.ts index 4acaab4..5443fe4 100644 --- a/admin/app/controllers/system_controller.ts +++ b/admin/app/controllers/system_controller.ts @@ -3,6 +3,7 @@ import { SystemService } from '#services/system_service' import { SystemUpdateService } from '#services/system_update_service' import { ContainerRegistryService } from '#services/container_registry_service' import { AutoUpdateService } from '#services/auto_update_service' +import { AppAutoUpdateService } from '#services/app_auto_update_service' import { DownloadService } from '#services/download_service' import { QueueService } from '#services/queue_service' import { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job' @@ -18,6 +19,7 @@ import { subscribeToReleaseNotesValidator, updateCustomAppValidator, updateServiceValidator, + setServiceAutoUpdateValidator, } from '#validators/system' import { DEFAULT_CPUS, @@ -150,6 +152,44 @@ export default class SystemController { } } + async getAppAutoUpdateStatus({ response }: HttpContext) { + // Constructed inline reusing already-injected singletons + the QueueService + // singleton (its constructor is private to prevent Redis connection leaks), + // mirroring getAutoUpdateStatus. Apps need no SystemUpdateService (no sidecar). + const appAutoUpdateService = new AppAutoUpdateService( + this.dockerService, + new DownloadService(QueueService.getInstance()), + this.systemService, + this.containerRegistryService + ) + + try { + const status = await appAutoUpdateService.getStatus() + response.send(status) + } catch (error) { + logger.error({ err: error }, '[SystemController] Failed to get app auto-update status') + response.status(500).send({ error: 'Failed to retrieve app auto-update status' }) + } + } + + async setServiceAutoUpdate({ request, response }: HttpContext) { + const payload = await request.validateUsing(setServiceAutoUpdateValidator) + const service = await Service.query().where('service_name', payload.service_name).first() + if (!service) { + return response.status(404).send({ error: `Service ${payload.service_name} not found` }) + } + + service.auto_update_enabled = payload.enabled + // Re-enabling clears any prior self-disable so the app gets a fresh start. + if (payload.enabled) { + service.auto_update_consecutive_failures = 0 + service.auto_update_disabled_reason = null + } + await service.save() + + return response.send({ success: true, message: 'App auto-update preference updated' }) + } + async subscribeToReleaseNotes({ request }: HttpContext) { const reqData = await request.validateUsing(subscribeToReleaseNotesValidator); diff --git a/admin/app/jobs/app_auto_update_job.ts b/admin/app/jobs/app_auto_update_job.ts new file mode 100644 index 0000000..3008901 --- /dev/null +++ b/admin/app/jobs/app_auto_update_job.ts @@ -0,0 +1,78 @@ +import { Job } from 'bullmq' +import { QueueService } from '#services/queue_service' +import { DockerService } from '#services/docker_service' +import { DownloadService } from '#services/download_service' +import { SystemService } from '#services/system_service' +import { ContainerRegistryService } from '#services/container_registry_service' +import { AppAutoUpdateService } from '#services/app_auto_update_service' +import logger from '@adonisjs/core/services/logger' + +/** + * Hourly job that evaluates whether any opted-in installed apps should auto-update + * right now and, if so, updates them. All gating (master switch, per-app opt-in, + * window, cool-off, pre-flight, per-app backoff) lives in {@link AppAutoUpdateService}; + * this job is just the scheduled trigger. Runs hourly so it can act anywhere inside a + * user's window regardless of the window's length. Mirrors {@link AutoUpdateJob}. + */ +export class AppAutoUpdateJob { + static get queue() { + return 'system' + } + + static get key() { + return 'app-auto-update' + } + + async handle(_job: Job) { + logger.info('[AppAutoUpdateJob] Evaluating app auto-updates...') + + const dockerService = new DockerService() + const appAutoUpdateService = new AppAutoUpdateService( + dockerService, + new DownloadService(QueueService.getInstance()), + new SystemService(dockerService), + new ContainerRegistryService() + ) + + const result = await appAutoUpdateService.attempt() + logger.info(`[AppAutoUpdateJob] ${result.updated} updated: ${result.reason}`) + return result + } + + static async schedule() { + const queueService = QueueService.getInstance() + const queue = queueService.getQueue(this.queue) + + await queue.upsertJobScheduler( + 'hourly-app-auto-update', + { pattern: '0 * * * *' }, // Top of every hour; attempt() gates on the window + { + name: this.key, + opts: { + removeOnComplete: { count: 12 }, + removeOnFail: { count: 5 }, + }, + } + ) + + logger.info('[AppAutoUpdateJob] App auto-update evaluation scheduled with cron: 0 * * * *') + } + + static async dispatch() { + const queueService = QueueService.getInstance() + const queue = queueService.getQueue(this.queue) + + const job = await queue.add( + this.key, + {}, + { + attempts: 1, + removeOnComplete: { count: 12 }, + removeOnFail: { count: 5 }, + } + ) + + logger.info(`[AppAutoUpdateJob] Dispatched ad-hoc app auto-update evaluation job ${job.id}`) + return job + } +} diff --git a/admin/app/jobs/check_service_updates_job.ts b/admin/app/jobs/check_service_updates_job.ts index 58be73c..cdbf68e 100644 --- a/admin/app/jobs/check_service_updates_job.ts +++ b/admin/app/jobs/check_service_updates_job.ts @@ -39,6 +39,14 @@ export class CheckServiceUpdatesJob { const latestUpdate = updates.length > 0 ? updates[0].tag : null + // Stamp/clear the cool-off anchor only when the available version *changes*. + // Registry tags carry no publish date, so the auto-update cool-off is measured + // from when a version was first detected; leaving the timestamp untouched while + // the same version persists keeps the cool-off clock running. + if (latestUpdate !== service.available_update_version) { + service.available_update_first_seen_at = latestUpdate ? DateTime.now() : null + } + service.available_update_version = latestUpdate service.update_checked_at = DateTime.now() await service.save() diff --git a/admin/app/models/service.ts b/admin/app/models/service.ts index b532f0f..debd701 100644 --- a/admin/app/models/service.ts +++ b/admin/app/models/service.ts @@ -88,6 +88,28 @@ export default class Service extends BaseModel { @column.dateTime() declare update_checked_at: DateTime | null + // Per-app opt-in for automatic updates. An app auto-updates only when both this + // and the global `appAutoUpdate.enabled` master switch are on. + @column({ + serialize(value) { + return Boolean(value) + }, + }) + declare auto_update_enabled: boolean + + // When the current `available_update_version` was first detected — the anchor for + // the auto-update cool-off (registry tags carry no publish timestamp). + @column.dateTime() + declare available_update_first_seen_at: DateTime | null + + // Per-app auto-update failure backoff; at the threshold the app self-disables via + // `auto_update_disabled_reason` without affecting other apps. + @column() + declare auto_update_consecutive_failures: number + + @column() + declare auto_update_disabled_reason: string | null + @column.dateTime({ autoCreate: true }) declare created_at: DateTime diff --git a/admin/app/services/app_auto_update_service.ts b/admin/app/services/app_auto_update_service.ts new file mode 100644 index 0000000..c6317b6 --- /dev/null +++ b/admin/app/services/app_auto_update_service.ts @@ -0,0 +1,398 @@ +import { inject } from '@adonisjs/core' +import logger from '@adonisjs/core/services/logger' +import { DateTime } from 'luxon' +import KVStore from '#models/kv_store' +import Service from '#models/service' +import { DockerService } from '#services/docker_service' +import { DownloadService } from '#services/download_service' +import { SystemService } from '#services/system_service' +import { ContainerRegistryService } from '#services/container_registry_service' +import { isNewerVersion, parseMajorVersion } from '../utils/version.js' +import { isWithinWindow } from '../utils/update_window.js' +import { + checkImageDiskSpace, + type Blocker, + type PreflightResult, +} from '../utils/image_disk_preflight.js' + +/** + * Defaults shared with the core auto-update. App auto-updates intentionally reuse + * the SAME window/cool-off settings (`autoUpdate.windowStart/windowEnd/cooloffHours`); + * only the enable flag (`appAutoUpdate.enabled`) is separate. + */ +const DEFAULT_WINDOW_START = '02:00' +const DEFAULT_WINDOW_END = '05:00' +const DEFAULT_COOLOFF_HOURS = 72 + +/** Per-app genuine failures before that app self-disables (others keep running). */ +const MAX_CONSECUTIVE_FAILURES = 3 + +export interface AppAutoUpdateConfig { + /** Global master switch (`appAutoUpdate.enabled`). */ + enabled: boolean + windowStart: string + windowEnd: string + cooloffHours: number +} + +/** An installed app that should be auto-updated this run. */ +export interface AppUpdateTarget { + service: Service + /** Exact registry tag to update to (the value in `available_update_version`). */ + targetVersion: string +} + +/** Per-app eligibility verdict (drives both selection and the status UI). */ +export interface AppEligibility { + eligible: boolean + reason: string + cooloffRemainingHours: number | null +} + +export interface AppAutoUpdateAppStatus { + service_name: string + friendly_name: string | null + auto_update_enabled: boolean + current_version: string + available_update_version: string | null + first_seen_at: string | null + eligible: boolean + reason: string + cooloff_remaining_hours: number | null + consecutive_failures: number + auto_disabled_reason: string | null +} + +export interface AppAutoUpdateStatus extends AppAutoUpdateConfig { + withinWindow: boolean + lastAttemptAt: string | null + lastResult: string | null + apps: AppAutoUpdateAppStatus[] +} + +/** + * Decision + safety layer for automatic updates of installed sibling apps (the + * containers NOMAD deploys via the Docker socket and manages in Supply Depot). + * + * This is the app-side counterpart to {@link AutoUpdateService} and intentionally + * reuses its generic window/disk pre-flight helpers. Unlike the core update, an + * app update needs no sidecar — the admin container recreates its siblings directly + * via {@link DockerService.updateContainer} (in-process pull → rename → health-check + * → rollback). Auto-update only decides *whether* each opted-in app should update now + * (master switch on + per-app toggle on + in window + an eligible minor/patch past + * its cool-off + pre-flight passes) and then drives the existing update path. + * + * Minor/patch-only is already guaranteed upstream by + * {@link ContainerRegistryService.getAvailableUpdates} (same-major filter); the + * major-version check here is defense-in-depth. + */ +@inject() +export class AppAutoUpdateService { + constructor( + private dockerService: DockerService, + private downloadService: DownloadService, + private systemService: SystemService, + private containerRegistryService: ContainerRegistryService + ) {} + + /** Read the global master switch plus the shared window/cool-off settings. */ + async getConfig(): Promise { + const [enabled, windowStart, windowEnd, cooloffHours] = await Promise.all([ + KVStore.getValue('appAutoUpdate.enabled'), + KVStore.getValue('autoUpdate.windowStart'), + KVStore.getValue('autoUpdate.windowEnd'), + KVStore.getValue('autoUpdate.cooloffHours'), + ]) + + const parsedCooloff = Number(cooloffHours) + return { + enabled: enabled ?? false, + windowStart: windowStart || DEFAULT_WINDOW_START, + windowEnd: windowEnd || DEFAULT_WINDOW_END, + // `Number(null) === 0`, so an unset value must fall through to the default + // rather than silently resolving to a zero cool-off. An explicit 0 is honored. + cooloffHours: + cooloffHours !== null && Number.isFinite(parsedCooloff) && parsedCooloff >= 0 + ? parsedCooloff + : DEFAULT_COOLOFF_HOURS, + } + } + + /** + * Pure per-app eligibility verdict. An app is eligible when it has a detected + * update that is the same major (defense-in-depth), strictly newer, not self- + * disabled, and past its cool-off (measured from first-detected). + */ + appEligibility(service: Service, cooloffHours: number, now: DateTime): AppEligibility { + if (!service.available_update_version) { + return { eligible: false, reason: 'Up to date', cooloffRemainingHours: null } + } + if (service.auto_update_disabled_reason) { + return { + eligible: false, + reason: 'Auto-update disabled after repeated failures', + cooloffRemainingHours: null, + } + } + + const currentTag = this.containerRegistryService.parseImageReference( + service.container_image + ).tag + if (currentTag === 'latest') { + return { + eligible: false, + reason: 'Pinned to :latest — cannot version-check', + cooloffRemainingHours: null, + } + } + if (parseMajorVersion(service.available_update_version) !== parseMajorVersion(currentTag)) { + return { + eligible: false, + reason: 'Major version — manual update required', + cooloffRemainingHours: null, + } + } + if (!isNewerVersion(service.available_update_version, currentTag)) { + return { eligible: false, reason: 'Up to date', cooloffRemainingHours: null } + } + if (!service.available_update_first_seen_at) { + return { eligible: false, reason: 'Cool-off pending', cooloffRemainingHours: cooloffHours } + } + + const ageHours = now.diff(service.available_update_first_seen_at, 'hours').hours + const remaining = cooloffHours - ageHours + if (remaining > 0) { + const rounded = Math.ceil(remaining) + return { + eligible: false, + reason: `In cool-off (${rounded}h remaining)`, + cooloffRemainingHours: rounded, + } + } + + return { + eligible: true, + reason: `Eligible → ${service.available_update_version}`, + cooloffRemainingHours: 0, + } + } + + /** Installed, opted-in apps that are eligible to update right now. */ + async getEligibleApps(config: AppAutoUpdateConfig, now: DateTime): Promise { + const apps = await Service.query().where('installed', true).where('auto_update_enabled', true) + const targets: AppUpdateTarget[] = [] + for (const service of apps) { + const verdict = this.appEligibility(service, config.cooloffHours, now) + if (verdict.eligible) { + targets.push({ service, targetVersion: service.available_update_version! }) + } + } + return targets + } + + /** + * Run-wide pre-flight checked once per attempt (independent of any single app): + * never auto-update while content/model downloads are running. Transient → `skip`. + */ + async runGlobalPreflight(): Promise { + const blockers: Blocker[] = [] + try { + const downloads = await this.downloadService.listDownloadJobs() + const active = downloads.filter( + (d) => !!d.status && ['waiting', 'active', 'delayed'].includes(d.status) + ) + if (active.length > 0) { + blockers.push({ reason: `${active.length} download(s) in progress`, severity: 'skip' }) + } + } catch (error) { + logger.warn(`[AppAutoUpdateService] Could not check active downloads: ${error.message}`) + } + return { ok: blockers.length === 0, blockers } + } + + /** Per-app pre-flight: not already mid-operation (`skip`) and enough disk (`failure`). */ + async runAppPreflight(target: AppUpdateTarget): Promise { + const blockers: Blocker[] = [] + const service = target.service + + if (service.installation_status !== 'idle') { + blockers.push({ + reason: `App has an operation in progress (status: ${service.installation_status})`, + severity: 'skip', + }) + } + + const hostArch = await this.getHostArch() + const targetImage = `${this.imageBase(service.container_image)}:${target.targetVersion}` + const diskBlocker = await checkImageDiskSpace({ + image: targetImage, + hostArch, + containerRegistryService: this.containerRegistryService, + systemService: this.systemService, + }) + if (diskBlocker) blockers.push(diskBlocker) + + return { ok: blockers.length === 0, blockers } + } + + /** + * Entry point invoked by AppAutoUpdateJob. Gates on the master switch + window, + * then runs each eligible app through pre-flight and {@link DockerService.updateContainer}. + * A failing app self-disables after repeated failures without affecting the others. + */ + async attempt(): Promise<{ updated: number; reason: string }> { + const config = await this.getConfig() + const now = DateTime.now() + + if (!config.enabled) { + return { updated: 0, reason: 'App auto-update is disabled' } + } + if (!isWithinWindow(config.windowStart, config.windowEnd, now)) { + const reason = `Outside update window (${config.windowStart}-${config.windowEnd})` + await this.recordRun(reason) + return { updated: 0, reason } + } + + const eligible = await this.getEligibleApps(config, now) + if (eligible.length === 0) { + const reason = 'No eligible app updates (all current, in cool-off, or major-only)' + await this.recordRun(reason) + return { updated: 0, reason } + } + + const global = await this.runGlobalPreflight() + if (!global.ok) { + const reason = `Pre-flight blocked: ${global.blockers.map((b) => b.reason).join('; ')}` + await this.recordRun(reason) + return { updated: 0, reason } + } + + let updated = 0 + let failed = 0 + let skipped = 0 + + for (const target of eligible) { + const name = target.service.service_name + const preflight = await this.runAppPreflight(target) + if (!preflight.ok) { + const summary = preflight.blockers.map((b) => b.reason).join('; ') + if (preflight.blockers.some((b) => b.severity === 'failure')) { + await this.recordAppFailure(target.service, summary) + failed++ + } else { + logger.info(`[AppAutoUpdateService] Skipped ${name}: ${summary}`) + skipped++ + } + continue + } + + logger.info(`[AppAutoUpdateService] Updating ${name} → ${target.targetVersion}`) + const result = await this.dockerService.updateContainer(name, target.targetVersion) + if (result.success) { + await this.recordAppSuccess(target.service) + updated++ + } else { + await this.recordAppFailure(target.service, result.message) + failed++ + } + } + + const reason = `${updated} updated, ${failed} failed, ${skipped} skipped` + await this.recordRun(reason) + logger.info(`[AppAutoUpdateService] Run complete: ${reason}`) + return { updated, reason } + } + + /** Clear an app's failure backoff after a successful auto-update. */ + private async recordAppSuccess(service: Service): Promise { + // updateContainer already advanced container_image and cleared + // available_update_version on its own (fresh) row; here we only touch the + // backoff fields, so Lucid persists just those dirty columns. + service.auto_update_consecutive_failures = 0 + service.auto_update_disabled_reason = null + await service.save() + } + + /** Record an app failure and self-disable it once the threshold is reached. */ + private async recordAppFailure(service: Service, reason: string): Promise { + const failures = (service.auto_update_consecutive_failures || 0) + 1 + service.auto_update_consecutive_failures = failures + if (failures >= MAX_CONSECUTIVE_FAILURES) { + service.auto_update_disabled_reason = `Auto-update disabled after ${failures} consecutive failures. Last error: ${reason}` + logger.error( + `[AppAutoUpdateService] ${service.service_name} auto-disabled after ${failures} failures` + ) + } + await service.save() + logger.error( + `[AppAutoUpdateService] ${service.service_name} failure ${failures}/${MAX_CONSECUTIVE_FAILURES}: ${reason}` + ) + } + + /** Record the global last-attempt summary for the settings UI. */ + private async recordRun(reason: string): Promise { + await KVStore.setValue('appAutoUpdate.lastAttemptAt', DateTime.now().toISO()!) + await KVStore.setValue('appAutoUpdate.lastResult', reason) + } + + /** Full state snapshot for the settings UI (opted-in apps + their eligibility). */ + async getStatus(): Promise { + const config = await this.getConfig() + const now = DateTime.now() + + const apps = await Service.query().where('installed', true).where('auto_update_enabled', true) + const appStatuses: AppAutoUpdateAppStatus[] = apps.map((service) => { + const verdict = this.appEligibility(service, config.cooloffHours, now) + return { + service_name: service.service_name, + friendly_name: service.friendly_name, + auto_update_enabled: service.auto_update_enabled, + current_version: this.containerRegistryService.parseImageReference(service.container_image) + .tag, + available_update_version: service.available_update_version, + first_seen_at: service.available_update_first_seen_at?.toISO() ?? null, + eligible: verdict.eligible, + reason: verdict.reason, + cooloff_remaining_hours: verdict.cooloffRemainingHours, + consecutive_failures: service.auto_update_consecutive_failures || 0, + auto_disabled_reason: service.auto_update_disabled_reason, + } + }) + + const [lastAttemptAt, lastResult] = await Promise.all([ + KVStore.getValue('appAutoUpdate.lastAttemptAt'), + KVStore.getValue('appAutoUpdate.lastResult'), + ]) + + return { + ...config, + withinWindow: isWithinWindow(config.windowStart, config.windowEnd, now), + lastAttemptAt: lastAttemptAt || null, + lastResult: lastResult || null, + apps: appStatuses, + } + } + + /** Strip the tag from an image reference, leaving "registry/namespace/repo". */ + private imageBase(image: string): string { + return image.includes(':') ? image.substring(0, image.lastIndexOf(':')) : image + } + + /** Map the Docker daemon's architecture string to OCI naming (amd64/arm64/...). */ + private async getHostArch(): Promise { + try { + const info = await this.dockerService.docker.info() + const arch = info.Architecture || '' + const archMap: Record = { + x86_64: 'amd64', + aarch64: 'arm64', + armv7l: 'arm', + amd64: 'amd64', + arm64: 'arm64', + } + return archMap[arch] || arch.toLowerCase() + } catch { + return 'amd64' + } + } +} diff --git a/admin/app/services/auto_update_service.ts b/admin/app/services/auto_update_service.ts index 013f209..6d2bee7 100644 --- a/admin/app/services/auto_update_service.ts +++ b/admin/app/services/auto_update_service.ts @@ -10,6 +10,12 @@ import { SystemService } from '#services/system_service' import { SystemUpdateService } from '#services/system_update_service' import { ContainerRegistryService } from '#services/container_registry_service' import { isNewerVersion, parseMajorVersion } from '../utils/version.js' +import { isWithinWindow as isWithinWindowUtil } from '../utils/update_window.js' +import { + checkImageDiskSpace, + type Blocker, + type PreflightResult, +} from '../utils/image_disk_preflight.js' /** Docker image repository for the NOMAD admin/core image (tag applied per-release). */ const NOMAD_IMAGE_REPO = 'ghcr.io/crosstalk-solutions/project-nomad' @@ -20,10 +26,6 @@ const DEFAULT_WINDOW_START = '02:00' const DEFAULT_WINDOW_END = '05:00' const DEFAULT_COOLOFF_HOURS = 72 -/** Require free space >= imageSize * factor to cover decompressed layers + headroom. */ -const DISK_SAFETY_FACTOR = 2 -/** Conservative fallback when the registry image size can't be determined. */ -const MIN_FREE_BYTES = 5 * 1024 * 1024 * 1024 // 5 GiB /** Genuine failures before auto-update disables itself to avoid an update loop. */ const MAX_CONSECUTIVE_FAILURES = 3 @@ -53,16 +55,9 @@ export interface EligibleTarget { publishedAt: string } -type BlockerSeverity = 'skip' | 'failure' -export interface Blocker { - reason: string - severity: BlockerSeverity -} - -export interface PreflightResult { - ok: boolean - blockers: Blocker[] -} +// Pre-flight types/primitives are shared with AppAutoUpdateService; re-exported +// here for back-compat with existing imports of this module. +export type { Blocker, BlockerSeverity, PreflightResult } from '../utils/image_disk_preflight.js' /** Minimal shape of a GitHub release entry we depend on. */ export interface GithubRelease { @@ -178,23 +173,7 @@ export class AutoUpdateService { * that wrap past midnight (start > end, e.g. 22:00-02:00) are handled. */ isWithinWindow(config: AutoUpdateConfig, now: DateTime = DateTime.now()): boolean { - const start = this.parseMinutes(config.windowStart) - const end = this.parseMinutes(config.windowEnd) - if (start === null || end === null) return false - - const current = now.hour * 60 + now.minute - if (start === end) return false // zero-length window - if (start < end) { - return current >= start && current < end - } - // Wraps midnight - return current >= start || current < end - } - - private parseMinutes(hhmm: string): number | null { - const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(hhmm) - if (!match) return null - return Number(match[1]) * 60 + Number(match[2]) + return isWithinWindowUtil(config.windowStart, config.windowEnd, now) } /** @@ -350,48 +329,13 @@ export class AutoUpdateService { /** Returns a disk blocker if free space is insufficient, otherwise null. */ private async checkDiskSpace(targetTag: string): Promise { - try { - const hostArch = await this.getHostArch() - const parsed = this.containerRegistryService.parseImageReference( - `${NOMAD_IMAGE_REPO}:${targetTag}` - ) - const imageSize = await this.containerRegistryService.getImageDownloadSize( - parsed, - targetTag, - hostArch - ) - const required = imageSize !== null ? imageSize * DISK_SAFETY_FACTOR : MIN_FREE_BYTES - - const free = await this.getFreeBytes() - if (free === null) { - logger.warn('[AutoUpdateService] Could not determine free disk space; skipping disk check') - return null - } - - if (free < required) { - return { - reason: `Insufficient disk space: ${this.gib(free)} free, ${this.gib(required)} required`, - severity: 'failure', - } - } - return null - } catch (error) { - logger.warn(`[AutoUpdateService] Disk space check failed: ${error.message}`) - return null - } - } - - /** Free bytes on the root filesystem (best-effort, falls back to max available). */ - private async getFreeBytes(): Promise { - const info = await this.systemService.getSystemInfo() - if (!info?.fsSize?.length) return null - const root = info.fsSize.find((f) => f.mount === '/') - if (root) return root.available - return Math.max(...info.fsSize.map((f) => f.available)) - } - - private gib(bytes: number): string { - return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GiB` + const hostArch = await this.getHostArch() + return checkImageDiskSpace({ + image: `${NOMAD_IMAGE_REPO}:${targetTag}`, + hostArch, + containerRegistryService: this.containerRegistryService, + systemService: this.systemService, + }) } /** Map the Docker daemon's architecture string to OCI naming (amd64/arm64/...). */ diff --git a/admin/app/services/system_service.ts b/admin/app/services/system_service.ts index f261496..21bb422 100644 --- a/admin/app/services/system_service.ts +++ b/admin/app/services/system_service.ts @@ -309,6 +309,7 @@ export class SystemService { 'display_order', 'container_image', 'available_update_version', + 'auto_update_enabled', 'is_custom', 'is_user_modified', 'category' @@ -341,6 +342,7 @@ export class SystemService { display_order: service.display_order, container_image: service.container_image, available_update_version: service.available_update_version, + auto_update_enabled: service.auto_update_enabled, is_custom: service.is_custom, is_user_modified: service.is_user_modified, category: service.category, @@ -838,6 +840,14 @@ export class SystemService { await KVStore.setValue('autoUpdate.consecutiveFailures', '0') await KVStore.clearValue('autoUpdate.autoDisabledReason') } + // Re-enabling the global app auto-update master switch clears every app's + // per-app failure backoff so previously self-disabled apps get a fresh start. + if (key === 'appAutoUpdate.enabled' && (value === true || value === 'true')) { + await Service.query().update({ + auto_update_consecutive_failures: 0, + auto_update_disabled_reason: null, + }) + } } /** diff --git a/admin/app/utils/image_disk_preflight.ts b/admin/app/utils/image_disk_preflight.ts new file mode 100644 index 0000000..cabf4e3 --- /dev/null +++ b/admin/app/utils/image_disk_preflight.ts @@ -0,0 +1,83 @@ +import logger from '@adonisjs/core/services/logger' +import type { ContainerRegistryService } from '#services/container_registry_service' +import type { SystemService } from '#services/system_service' + +/** + * Shared pre-flight primitives for update flows (core app + installed apps). + * Kept framework-light (plain functions + injected service instances) so both + * {@link AutoUpdateService} and {@link AppAutoUpdateService} reuse one implementation. + */ + +export type BlockerSeverity = 'skip' | 'failure' + +export interface Blocker { + reason: string + severity: BlockerSeverity +} + +export interface PreflightResult { + ok: boolean + blockers: Blocker[] +} + +/** Require free space >= imageSize * factor to cover decompressed layers + headroom. */ +export const DISK_SAFETY_FACTOR = 2 +/** Conservative fallback when the registry image size can't be determined. */ +export const MIN_FREE_BYTES = 5 * 1024 * 1024 * 1024 // 5 GiB + +/** Free bytes on the root filesystem (best-effort, falls back to max available). */ +export async function getFreeBytes(systemService: SystemService): Promise { + const info = await systemService.getSystemInfo() + if (!info?.fsSize?.length) return null + const root = info.fsSize.find((f) => f.mount === '/') + if (root) return root.available + return Math.max(...info.fsSize.map((f) => f.available)) +} + +function gib(bytes: number): string { + return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GiB` +} + +/** + * Returns a `failure` disk blocker if free space is insufficient for the given + * image reference, otherwise null. Mirrors the core update's behavior: estimate + * the image's compressed download size from the registry manifest, require + * `size * DISK_SAFETY_FACTOR` (or {@link MIN_FREE_BYTES} when size is unknown), + * and never block on transient lookup errors (returns null on failure). + * + * @param image Full image reference INCLUDING tag (e.g. "ollama/ollama:0.23.2"). + */ +export async function checkImageDiskSpace(params: { + image: string + hostArch: string + containerRegistryService: ContainerRegistryService + systemService: SystemService +}): Promise { + const { image, hostArch, containerRegistryService, systemService } = params + try { + const parsed = containerRegistryService.parseImageReference(image) + const imageSize = await containerRegistryService.getImageDownloadSize( + parsed, + parsed.tag, + hostArch + ) + const required = imageSize !== null ? imageSize * DISK_SAFETY_FACTOR : MIN_FREE_BYTES + + const free = await getFreeBytes(systemService) + if (free === null) { + logger.warn('[ImageDiskPreflight] Could not determine free disk space; skipping disk check') + return null + } + + if (free < required) { + return { + reason: `Insufficient disk space: ${gib(free)} free, ${gib(required)} required`, + severity: 'failure', + } + } + return null + } catch (error) { + logger.warn(`[ImageDiskPreflight] Disk space check failed: ${error.message}`) + return null + } +} diff --git a/admin/app/utils/update_window.ts b/admin/app/utils/update_window.ts new file mode 100644 index 0000000..87597ab --- /dev/null +++ b/admin/app/utils/update_window.ts @@ -0,0 +1,35 @@ +import { DateTime } from 'luxon' + +/** + * Shared update-window helpers used by both the core auto-update + * ({@link AutoUpdateService}) and the per-app auto-update ({@link AppAutoUpdateService}). + * + * The window is interpreted in the container's local time (set via the TZ env var). + * Windows that wrap past midnight (start > end, e.g. 22:00-02:00) are supported. + */ + +/** Parse an "HH:MM" 24-hour string into minutes-since-midnight, or null if malformed. */ +export function parseWindowMinutes(hhmm: string): number | null { + const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(hhmm) + if (!match) return null + return Number(match[1]) * 60 + Number(match[2]) +} + +/** Whether `now` falls inside the [windowStart, windowEnd) window (handles midnight wrap). */ +export function isWithinWindow( + windowStart: string, + windowEnd: string, + now: DateTime = DateTime.now() +): boolean { + const start = parseWindowMinutes(windowStart) + const end = parseWindowMinutes(windowEnd) + if (start === null || end === null) return false + + const current = now.hour * 60 + now.minute + if (start === end) return false // zero-length window + if (start < end) { + return current >= start && current < end + } + // Wraps midnight + return current >= start || current < end +} diff --git a/admin/app/validators/system.ts b/admin/app/validators/system.ts index 5e9b915..84dfa74 100644 --- a/admin/app/validators/system.ts +++ b/admin/app/validators/system.ts @@ -38,6 +38,15 @@ export const preflightValidator = vine.compile( }) ) +// Toggle per-app automatic updates (opt-in). The global master switch lives in +// the KVStore (`appAutoUpdate.enabled`) and flows through the settings endpoint. +export const setServiceAutoUpdateValidator = vine.compile( + vine.object({ + service_name: vine.string().trim(), + enabled: vine.boolean(), + }) +) + // Shared sub-schema for a volume bind mapping. A colon is Docker's bind delimiter // (host:container:options) — forbid it in either field so a path can't smuggle in an // extra segment that the guard reads as safe but Docker re-parses as a different mount. diff --git a/admin/commands/app_auto_update/dry_run.ts b/admin/commands/app_auto_update/dry_run.ts new file mode 100644 index 0000000..40baa96 --- /dev/null +++ b/admin/commands/app_auto_update/dry_run.ts @@ -0,0 +1,227 @@ +import { BaseCommand, flags } from '@adonisjs/core/ace' +import type { CommandOptions } from '@adonisjs/core/types/ace' + +/** + * Exercise the app auto-update decision logic WITHOUT ever triggering a real update. + * + * # Prove the per-app eligibility + window logic deterministically (no DB/Docker): + * node ace app-auto-update:dry-run --scenarios + * + * # Show, against the live DB, which opted-in apps WOULD update right now: + * node ace app-auto-update:dry-run + */ +export default class AppAutoUpdateDryRun extends BaseCommand { + static commandName = 'app-auto-update:dry-run' + static description = 'Dry-run the app auto-update decision logic (never triggers an update)' + + @flags.boolean({ description: 'Run the built-in deterministic scenario suite and exit' }) + declare scenarios: boolean + + static options: CommandOptions = { + startApp: true, + } + + async run() { + const { DateTime } = await import('luxon') + const { DockerService } = await import('#services/docker_service') + const { DownloadService } = await import('#services/download_service') + const { SystemService } = await import('#services/system_service') + const { ContainerRegistryService } = await import('#services/container_registry_service') + const { QueueService } = await import('#services/queue_service') + const { AppAutoUpdateService } = await import('#services/app_auto_update_service') + const { isWithinWindow } = await import('../../app/utils/update_window.js') + + const dockerService = new DockerService() + const svc = new AppAutoUpdateService( + dockerService, + new DownloadService(QueueService.getInstance()), + new SystemService(dockerService), + new ContainerRegistryService() + ) + + if (this.scenarios) { + const ok = this.runScenarios(svc, DateTime, isWithinWindow) + if (!ok) this.exitCode = 1 + return + } + + // --- Live read-only snapshot (no update triggered) ---------------------- + const status = await svc.getStatus() + this.logger.log('') + this.logger.log(` Master switch : ${status.enabled ? 'enabled' : 'disabled'}`) + this.logger.log( + ` Window : ${status.windowStart}-${status.windowEnd} ` + + `(currently ${status.withinWindow ? 'inside' : 'outside'})` + ) + this.logger.log(` Cool-off hours : ${status.cooloffHours}`) + this.logger.log('') + if (status.apps.length === 0) { + this.logger.info('No apps are opted into auto-update.') + return + } + this.logger.log('Opted-in apps:') + for (const app of status.apps) { + const tag = app.eligible ? this.colors.green('WOULD UPDATE') : this.colors.dim('skip') + this.logger.log( + ` ${tag} ${app.friendly_name || app.service_name}: ${app.current_version}` + + `${app.available_update_version ? ' → ' + app.available_update_version : ''} — ${app.reason}` + ) + } + } + + /** + * Deterministic acceptance suite over the pure decision helpers — no DB or Docker. + * Uses ContainerRegistryService.parseImageReference (pure) via appEligibility. + */ + private runScenarios(svc: any, DateTime: any, isWithinWindow: any): boolean { + const now = DateTime.fromISO('2026-06-04T12:00:00Z') + const daysAgo = (d: number) => now.minus({ days: d }) + const hoursAgo = (h: number) => now.minus({ hours: h }) + + const mk = (o: Record) => ({ + service_name: 'nomad_test', + container_image: 'ollama/ollama:0.18.1', + available_update_version: null, + available_update_first_seen_at: null, + auto_update_disabled_reason: null, + ...o, + }) + + type Case = { name: string; service: any; cooloff: number; expect: boolean } + const cases: Case[] = [ + { name: 'no update → not eligible', service: mk({}), cooloff: 72, expect: false }, + { + name: 'major bump → not eligible', + service: mk({ + available_update_version: '1.0.0', + available_update_first_seen_at: daysAgo(10), + }), + cooloff: 72, + expect: false, + }, + { + name: 'minor newer inside cool-off → not eligible', + service: mk({ + available_update_version: '0.19.0', + available_update_first_seen_at: hoursAgo(10), + }), + cooloff: 72, + expect: false, + }, + { + name: 'minor newer past cool-off → eligible', + service: mk({ + available_update_version: '0.19.0', + available_update_first_seen_at: daysAgo(5), + }), + cooloff: 72, + expect: true, + }, + { + name: 'null first-seen → not eligible', + service: mk({ available_update_version: '0.19.0', available_update_first_seen_at: null }), + cooloff: 72, + expect: false, + }, + { + name: 'self-disabled → not eligible', + service: mk({ + available_update_version: '0.19.0', + available_update_first_seen_at: daysAgo(30), + auto_update_disabled_reason: 'disabled', + }), + cooloff: 72, + expect: false, + }, + { + name: ':latest pinned → not eligible', + service: mk({ + container_image: 'foo/bar:latest', + available_update_version: '1.2.3', + available_update_first_seen_at: daysAgo(30), + }), + cooloff: 72, + expect: false, + }, + { + name: 'cool-off 0 applies immediately', + service: mk({ + available_update_version: '0.18.2', + available_update_first_seen_at: hoursAgo(1), + }), + cooloff: 0, + expect: true, + }, + ] + + type WinCase = { name: string; start: string; end: string; at: string; expect: boolean } + const at = (hhmm: string) => `2026-06-04T${hhmm}:00` + const windows: WinCase[] = [ + { + name: 'normal 20:00-23:00 @ 21:00 → in', + start: '20:00', + end: '23:00', + at: at('21:00'), + expect: true, + }, + { + name: 'normal 20:00-23:00 @ 19:00 → out', + start: '20:00', + end: '23:00', + at: at('19:00'), + expect: false, + }, + { + name: 'wrap 22:00-02:00 @ 01:00 → in', + start: '22:00', + end: '02:00', + at: at('01:00'), + expect: true, + }, + { + name: 'wrap 22:00-02:00 @ 12:00 → out', + start: '22:00', + end: '02:00', + at: at('12:00'), + expect: false, + }, + ] + + let passed = 0 + let failed = 0 + + this.logger.log('') + this.logger.log('Eligibility scenarios:') + for (const c of cases) { + const got = svc.appEligibility(c.service, c.cooloff, now).eligible + const ok = got === c.expect + this.report(ok, `${c.name} (expected ${c.expect}, got ${got})`) + ok ? passed++ : failed++ + } + + this.logger.log('') + this.logger.log('Window scenarios:') + for (const c of windows) { + const got = isWithinWindow(c.start, c.end, DateTime.fromISO(c.at)) + const ok = got === c.expect + this.report(ok, `${c.name} (expected ${c.expect}, got ${got})`) + ok ? passed++ : failed++ + } + + this.logger.log('') + if (failed === 0) { + this.logger.success(`All ${passed} scenarios passed`) + } else { + this.logger.error(`${failed} scenario(s) failed, ${passed} passed`) + } + return failed === 0 + } + + private report(ok: boolean, message: string) { + if (ok) { + this.logger.log(` ${this.colors.green('✓')} ${message}`) + } else { + this.logger.log(` ${this.colors.red('✗')} ${message}`) + } + } +} diff --git a/admin/commands/queue/work.ts b/admin/commands/queue/work.ts index 7e68122..6db7f80 100644 --- a/admin/commands/queue/work.ts +++ b/admin/commands/queue/work.ts @@ -10,6 +10,7 @@ import { EmbedFileJob } from '#jobs/embed_file_job' import { CheckUpdateJob } from '#jobs/check_update_job' import { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job' import { AutoUpdateJob } from '#jobs/auto_update_job' +import { AppAutoUpdateJob } from '#jobs/app_auto_update_job' export default class QueueWork extends BaseCommand { static commandName = 'queue:work' @@ -105,6 +106,7 @@ export default class QueueWork extends BaseCommand { await CheckUpdateJob.scheduleNightly() await CheckServiceUpdatesJob.scheduleNightly() await AutoUpdateJob.schedule() + await AppAutoUpdateJob.schedule() // Safety net: log unhandled rejections instead of crashing the worker process. // Individual job errors are already caught by BullMQ; this catches anything that @@ -136,6 +138,7 @@ export default class QueueWork extends BaseCommand { handlers.set(CheckUpdateJob.key, new CheckUpdateJob()) handlers.set(CheckServiceUpdatesJob.key, new CheckServiceUpdatesJob()) handlers.set(AutoUpdateJob.key, new AutoUpdateJob()) + handlers.set(AppAutoUpdateJob.key, new AppAutoUpdateJob()) queues.set(RunDownloadJob.key, RunDownloadJob.queue) queues.set(RunExtractPmtilesJob.key, RunExtractPmtilesJob.queue) @@ -145,6 +148,7 @@ export default class QueueWork extends BaseCommand { queues.set(CheckUpdateJob.key, CheckUpdateJob.queue) queues.set(CheckServiceUpdatesJob.key, CheckServiceUpdatesJob.queue) queues.set(AutoUpdateJob.key, AutoUpdateJob.queue) + queues.set(AppAutoUpdateJob.key, AppAutoUpdateJob.queue) return [handlers, queues] } diff --git a/admin/constants/kv_store.ts b/admin/constants/kv_store.ts index da7a3cb..3c66261 100644 --- a/admin/constants/kv_store.ts +++ b/admin/constants/kv_store.ts @@ -1,3 +1,3 @@ import { KVStoreKey } from "../types/kv_store.js"; -export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'ui.theme', 'system.earlyAccess', 'ai.assistantCustomName', 'ai.remoteOllamaUrl', 'ai.ollamaFlashAttention', 'rag.defaultIngestPolicy', 'autoUpdate.enabled', 'autoUpdate.windowStart', 'autoUpdate.windowEnd', 'autoUpdate.cooloffHours']; \ No newline at end of file +export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'ui.theme', 'system.earlyAccess', 'ai.assistantCustomName', 'ai.remoteOllamaUrl', 'ai.ollamaFlashAttention', 'rag.defaultIngestPolicy', 'autoUpdate.enabled', 'autoUpdate.windowStart', 'autoUpdate.windowEnd', 'autoUpdate.cooloffHours', 'appAutoUpdate.enabled']; \ No newline at end of file diff --git a/admin/database/migrations/1772000000003_add_app_auto_update_fields_to_services.ts b/admin/database/migrations/1772000000003_add_app_auto_update_fields_to_services.ts new file mode 100644 index 0000000..6a7feea --- /dev/null +++ b/admin/database/migrations/1772000000003_add_app_auto_update_fields_to_services.ts @@ -0,0 +1,28 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'services' + + async up() { + this.schema.alterTable(this.tableName, (table) => { + // Per-app opt-in for automatic updates (gated additionally by the global + // `appAutoUpdate.enabled` master switch). Default off — auto-update is opt-in. + table.boolean('auto_update_enabled').notNullable().defaultTo(false) + // Cool-off anchor: when the currently-available update was first detected. + // Registry tags carry no publish date, so cool-off is measured from first-seen. + table.timestamp('available_update_first_seen_at').nullable() + // Per-app failure backoff so one flapping app self-disables without affecting others. + table.integer('auto_update_consecutive_failures').notNullable().defaultTo(0) + table.string('auto_update_disabled_reason', 255).nullable() + }) + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.dropColumn('auto_update_enabled') + table.dropColumn('available_update_first_seen_at') + table.dropColumn('auto_update_consecutive_failures') + table.dropColumn('auto_update_disabled_reason') + }) + } +} diff --git a/admin/database/seeders/service_seeder.ts b/admin/database/seeders/service_seeder.ts index e67ac90..b691cdc 100644 --- a/admin/database/seeders/service_seeder.ts +++ b/admin/database/seeders/service_seeder.ts @@ -14,6 +14,10 @@ type ServiceSeedRecord = Omit< | 'update_checked_at' | 'metadata' | 'is_user_modified' + | 'auto_update_enabled' + | 'available_update_first_seen_at' + | 'auto_update_consecutive_failures' + | 'auto_update_disabled_reason' > & { metadata?: string | null } export default class ServiceSeeder extends BaseSeeder { diff --git a/admin/inertia/components/updates/AppAutoUpdateSection.tsx b/admin/inertia/components/updates/AppAutoUpdateSection.tsx new file mode 100644 index 0000000..8dc29a7 --- /dev/null +++ b/admin/inertia/components/updates/AppAutoUpdateSection.tsx @@ -0,0 +1,101 @@ +import StyledSectionHeader from '~/components/StyledSectionHeader' +import Switch from '~/components/inputs/Switch' +import api from '~/lib/api' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useNotifications } from '~/context/NotificationContext' +import { useAppAutoUpdateStatus } from '~/hooks/useAppAutoUpdateStatus' + +export default function AppAutoUpdateSection() { + const { addNotification } = useNotifications() + const queryClient = useQueryClient() + const { data: status, isLoading } = useAppAutoUpdateStatus() + + const enabled = status?.enabled ?? false + + const toggleMutation = useMutation({ + mutationFn: (value: boolean) => api.updateSetting('appAutoUpdate.enabled', value), + onSuccess: (_data, value) => { + queryClient.invalidateQueries({ queryKey: ['app-auto-update-status'] }) + addNotification({ + type: 'success', + message: value ? 'App automatic updates enabled.' : 'App automatic updates disabled.', + }) + }, + onError: () => { + addNotification({ type: 'error', message: 'Failed to update app auto-update setting.' }) + }, + }) + + return ( + <> + +
+ toggleMutation.mutate(value)} + disabled={toggleMutation.isPending || isLoading} + label="Enable Automatic App Updates" + description="Automatically install minor and patch updates for apps you've opted in (toggle each app in Supply Depot). Major versions always require a manual update. Uses the same update window and cool-off period as the core schedule above." + /> + + {enabled && status && ( +
+

+ Update window: + {status.windowStart}–{status.windowEnd} ( + {status.withinWindow ? 'currently inside' : 'currently outside'}); cool-off{' '} + {status.cooloffHours}h. + {status.lastResult && ( + <> + {' '} + Last run: + {status.lastResult} + {status.lastAttemptAt + ? ` (${new Date(status.lastAttemptAt).toLocaleString()})` + : ''} + + )} +

+ + {status.apps.length === 0 ? ( +

+ No apps are opted in yet. Enable auto-update on individual apps from the Supply Depot. +

+ ) : ( +
    + {status.apps.map((app) => ( +
  • +
    +

    + {app.friendly_name || app.service_name} +

    +

    + {app.current_version} + {app.available_update_version + ? ` → ${app.available_update_version}` + : ' (up to date)'} +

    + {app.auto_disabled_reason && ( +

    {app.auto_disabled_reason}

    + )} +
    + + {app.reason} + +
  • + ))} +
+ )} +
+ )} +
+ + ) +} diff --git a/admin/inertia/components/updates/ContentUpdatesSection.tsx b/admin/inertia/components/updates/ContentUpdatesSection.tsx new file mode 100644 index 0000000..d71e1fa --- /dev/null +++ b/admin/inertia/components/updates/ContentUpdatesSection.tsx @@ -0,0 +1,227 @@ +import { useState } from 'react' +import StyledButton from '~/components/StyledButton' +import StyledTable from '~/components/StyledTable' +import StyledSectionHeader from '~/components/StyledSectionHeader' +import ActiveDownloads from '~/components/ActiveDownloads' +import Alert from '~/components/Alert' +import type { ContentUpdateCheckResult, ResourceUpdateInfo } from '../../../types/collections' +import api from '~/lib/api' +import { useQueryClient } from '@tanstack/react-query' +import { useNotifications } from '~/context/NotificationContext' +import { formatBytes } from '~/lib/util' + +export default function ContentUpdatesSection() { + const { addNotification } = useNotifications() + const queryClient = useQueryClient() + const [checkResult, setCheckResult] = useState(null) + const [isChecking, setIsChecking] = useState(false) + const [applyingIds, setApplyingIds] = useState>(new Set()) + const [isApplyingAll, setIsApplyingAll] = useState(false) + + const handleCheck = async () => { + setIsChecking(true) + try { + const result = await api.checkForContentUpdates() + if (result) { + setCheckResult(result) + } + } catch { + setCheckResult({ + updates: [], + checked_at: new Date().toISOString(), + error: 'Failed to check for content updates', + }) + } finally { + setIsChecking(false) + } + } + + const handleApply = async (update: ResourceUpdateInfo) => { + setApplyingIds((prev) => new Set(prev).add(update.resource_id)) + try { + const result = await api.applyContentUpdate(update) + if (result?.success) { + addNotification({ type: 'success', message: `Update started for ${update.resource_id}` }) + // Remove from the updates list + setCheckResult((prev) => + prev + ? { ...prev, updates: prev.updates.filter((u) => u.resource_id !== update.resource_id) } + : prev + ) + // Force Active Downloads to refetch now — small updates finish before the next + // idle poll fires, so without this the user wouldn't see them. + queryClient.invalidateQueries({ queryKey: ['download-jobs'] }) + } else { + addNotification({ type: 'error', message: result?.error || 'Failed to start update' }) + } + } catch { + addNotification({ type: 'error', message: `Failed to start update for ${update.resource_id}` }) + } finally { + setApplyingIds((prev) => { + const next = new Set(prev) + next.delete(update.resource_id) + return next + }) + } + } + + const handleApplyAll = async () => { + if (!checkResult?.updates.length) return + setIsApplyingAll(true) + try { + const result = await api.applyAllContentUpdates(checkResult.updates) + if (result?.results) { + const succeeded = result.results.filter((r) => r.success).length + const failed = result.results.filter((r) => !r.success).length + if (succeeded > 0) { + addNotification({ type: 'success', message: `Started ${succeeded} update(s)` }) + } + if (failed > 0) { + addNotification({ type: 'error', message: `${failed} update(s) could not be started` }) + } + // Remove successful updates from the list + const successIds = new Set(result.results.filter((r) => r.success).map((r) => r.resource_id)) + setCheckResult((prev) => + prev + ? { ...prev, updates: prev.updates.filter((u) => !successIds.has(u.resource_id)) } + : prev + ) + if (successIds.size > 0) { + queryClient.invalidateQueries({ queryKey: ['download-jobs'] }) + } + } + } catch { + addNotification({ type: 'error', message: 'Failed to apply updates' }) + } finally { + setIsApplyingAll(false) + } + } + + return ( +
+ + +
+
+

+ Check if newer versions of your installed ZIM files and maps are available. +

+ + Check for Content Updates + +
+ + {checkResult?.error && ( + + )} + + {checkResult && !checkResult.error && checkResult.updates.length === 0 && ( + + )} + + {checkResult && checkResult.updates.length > 0 && ( +
+
+

+ {checkResult.updates.length} update(s) available +

+ + Update All ({checkResult.updates.length}) + +
+ ( + {record.resource_id} + ), + }, + { + accessor: 'resource_type', + title: 'Type', + render: (record) => ( + + {record.resource_type === 'zim' ? 'ZIM' : 'Map'} + + ), + }, + { + accessor: 'size_bytes', + title: 'Size', + render: (record) => ( + + {record.size_bytes ? formatBytes(record.size_bytes, 1) : '—'} + + ), + }, + { + accessor: 'installed_version', + title: 'Version', + render: (record) => ( + + {record.installed_version} → {record.latest_version} + + ), + }, + { + accessor: 'resource_id', + title: '', + render: (record) => ( + handleApply(record)} + loading={applyingIds.has(record.resource_id)} + > + Update + + ), + }, + ]} + /> +
+ )} + + {checkResult?.checked_at && ( +

+ Last checked: {new Date(checkResult.checked_at).toLocaleString()} +

+ )} +
+ + +
+ ) +} diff --git a/admin/inertia/components/updates/CoreAutoUpdateSection.tsx b/admin/inertia/components/updates/CoreAutoUpdateSection.tsx new file mode 100644 index 0000000..fcebbb3 --- /dev/null +++ b/admin/inertia/components/updates/CoreAutoUpdateSection.tsx @@ -0,0 +1,185 @@ +import { useEffect, useState } from 'react' +import StyledButton from '~/components/StyledButton' +import StyledSectionHeader from '~/components/StyledSectionHeader' +import Alert from '~/components/Alert' +import api from '~/lib/api' +import Input from '~/components/inputs/Input' +import Switch from '~/components/inputs/Switch' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useNotifications } from '~/context/NotificationContext' +import { useAutoUpdateStatus } from '~/hooks/useAutoUpdateStatus' + +const COOLOFF_OPTIONS = [ + { value: 24, label: '24 hours (1 day)' }, + { value: 48, label: '48 hours (2 days)' }, + { value: 72, label: '72 hours (3 days)' }, + { value: 168, label: '7 days' }, +] + +export default function CoreAutoUpdateSection() { + const { addNotification } = useNotifications() + const queryClient = useQueryClient() + const { data: status, isLoading } = useAutoUpdateStatus() + + const [windowStart, setWindowStart] = useState('02:00') + const [windowEnd, setWindowEnd] = useState('05:00') + const [cooloff, setCooloff] = useState(72) + + // Seed editable fields once the persisted status loads. + useEffect(() => { + if (status) { + setWindowStart(status.windowStart) + setWindowEnd(status.windowEnd) + setCooloff(status.cooloffHours) + } + }, [status?.windowStart, status?.windowEnd, status?.cooloffHours]) + + const saveMutation = useMutation({ + mutationFn: ({ key, value }: { key: string; value: any }) => api.updateSetting(key, value), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['auto-update-status'] }) + }, + onError: () => { + addNotification({ type: 'error', message: 'Failed to update auto-update setting.' }) + }, + }) + + const enabled = status?.enabled ?? false + const autoDisabled = !!status?.autoDisabledReason + + const handleToggle = (value: boolean) => { + saveMutation.mutate( + { key: 'autoUpdate.enabled', value }, + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['auto-update-status'] }) + addNotification({ + type: 'success', + message: value ? 'Automatic updates enabled.' : 'Automatic updates disabled.', + }) + }, + } + ) + } + + const handleSaveWindow = async () => { + try { + await api.updateSetting('autoUpdate.windowStart', windowStart) + await api.updateSetting('autoUpdate.windowEnd', windowEnd) + await api.updateSetting('autoUpdate.cooloffHours', String(cooloff)) + queryClient.invalidateQueries({ queryKey: ['auto-update-status'] }) + addNotification({ type: 'success', message: 'Auto-update schedule saved.' }) + } catch { + addNotification({ type: 'error', message: 'Failed to save auto-update schedule.' }) + } + } + + return ( + <> + +
+ {autoDisabled && ( + + )} + + + +
+ setWindowStart(e.target.value)} + disabled={!enabled} + helpText="Local server time" + /> + setWindowEnd(e.target.value)} + disabled={!enabled} + helpText="Local server time" + /> +
+ +

Delay after a release is published

+ +
+
+ +
+ + Save Schedule + +
+ + {enabled && status && ( +
+

+ Status: + {status.eligibleTarget + ? `Eligible update ready: ${status.eligibleTarget.version}` + : 'No eligible update — system is current or the latest release is a major version / still in cool-off.'} +

+

+ Update window: + {status.withinWindow ? 'Currently inside the window' : 'Currently outside the window'} +

+ {status.lastResult && ( +

+ Last check: + {status.lastResult} + {status.lastAttemptAt + ? ` (${new Date(status.lastAttemptAt).toLocaleString()})` + : ''} +

+ )} + {status.lastError && ( +

+ Last error: + {status.lastError} +

+ )} +
+ )} +
+ + ) +} diff --git a/admin/inertia/hooks/useAppAutoUpdateStatus.ts b/admin/inertia/hooks/useAppAutoUpdateStatus.ts new file mode 100644 index 0000000..810a572 --- /dev/null +++ b/admin/inertia/hooks/useAppAutoUpdateStatus.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query' +import api from '~/lib/api' +import { AppAutoUpdateStatus } from '../../types/system' + +export const useAppAutoUpdateStatus = () => { + return useQuery({ + queryKey: ['app-auto-update-status'], + queryFn: () => api.getAppAutoUpdateStatus(), + refetchOnWindowFocus: false, + }) +} diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index 10400a9..6b9a4de 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -2,7 +2,7 @@ import axios, { AxiosError, AxiosInstance } from 'axios' import { ListRemoteZimFilesResponse, ListZimFilesResponse } from '../../types/zim' import { ServiceSlim } from '../../types/services' import { FileEntry } from '../../types/files' -import { AutoUpdateStatus, CheckLatestVersionResult, SystemInformationResponse, SystemUpdateStatus } from '../../types/system' +import { AppAutoUpdateStatus, AutoUpdateStatus, CheckLatestVersionResult, SystemInformationResponse, SystemUpdateStatus } from '../../types/system' import { DownloadJobWithProgress, WikipediaState } from '../../types/downloads' import type { Country, CountryCode, CountryGroup, MapExtractPreflight } from '../../types/maps' import { EmbedJobWithProgress, FileWarningsResult, StoredFileInfo } from '../../types/rag' @@ -549,6 +549,23 @@ class API { })() } + async getAppAutoUpdateStatus() { + return catchInternal(async () => { + const response = await this.client.get('/system/apps/auto-update/status') + return response.data + })() + } + + async setServiceAutoUpdate(serviceName: string, enabled: boolean) { + return catchInternal(async () => { + const response = await this.client.post<{ success: boolean; message: string }>( + '/system/services/auto-update', + { service_name: serviceName, enabled } + ) + return response.data + })() + } + async healthCheck() { return catchInternal(async () => { const response = await this.client.get<{ status: string }>('/health', { diff --git a/admin/inertia/pages/settings/update.tsx b/admin/inertia/pages/settings/update.tsx index ffba3ba..4ffc033 100644 --- a/admin/inertia/pages/settings/update.tsx +++ b/admin/inertia/pages/settings/update.tsx @@ -1,22 +1,20 @@ import { Head } from '@inertiajs/react' import SettingsLayout from '~/layouts/SettingsLayout' import StyledButton from '~/components/StyledButton' -import StyledTable from '~/components/StyledTable' import StyledSectionHeader from '~/components/StyledSectionHeader' -import ActiveDownloads from '~/components/ActiveDownloads' import Alert from '~/components/Alert' import { useEffect, useRef, useState } from 'react' import { IconAlertCircle, IconArrowBigUpLines, IconCheck, IconCircleCheck, IconReload } from '@tabler/icons-react' import { SystemUpdateStatus } from '../../../types/system' -import type { ContentUpdateCheckResult, ResourceUpdateInfo } from '../../../types/collections' import api from '~/lib/api' import Input from '~/components/inputs/Input' import Switch from '~/components/inputs/Switch' -import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useMutation } from '@tanstack/react-query' import { useNotifications } from '~/context/NotificationContext' import { useSystemSetting } from '~/hooks/useSystemSetting' -import { useAutoUpdateStatus } from '~/hooks/useAutoUpdateStatus' -import { formatBytes } from '~/lib/util' +import CoreAutoUpdateSection from '~/components/updates/CoreAutoUpdateSection' +import AppAutoUpdateSection from '~/components/updates/AppAutoUpdateSection' +import ContentUpdatesSection from '~/components/updates/ContentUpdatesSection' type Props = { updateAvailable: boolean @@ -42,397 +40,6 @@ const ADVANCED_STAGES: ReadonlySet = new Set([ 'complete', ]) -function ContentUpdatesSection() { - const { addNotification } = useNotifications() - const queryClient = useQueryClient() - const [checkResult, setCheckResult] = useState(null) - const [isChecking, setIsChecking] = useState(false) - const [applyingIds, setApplyingIds] = useState>(new Set()) - const [isApplyingAll, setIsApplyingAll] = useState(false) - - const handleCheck = async () => { - setIsChecking(true) - try { - const result = await api.checkForContentUpdates() - if (result) { - setCheckResult(result) - } - } catch { - setCheckResult({ - updates: [], - checked_at: new Date().toISOString(), - error: 'Failed to check for content updates', - }) - } finally { - setIsChecking(false) - } - } - - const handleApply = async (update: ResourceUpdateInfo) => { - setApplyingIds((prev) => new Set(prev).add(update.resource_id)) - try { - const result = await api.applyContentUpdate(update) - if (result?.success) { - addNotification({ type: 'success', message: `Update started for ${update.resource_id}` }) - // Remove from the updates list - setCheckResult((prev) => - prev - ? { ...prev, updates: prev.updates.filter((u) => u.resource_id !== update.resource_id) } - : prev - ) - // Force Active Downloads to refetch now — small updates finish before the next - // idle poll fires, so without this the user wouldn't see them. - queryClient.invalidateQueries({ queryKey: ['download-jobs'] }) - } else { - addNotification({ type: 'error', message: result?.error || 'Failed to start update' }) - } - } catch { - addNotification({ type: 'error', message: `Failed to start update for ${update.resource_id}` }) - } finally { - setApplyingIds((prev) => { - const next = new Set(prev) - next.delete(update.resource_id) - return next - }) - } - } - - const handleApplyAll = async () => { - if (!checkResult?.updates.length) return - setIsApplyingAll(true) - try { - const result = await api.applyAllContentUpdates(checkResult.updates) - if (result?.results) { - const succeeded = result.results.filter((r) => r.success).length - const failed = result.results.filter((r) => !r.success).length - if (succeeded > 0) { - addNotification({ type: 'success', message: `Started ${succeeded} update(s)` }) - } - if (failed > 0) { - addNotification({ type: 'error', message: `${failed} update(s) could not be started` }) - } - // Remove successful updates from the list - const successIds = new Set(result.results.filter((r) => r.success).map((r) => r.resource_id)) - setCheckResult((prev) => - prev - ? { ...prev, updates: prev.updates.filter((u) => !successIds.has(u.resource_id)) } - : prev - ) - if (successIds.size > 0) { - queryClient.invalidateQueries({ queryKey: ['download-jobs'] }) - } - } - } catch { - addNotification({ type: 'error', message: 'Failed to apply updates' }) - } finally { - setIsApplyingAll(false) - } - } - - return ( -
- - -
-
-

- Check if newer versions of your installed ZIM files and maps are available. -

- - Check for Content Updates - -
- - {checkResult?.error && ( - - )} - - {checkResult && !checkResult.error && checkResult.updates.length === 0 && ( - - )} - - {checkResult && checkResult.updates.length > 0 && ( -
-
-

- {checkResult.updates.length} update(s) available -

- - Update All ({checkResult.updates.length}) - -
- ( - {record.resource_id} - ), - }, - { - accessor: 'resource_type', - title: 'Type', - render: (record) => ( - - {record.resource_type === 'zim' ? 'ZIM' : 'Map'} - - ), - }, - { - accessor: 'size_bytes', - title: 'Size', - render: (record) => ( - - {record.size_bytes ? formatBytes(record.size_bytes, 1) : '—'} - - ), - }, - { - accessor: 'installed_version', - title: 'Version', - render: (record) => ( - - {record.installed_version} → {record.latest_version} - - ), - }, - { - accessor: 'resource_id', - title: '', - render: (record) => ( - handleApply(record)} - loading={applyingIds.has(record.resource_id)} - > - Update - - ), - }, - ]} - /> -
- )} - - {checkResult?.checked_at && ( -

- Last checked: {new Date(checkResult.checked_at).toLocaleString()} -

- )} -
- - -
- ) -} - -const COOLOFF_OPTIONS = [ - { value: 24, label: '24 hours (1 day)' }, - { value: 48, label: '48 hours (2 days)' }, - { value: 72, label: '72 hours (3 days)' }, - { value: 168, label: '7 days' }, -] - -function AutoUpdateSection() { - const { addNotification } = useNotifications() - const queryClient = useQueryClient() - const { data: status, isLoading } = useAutoUpdateStatus() - - const [windowStart, setWindowStart] = useState('02:00') - const [windowEnd, setWindowEnd] = useState('05:00') - const [cooloff, setCooloff] = useState(72) - - // Seed editable fields once the persisted status loads. - useEffect(() => { - if (status) { - setWindowStart(status.windowStart) - setWindowEnd(status.windowEnd) - setCooloff(status.cooloffHours) - } - }, [status?.windowStart, status?.windowEnd, status?.cooloffHours]) - - const saveMutation = useMutation({ - mutationFn: ({ key, value }: { key: string; value: any }) => api.updateSetting(key, value), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['auto-update-status'] }) - }, - onError: () => { - addNotification({ type: 'error', message: 'Failed to update auto-update setting.' }) - }, - }) - - const enabled = status?.enabled ?? false - const autoDisabled = !!status?.autoDisabledReason - - const handleToggle = (value: boolean) => { - saveMutation.mutate( - { key: 'autoUpdate.enabled', value }, - { - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['auto-update-status'] }) - addNotification({ - type: 'success', - message: value ? 'Automatic updates enabled.' : 'Automatic updates disabled.', - }) - }, - } - ) - } - - const handleSaveWindow = async () => { - try { - await api.updateSetting('autoUpdate.windowStart', windowStart) - await api.updateSetting('autoUpdate.windowEnd', windowEnd) - await api.updateSetting('autoUpdate.cooloffHours', String(cooloff)) - queryClient.invalidateQueries({ queryKey: ['auto-update-status'] }) - addNotification({ type: 'success', message: 'Auto-update schedule saved.' }) - } catch { - addNotification({ type: 'error', message: 'Failed to save auto-update schedule.' }) - } - } - - return ( - <> - -
- {autoDisabled && ( - - )} - - - -
- setWindowStart(e.target.value)} - disabled={!enabled} - helpText="Local server time" - /> - setWindowEnd(e.target.value)} - disabled={!enabled} - helpText="Local server time" - /> -
- -

Delay after a release is published

- -
-
- -
- - Save Schedule - -
- - {enabled && status && ( -
-

- Status: - {status.eligibleTarget - ? `Eligible update ready: ${status.eligibleTarget.version}` - : 'No eligible update — system is current or the latest release is a major version / still in cool-off.'} -

-

- Update window: - {status.withinWindow ? 'Currently inside the window' : 'Currently outside the window'} -

- {status.lastResult && ( -

- Last check: - {status.lastResult} - {status.lastAttemptAt - ? ` (${new Date(status.lastAttemptAt).toLocaleString()})` - : ''} -

- )} - {status.lastError && ( -

- Last error: - {status.lastError} -

- )} -
- )} -
- - ) -} - export default function SystemUpdatePage(props: { system: Props }) { const { addNotification } = useNotifications() @@ -844,7 +451,8 @@ export default function SystemUpdatePage(props: { system: Props }) { description="Receive release candidate (RC) versions before they are officially released. Note: RC versions may contain bugs and are not recommended for environments where stability and data integrity are critical." /> - + +
diff --git a/admin/inertia/pages/supply-depot.tsx b/admin/inertia/pages/supply-depot.tsx index 887a7fa..6e498c1 100644 --- a/admin/inertia/pages/supply-depot.tsx +++ b/admin/inertia/pages/supply-depot.tsx @@ -1,4 +1,4 @@ -import { Head } from '@inertiajs/react' +import { Head, router } from '@inertiajs/react' import { useEffect, useRef, useState } from 'react' import { IconAlertTriangle, @@ -7,6 +7,7 @@ import { IconBox, IconBrandDocker, IconChartBar, + IconClockBolt, IconCloudDownload, IconFileText, IconPackage, @@ -30,7 +31,9 @@ import ServiceStatsModal from '~/components/ServiceStatsModal' import StyledSectionHeader from '~/components/StyledSectionHeader' import UpdateServiceModal from '~/components/UpdateServiceModal' import useErrorNotification from '~/hooks/useErrorNotification' +import { useNotifications } from '~/context/NotificationContext' import useInternetStatus from '~/hooks/useInternetStatus' +import { useAppAutoUpdateStatus } from '~/hooks/useAppAutoUpdateStatus' import useServiceInstallationActivity from '~/hooks/useServiceInstallationActivity' import { useTransmit } from 'react-adonis-transmit' import { BROADCAST_CHANNELS } from '../../constants/broadcast' @@ -84,9 +87,14 @@ type Modal = export default function SupplyDepotPage(props: { system: { services: ServiceSlim[] } }) { const { showError } = useErrorNotification() + const { addNotification } = useNotifications() const { isOnline } = useInternetStatus() const { subscribe } = useTransmit() const installActivity = useServiceInstallationActivity() + // Global master switch for app auto-updates (Settings → Updates). Per-app + // toggles are inert until this is on, so the UI reflects that state. + const { data: appAutoUpdateStatus } = useAppAutoUpdateStatus() + const appAutoUpdateMasterEnabled = appAutoUpdateStatus?.enabled ?? false const [activeCategory, setActiveCategory] = useState('all') const [search, setSearch] = useState('') @@ -97,6 +105,9 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim const [customAppOpen, setCustomAppOpen] = useState(false) const [editApp, setEditApp] = useState(null) const [removeImage, setRemoveImage] = useState(false) + // Optimistic per-app auto-update toggle state, keyed by service_name. Lets the + // toggle reflect instantly without a full page reload (props come from Inertia). + const [autoUpdateOverrides, setAutoUpdateOverrides] = useState>({}) // Preflight state — scoped to the current install modal const [preflight, setPreflight] = useState<{ @@ -253,6 +264,25 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim if (!result?.success) showError(result?.message || 'Failed to update service.') } + // Toggle per-app automatic updates (opt-in). Optimistically reflects the new + // state, reverting if the request fails. Gated by the global master switch in + // Settings → Updates; this only sets the per-app preference. + async function handleToggleAutoUpdate(service: ServiceSlim, enabled: boolean) { + setOpenDropdown(null) + setAutoUpdateOverrides((prev) => ({ ...prev, [service.service_name]: enabled })) + const result = await api.setServiceAutoUpdate(service.service_name, enabled) + if (!result?.success) { + setAutoUpdateOverrides((prev) => ({ ...prev, [service.service_name]: !enabled })) + showError(result?.message || 'Failed to update auto-update preference.') + return + } + const appName = service.friendly_name || service.service_name + addNotification({ + message: `Auto-updates for ${appName} are ${enabled ? 'on' : 'off'}.`, + type: 'success', + }) + } + function handleCustomAppCreated() { setCustomAppOpen(false) // Page will reload when installation completes via broadcast @@ -410,6 +440,11 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim onEdit={() => handleEdit(service)} onUpdate={() => handleUpdate(service)} onUpdateVersion={() => setModal({ type: 'update', service })} + autoUpdateEnabled={ + autoUpdateOverrides[service.service_name] ?? service.auto_update_enabled + } + autoUpdateMasterEnabled={appAutoUpdateMasterEnabled} + onToggleAutoUpdate={(enabled) => handleToggleAutoUpdate(service, enabled)} /> ))}
@@ -682,6 +717,11 @@ interface AppCardProps { onEdit: () => void onUpdate: () => void onUpdateVersion: () => void + // Installed-only: per-app auto-update preference + toggle handler. + autoUpdateEnabled?: boolean + // Global master switch (Settings → Updates). When off, per-app toggles are inert. + autoUpdateMasterEnabled?: boolean + onToggleAutoUpdate?: (enabled: boolean) => void } function AppCard({ @@ -700,6 +740,9 @@ function AppCard({ onEdit, onUpdate, onUpdateVersion, + autoUpdateEnabled, + autoUpdateMasterEnabled, + onToggleAutoUpdate, }: AppCardProps) { const isRunning = service.status === 'running' const isStopped = service.installed && !isRunning @@ -879,6 +922,25 @@ function AppCard({ } label="Logs" onClick={onLogs} /> } label="Stats" onClick={onStats} /> } label="Edit" onClick={onEdit} /> + {!service.is_custom && onToggleAutoUpdate ? ( + autoUpdateMasterEnabled ? ( + + } + label={`Auto-update: ${autoUpdateEnabled ? 'On' : 'Off'}`} + onClick={() => onToggleAutoUpdate(!autoUpdateEnabled)} + /> + ) : ( + } + label="App auto-updates off — open Settings" + onClick={() => router.visit('/settings/update')} + /> + ) + ) : null} {service.available_update_version && !service.is_custom ? ( } diff --git a/admin/start/routes.ts b/admin/start/routes.ts index 0379b98..8af8966 100644 --- a/admin/start/routes.ts +++ b/admin/start/routes.ts @@ -182,6 +182,8 @@ router router.get('/services/:name/stats', [SystemController, 'getServiceStats']) router.get('/services/:name/available-versions', [SystemController, 'getAvailableVersions']) router.post('/services/update', [SystemController, 'updateService']) + router.post('/services/auto-update', [SystemController, 'setServiceAutoUpdate']) + router.get('/apps/auto-update/status', [SystemController, 'getAppAutoUpdateStatus']) router.post('/subscribe-release-notes', [SystemController, 'subscribeToReleaseNotes']) router.get('/latest-version', [SystemController, 'checkLatestVersion']) router.post('/update', [SystemController, 'requestSystemUpdate']) diff --git a/admin/tests/unit/app_auto_update.spec.ts b/admin/tests/unit/app_auto_update.spec.ts new file mode 100644 index 0000000..ed99e8a --- /dev/null +++ b/admin/tests/unit/app_auto_update.spec.ts @@ -0,0 +1,143 @@ +import * as assert from 'node:assert/strict' +import { test } from 'node:test' +import { DateTime } from 'luxon' + +import { AppAutoUpdateService } from '../../app/services/app_auto_update_service.js' +import { ContainerRegistryService } from '../../app/services/container_registry_service.js' +import { isWithinWindow } from '../../app/utils/update_window.js' + +// appEligibility only touches ContainerRegistryService.parseImageReference (pure, +// offline), so the other constructor deps are irrelevant for these tests. +const svc = new AppAutoUpdateService( + null as any, + null as any, + null as any, + new ContainerRegistryService() +) + +const NOW = DateTime.fromISO('2026-06-04T12:00:00Z') +const daysAgo = (d: number) => NOW.minus({ days: d }) +const hoursAgo = (h: number) => NOW.minus({ hours: h }) + +function makeService(overrides: Record = {}) { + return { + service_name: 'nomad_test', + container_image: 'ollama/ollama:0.18.1', + available_update_version: null, + available_update_first_seen_at: null, + auto_update_disabled_reason: null, + auto_update_enabled: true, + installed: true, + ...overrides, + } as any +} + +// ── Per-app eligibility ─────────────────────────────────────────────────────── + +test('no available update → not eligible (up to date)', () => { + const v = svc.appEligibility(makeService(), 72, NOW) + assert.equal(v.eligible, false) +}) + +test('major bump is never auto-eligible (manual update required)', () => { + const v = svc.appEligibility( + makeService({ available_update_version: '1.0.0', available_update_first_seen_at: daysAgo(10) }), + 72, + NOW + ) + assert.equal(v.eligible, false) + assert.match(v.reason, /Major version/) +}) + +test('same-major newer but still inside cool-off → not eligible', () => { + const v = svc.appEligibility( + makeService({ + available_update_version: '0.19.0', + available_update_first_seen_at: hoursAgo(10), + }), + 72, + NOW + ) + assert.equal(v.eligible, false) + assert.match(v.reason, /cool-off/i) +}) + +test('same-major newer and past cool-off → eligible', () => { + const v = svc.appEligibility( + makeService({ available_update_version: '0.19.0', available_update_first_seen_at: daysAgo(5) }), + 72, + NOW + ) + assert.equal(v.eligible, true) +}) + +test('null first-seen → not eligible (cool-off pending)', () => { + const v = svc.appEligibility( + makeService({ available_update_version: '0.19.0', available_update_first_seen_at: null }), + 72, + NOW + ) + assert.equal(v.eligible, false) +}) + +test('self-disabled app → not eligible regardless of cool-off', () => { + const v = svc.appEligibility( + makeService({ + available_update_version: '0.19.0', + available_update_first_seen_at: daysAgo(30), + auto_update_disabled_reason: 'Auto-update disabled after 3 consecutive failures.', + }), + 72, + NOW + ) + assert.equal(v.eligible, false) +}) + +test(':latest-pinned app cannot be version-checked → not eligible', () => { + const v = svc.appEligibility( + makeService({ + container_image: 'foo/bar:latest', + available_update_version: '1.2.3', + available_update_first_seen_at: daysAgo(30), + }), + 72, + NOW + ) + assert.equal(v.eligible, false) +}) + +test('available equal to current (not newer) → not eligible', () => { + const v = svc.appEligibility( + makeService({ + available_update_version: '0.18.1', + available_update_first_seen_at: daysAgo(30), + }), + 72, + NOW + ) + assert.equal(v.eligible, false) +}) + +test('cool-off of 0 applies immediately', () => { + const v = svc.appEligibility( + makeService({ + available_update_version: '0.18.2', + available_update_first_seen_at: hoursAgo(1), + }), + 0, + NOW + ) + assert.equal(v.eligible, true) +}) + +// ── Shared update window (reused from the core auto-update) ──────────────────── + +test('window: normal 20:00-23:00 includes 21:00, excludes 19:00', () => { + assert.equal(isWithinWindow('20:00', '23:00', DateTime.fromISO('2026-06-04T21:00:00')), true) + assert.equal(isWithinWindow('20:00', '23:00', DateTime.fromISO('2026-06-04T19:00:00')), false) +}) + +test('window: midnight-wrapping 22:00-02:00 includes 01:00, excludes 12:00', () => { + assert.equal(isWithinWindow('22:00', '02:00', DateTime.fromISO('2026-06-04T01:00:00')), true) + assert.equal(isWithinWindow('22:00', '02:00', DateTime.fromISO('2026-06-04T12:00:00')), false) +}) diff --git a/admin/types/kv_store.ts b/admin/types/kv_store.ts index 4193f57..245bd8e 100644 --- a/admin/types/kv_store.ts +++ b/admin/types/kv_store.ts @@ -16,6 +16,9 @@ export const KV_STORE_SCHEMA = { 'autoUpdate.lastResult': 'string', 'autoUpdate.consecutiveFailures': 'string', 'autoUpdate.autoDisabledReason': 'string', + 'appAutoUpdate.enabled': 'boolean', + 'appAutoUpdate.lastAttemptAt': 'string', + 'appAutoUpdate.lastResult': 'string', 'ui.hasVisitedEasySetup': 'boolean', 'ui.theme': 'string', 'ai.assistantCustomName': 'string', diff --git a/admin/types/services.ts b/admin/types/services.ts index 526af20..e9ccdc3 100644 --- a/admin/types/services.ts +++ b/admin/types/services.ts @@ -14,6 +14,7 @@ export type ServiceSlim = Pick< | 'display_order' | 'container_image' | 'available_update_version' + | 'auto_update_enabled' | 'is_custom' | 'is_user_modified' | 'category' diff --git a/admin/types/system.ts b/admin/types/system.ts index 265ea36..cc27eba 100644 --- a/admin/types/system.ts +++ b/admin/types/system.ts @@ -106,4 +106,29 @@ export type AutoUpdateStatus = { lastError: string | null consecutiveFailures: number autoDisabledReason: string | null +} + +export type AppAutoUpdateAppStatus = { + service_name: string + friendly_name: string | null + auto_update_enabled: boolean + current_version: string + available_update_version: string | null + first_seen_at: string | null + eligible: boolean + reason: string + cooloff_remaining_hours: number | null + consecutive_failures: number + auto_disabled_reason: string | null +} + +export type AppAutoUpdateStatus = { + enabled: boolean + windowStart: string + windowEnd: string + cooloffHours: number + withinWindow: boolean + lastAttemptAt: string | null + lastResult: string | null + apps: AppAutoUpdateAppStatus[] } \ No newline at end of file