mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-04-02 23:09:26 +02: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 { 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) {
|
||||||
|
|
|
||||||
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 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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 { 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 } })
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user