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 { DockerService } from '#services/docker_service';
import { SystemService } from '#services/system_service' import { SystemService } from '#services/system_service'
import { SystemUpdateService } from '#services/system_update_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 { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http' import type { HttpContext } from '@adonisjs/core/http'
@ -46,8 +46,9 @@ export default class SystemController {
response.send({ success: result.success, message: result.message }); response.send({ success: result.success, message: result.message });
} }
async checkLatestVersion({ }: HttpContext) { async checkLatestVersion({ request }: HttpContext) {
return await this.systemService.checkLatestVersion(); const payload = await request.validateUsing(checkLatestVersionValidator)
return await this.systemService.checkLatestVersion(payload.force);
} }
async forceReinstallService({ request, response }: HttpContext) { 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 env from '#start/env'
import KVStore from '#models/kv_store' import KVStore from '#models/kv_store'
import { KVStoreKey } from '../../types/kv_store.js' import { KVStoreKey } from '../../types/kv_store.js'
import { parseBoolean } from '../utils/misc.js'
@inject() @inject()
export class SystemService { export class SystemService {
@ -187,7 +188,7 @@ export class SystemService {
} }
} }
async checkLatestVersion(): Promise<{ async checkLatestVersion(force?: boolean): Promise<{
success: boolean success: boolean
updateAvailable: boolean updateAvailable: boolean
currentVersion: string currentVersion: string
@ -195,6 +196,21 @@ export class SystemService {
message?: string message?: string
}> { }> {
try { 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( const response = await axios.get(
'https://api.github.com/repos/Crosstalk-Solutions/project-nomad/releases/latest', '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 latestVersion = response.data.tag_name.replace(/^v/, '') // Remove leading 'v' if present
const currentVersion = SystemService.getAppVersion()
logger.info(`Current version: ${currentVersion}, Latest version: ${latestVersion}`) logger.info(`Current version: ${currentVersion}, Latest version: ${latestVersion}`)
// NOTE: this will always return true in dev environment! See getAppVersion() const updateAvailable = process.env.NODE_ENV === 'development' ? false : latestVersion !== currentVersion
const updateAvailable = latestVersion !== currentVersion
// Cache the results in KVStore for frontend checks
await KVStore.setValue('system.updateAvailable', updateAvailable.toString())
await KVStore.setValue('system.latestVersion', latestVersion)
return { return {
success: true, success: true,

View File

@ -18,3 +18,9 @@ export const subscribeToReleaseNotesValidator = vine.compile(
email: vine.string().email().trim(), 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 { DownloadModelJob } from '#jobs/download_model_job'
import { RunBenchmarkJob } from '#jobs/run_benchmark_job' import { RunBenchmarkJob } from '#jobs/run_benchmark_job'
import { EmbedFileJob } from '#jobs/embed_file_job' import { EmbedFileJob } from '#jobs/embed_file_job'
import { CheckUpdateJob } from '#jobs/check_update_job'
export default class QueueWork extends BaseCommand { export default class QueueWork extends BaseCommand {
static commandName = 'queue:work' static commandName = 'queue:work'
@ -75,6 +76,9 @@ export default class QueueWork extends BaseCommand {
this.logger.info(`Worker started for queue: ${queueName}`) 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 // Graceful shutdown for all workers
process.on('SIGTERM', async () => { process.on('SIGTERM', async () => {
this.logger.info('SIGTERM received. Shutting down workers...') 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(DownloadModelJob.key, new DownloadModelJob())
handlers.set(RunBenchmarkJob.key, new RunBenchmarkJob()) handlers.set(RunBenchmarkJob.key, new RunBenchmarkJob())
handlers.set(EmbedFileJob.key, new EmbedFileJob()) handlers.set(EmbedFileJob.key, new EmbedFileJob())
handlers.set(CheckUpdateJob.key, new CheckUpdateJob())
queues.set(RunDownloadJob.key, RunDownloadJob.queue) queues.set(RunDownloadJob.key, RunDownloadJob.queue)
queues.set(DownloadModelJob.key, DownloadModelJob.queue) queues.set(DownloadModelJob.key, DownloadModelJob.queue)
queues.set(RunBenchmarkJob.key, RunBenchmarkJob.queue) queues.set(RunBenchmarkJob.key, RunBenchmarkJob.queue)
queues.set(EmbedFileJob.key, EmbedFileJob.queue) queues.set(EmbedFileJob.key, EmbedFileJob.queue)
queues.set(CheckUpdateJob.key, CheckUpdateJob.queue)
return [handlers, queues] return [handlers, queues]
} }
@ -111,6 +117,7 @@ export default class QueueWork extends BaseCommand {
[DownloadModelJob.queue]: 2, // Lower concurrency for resource-intensive model downloads [DownloadModelJob.queue]: 2, // Lower concurrency for resource-intensive model downloads
[RunBenchmarkJob.queue]: 1, // Run benchmarks one at a time for accurate results [RunBenchmarkJob.queue]: 1, // Run benchmarks one at a time for accurate results
[EmbedFileJob.queue]: 2, // Lower concurrency for embedding jobs, can be resource intensive [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, default: 3,
} }

View File

@ -1,6 +1,7 @@
import * as Icons from '@tabler/icons-react' import * as Icons from '@tabler/icons-react'
import classNames from '~/lib/classNames' import classNames from '~/lib/classNames'
import DynamicIcon from './DynamicIcon' import DynamicIcon from './DynamicIcon'
import StyledButton, { StyledButtonProps } from './StyledButton'
export type AlertProps = React.HTMLAttributes<HTMLDivElement> & { export type AlertProps = React.HTMLAttributes<HTMLDivElement> & {
title: string title: string
@ -11,6 +12,7 @@ export type AlertProps = React.HTMLAttributes<HTMLDivElement> & {
onDismiss?: () => void onDismiss?: () => void
icon?: keyof typeof Icons icon?: keyof typeof Icons
variant?: 'standard' | 'bordered' | 'solid' variant?: 'standard' | 'bordered' | 'solid'
buttonProps?: StyledButtonProps
} }
export default function Alert({ export default function Alert({
@ -22,6 +24,7 @@ export default function Alert({
onDismiss, onDismiss,
icon, icon,
variant = 'standard', variant = 'standard',
buttonProps,
...props ...props
}: AlertProps) { }: AlertProps) {
const getDefaultIcon = (): keyof typeof Icons => { const getDefaultIcon = (): keyof typeof Icons => {
@ -56,7 +59,7 @@ export default function Alert({
} }
const getVariantStyles = () => { const getVariantStyles = () => {
const baseStyles = 'rounded-md transition-all duration-200' const baseStyles = 'rounded-lg transition-all duration-200'
const variantStyles: string[] = [] const variantStyles: string[] = []
switch (variant) { switch (variant) {
@ -72,20 +75,20 @@ export default function Alert({
? 'border-desert-stone' ? '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': case 'solid':
variantStyles.push( variantStyles.push(
type === 'warning' 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' : 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' : 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' : 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: default:
variantStyles.push( variantStyles.push(
type === 'warning' type === 'warning'
@ -98,7 +101,7 @@ export default function Alert({
? 'bg-desert-green bg-opacity-20 border-desert-green-light' ? '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 ( return (
<div {...props} className={classNames(getVariantStyles(), 'p-4', props.className)} role="alert"> <div {...props} className={classNames(getVariantStyles(), 'p-5', props.className)} role="alert">
<div className="flex gap-3"> <div className="flex gap-4 items-center">
<DynamicIcon icon={getDefaultIcon()} className={getIconColor() + ' size-5 shrink-0'} /> <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"> <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 && ( {message && (
<div className={classNames('mt-1 text-sm', getMessageColor())}> <div className={classNames('mt-2 text-sm leading-relaxed', getMessageColor())}>
<p>{message}</p> <p>{message}</p>
</div> </div>
)} )}
{children && <div className="mt-3">{children}</div>} {children && <div className="mt-3">{children}</div>}
</div> </div>
{buttonProps && (
<div className="flex-shrink-0 ml-auto">
<StyledButton {...buttonProps} />
</div>
)}
{dismissible && ( {dismissible && (
<button <button
type="button" type="button"
onClick={onDismiss} onClick={onDismiss}
className={classNames( 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(), 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 === 'warning' ? 'focus:ring-desert-orange' : '',
type === 'error' ? 'focus:ring-desert-red' : '', type === 'error' ? 'focus:ring-desert-red' : '',
type === 'success' ? 'focus:ring-desert-olive' : '', type === 'success' ? 'focus:ring-desert-olive' : '',
@ -185,7 +196,7 @@ export default function Alert({
)} )}
aria-label="Dismiss alert" aria-label="Dismiss alert"
> >
<DynamicIcon icon="IconX" className="size-5" /> <DynamicIcon icon="IconX" className="size-4" />
</button> </button>
)} )}
</div> </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 { ListRemoteZimFilesResponse, ListZimFilesResponse } from '../../types/zim'
import { ServiceSlim } from '../../types/services' import { ServiceSlim } from '../../types/services'
import { FileEntry } from '../../types/files' import { FileEntry } from '../../types/files'
import { SystemInformationResponse, SystemUpdateStatus } from '../../types/system' import { CheckLatestVersionResult, SystemInformationResponse, SystemUpdateStatus } from '../../types/system'
import { import {
CuratedCategory, CuratedCategory,
CuratedCollectionWithStatus, 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 }> { async deleteModel(model: string): Promise<{ success: boolean; message: string }> {
return catchInternal(async () => { return catchInternal(async () => {
const response = await this.client.delete('/ollama/models', { data: { model } }) 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 { ServiceSlim } from '../../types/services'
import DynamicIcon, { DynamicIconName } from '~/components/DynamicIcon' import DynamicIcon, { DynamicIconName } from '~/components/DynamicIcon'
import { SERVICE_NAMES } from '../../constants/service_names' 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) // Maps is a Core Capability (display_order: 4)
const MAPS_ITEM = { const MAPS_ITEM = {
@ -99,6 +101,7 @@ export default function Home(props: {
} }
}) { }) {
const items: DashboardItem[] = [] const items: DashboardItem[] = []
const updateInfo = useUpdateAvailable();
// Add installed services (non-dependency services only) // Add installed services (non-dependency services only)
props.system.services props.system.services
@ -137,6 +140,26 @@ export default function Home(props: {
return ( return (
<AppLayout> <AppLayout>
<Head title="Command Center" /> <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"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 p-4">
{items.map((item) => ( {items.map((item) => (
<a key={item.label} href={item.to} target={item.target}> <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 StyledButton from '~/components/StyledButton'
import { IconArrowLeft } from '@tabler/icons-react' import { IconArrowLeft } from '@tabler/icons-react'
import { FileEntry } from '../../types/files' import { FileEntry } from '../../types/files'
import AlertWithButton from '~/components/AlertWithButton' import Alert from '~/components/Alert'
export default function Maps(props: { export default function Maps(props: {
maps: { baseAssetsExist: boolean; regionFiles: FileEntry[] } maps: { baseAssetsExist: boolean; regionFiles: FileEntry[] }
@ -33,7 +33,7 @@ export default function Maps(props: {
</div> </div>
{alertMessage && ( {alertMessage && (
<div className="absolute top-20 left-4 right-4 z-50"> <div className="absolute top-20 left-4 right-4 z-50">
<AlertWithButton <Alert
title={alertMessage} title={alertMessage}
type="warning" type="warning"
variant="solid" variant="solid"

View File

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

View File

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