Skip to content

Commit 2e0ab10

Browse files
committed
feat: cron job for system update checks
1 parent 4074153 commit 2e0ab10

File tree

14 files changed

+205
-46
lines changed

14 files changed

+205
-46
lines changed

admin/app/controllers/system_controller.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { DockerService } from '#services/docker_service';
22
import { SystemService } from '#services/system_service'
33
import { SystemUpdateService } from '#services/system_update_service'
4-
import { affectServiceValidator, installServiceValidator, subscribeToReleaseNotesValidator } from '#validators/system';
4+
import { affectServiceValidator, checkLatestVersionValidator, installServiceValidator, subscribeToReleaseNotesValidator } from '#validators/system';
55
import { inject } from '@adonisjs/core'
66
import type { HttpContext } from '@adonisjs/core/http'
77

@@ -46,8 +46,9 @@ export default class SystemController {
4646
response.send({ success: result.success, message: result.message });
4747
}
4848

49-
async checkLatestVersion({ }: HttpContext) {
50-
return await this.systemService.checkLatestVersion();
49+
async checkLatestVersion({ request }: HttpContext) {
50+
const payload = await request.validateUsing(checkLatestVersionValidator)
51+
return await this.systemService.checkLatestVersion(payload.force);
5152
}
5253

5354
async forceReinstallService({ request, response }: HttpContext) {

admin/app/jobs/check_update_job.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { Job } from 'bullmq'
2+
import { QueueService } from '#services/queue_service'
3+
import { DockerService } from '#services/docker_service'
4+
import { SystemService } from '#services/system_service'
5+
import logger from '@adonisjs/core/services/logger'
6+
import KVStore from '#models/kv_store'
7+
8+
export class CheckUpdateJob {
9+
static get queue() {
10+
return 'system'
11+
}
12+
13+
static get key() {
14+
return 'check-update'
15+
}
16+
17+
async handle(_job: Job) {
18+
logger.info('[CheckUpdateJob] Running update check...')
19+
20+
const dockerService = new DockerService()
21+
const systemService = new SystemService(dockerService)
22+
23+
try {
24+
const result = await systemService.checkLatestVersion()
25+
26+
if (result.updateAvailable) {
27+
logger.info(
28+
`[CheckUpdateJob] Update available: ${result.currentVersion}${result.latestVersion}`
29+
)
30+
} else {
31+
await KVStore.setValue('system.updateAvailable', "false")
32+
logger.info(
33+
`[CheckUpdateJob] System is up to date (${result.currentVersion})`
34+
)
35+
}
36+
37+
return result
38+
} catch (error) {
39+
logger.error(`[CheckUpdateJob] Update check failed: ${error.message}`)
40+
throw error
41+
}
42+
}
43+
44+
static async scheduleNightly() {
45+
const queueService = new QueueService()
46+
const queue = queueService.getQueue(this.queue)
47+
48+
await queue.upsertJobScheduler(
49+
'nightly-update-check',
50+
{ pattern: '0 2,14 * * *' }, // Every 12 hours at 2am and 2pm
51+
{
52+
name: this.key,
53+
opts: {
54+
removeOnComplete: { count: 7 },
55+
removeOnFail: { count: 5 },
56+
},
57+
}
58+
)
59+
60+
logger.info('[CheckUpdateJob] Update check scheduled with cron: 0 2,14 * * *')
61+
}
62+
63+
static async dispatch() {
64+
const queueService = new QueueService()
65+
const queue = queueService.getQueue(this.queue)
66+
67+
const job = await queue.add(this.key, {}, {
68+
attempts: 3,
69+
backoff: { type: 'exponential', delay: 60000 },
70+
removeOnComplete: { count: 7 },
71+
removeOnFail: { count: 5 },
72+
})
73+
74+
logger.info(`[CheckUpdateJob] Dispatched ad-hoc update check job ${job.id}`)
75+
return job
76+
}
77+
}

admin/app/services/system_service.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import axios from 'axios'
1212
import env from '#start/env'
1313
import KVStore from '#models/kv_store'
1414
import { KVStoreKey } from '../../types/kv_store.js'
15+
import { parseBoolean } from '../utils/misc.js'
1516

1617
@inject()
1718
export class SystemService {
@@ -187,14 +188,29 @@ export class SystemService {
187188
}
188189
}
189190

190-
async checkLatestVersion(): Promise<{
191+
async checkLatestVersion(force?: boolean): Promise<{
191192
success: boolean
192193
updateAvailable: boolean
193194
currentVersion: string
194195
latestVersion: string
195196
message?: string
196197
}> {
197198
try {
199+
const currentVersion = SystemService.getAppVersion()
200+
const cachedUpdateAvailable = await KVStore.getValue('system.updateAvailable')
201+
const cachedLatestVersion = await KVStore.getValue('system.latestVersion')
202+
203+
// Use cached values if not forcing a fresh check.
204+
// the CheckUpdateJob will update these values every 12 hours
205+
if (!force) {
206+
return {
207+
success: true,
208+
updateAvailable: parseBoolean(cachedUpdateAvailable || "false"),
209+
currentVersion,
210+
latestVersion: cachedLatestVersion || '',
211+
}
212+
}
213+
198214
const response = await axios.get(
199215
'https://api.github.com/repos/Crosstalk-Solutions/project-nomad/releases/latest',
200216
{
@@ -208,12 +224,13 @@ export class SystemService {
208224
}
209225

210226
const latestVersion = response.data.tag_name.replace(/^v/, '') // Remove leading 'v' if present
211-
const currentVersion = SystemService.getAppVersion()
212-
213227
logger.info(`Current version: ${currentVersion}, Latest version: ${latestVersion}`)
214228

215-
// NOTE: this will always return true in dev environment! See getAppVersion()
216-
const updateAvailable = latestVersion !== currentVersion
229+
const updateAvailable = process.env.NODE_ENV === 'development' ? false : latestVersion !== currentVersion
230+
231+
// Cache the results in KVStore for frontend checks
232+
await KVStore.setValue('system.updateAvailable', updateAvailable.toString())
233+
await KVStore.setValue('system.latestVersion', latestVersion)
217234

218235
return {
219236
success: true,

admin/app/validators/system.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,9 @@ export const subscribeToReleaseNotesValidator = vine.compile(
1818
email: vine.string().email().trim(),
1919
})
2020
)
21+
22+
export const checkLatestVersionValidator = vine.compile(
23+
vine.object({
24+
force: vine.boolean().optional(), // Optional flag to force bypassing cache and checking for updates immediately
25+
})
26+
)

admin/commands/queue/work.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { RunDownloadJob } from '#jobs/run_download_job'
66
import { DownloadModelJob } from '#jobs/download_model_job'
77
import { RunBenchmarkJob } from '#jobs/run_benchmark_job'
88
import { EmbedFileJob } from '#jobs/embed_file_job'
9+
import { CheckUpdateJob } from '#jobs/check_update_job'
910

1011
export default class QueueWork extends BaseCommand {
1112
static commandName = 'queue:work'
@@ -75,6 +76,9 @@ export default class QueueWork extends BaseCommand {
7576
this.logger.info(`Worker started for queue: ${queueName}`)
7677
}
7778

79+
// Schedule nightly update check (idempotent, will persist over restarts)
80+
await CheckUpdateJob.scheduleNightly()
81+
7882
// Graceful shutdown for all workers
7983
process.on('SIGTERM', async () => {
8084
this.logger.info('SIGTERM received. Shutting down workers...')
@@ -92,11 +96,13 @@ export default class QueueWork extends BaseCommand {
9296
handlers.set(DownloadModelJob.key, new DownloadModelJob())
9397
handlers.set(RunBenchmarkJob.key, new RunBenchmarkJob())
9498
handlers.set(EmbedFileJob.key, new EmbedFileJob())
99+
handlers.set(CheckUpdateJob.key, new CheckUpdateJob())
95100

96101
queues.set(RunDownloadJob.key, RunDownloadJob.queue)
97102
queues.set(DownloadModelJob.key, DownloadModelJob.queue)
98103
queues.set(RunBenchmarkJob.key, RunBenchmarkJob.queue)
99104
queues.set(EmbedFileJob.key, EmbedFileJob.queue)
105+
queues.set(CheckUpdateJob.key, CheckUpdateJob.queue)
100106

101107
return [handlers, queues]
102108
}
@@ -111,6 +117,7 @@ export default class QueueWork extends BaseCommand {
111117
[DownloadModelJob.queue]: 2, // Lower concurrency for resource-intensive model downloads
112118
[RunBenchmarkJob.queue]: 1, // Run benchmarks one at a time for accurate results
113119
[EmbedFileJob.queue]: 2, // Lower concurrency for embedding jobs, can be resource intensive
120+
[CheckUpdateJob.queue]: 1, // No need to run more than one update check at a time
114121
default: 3,
115122
}
116123

admin/inertia/components/Alert.tsx

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as Icons from '@tabler/icons-react'
22
import classNames from '~/lib/classNames'
33
import DynamicIcon from './DynamicIcon'
4+
import StyledButton, { StyledButtonProps } from './StyledButton'
45

56
export type AlertProps = React.HTMLAttributes<HTMLDivElement> & {
67
title: string
@@ -11,6 +12,7 @@ export type AlertProps = React.HTMLAttributes<HTMLDivElement> & {
1112
onDismiss?: () => void
1213
icon?: keyof typeof Icons
1314
variant?: 'standard' | 'bordered' | 'solid'
15+
buttonProps?: StyledButtonProps
1416
}
1517

1618
export default function Alert({
@@ -22,6 +24,7 @@ export default function Alert({
2224
onDismiss,
2325
icon,
2426
variant = 'standard',
27+
buttonProps,
2528
...props
2629
}: AlertProps) {
2730
const getDefaultIcon = (): keyof typeof Icons => {
@@ -56,7 +59,7 @@ export default function Alert({
5659
}
5760

5861
const getVariantStyles = () => {
59-
const baseStyles = 'rounded-md transition-all duration-200'
62+
const baseStyles = 'rounded-lg transition-all duration-200'
6063
const variantStyles: string[] = []
6164

6265
switch (variant) {
@@ -72,20 +75,20 @@ export default function Alert({
7275
? 'border-desert-stone'
7376
: ''
7477
)
75-
return classNames(baseStyles, 'border-2 bg-desert-white', ...variantStyles)
78+
return classNames(baseStyles, 'border-2 bg-desert-white shadow-md', ...variantStyles)
7679
case 'solid':
7780
variantStyles.push(
7881
type === 'warning'
79-
? 'bg-desert-orange text-desert-white border-desert-orange-dark'
82+
? 'bg-desert-orange text-desert-white border border-desert-orange-dark'
8083
: type === 'error'
81-
? 'bg-desert-red text-desert-white border-desert-red-dark'
84+
? 'bg-desert-red text-desert-white border border-desert-red-dark'
8285
: type === 'success'
83-
? 'bg-desert-olive text-desert-white border-desert-olive-dark'
86+
? 'bg-desert-olive text-desert-white border border-desert-olive-dark'
8487
: type === 'info'
85-
? 'bg-desert-green text-desert-white border-desert-green-dark'
88+
? 'bg-desert-green text-desert-white border border-desert-green-dark'
8689
: ''
8790
)
88-
return classNames(baseStyles, 'shadow-sm', ...variantStyles)
91+
return classNames(baseStyles, 'shadow-lg', ...variantStyles)
8992
default:
9093
variantStyles.push(
9194
type === 'warning'
@@ -98,7 +101,7 @@ export default function Alert({
98101
? 'bg-desert-green bg-opacity-20 border-desert-green-light'
99102
: ''
100103
)
101-
return classNames(baseStyles, 'border shadow-sm', ...variantStyles)
104+
return classNames(baseStyles, 'border-l-4 border-y border-r shadow-sm', ...variantStyles)
102105
}
103106
}
104107

@@ -156,36 +159,44 @@ export default function Alert({
156159
}
157160

158161
return (
159-
<div {...props} className={classNames(getVariantStyles(), 'p-4', props.className)} role="alert">
160-
<div className="flex gap-3">
161-
<DynamicIcon icon={getDefaultIcon()} className={getIconColor() + ' size-5 shrink-0'} />
162+
<div {...props} className={classNames(getVariantStyles(), 'p-5', props.className)} role="alert">
163+
<div className="flex gap-4 items-center">
164+
<div className="flex-shrink-0 mt-0.5">
165+
<DynamicIcon icon={icon || getDefaultIcon()} className={classNames(getIconColor(), 'size-6')} />
166+
</div>
162167

163168
<div className="flex-1 min-w-0">
164-
<h3 className={classNames('text-sm font-semibold', getTitleColor())}>{title}</h3>
169+
<h3 className={classNames('text-base font-semibold leading-tight', getTitleColor())}>{title}</h3>
165170
{message && (
166-
<div className={classNames('mt-1 text-sm', getMessageColor())}>
171+
<div className={classNames('mt-2 text-sm leading-relaxed', getMessageColor())}>
167172
<p>{message}</p>
168173
</div>
169174
)}
170175
{children && <div className="mt-3">{children}</div>}
171176
</div>
172177

178+
{buttonProps && (
179+
<div className="flex-shrink-0 ml-auto">
180+
<StyledButton {...buttonProps} />
181+
</div>
182+
)}
183+
173184
{dismissible && (
174185
<button
175186
type="button"
176187
onClick={onDismiss}
177188
className={classNames(
178-
'shrink-0 rounded-md p-1.5 transition-colors duration-150',
189+
'flex-shrink-0 rounded-lg p-1.5 transition-all duration-200',
179190
getCloseButtonStyles(),
180-
'focus:outline-none focus:ring-2 focus:ring-offset-2',
191+
'focus:outline-none focus:ring-2 focus:ring-offset-1',
181192
type === 'warning' ? 'focus:ring-desert-orange' : '',
182193
type === 'error' ? 'focus:ring-desert-red' : '',
183194
type === 'success' ? 'focus:ring-desert-olive' : '',
184195
type === 'info' ? 'focus:ring-desert-stone' : ''
185196
)}
186197
aria-label="Dismiss alert"
187198
>
188-
<DynamicIcon icon="IconX" className="size-5" />
199+
<DynamicIcon icon="IconX" className="size-4" />
189200
</button>
190201
)}
191202
</div>

admin/inertia/components/AlertWithButton.tsx

Lines changed: 0 additions & 16 deletions
This file was deleted.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import api from "~/lib/api"
2+
import { CheckLatestVersionResult } from "../../types/system"
3+
import { useQuery } from "@tanstack/react-query"
4+
5+
6+
export const useUpdateAvailable = () => {
7+
const queryData = useQuery<CheckLatestVersionResult | undefined>({
8+
queryKey: ['system-update-available'],
9+
queryFn: () => api.checkLatestVersion(),
10+
refetchInterval: Infinity, // Disable automatic refetching
11+
refetchOnWindowFocus: false,
12+
})
13+
14+
return queryData.data
15+
}

admin/inertia/lib/api.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import axios, { AxiosInstance } from 'axios'
22
import { ListRemoteZimFilesResponse, ListZimFilesResponse } from '../../types/zim'
33
import { ServiceSlim } from '../../types/services'
44
import { FileEntry } from '../../types/files'
5-
import { SystemInformationResponse, SystemUpdateStatus } from '../../types/system'
5+
import { CheckLatestVersionResult, SystemInformationResponse, SystemUpdateStatus } from '../../types/system'
66
import {
77
CuratedCategory,
88
CuratedCollectionWithStatus,
@@ -37,6 +37,15 @@ class API {
3737
})()
3838
}
3939

40+
async checkLatestVersion(force: boolean = false) {
41+
return catchInternal(async () => {
42+
const response = await this.client.get<CheckLatestVersionResult>('/system/latest-version', {
43+
params: { force },
44+
})
45+
return response.data
46+
})()
47+
}
48+
4049
async deleteModel(model: string): Promise<{ success: boolean; message: string }> {
4150
return catchInternal(async () => {
4251
const response = await this.client.delete('/ollama/models', { data: { model } })

0 commit comments

Comments
 (0)