feat(supply-depot): opt-in automatic updates for installed apps
This commit is contained in:
parent
02d985db5e
commit
cf8db6218d
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AppAutoUpdateConfig> {
|
||||
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<AppUpdateTarget[]> {
|
||||
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<PreflightResult> {
|
||||
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<PreflightResult> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<AppAutoUpdateStatus> {
|
||||
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<string> {
|
||||
try {
|
||||
const info = await this.dockerService.docker.info()
|
||||
const arch = info.Architecture || ''
|
||||
const archMap: Record<string, string> = {
|
||||
x86_64: 'amd64',
|
||||
aarch64: 'arm64',
|
||||
armv7l: 'arm',
|
||||
amd64: 'amd64',
|
||||
arm64: 'arm64',
|
||||
}
|
||||
return archMap[arch] || arch.toLowerCase()
|
||||
} catch {
|
||||
return 'amd64'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Blocker | null> {
|
||||
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<number | null> {
|
||||
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/...). */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<number | null> {
|
||||
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<Blocker | null> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<string, any>) => ({
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
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'];
|
||||
|
|
@ -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')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
<StyledSectionHeader title="Automatic App Updates" className="mt-8" />
|
||||
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden mt-6 p-6">
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={(value) => 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 && (
|
||||
<div className="mt-6 pt-4 border-t border-desert-stone-light text-sm">
|
||||
<p className="text-desert-stone mb-3">
|
||||
<span className="font-medium">Update window: </span>
|
||||
{status.windowStart}–{status.windowEnd} (
|
||||
{status.withinWindow ? 'currently inside' : 'currently outside'}); cool-off{' '}
|
||||
{status.cooloffHours}h.
|
||||
{status.lastResult && (
|
||||
<>
|
||||
{' '}
|
||||
<span className="font-medium">Last run: </span>
|
||||
{status.lastResult}
|
||||
{status.lastAttemptAt
|
||||
? ` (${new Date(status.lastAttemptAt).toLocaleString()})`
|
||||
: ''}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{status.apps.length === 0 ? (
|
||||
<p className="text-desert-stone-dark">
|
||||
No apps are opted in yet. Enable auto-update on individual apps from the Supply Depot.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{status.apps.map((app) => (
|
||||
<li
|
||||
key={app.service_name}
|
||||
className="flex items-start justify-between gap-4 rounded-md bg-surface-secondary px-3 py-2"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-text-primary">
|
||||
{app.friendly_name || app.service_name}
|
||||
</p>
|
||||
<p className="text-desert-stone">
|
||||
{app.current_version}
|
||||
{app.available_update_version
|
||||
? ` → ${app.available_update_version}`
|
||||
: ' (up to date)'}
|
||||
</p>
|
||||
{app.auto_disabled_reason && (
|
||||
<p className="text-desert-red mt-0.5">{app.auto_disabled_reason}</p>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 text-xs font-medium ${
|
||||
app.eligible ? 'text-desert-green' : 'text-desert-stone'
|
||||
}`}
|
||||
>
|
||||
{app.reason}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<ContentUpdateCheckResult | null>(null)
|
||||
const [isChecking, setIsChecking] = useState(false)
|
||||
const [applyingIds, setApplyingIds] = useState<Set<string>>(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 (
|
||||
<div className="mt-8">
|
||||
<StyledSectionHeader title="Content Updates" />
|
||||
|
||||
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-desert-stone-dark">
|
||||
Check if newer versions of your installed ZIM files and maps are available.
|
||||
</p>
|
||||
<StyledButton
|
||||
variant="primary"
|
||||
icon="IconRefresh"
|
||||
onClick={handleCheck}
|
||||
loading={isChecking}
|
||||
>
|
||||
Check for Content Updates
|
||||
</StyledButton>
|
||||
</div>
|
||||
|
||||
{checkResult?.error && (
|
||||
<Alert
|
||||
type="warning"
|
||||
title="Update Check Issue"
|
||||
message={checkResult.error}
|
||||
variant="bordered"
|
||||
className="my-4"
|
||||
/>
|
||||
)}
|
||||
|
||||
{checkResult && !checkResult.error && checkResult.updates.length === 0 && (
|
||||
<Alert
|
||||
type="success"
|
||||
title="All Content Up to Date"
|
||||
message="All your installed content is running the latest available version."
|
||||
variant="bordered"
|
||||
className="my-4"
|
||||
/>
|
||||
)}
|
||||
|
||||
{checkResult && checkResult.updates.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-sm text-desert-stone-dark">
|
||||
{checkResult.updates.length} update(s) available
|
||||
</p>
|
||||
<StyledButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon="IconDownload"
|
||||
onClick={handleApplyAll}
|
||||
loading={isApplyingAll}
|
||||
>
|
||||
Update All ({checkResult.updates.length})
|
||||
</StyledButton>
|
||||
</div>
|
||||
<StyledTable
|
||||
data={checkResult.updates}
|
||||
columns={[
|
||||
{
|
||||
accessor: 'resource_id',
|
||||
title: 'Title',
|
||||
render: (record) => (
|
||||
<span className="font-medium text-desert-green">{record.resource_id}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessor: 'resource_type',
|
||||
title: 'Type',
|
||||
render: (record) => (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${record.resource_type === 'zim'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-emerald-100 text-emerald-800'
|
||||
}`}
|
||||
>
|
||||
{record.resource_type === 'zim' ? 'ZIM' : 'Map'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessor: 'size_bytes',
|
||||
title: 'Size',
|
||||
render: (record) => (
|
||||
<span className="text-desert-stone-dark">
|
||||
{record.size_bytes ? formatBytes(record.size_bytes, 1) : '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessor: 'installed_version',
|
||||
title: 'Version',
|
||||
render: (record) => (
|
||||
<span className="text-desert-stone-dark">
|
||||
{record.installed_version} → {record.latest_version}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessor: 'resource_id',
|
||||
title: '',
|
||||
render: (record) => (
|
||||
<StyledButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="IconDownload"
|
||||
onClick={() => handleApply(record)}
|
||||
loading={applyingIds.has(record.resource_id)}
|
||||
>
|
||||
Update
|
||||
</StyledButton>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkResult?.checked_at && (
|
||||
<p className="text-xs text-desert-stone mt-3">
|
||||
Last checked: {new Date(checkResult.checked_at).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ActiveDownloads withHeader />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<>
|
||||
<StyledSectionHeader title="Automatic Core Updates" className="mt-8" />
|
||||
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden mt-6 p-6">
|
||||
{autoDisabled && (
|
||||
<Alert
|
||||
type="warning"
|
||||
title="Automatic Core Updates Disabled"
|
||||
message={status?.autoDisabledReason || 'Automatic core updates were disabled after repeated failures.'}
|
||||
variant="bordered"
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={handleToggle}
|
||||
disabled={saveMutation.isPending || isLoading}
|
||||
label="Enable Automatic Core Updates"
|
||||
description="Automatically install minor and patch updates during your chosen window. Major versions always require a manual update due to their potentially breaking nature."
|
||||
/>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Input
|
||||
name="autoUpdateWindowStart"
|
||||
label="Window Start"
|
||||
type="time"
|
||||
value={windowStart}
|
||||
onChange={(e) => setWindowStart(e.target.value)}
|
||||
disabled={!enabled}
|
||||
helpText="Local server time"
|
||||
/>
|
||||
<Input
|
||||
name="autoUpdateWindowEnd"
|
||||
label="Window End"
|
||||
type="time"
|
||||
value={windowEnd}
|
||||
onChange={(e) => setWindowEnd(e.target.value)}
|
||||
disabled={!enabled}
|
||||
helpText="Local server time"
|
||||
/>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="autoUpdateCooloff"
|
||||
className="block text-base/6 font-medium text-text-primary"
|
||||
>
|
||||
Cool-off Period
|
||||
</label>
|
||||
<p className="mt-1 text-sm text-text-muted">Delay after a release is published</p>
|
||||
<select
|
||||
id="autoUpdateCooloff"
|
||||
value={cooloff}
|
||||
onChange={(e) => setCooloff(Number(e.target.value))}
|
||||
disabled={!enabled}
|
||||
className="mt-1.5 block w-full rounded-md bg-surface-primary px-3 py-2 text-base text-text-primary border border-border-default focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-primary sm:text-sm/6 disabled:opacity-50"
|
||||
>
|
||||
{COOLOFF_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<StyledButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSaveWindow}
|
||||
disabled={!enabled}
|
||||
>
|
||||
Save Schedule
|
||||
</StyledButton>
|
||||
</div>
|
||||
|
||||
{enabled && status && (
|
||||
<div className="mt-6 pt-4 border-t border-desert-stone-light text-sm space-y-1">
|
||||
<p className="text-desert-stone-dark">
|
||||
<span className="font-medium">Status: </span>
|
||||
{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.'}
|
||||
</p>
|
||||
<p className="text-desert-stone">
|
||||
<span className="font-medium">Update window: </span>
|
||||
{status.withinWindow ? 'Currently inside the window' : 'Currently outside the window'}
|
||||
</p>
|
||||
{status.lastResult && (
|
||||
<p className="text-desert-stone">
|
||||
<span className="font-medium">Last check: </span>
|
||||
{status.lastResult}
|
||||
{status.lastAttemptAt
|
||||
? ` (${new Date(status.lastAttemptAt).toLocaleString()})`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
{status.lastError && (
|
||||
<p className="text-desert-red">
|
||||
<span className="font-medium">Last error: </span>
|
||||
{status.lastError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<AppAutoUpdateStatus | undefined>({
|
||||
queryKey: ['app-auto-update-status'],
|
||||
queryFn: () => api.getAppAutoUpdateStatus(),
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
}
|
||||
|
|
@ -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<AppAutoUpdateStatus>('/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', {
|
||||
|
|
|
|||
|
|
@ -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<SystemUpdateStatus['stage']> = new Set([
|
|||
'complete',
|
||||
])
|
||||
|
||||
function ContentUpdatesSection() {
|
||||
const { addNotification } = useNotifications()
|
||||
const queryClient = useQueryClient()
|
||||
const [checkResult, setCheckResult] = useState<ContentUpdateCheckResult | null>(null)
|
||||
const [isChecking, setIsChecking] = useState(false)
|
||||
const [applyingIds, setApplyingIds] = useState<Set<string>>(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 (
|
||||
<div className="mt-8">
|
||||
<StyledSectionHeader title="Content Updates" />
|
||||
|
||||
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-desert-stone-dark">
|
||||
Check if newer versions of your installed ZIM files and maps are available.
|
||||
</p>
|
||||
<StyledButton
|
||||
variant="primary"
|
||||
icon="IconRefresh"
|
||||
onClick={handleCheck}
|
||||
loading={isChecking}
|
||||
>
|
||||
Check for Content Updates
|
||||
</StyledButton>
|
||||
</div>
|
||||
|
||||
{checkResult?.error && (
|
||||
<Alert
|
||||
type="warning"
|
||||
title="Update Check Issue"
|
||||
message={checkResult.error}
|
||||
variant="bordered"
|
||||
className="my-4"
|
||||
/>
|
||||
)}
|
||||
|
||||
{checkResult && !checkResult.error && checkResult.updates.length === 0 && (
|
||||
<Alert
|
||||
type="success"
|
||||
title="All Content Up to Date"
|
||||
message="All your installed content is running the latest available version."
|
||||
variant="bordered"
|
||||
className="my-4"
|
||||
/>
|
||||
)}
|
||||
|
||||
{checkResult && checkResult.updates.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-sm text-desert-stone-dark">
|
||||
{checkResult.updates.length} update(s) available
|
||||
</p>
|
||||
<StyledButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon="IconDownload"
|
||||
onClick={handleApplyAll}
|
||||
loading={isApplyingAll}
|
||||
>
|
||||
Update All ({checkResult.updates.length})
|
||||
</StyledButton>
|
||||
</div>
|
||||
<StyledTable
|
||||
data={checkResult.updates}
|
||||
columns={[
|
||||
{
|
||||
accessor: 'resource_id',
|
||||
title: 'Title',
|
||||
render: (record) => (
|
||||
<span className="font-medium text-desert-green">{record.resource_id}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessor: 'resource_type',
|
||||
title: 'Type',
|
||||
render: (record) => (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${record.resource_type === 'zim'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-emerald-100 text-emerald-800'
|
||||
}`}
|
||||
>
|
||||
{record.resource_type === 'zim' ? 'ZIM' : 'Map'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessor: 'size_bytes',
|
||||
title: 'Size',
|
||||
render: (record) => (
|
||||
<span className="text-desert-stone-dark">
|
||||
{record.size_bytes ? formatBytes(record.size_bytes, 1) : '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessor: 'installed_version',
|
||||
title: 'Version',
|
||||
render: (record) => (
|
||||
<span className="text-desert-stone-dark">
|
||||
{record.installed_version} → {record.latest_version}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessor: 'resource_id',
|
||||
title: '',
|
||||
render: (record) => (
|
||||
<StyledButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="IconDownload"
|
||||
onClick={() => handleApply(record)}
|
||||
loading={applyingIds.has(record.resource_id)}
|
||||
>
|
||||
Update
|
||||
</StyledButton>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkResult?.checked_at && (
|
||||
<p className="text-xs text-desert-stone mt-3">
|
||||
Last checked: {new Date(checkResult.checked_at).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ActiveDownloads withHeader />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<StyledSectionHeader title="Automatic Updates" className="mt-8" />
|
||||
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden mt-6 p-6">
|
||||
{autoDisabled && (
|
||||
<Alert
|
||||
type="warning"
|
||||
title="Automatic Updates Disabled"
|
||||
message={status?.autoDisabledReason || 'Auto-update was disabled after repeated failures.'}
|
||||
variant="bordered"
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={handleToggle}
|
||||
disabled={saveMutation.isPending || isLoading}
|
||||
label="Enable Automatic Updates"
|
||||
description="Automatically install minor and patch updates during your chosen window. Major versions always require a manual update due to their potentially breaking nature."
|
||||
/>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Input
|
||||
name="autoUpdateWindowStart"
|
||||
label="Window Start"
|
||||
type="time"
|
||||
value={windowStart}
|
||||
onChange={(e) => setWindowStart(e.target.value)}
|
||||
disabled={!enabled}
|
||||
helpText="Local server time"
|
||||
/>
|
||||
<Input
|
||||
name="autoUpdateWindowEnd"
|
||||
label="Window End"
|
||||
type="time"
|
||||
value={windowEnd}
|
||||
onChange={(e) => setWindowEnd(e.target.value)}
|
||||
disabled={!enabled}
|
||||
helpText="Local server time"
|
||||
/>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="autoUpdateCooloff"
|
||||
className="block text-base/6 font-medium text-text-primary"
|
||||
>
|
||||
Cool-off Period
|
||||
</label>
|
||||
<p className="mt-1 text-sm text-text-muted">Delay after a release is published</p>
|
||||
<select
|
||||
id="autoUpdateCooloff"
|
||||
value={cooloff}
|
||||
onChange={(e) => setCooloff(Number(e.target.value))}
|
||||
disabled={!enabled}
|
||||
className="mt-1.5 block w-full rounded-md bg-surface-primary px-3 py-2 text-base text-text-primary border border-border-default focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-primary sm:text-sm/6 disabled:opacity-50"
|
||||
>
|
||||
{COOLOFF_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<StyledButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSaveWindow}
|
||||
disabled={!enabled}
|
||||
>
|
||||
Save Schedule
|
||||
</StyledButton>
|
||||
</div>
|
||||
|
||||
{enabled && status && (
|
||||
<div className="mt-6 pt-4 border-t border-desert-stone-light text-sm space-y-1">
|
||||
<p className="text-desert-stone-dark">
|
||||
<span className="font-medium">Status: </span>
|
||||
{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.'}
|
||||
</p>
|
||||
<p className="text-desert-stone">
|
||||
<span className="font-medium">Update window: </span>
|
||||
{status.withinWindow ? 'Currently inside the window' : 'Currently outside the window'}
|
||||
</p>
|
||||
{status.lastResult && (
|
||||
<p className="text-desert-stone">
|
||||
<span className="font-medium">Last check: </span>
|
||||
{status.lastResult}
|
||||
{status.lastAttemptAt
|
||||
? ` (${new Date(status.lastAttemptAt).toLocaleString()})`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
{status.lastError && (
|
||||
<p className="text-desert-red">
|
||||
<span className="font-medium">Last error: </span>
|
||||
{status.lastError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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."
|
||||
/>
|
||||
</div>
|
||||
<AutoUpdateSection />
|
||||
<CoreAutoUpdateSection />
|
||||
<AppAutoUpdateSection />
|
||||
<ContentUpdatesSection />
|
||||
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden py-6 mt-12">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center p-8 gap-y-8 md:gap-y-0 gap-x-8">
|
||||
|
|
|
|||
|
|
@ -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<CustomAppInitial | null>(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<Record<string, boolean>>({})
|
||||
|
||||
// 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)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -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({
|
|||
<DropdownItem icon={<IconFileText className="h-4 w-4" />} label="Logs" onClick={onLogs} />
|
||||
<DropdownItem icon={<IconChartBar className="h-4 w-4" />} label="Stats" onClick={onStats} />
|
||||
<DropdownItem icon={<IconPencil className="h-4 w-4" />} label="Edit" onClick={onEdit} />
|
||||
{!service.is_custom && onToggleAutoUpdate ? (
|
||||
autoUpdateMasterEnabled ? (
|
||||
<DropdownItem
|
||||
icon={
|
||||
<IconClockBolt
|
||||
className={`h-4 w-4 ${autoUpdateEnabled ? 'text-desert-green' : ''}`}
|
||||
/>
|
||||
}
|
||||
label={`Auto-update: ${autoUpdateEnabled ? 'On' : 'Off'}`}
|
||||
onClick={() => onToggleAutoUpdate(!autoUpdateEnabled)}
|
||||
/>
|
||||
) : (
|
||||
<DropdownItem
|
||||
icon={<IconClockBolt className="h-4 w-4" />}
|
||||
label="App auto-updates off — open Settings"
|
||||
onClick={() => router.visit('/settings/update')}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
{service.available_update_version && !service.is_custom ? (
|
||||
<DropdownItem
|
||||
icon={<IconArrowUp className="h-4 w-4 text-desert-green" />}
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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<string, any> = {}) {
|
||||
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)
|
||||
})
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export type ServiceSlim = Pick<
|
|||
| 'display_order'
|
||||
| 'container_image'
|
||||
| 'available_update_version'
|
||||
| 'auto_update_enabled'
|
||||
| 'is_custom'
|
||||
| 'is_user_modified'
|
||||
| 'category'
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
}
|
||||
Loading…
Reference in New Issue