feat: cron job for system update checks

This commit is contained in:
Jake Turner 2026-02-06 15:40:03 -08:00 committed by Jake Turner
parent 40741530fd
commit 2e0ab10075
14 changed files with 205 additions and 46 deletions

View File

@ -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) {

View File

@ -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
}
}

View File

@ -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,

View File

@ -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
})
)

View File

@ -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,
}

View File

@ -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<HTMLDivElement> & {
title: string
@ -11,6 +12,7 @@ export type AlertProps = React.HTMLAttributes<HTMLDivElement> & {
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 (
<div {...props} className={classNames(getVariantStyles(), 'p-4', props.className)} role="alert">
<div className="flex gap-3">
<DynamicIcon icon={getDefaultIcon()} className={getIconColor() + ' size-5 shrink-0'} />
<div {...props} className={classNames(getVariantStyles(), 'p-5', props.className)} role="alert">
<div className="flex gap-4 items-center">
<div className="flex-shrink-0 mt-0.5">
<DynamicIcon icon={icon || getDefaultIcon()} className={classNames(getIconColor(), 'size-6')} />
</div>
<div className="flex-1 min-w-0">
<h3 className={classNames('text-sm font-semibold', getTitleColor())}>{title}</h3>
<h3 className={classNames('text-base font-semibold leading-tight', getTitleColor())}>{title}</h3>
{message && (
<div className={classNames('mt-1 text-sm', getMessageColor())}>
<div className={classNames('mt-2 text-sm leading-relaxed', getMessageColor())}>
<p>{message}</p>
</div>
)}
{children && <div className="mt-3">{children}</div>}
</div>
{buttonProps && (
<div className="flex-shrink-0 ml-auto">
<StyledButton {...buttonProps} />
</div>
)}
{dismissible && (
<button
type="button"
onClick={onDismiss}
className={classNames(
'shrink-0 rounded-md p-1.5 transition-colors duration-150',
'flex-shrink-0 rounded-lg p-1.5 transition-all duration-200',
getCloseButtonStyles(),
'focus:outline-none focus:ring-2 focus:ring-offset-2',
'focus:outline-none focus:ring-2 focus:ring-offset-1',
type === 'warning' ? 'focus:ring-desert-orange' : '',
type === 'error' ? 'focus:ring-desert-red' : '',
type === 'success' ? 'focus:ring-desert-olive' : '',
@ -185,7 +196,7 @@ export default function Alert({
)}
aria-label="Dismiss alert"
>
<DynamicIcon icon="IconX" className="size-5" />
<DynamicIcon icon="IconX" className="size-4" />
</button>
)}
</div>

View File

@ -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 (
<Alert {...alertProps}>
<StyledButton {...buttonProps} />
</Alert>
)
}
export default AlertWithButton

View File

@ -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<CheckLatestVersionResult | undefined>({
queryKey: ['system-update-available'],
queryFn: () => api.checkLatestVersion(),
refetchInterval: Infinity, // Disable automatic refetching
refetchOnWindowFocus: false,
})
return queryData.data
}

View File

@ -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<CheckLatestVersionResult>('/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 } })

View File

@ -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 (
<AppLayout>
<Head title="Command Center" />
{
updateInfo?.updateAvailable && (
<div className='flex justify-center items-center p-4 w-full'>
<Alert
title="An update is available for Project N.O.M.A.D.!"
type="info"
variant="solid"
className="w-full"
buttonProps={{
variant: 'secondary',
children: 'Go to Settings',
icon: 'IconSettings',
onClick: () => {
window.location.href = '/settings/update'
},
}}
/>
</div>
)
}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 p-4">
{items.map((item) => (
<a key={item.label} href={item.to} target={item.target}>

View File

@ -4,7 +4,7 @@ import MapComponent from '~/components/maps/MapComponent'
import StyledButton from '~/components/StyledButton'
import { IconArrowLeft } from '@tabler/icons-react'
import { FileEntry } from '../../types/files'
import AlertWithButton from '~/components/AlertWithButton'
import Alert from '~/components/Alert'
export default function Maps(props: {
maps: { baseAssetsExist: boolean; regionFiles: FileEntry[] }
@ -33,7 +33,7 @@ export default function Maps(props: {
</div>
{alertMessage && (
<div className="absolute top-20 left-4 right-4 z-50">
<AlertWithButton
<Alert
title={alertMessage}
type="warning"
variant="solid"

View File

@ -15,7 +15,7 @@ import StyledSectionHeader from '~/components/StyledSectionHeader'
import CuratedCollectionCard from '~/components/CuratedCollectionCard'
import { CuratedCollectionWithStatus } from '../../../types/downloads'
import ActiveDownloads from '~/components/ActiveDownloads'
import AlertWithButton from '~/components/AlertWithButton'
import Alert from '~/components/Alert'
const CURATED_COLLECTIONS_KEY = 'curated-map-collections'
@ -205,7 +205,7 @@ export default function MapsManager(props: {
</div>
</div>
{!props.maps.baseAssetsExist && (
<AlertWithButton
<Alert
title="The base map assets have not been installed. Please download them first to enable map functionality."
type="warning"
variant="solid"

View File

@ -1,3 +1,3 @@
export type KVStoreKey = 'chat.suggestionsEnabled' | 'rag.docsEmbedded'
export type KVStoreKey = 'chat.suggestionsEnabled' | 'rag.docsEmbedded' | 'system.updateAvailable' | 'system.latestVersion'
export type KVStoreValue = string | null

View File

@ -67,3 +67,12 @@ export type SystemUpdateStatus = {
message: string
timestamp: string
}
export type CheckLatestVersionResult = {
success: boolean,
updateAvailable: boolean,
currentVersion: string,
latestVersion: string,
message?: string
}