From 2e0ab10075cdef85bc58c8bdbe7fc95268af5fdc Mon Sep 17 00:00:00 2001 From: Jake Turner Date: Fri, 6 Feb 2026 15:40:03 -0800 Subject: [PATCH] feat: cron job for system update checks --- admin/app/controllers/system_controller.ts | 7 +- admin/app/jobs/check_update_job.ts | 77 ++++++++++++++++++++ admin/app/services/system_service.ts | 27 +++++-- admin/app/validators/system.ts | 6 ++ admin/commands/queue/work.ts | 7 ++ admin/inertia/components/Alert.tsx | 43 +++++++---- admin/inertia/components/AlertWithButton.tsx | 16 ---- admin/inertia/hooks/useUpdateAvailable.ts | 15 ++++ admin/inertia/lib/api.ts | 11 ++- admin/inertia/pages/home.tsx | 23 ++++++ admin/inertia/pages/maps.tsx | 4 +- admin/inertia/pages/settings/maps.tsx | 4 +- admin/types/kv_store.ts | 2 +- admin/types/system.ts | 9 +++ 14 files changed, 205 insertions(+), 46 deletions(-) create mode 100644 admin/app/jobs/check_update_job.ts delete mode 100644 admin/inertia/components/AlertWithButton.tsx create mode 100644 admin/inertia/hooks/useUpdateAvailable.ts diff --git a/admin/app/controllers/system_controller.ts b/admin/app/controllers/system_controller.ts index bb76858..7907f40 100644 --- a/admin/app/controllers/system_controller.ts +++ b/admin/app/controllers/system_controller.ts @@ -1,7 +1,7 @@ import { DockerService } from '#services/docker_service'; import { SystemService } from '#services/system_service' import { SystemUpdateService } from '#services/system_update_service' -import { affectServiceValidator, installServiceValidator, subscribeToReleaseNotesValidator } from '#validators/system'; +import { affectServiceValidator, checkLatestVersionValidator, installServiceValidator, subscribeToReleaseNotesValidator } from '#validators/system'; import { inject } from '@adonisjs/core' import type { HttpContext } from '@adonisjs/core/http' @@ -46,8 +46,9 @@ export default class SystemController { response.send({ success: result.success, message: result.message }); } - async checkLatestVersion({ }: HttpContext) { - return await this.systemService.checkLatestVersion(); + async checkLatestVersion({ request }: HttpContext) { + const payload = await request.validateUsing(checkLatestVersionValidator) + return await this.systemService.checkLatestVersion(payload.force); } async forceReinstallService({ request, response }: HttpContext) { diff --git a/admin/app/jobs/check_update_job.ts b/admin/app/jobs/check_update_job.ts new file mode 100644 index 0000000..e4fa59b --- /dev/null +++ b/admin/app/jobs/check_update_job.ts @@ -0,0 +1,77 @@ +import { Job } from 'bullmq' +import { QueueService } from '#services/queue_service' +import { DockerService } from '#services/docker_service' +import { SystemService } from '#services/system_service' +import logger from '@adonisjs/core/services/logger' +import KVStore from '#models/kv_store' + +export class CheckUpdateJob { + static get queue() { + return 'system' + } + + static get key() { + return 'check-update' + } + + async handle(_job: Job) { + logger.info('[CheckUpdateJob] Running update check...') + + const dockerService = new DockerService() + const systemService = new SystemService(dockerService) + + try { + const result = await systemService.checkLatestVersion() + + if (result.updateAvailable) { + logger.info( + `[CheckUpdateJob] Update available: ${result.currentVersion} → ${result.latestVersion}` + ) + } else { + await KVStore.setValue('system.updateAvailable', "false") + logger.info( + `[CheckUpdateJob] System is up to date (${result.currentVersion})` + ) + } + + return result + } catch (error) { + logger.error(`[CheckUpdateJob] Update check failed: ${error.message}`) + throw error + } + } + + static async scheduleNightly() { + const queueService = new QueueService() + const queue = queueService.getQueue(this.queue) + + await queue.upsertJobScheduler( + 'nightly-update-check', + { pattern: '0 2,14 * * *' }, // Every 12 hours at 2am and 2pm + { + name: this.key, + opts: { + removeOnComplete: { count: 7 }, + removeOnFail: { count: 5 }, + }, + } + ) + + logger.info('[CheckUpdateJob] Update check scheduled with cron: 0 2,14 * * *') + } + + static async dispatch() { + const queueService = new QueueService() + const queue = queueService.getQueue(this.queue) + + const job = await queue.add(this.key, {}, { + attempts: 3, + backoff: { type: 'exponential', delay: 60000 }, + removeOnComplete: { count: 7 }, + removeOnFail: { count: 5 }, + }) + + logger.info(`[CheckUpdateJob] Dispatched ad-hoc update check job ${job.id}`) + return job + } +} diff --git a/admin/app/services/system_service.ts b/admin/app/services/system_service.ts index 3ca2744..1729f12 100644 --- a/admin/app/services/system_service.ts +++ b/admin/app/services/system_service.ts @@ -12,6 +12,7 @@ import axios from 'axios' import env from '#start/env' import KVStore from '#models/kv_store' import { KVStoreKey } from '../../types/kv_store.js' +import { parseBoolean } from '../utils/misc.js' @inject() export class SystemService { @@ -187,7 +188,7 @@ export class SystemService { } } - async checkLatestVersion(): Promise<{ + async checkLatestVersion(force?: boolean): Promise<{ success: boolean updateAvailable: boolean currentVersion: string @@ -195,6 +196,21 @@ export class SystemService { message?: string }> { try { + const currentVersion = SystemService.getAppVersion() + const cachedUpdateAvailable = await KVStore.getValue('system.updateAvailable') + const cachedLatestVersion = await KVStore.getValue('system.latestVersion') + + // Use cached values if not forcing a fresh check. + // the CheckUpdateJob will update these values every 12 hours + if (!force) { + return { + success: true, + updateAvailable: parseBoolean(cachedUpdateAvailable || "false"), + currentVersion, + latestVersion: cachedLatestVersion || '', + } + } + const response = await axios.get( 'https://api.github.com/repos/Crosstalk-Solutions/project-nomad/releases/latest', { @@ -208,12 +224,13 @@ export class SystemService { } const latestVersion = response.data.tag_name.replace(/^v/, '') // Remove leading 'v' if present - const currentVersion = SystemService.getAppVersion() - logger.info(`Current version: ${currentVersion}, Latest version: ${latestVersion}`) - // NOTE: this will always return true in dev environment! See getAppVersion() - const updateAvailable = latestVersion !== currentVersion + const updateAvailable = process.env.NODE_ENV === 'development' ? false : latestVersion !== currentVersion + + // Cache the results in KVStore for frontend checks + await KVStore.setValue('system.updateAvailable', updateAvailable.toString()) + await KVStore.setValue('system.latestVersion', latestVersion) return { success: true, diff --git a/admin/app/validators/system.ts b/admin/app/validators/system.ts index fcd92bd..a167f89 100644 --- a/admin/app/validators/system.ts +++ b/admin/app/validators/system.ts @@ -18,3 +18,9 @@ export const subscribeToReleaseNotesValidator = vine.compile( email: vine.string().email().trim(), }) ) + +export const checkLatestVersionValidator = vine.compile( + vine.object({ + force: vine.boolean().optional(), // Optional flag to force bypassing cache and checking for updates immediately + }) +) diff --git a/admin/commands/queue/work.ts b/admin/commands/queue/work.ts index e381aa0..78bcf65 100644 --- a/admin/commands/queue/work.ts +++ b/admin/commands/queue/work.ts @@ -6,6 +6,7 @@ import { RunDownloadJob } from '#jobs/run_download_job' import { DownloadModelJob } from '#jobs/download_model_job' import { RunBenchmarkJob } from '#jobs/run_benchmark_job' import { EmbedFileJob } from '#jobs/embed_file_job' +import { CheckUpdateJob } from '#jobs/check_update_job' export default class QueueWork extends BaseCommand { static commandName = 'queue:work' @@ -75,6 +76,9 @@ export default class QueueWork extends BaseCommand { this.logger.info(`Worker started for queue: ${queueName}`) } + // Schedule nightly update check (idempotent, will persist over restarts) + await CheckUpdateJob.scheduleNightly() + // Graceful shutdown for all workers process.on('SIGTERM', async () => { this.logger.info('SIGTERM received. Shutting down workers...') @@ -92,11 +96,13 @@ export default class QueueWork extends BaseCommand { handlers.set(DownloadModelJob.key, new DownloadModelJob()) handlers.set(RunBenchmarkJob.key, new RunBenchmarkJob()) handlers.set(EmbedFileJob.key, new EmbedFileJob()) + handlers.set(CheckUpdateJob.key, new CheckUpdateJob()) queues.set(RunDownloadJob.key, RunDownloadJob.queue) queues.set(DownloadModelJob.key, DownloadModelJob.queue) queues.set(RunBenchmarkJob.key, RunBenchmarkJob.queue) queues.set(EmbedFileJob.key, EmbedFileJob.queue) + queues.set(CheckUpdateJob.key, CheckUpdateJob.queue) return [handlers, queues] } @@ -111,6 +117,7 @@ export default class QueueWork extends BaseCommand { [DownloadModelJob.queue]: 2, // Lower concurrency for resource-intensive model downloads [RunBenchmarkJob.queue]: 1, // Run benchmarks one at a time for accurate results [EmbedFileJob.queue]: 2, // Lower concurrency for embedding jobs, can be resource intensive + [CheckUpdateJob.queue]: 1, // No need to run more than one update check at a time default: 3, } diff --git a/admin/inertia/components/Alert.tsx b/admin/inertia/components/Alert.tsx index 4cebe19..aace517 100644 --- a/admin/inertia/components/Alert.tsx +++ b/admin/inertia/components/Alert.tsx @@ -1,6 +1,7 @@ import * as Icons from '@tabler/icons-react' import classNames from '~/lib/classNames' import DynamicIcon from './DynamicIcon' +import StyledButton, { StyledButtonProps } from './StyledButton' export type AlertProps = React.HTMLAttributes & { title: string @@ -11,6 +12,7 @@ export type AlertProps = React.HTMLAttributes & { onDismiss?: () => void icon?: keyof typeof Icons variant?: 'standard' | 'bordered' | 'solid' + buttonProps?: StyledButtonProps } export default function Alert({ @@ -22,6 +24,7 @@ export default function Alert({ onDismiss, icon, variant = 'standard', + buttonProps, ...props }: AlertProps) { const getDefaultIcon = (): keyof typeof Icons => { @@ -56,7 +59,7 @@ export default function Alert({ } const getVariantStyles = () => { - const baseStyles = 'rounded-md transition-all duration-200' + const baseStyles = 'rounded-lg transition-all duration-200' const variantStyles: string[] = [] switch (variant) { @@ -72,20 +75,20 @@ export default function Alert({ ? 'border-desert-stone' : '' ) - return classNames(baseStyles, 'border-2 bg-desert-white', ...variantStyles) + return classNames(baseStyles, 'border-2 bg-desert-white shadow-md', ...variantStyles) case 'solid': variantStyles.push( type === 'warning' - ? 'bg-desert-orange text-desert-white border-desert-orange-dark' + ? 'bg-desert-orange text-desert-white border border-desert-orange-dark' : type === 'error' - ? 'bg-desert-red text-desert-white border-desert-red-dark' + ? 'bg-desert-red text-desert-white border border-desert-red-dark' : type === 'success' - ? 'bg-desert-olive text-desert-white border-desert-olive-dark' + ? 'bg-desert-olive text-desert-white border border-desert-olive-dark' : type === 'info' - ? 'bg-desert-green text-desert-white border-desert-green-dark' + ? 'bg-desert-green text-desert-white border border-desert-green-dark' : '' ) - return classNames(baseStyles, 'shadow-sm', ...variantStyles) + return classNames(baseStyles, 'shadow-lg', ...variantStyles) default: variantStyles.push( type === 'warning' @@ -98,7 +101,7 @@ export default function Alert({ ? 'bg-desert-green bg-opacity-20 border-desert-green-light' : '' ) - return classNames(baseStyles, 'border shadow-sm', ...variantStyles) + return classNames(baseStyles, 'border-l-4 border-y border-r shadow-sm', ...variantStyles) } } @@ -156,28 +159,36 @@ export default function Alert({ } return ( -
-
- +
+
+
+ +
-

{title}

+

{title}

{message && ( -
+

{message}

)} {children &&
{children}
}
+ {buttonProps && ( +
+ +
+ )} + {dismissible && ( )}
diff --git a/admin/inertia/components/AlertWithButton.tsx b/admin/inertia/components/AlertWithButton.tsx deleted file mode 100644 index a7d28ea..0000000 --- a/admin/inertia/components/AlertWithButton.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import Alert, { AlertProps } from './Alert' -import StyledButton, { StyledButtonProps } from './StyledButton' - -export type AlertWithButtonProps = { - buttonProps: StyledButtonProps -} & AlertProps - -const AlertWithButton = ({ buttonProps, ...alertProps }: AlertWithButtonProps) => { - return ( - - - - ) -} - -export default AlertWithButton \ No newline at end of file diff --git a/admin/inertia/hooks/useUpdateAvailable.ts b/admin/inertia/hooks/useUpdateAvailable.ts new file mode 100644 index 0000000..f281202 --- /dev/null +++ b/admin/inertia/hooks/useUpdateAvailable.ts @@ -0,0 +1,15 @@ +import api from "~/lib/api" +import { CheckLatestVersionResult } from "../../types/system" +import { useQuery } from "@tanstack/react-query" + + +export const useUpdateAvailable = () => { + const queryData = useQuery({ + queryKey: ['system-update-available'], + queryFn: () => api.checkLatestVersion(), + refetchInterval: Infinity, // Disable automatic refetching + refetchOnWindowFocus: false, + }) + + return queryData.data +} \ No newline at end of file diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index 277261e..0df5ac0 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -2,7 +2,7 @@ import axios, { AxiosInstance } from 'axios' import { ListRemoteZimFilesResponse, ListZimFilesResponse } from '../../types/zim' import { ServiceSlim } from '../../types/services' import { FileEntry } from '../../types/files' -import { SystemInformationResponse, SystemUpdateStatus } from '../../types/system' +import { CheckLatestVersionResult, SystemInformationResponse, SystemUpdateStatus } from '../../types/system' import { CuratedCategory, CuratedCollectionWithStatus, @@ -37,6 +37,15 @@ class API { })() } + async checkLatestVersion(force: boolean = false) { + return catchInternal(async () => { + const response = await this.client.get('/system/latest-version', { + params: { force }, + }) + return response.data + })() + } + async deleteModel(model: string): Promise<{ success: boolean; message: string }> { return catchInternal(async () => { const response = await this.client.delete('/ollama/models', { data: { model } }) diff --git a/admin/inertia/pages/home.tsx b/admin/inertia/pages/home.tsx index e2ba242..6e731ea 100644 --- a/admin/inertia/pages/home.tsx +++ b/admin/inertia/pages/home.tsx @@ -13,6 +13,8 @@ import { getServiceLink } from '~/lib/navigation' import { ServiceSlim } from '../../types/services' import DynamicIcon, { DynamicIconName } from '~/components/DynamicIcon' import { SERVICE_NAMES } from '../../constants/service_names' +import { useUpdateAvailable } from '~/hooks/useUpdateAvailable' +import Alert from '~/components/Alert' // Maps is a Core Capability (display_order: 4) const MAPS_ITEM = { @@ -99,6 +101,7 @@ export default function Home(props: { } }) { const items: DashboardItem[] = [] + const updateInfo = useUpdateAvailable(); // Add installed services (non-dependency services only) props.system.services @@ -137,6 +140,26 @@ export default function Home(props: { return ( + { + updateInfo?.updateAvailable && ( +
+ { + window.location.href = '/settings/update' + }, + }} + /> +
+ ) + } {alertMessage && (
-
{!props.maps.baseAssetsExist && ( -