mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-03-28 11:39:26 +01:00
feat: cron job for system update checks
This commit is contained in:
parent
40741530fd
commit
2e0ab10075
|
|
@ -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) {
|
||||
|
|
|
|||
77
admin/app/jobs/check_update_job.ts
Normal file
77
admin/app/jobs/check_update_job.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
15
admin/inertia/hooks/useUpdateAvailable.ts
Normal file
15
admin/inertia/hooks/useUpdateAvailable.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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 } })
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -67,3 +67,12 @@ export type SystemUpdateStatus = {
|
|||
message: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
|
||||
export type CheckLatestVersionResult = {
|
||||
success: boolean,
|
||||
updateAvailable: boolean,
|
||||
currentVersion: string,
|
||||
latestVersion: string,
|
||||
message?: string
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user