feat: support for updating services

This commit is contained in:
Jake Turner 2026-03-09 04:55:43 +00:00 committed by Jake Turner
parent 7db8568e19
commit 58b106f388
19 changed files with 1356 additions and 61 deletions

View File

@ -1,7 +1,9 @@
import { DockerService } from '#services/docker_service';
import { SystemService } from '#services/system_service'
import { SystemUpdateService } from '#services/system_update_service'
import { affectServiceValidator, checkLatestVersionValidator, installServiceValidator, subscribeToReleaseNotesValidator } from '#validators/system';
import { ContainerRegistryService } from '#services/container_registry_service'
import { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job'
import { affectServiceValidator, checkLatestVersionValidator, installServiceValidator, subscribeToReleaseNotesValidator, updateServiceValidator } from '#validators/system';
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
@ -10,7 +12,8 @@ export default class SystemController {
constructor(
private systemService: SystemService,
private dockerService: DockerService,
private systemUpdateService: SystemUpdateService
private systemUpdateService: SystemUpdateService,
private containerRegistryService: ContainerRegistryService
) { }
async getInternetStatus({ }: HttpContext) {
@ -104,9 +107,70 @@ export default class SystemController {
response.send({ logs });
}
async subscribeToReleaseNotes({ request }: HttpContext) {
const reqData = await request.validateUsing(subscribeToReleaseNotesValidator);
return await this.systemService.subscribeToReleaseNotes(reqData.email);
}
async checkServiceUpdates({ response }: HttpContext) {
await CheckServiceUpdatesJob.dispatch()
response.send({ success: true, message: 'Service update check dispatched' })
}
async getAvailableVersions({ params, response }: HttpContext) {
const serviceName = params.name
const service = await (await import('#models/service')).default
.query()
.where('service_name', serviceName)
.where('installed', true)
.first()
if (!service) {
return response.status(404).send({ error: `Service ${serviceName} not found or not installed` })
}
try {
const hostArch = await this.getHostArch()
const updates = await this.containerRegistryService.getAvailableUpdates(
service.container_image,
hostArch,
service.source_repo
)
response.send({ versions: updates })
} catch (error) {
response.status(500).send({ error: `Failed to fetch versions: ${error.message}` })
}
}
async updateService({ request, response }: HttpContext) {
const payload = await request.validateUsing(updateServiceValidator)
const result = await this.dockerService.updateContainer(
payload.service_name,
payload.target_version
)
if (result.success) {
response.send({ success: true, message: result.message })
} else {
response.status(400).send({ error: result.message })
}
}
private async getHostArch(): Promise<string> {
try {
const info = await this.dockerService.docker.info()
const arch = info.Architecture || ''
const archMap: Record<string, string> = {
x86_64: 'amd64',
aarch64: 'arm64',
armv7l: 'arm',
amd64: 'amd64',
arm64: 'arm64',
}
return archMap[arch] || arch.toLowerCase()
} catch {
return 'amd64'
}
}
}

View File

@ -0,0 +1,134 @@
import { Job } from 'bullmq'
import { QueueService } from '#services/queue_service'
import { DockerService } from '#services/docker_service'
import { ContainerRegistryService } from '#services/container_registry_service'
import Service from '#models/service'
import logger from '@adonisjs/core/services/logger'
import transmit from '@adonisjs/transmit/services/main'
import { BROADCAST_CHANNELS } from '../../constants/broadcast.js'
import { DateTime } from 'luxon'
export class CheckServiceUpdatesJob {
static get queue() {
return 'service-updates'
}
static get key() {
return 'check-service-updates'
}
async handle(_job: Job) {
logger.info('[CheckServiceUpdatesJob] Checking for service updates...')
const dockerService = new DockerService()
const registryService = new ContainerRegistryService()
// Determine host architecture
const hostArch = await this.getHostArch(dockerService)
const installedServices = await Service.query().where('installed', true)
let updatesFound = 0
for (const service of installedServices) {
try {
const updates = await registryService.getAvailableUpdates(
service.container_image,
hostArch,
service.source_repo
)
const latestUpdate = updates.length > 0 ? updates[0].tag : null
service.available_update_version = latestUpdate
service.update_checked_at = DateTime.now()
await service.save()
if (latestUpdate) {
updatesFound++
logger.info(
`[CheckServiceUpdatesJob] Update available for ${service.service_name}: ${service.container_image}${latestUpdate}`
)
}
} catch (error) {
logger.error(
`[CheckServiceUpdatesJob] Failed to check updates for ${service.service_name}: ${error.message}`
)
// Continue checking other services
}
}
logger.info(
`[CheckServiceUpdatesJob] Completed. ${updatesFound} update(s) found for ${installedServices.length} service(s).`
)
// Broadcast completion so the frontend can refresh
transmit.broadcast(BROADCAST_CHANNELS.SERVICE_UPDATES, {
status: 'completed',
updatesFound,
timestamp: new Date().toISOString(),
})
return { updatesFound }
}
private async getHostArch(dockerService: DockerService): Promise<string> {
try {
const info = await dockerService.docker.info()
const arch = info.Architecture || ''
// Map Docker architecture names to OCI names
const archMap: Record<string, string> = {
x86_64: 'amd64',
aarch64: 'arm64',
armv7l: 'arm',
amd64: 'amd64',
arm64: 'arm64',
}
return archMap[arch] || arch.toLowerCase()
} catch (error) {
logger.warn(
`[CheckServiceUpdatesJob] Could not detect host architecture: ${error.message}. Defaulting to amd64.`
)
return 'amd64'
}
}
static async scheduleNightly() {
const queueService = new QueueService()
const queue = queueService.getQueue(this.queue)
await queue.upsertJobScheduler(
'nightly-service-update-check',
{ pattern: '0 3 * * *' },
{
name: this.key,
opts: {
removeOnComplete: { count: 7 },
removeOnFail: { count: 5 },
},
}
)
logger.info('[CheckServiceUpdatesJob] Service update check scheduled with cron: 0 3 * * *')
}
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(`[CheckServiceUpdatesJob] Dispatched ad-hoc service update check job ${job.id}`)
return job
}
}

View File

@ -62,6 +62,15 @@ export default class Service extends BaseModel {
@column()
declare metadata: string | null
@column()
declare source_repo: string | null
@column()
declare available_update_version: string | null
@column.dateTime()
declare update_checked_at: DateTime | null
@column.dateTime({ autoCreate: true })
declare created_at: DateTime

View File

@ -0,0 +1,484 @@
import logger from '@adonisjs/core/services/logger'
import { isNewerVersion, parseMajorVersion } from '../utils/version.js'
export interface ParsedImageReference {
registry: string
namespace: string
repo: string
tag: string
/** Full name for registry API calls: namespace/repo */
fullName: string
}
export interface AvailableUpdate {
tag: string
isLatest: boolean
releaseUrl?: string
}
interface TokenCacheEntry {
token: string
expiresAt: number
}
const SEMVER_TAG_PATTERN = /^v?(\d+\.\d+(?:\.\d+)?)$/
const PLATFORM_SUFFIXES = ['-arm64', '-amd64', '-alpine', '-slim', '-cuda', '-rocm']
const REJECTED_TAGS = new Set(['latest', 'nightly', 'edge', 'dev', 'beta', 'alpha', 'canary', 'rc', 'test', 'debug'])
export class ContainerRegistryService {
private tokenCache = new Map<string, TokenCacheEntry>()
private sourceUrlCache = new Map<string, string | null>()
private releaseTagPrefixCache = new Map<string, string>()
/**
* Parse a Docker image reference string into its components.
*/
parseImageReference(image: string): ParsedImageReference {
let registry: string
let remainder: string
let tag = 'latest'
// Split off the tag
const lastColon = image.lastIndexOf(':')
if (lastColon > -1 && !image.substring(lastColon).includes('/')) {
tag = image.substring(lastColon + 1)
image = image.substring(0, lastColon)
}
// Determine registry vs image path
const parts = image.split('/')
if (parts.length === 1) {
// e.g. "nginx" → Docker Hub library image
registry = 'registry-1.docker.io'
remainder = `library/${parts[0]}`
} else if (parts.length === 2 && !parts[0].includes('.') && !parts[0].includes(':')) {
// e.g. "ollama/ollama" → Docker Hub user image
registry = 'registry-1.docker.io'
remainder = image
} else {
// e.g. "ghcr.io/kiwix/kiwix-serve" → custom registry
registry = parts[0]
remainder = parts.slice(1).join('/')
}
const namespaceParts = remainder.split('/')
const repo = namespaceParts.pop()!
const namespace = namespaceParts.join('/')
return {
registry,
namespace,
repo,
tag,
fullName: remainder,
}
}
/**
* Get an anonymous auth token for the given registry and repository.
* NOTE: This could be expanded in the future to support private repo authentication
*/
private async getToken(registry: string, fullName: string): Promise<string> {
const cacheKey = `${registry}/${fullName}`
const cached = this.tokenCache.get(cacheKey)
if (cached && cached.expiresAt > Date.now()) {
return cached.token
}
let tokenUrl: string
if (registry === 'registry-1.docker.io') {
tokenUrl = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${fullName}:pull`
} else if (registry === 'ghcr.io') {
tokenUrl = `https://ghcr.io/token?service=ghcr.io&scope=repository:${fullName}:pull`
} else {
// For other registries, try the standard v2 token endpoint
tokenUrl = `https://${registry}/token?service=${registry}&scope=repository:${fullName}:pull`
}
const response = await this.fetchWithRetry(tokenUrl)
if (!response.ok) {
throw new Error(`Failed to get auth token from ${registry}: ${response.status}`)
}
const data = (await response.json()) as { token?: string; access_token?: string }
const token = data.token || data.access_token || ''
if (!token) {
throw new Error(`No token returned from ${registry}`)
}
// Cache for 5 minutes (tokens usually last longer, but be conservative)
this.tokenCache.set(cacheKey, {
token,
expiresAt: Date.now() + 5 * 60 * 1000,
})
return token
}
/**
* List all tags for a given image from the registry.
*/
async listTags(parsed: ParsedImageReference): Promise<string[]> {
const token = await this.getToken(parsed.registry, parsed.fullName)
const allTags: string[] = []
let url = `https://${parsed.registry}/v2/${parsed.fullName}/tags/list?n=1000`
while (url) {
const response = await this.fetchWithRetry(url, {
headers: { Authorization: `Bearer ${token}` },
})
if (!response.ok) {
throw new Error(`Failed to list tags for ${parsed.fullName}: ${response.status}`)
}
const data = (await response.json()) as { tags?: string[] }
if (data.tags) {
allTags.push(...data.tags)
}
// Handle pagination via Link header
const linkHeader = response.headers.get('link')
if (linkHeader) {
const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/)
url = match ? match[1] : ''
} else {
url = ''
}
}
return allTags
}
/**
* Check if a specific tag supports the given architecture by fetching its manifest.
*/
async checkArchSupport(parsed: ParsedImageReference, tag: string, hostArch: string): Promise<boolean> {
try {
const token = await this.getToken(parsed.registry, parsed.fullName)
const url = `https://${parsed.registry}/v2/${parsed.fullName}/manifests/${tag}`
const response = await this.fetchWithRetry(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: [
'application/vnd.oci.image.index.v1+json',
'application/vnd.docker.distribution.manifest.list.v2+json',
'application/vnd.oci.image.manifest.v1+json',
'application/vnd.docker.distribution.manifest.v2+json',
].join(', '),
},
})
if (!response.ok) return true // If we can't check, assume it's compatible
const manifest = (await response.json()) as {
mediaType?: string
manifests?: Array<{ platform?: { architecture?: string } }>
}
const mediaType = manifest.mediaType || response.headers.get('content-type') || ''
// Manifest list — check if any platform matches
if (
mediaType.includes('manifest.list') ||
mediaType.includes('image.index') ||
manifest.manifests
) {
const manifests = manifest.manifests || []
return manifests.some(
(m: any) => m.platform && m.platform.architecture === hostArch
)
}
// Single manifest — assume compatible (can't easily determine arch without fetching config blob)
return true
} catch (error) {
logger.warn(`[ContainerRegistryService] Error checking arch for ${tag}: ${error.message}`)
return true // Assume compatible on error
}
}
/**
* Extract the source repository URL from an image's OCI labels.
* Uses the standardized `org.opencontainers.image.source` label.
* Result is cached per image (not per tag).
*/
async getSourceUrl(parsed: ParsedImageReference): Promise<string | null> {
const cacheKey = `${parsed.registry}/${parsed.fullName}`
if (this.sourceUrlCache.has(cacheKey)) {
return this.sourceUrlCache.get(cacheKey)!
}
try {
const token = await this.getToken(parsed.registry, parsed.fullName)
// First get the manifest to find the config blob digest
const manifestUrl = `https://${parsed.registry}/v2/${parsed.fullName}/manifests/${parsed.tag}`
const manifestRes = await this.fetchWithRetry(manifestUrl, {
headers: {
Authorization: `Bearer ${token}`,
Accept: [
'application/vnd.oci.image.manifest.v1+json',
'application/vnd.docker.distribution.manifest.v2+json',
'application/vnd.oci.image.index.v1+json',
'application/vnd.docker.distribution.manifest.list.v2+json',
].join(', '),
},
})
if (!manifestRes.ok) {
this.sourceUrlCache.set(cacheKey, null)
return null
}
const manifest = (await manifestRes.json()) as {
config?: { digest?: string }
manifests?: Array<{ digest?: string; mediaType?: string; platform?: { architecture?: string } }>
}
// If this is a manifest list, pick the first manifest to get the config
let configDigest = manifest.config?.digest
if (!configDigest && manifest.manifests?.length) {
const firstManifest = manifest.manifests[0]
if (firstManifest.digest) {
const childRes = await this.fetchWithRetry(
`https://${parsed.registry}/v2/${parsed.fullName}/manifests/${firstManifest.digest}`,
{
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json',
},
}
)
if (childRes.ok) {
const childManifest = (await childRes.json()) as { config?: { digest?: string } }
configDigest = childManifest.config?.digest
}
}
}
if (!configDigest) {
this.sourceUrlCache.set(cacheKey, null)
return null
}
// Fetch the config blob to read labels
const blobUrl = `https://${parsed.registry}/v2/${parsed.fullName}/blobs/${configDigest}`
const blobRes = await this.fetchWithRetry(blobUrl, {
headers: { Authorization: `Bearer ${token}` },
})
if (!blobRes.ok) {
this.sourceUrlCache.set(cacheKey, null)
return null
}
const config = (await blobRes.json()) as {
config?: { Labels?: Record<string, string> }
}
const sourceUrl = config.config?.Labels?.['org.opencontainers.image.source'] || null
this.sourceUrlCache.set(cacheKey, sourceUrl)
return sourceUrl
} catch (error) {
logger.warn(`[ContainerRegistryService] Failed to get source URL for ${cacheKey}: ${error.message}`)
this.sourceUrlCache.set(cacheKey, null)
return null
}
}
/**
* Detect whether a GitHub/GitLab repo uses a 'v' prefix on release tags.
* Probes the GitHub API with the current tag to determine the convention,
* then caches the result per source URL.
*/
async detectReleaseTagPrefix(sourceUrl: string, sampleTag: string): Promise<string> {
if (this.releaseTagPrefixCache.has(sourceUrl)) {
return this.releaseTagPrefixCache.get(sourceUrl)!
}
try {
const url = new URL(sourceUrl)
if (url.hostname !== 'github.com') {
this.releaseTagPrefixCache.set(sourceUrl, '')
return ''
}
const cleanPath = url.pathname.replace(/\.git$/, '').replace(/\/$/, '')
const strippedTag = sampleTag.replace(/^v/, '')
const vTag = `v${strippedTag}`
// Try both variants against GitHub's API — the one that 200s tells us the convention
// Try v-prefixed first since it's more common
const vRes = await this.fetchWithRetry(
`https://api.github.com/repos${cleanPath}/releases/tags/${vTag}`,
{ headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'ProjectNomad' } },
1
)
if (vRes.ok) {
this.releaseTagPrefixCache.set(sourceUrl, 'v')
return 'v'
}
const plainRes = await this.fetchWithRetry(
`https://api.github.com/repos${cleanPath}/releases/tags/${strippedTag}`,
{ headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'ProjectNomad' } },
1
)
if (plainRes.ok) {
this.releaseTagPrefixCache.set(sourceUrl, '')
return ''
}
} catch {
// On error, fall through to default
}
// Default: no prefix modification
this.releaseTagPrefixCache.set(sourceUrl, '')
return ''
}
/**
* Build a release URL for a specific tag given a source repository URL and
* the detected release tag prefix convention.
* Supports GitHub and GitLab URL patterns.
*/
buildReleaseUrl(sourceUrl: string, tag: string, releaseTagPrefix: string): string | undefined {
try {
const url = new URL(sourceUrl)
if (url.hostname === 'github.com' || url.hostname.includes('gitlab')) {
const cleanPath = url.pathname.replace(/\.git$/, '').replace(/\/$/, '')
const strippedTag = tag.replace(/^v/, '')
const releaseTag = releaseTagPrefix ? `${releaseTagPrefix}${strippedTag}` : strippedTag
return `${url.origin}${cleanPath}/releases/tag/${releaseTag}`
}
} catch {
// Invalid URL, skip
}
return undefined
}
/**
* Filter and sort tags to find compatible updates for a service.
*/
filterCompatibleUpdates(
tags: string[],
currentTag: string,
majorVersion: number
): string[] {
return tags
.filter((tag) => {
// Must match semver pattern
if (!SEMVER_TAG_PATTERN.test(tag)) return false
// Reject known non-version tags
if (REJECTED_TAGS.has(tag.toLowerCase())) return false
// Reject platform suffixes
if (PLATFORM_SUFFIXES.some((suffix) => tag.toLowerCase().endsWith(suffix))) return false
// Must be same major version
if (parseMajorVersion(tag) !== majorVersion) return false
// Must be newer than current
return isNewerVersion(tag, currentTag)
})
.sort((a, b) => (isNewerVersion(a, b) ? -1 : 1)) // Newest first
}
/**
* High-level method to get available updates for a service.
* Returns a sorted list of compatible newer versions (newest first).
*/
async getAvailableUpdates(
containerImage: string,
hostArch: string,
fallbackSourceRepo?: string | null
): Promise<AvailableUpdate[]> {
const parsed = this.parseImageReference(containerImage)
const currentTag = parsed.tag
if (currentTag === 'latest') {
logger.warn(
`[ContainerRegistryService] Cannot check updates for ${containerImage} — using :latest tag`
)
return []
}
const majorVersion = parseMajorVersion(currentTag)
// Fetch tags and source URL in parallel
const [tags, ociSourceUrl] = await Promise.all([
this.listTags(parsed),
this.getSourceUrl(parsed),
])
// OCI label takes precedence, fall back to DB-stored source_repo
const sourceUrl = ociSourceUrl || fallbackSourceRepo || null
const compatible = this.filterCompatibleUpdates(tags, currentTag, majorVersion)
// Detect release tag prefix convention (e.g. 'v' vs no prefix) if we have a source URL
let releaseTagPrefix = ''
if (sourceUrl) {
releaseTagPrefix = await this.detectReleaseTagPrefix(sourceUrl, currentTag)
}
// Check architecture support for the top candidates (limit checks to save API calls)
const maxArchChecks = 10
const results: AvailableUpdate[] = []
for (const tag of compatible.slice(0, maxArchChecks)) {
const supported = await this.checkArchSupport(parsed, tag, hostArch)
if (supported) {
results.push({
tag,
isLatest: results.length === 0,
releaseUrl: sourceUrl ? this.buildReleaseUrl(sourceUrl, tag, releaseTagPrefix) : undefined,
})
}
}
// For remaining tags (beyond arch check limit), include them but mark as not latest
for (const tag of compatible.slice(maxArchChecks)) {
results.push({
tag,
isLatest: false,
releaseUrl: sourceUrl ? this.buildReleaseUrl(sourceUrl, tag, releaseTagPrefix) : undefined,
})
}
return results
}
/**
* Fetch with retry and exponential backoff for rate limiting.
*/
private async fetchWithRetry(
url: string,
init?: RequestInit,
maxRetries = 3
): Promise<Response> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(url, init)
if (response.status === 429 && attempt < maxRetries) {
const retryAfter = response.headers.get('retry-after')
const delay = retryAfter
? parseInt(retryAfter, 10) * 1000
: Math.pow(2, attempt) * 1000
logger.warn(
`[ContainerRegistryService] Rate limited on ${url}, retrying in ${delay}ms`
)
await new Promise((resolve) => setTimeout(resolve, delay))
continue
}
return response
}
throw new Error(`Failed to fetch ${url} after ${maxRetries} retries`)
}
}

View File

@ -113,8 +113,8 @@ export class DockerService {
const containers = await this.docker.listContainers({ all: true })
const containerMap = new Map<string, Docker.ContainerInfo>()
containers.forEach((container) => {
const name = container.Names[0].replace('/', '')
if (name.startsWith('nomad_')) {
const name = container.Names[0]?.replace('/', '')
if (name && name.startsWith('nomad_')) {
containerMap.set(name, container)
}
})
@ -792,6 +792,186 @@ export class DockerService {
// }
// }
/**
* Update a service container to a new image version while preserving volumes and data.
* Includes automatic rollback if the new container fails health checks.
*/
async updateContainer(
serviceName: string,
targetVersion: string
): Promise<{ success: boolean; message: string }> {
try {
const service = await Service.query().where('service_name', serviceName).first()
if (!service) {
return { success: false, message: `Service ${serviceName} not found` }
}
if (!service.installed) {
return { success: false, message: `Service ${serviceName} is not installed` }
}
if (this.activeInstallations.has(serviceName)) {
return { success: false, message: `Service ${serviceName} already has an operation in progress` }
}
this.activeInstallations.add(serviceName)
// Compute new image string
const currentImage = service.container_image
const imageBase = currentImage.includes(':')
? currentImage.substring(0, currentImage.lastIndexOf(':'))
: currentImage
const newImage = `${imageBase}:${targetVersion}`
// Step 1: Pull new image
this._broadcast(serviceName, 'update-pulling', `Pulling image ${newImage}...`)
const pullStream = await this.docker.pull(newImage)
await new Promise((res) => this.docker.modem.followProgress(pullStream, res))
// Step 2: Find and stop existing container
this._broadcast(serviceName, 'update-stopping', `Stopping current container...`)
const containers = await this.docker.listContainers({ all: true })
const existingContainer = containers.find((c) => c.Names.includes(`/${serviceName}`))
if (!existingContainer) {
this.activeInstallations.delete(serviceName)
return { success: false, message: `Container for ${serviceName} not found` }
}
const oldContainer = this.docker.getContainer(existingContainer.Id)
// Inspect to capture full config before stopping
const inspectData = await oldContainer.inspect()
if (existingContainer.State === 'running') {
await oldContainer.stop({ t: 15 })
}
// Step 3: Rename old container as safety net
const oldName = `${serviceName}_old`
await oldContainer.rename({ name: oldName })
// Step 4: Create new container with inspected config + new image
this._broadcast(serviceName, 'update-creating', `Creating updated container...`)
const hostConfig = inspectData.HostConfig || {}
const newContainerConfig: any = {
Image: newImage,
name: serviceName,
Env: inspectData.Config?.Env || undefined,
Cmd: inspectData.Config?.Cmd || undefined,
ExposedPorts: inspectData.Config?.ExposedPorts || undefined,
WorkingDir: inspectData.Config?.WorkingDir || undefined,
User: inspectData.Config?.User || undefined,
HostConfig: {
Binds: hostConfig.Binds || undefined,
PortBindings: hostConfig.PortBindings || undefined,
RestartPolicy: hostConfig.RestartPolicy || undefined,
DeviceRequests: hostConfig.DeviceRequests || undefined,
Devices: hostConfig.Devices || undefined,
},
NetworkingConfig: inspectData.NetworkSettings?.Networks
? {
EndpointsConfig: Object.fromEntries(
Object.keys(inspectData.NetworkSettings.Networks).map((net) => [net, {}])
),
}
: undefined,
}
// Remove undefined values from HostConfig
Object.keys(newContainerConfig.HostConfig).forEach((key) => {
if (newContainerConfig.HostConfig[key] === undefined) {
delete newContainerConfig.HostConfig[key]
}
})
let newContainer: any
try {
newContainer = await this.docker.createContainer(newContainerConfig)
} catch (createError) {
// Rollback: rename old container back
this._broadcast(serviceName, 'update-rollback', `Failed to create new container: ${createError.message}. Rolling back...`)
const rollbackContainer = this.docker.getContainer((await this.docker.listContainers({ all: true })).find((c) => c.Names.includes(`/${oldName}`))!.Id)
await rollbackContainer.rename({ name: serviceName })
await rollbackContainer.start()
this.activeInstallations.delete(serviceName)
return { success: false, message: `Failed to create updated container: ${createError.message}` }
}
// Step 5: Start new container
this._broadcast(serviceName, 'update-starting', `Starting updated container...`)
await newContainer.start()
// Step 6: Health check — verify container stays running for 5 seconds
await new Promise((resolve) => setTimeout(resolve, 5000))
const newContainerInfo = await newContainer.inspect()
if (newContainerInfo.State?.Running) {
// Healthy — clean up old container
try {
const oldContainerRef = this.docker.getContainer(
(await this.docker.listContainers({ all: true })).find((c) =>
c.Names.includes(`/${oldName}`)
)?.Id || ''
)
await oldContainerRef.remove({ force: true })
} catch {
// Old container may already be gone
}
// Update DB
service.container_image = newImage
service.available_update_version = null
await service.save()
this.activeInstallations.delete(serviceName)
this._broadcast(
serviceName,
'update-complete',
`Successfully updated ${serviceName} to ${targetVersion}`
)
return { success: true, message: `Service ${serviceName} updated to ${targetVersion}` }
} else {
// Unhealthy — rollback
this._broadcast(
serviceName,
'update-rollback',
`New container failed health check. Rolling back to previous version...`
)
try {
await newContainer.stop({ t: 5 }).catch(() => {})
await newContainer.remove({ force: true })
} catch {
// Best effort cleanup
}
// Restore old container
const oldContainers = await this.docker.listContainers({ all: true })
const oldRef = oldContainers.find((c) => c.Names.includes(`/${oldName}`))
if (oldRef) {
const rollbackContainer = this.docker.getContainer(oldRef.Id)
await rollbackContainer.rename({ name: serviceName })
await rollbackContainer.start()
}
this.activeInstallations.delete(serviceName)
return {
success: false,
message: `Update failed: new container did not stay running. Rolled back to previous version.`,
}
}
} catch (error) {
this.activeInstallations.delete(serviceName)
this._broadcast(
serviceName,
'update-rollback',
`Update failed: ${error.message}`
)
logger.error(`[DockerService] Update failed for ${serviceName}: ${error.message}`)
return { success: false, message: `Update failed: ${error.message}` }
}
}
private _broadcast(service: string, status: string, message: string) {
transmit.broadcast(BROADCAST_CHANNELS.SERVICE_INSTALLATION, {
service_name: service,

View File

@ -13,6 +13,7 @@ import axios from 'axios'
import env from '#start/env'
import KVStore from '#models/kv_store'
import { KV_STORE_SCHEMA, KVStoreKey } from '../../types/kv_store.js'
import { isNewerVersion } from '../utils/version.js'
@inject()
@ -142,7 +143,9 @@ export class SystemService {
'description',
'icon',
'powered_by',
'display_order'
'display_order',
'container_image',
'available_update_version'
)
.where('is_dependency_service', false)
if (installedOnly) {
@ -172,6 +175,8 @@ export class SystemService {
ui_location: service.ui_location || '',
powered_by: service.powered_by,
display_order: service.display_order,
container_image: service.container_image,
available_update_version: service.available_update_version,
})
}
@ -353,7 +358,7 @@ export class SystemService {
const updateAvailable = process.env.NODE_ENV === 'development'
? false
: this.isNewerVersion(latestVersion, currentVersion.trim())
: isNewerVersion(latestVersion, currentVersion.trim(), earlyAccess)
// Cache the results in KVStore for frontend checks
await KVStore.setValue('system.updateAvailable', updateAvailable)
@ -494,35 +499,4 @@ export class SystemService {
})
}
/**
* Compare two semantic version strings to determine if the first is newer than the second.
* @param version1 - The version to check (e.g., "1.25.0")
* @param version2 - The current version (e.g., "1.24.0")
* @returns true if version1 is newer than version2
*/
private isNewerVersion(version1: string, version2: string): boolean {
const [base1, pre1] = version1.split('-')
const [base2, pre2] = version2.split('-')
const v1Parts = base1.split('.').map((p) => parseInt(p, 10) || 0)
const v2Parts = base2.split('.').map((p) => parseInt(p, 10) || 0)
const maxLen = Math.max(v1Parts.length, v2Parts.length)
for (let i = 0; i < maxLen; i++) {
const a = v1Parts[i] || 0
const b = v2Parts[i] || 0
if (a > b) return true
if (a < b) return false
}
// Base versions equal — GA > RC, RC.n+1 > RC.n
if (!pre1 && pre2) return true // v1 is GA, v2 is RC → v1 is newer
if (pre1 && !pre2) return false // v1 is RC, v2 is GA → v2 is newer
if (!pre1 && !pre2) return false // both GA, equal
// Both prerelease: compare numeric suffix (e.g. "rc.2" vs "rc.1")
const pre1Num = parseInt(pre1.split('.')[1], 10) || 0
const pre2Num = parseInt(pre2.split('.')[1], 10) || 0
return pre1Num > pre2Num
}
}

View File

@ -0,0 +1,49 @@
/**
* Compare two semantic version strings to determine if the first is newer than the second.
* @param version1 - The version to check (e.g., "1.25.0")
* @param version2 - The current version (e.g., "1.24.0")
* @returns true if version1 is newer than version2
*/
export function isNewerVersion(version1: string, version2: string, includePreReleases = false): boolean {
const normalize = (v: string) => v.replace(/^v/, '')
const [base1, pre1] = normalize(version1).split('-')
const [base2, pre2] = normalize(version2).split('-')
// If pre-releases are not included and version1 is a pre-release, don't consider it newer
if (!includePreReleases && pre1) {
return false
}
const v1Parts = base1.split('.').map((p) => parseInt(p, 10) || 0)
const v2Parts = base2.split('.').map((p) => parseInt(p, 10) || 0)
const maxLen = Math.max(v1Parts.length, v2Parts.length)
for (let i = 0; i < maxLen; i++) {
const a = v1Parts[i] || 0
const b = v2Parts[i] || 0
if (a > b) return true
if (a < b) return false
}
// Base versions equal — GA > RC, RC.n+1 > RC.n
if (!pre1 && pre2) return true // v1 is GA, v2 is RC → v1 is newer
if (pre1 && !pre2) return false // v1 is RC, v2 is GA → v2 is newer
if (!pre1 && !pre2) return false // both GA, equal
// Both prerelease: compare numeric suffix (e.g. "rc.2" vs "rc.1")
const pre1Num = parseInt(pre1.split('.')[1], 10) || 0
const pre2Num = parseInt(pre2.split('.')[1], 10) || 0
return pre1Num > pre2Num
}
/**
* Parse the major version number from a tag string.
* Strips the 'v' prefix if present.
* @param tag - Version tag (e.g., "v3.8.1", "10.19.4")
* @returns The major version number
*/
export function parseMajorVersion(tag: string): number {
const normalized = tag.replace(/^v/, '')
const major = parseInt(normalized.split('.')[0], 10)
return isNaN(major) ? 0 : major
}

View File

@ -24,3 +24,10 @@ export const checkLatestVersionValidator = vine.compile(
force: vine.boolean().optional(), // Optional flag to force bypassing cache and checking for updates immediately
})
)
export const updateServiceValidator = vine.compile(
vine.object({
service_name: vine.string().trim(),
target_version: vine.string().trim(),
})
)

View File

@ -7,6 +7,7 @@ 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'
import { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job'
export default class QueueWork extends BaseCommand {
static commandName = 'queue:work'
@ -76,8 +77,9 @@ export default class QueueWork extends BaseCommand {
this.logger.info(`Worker started for queue: ${queueName}`)
}
// Schedule nightly update check (idempotent, will persist over restarts)
// Schedule nightly update checks (idempotent, will persist over restarts)
await CheckUpdateJob.scheduleNightly()
await CheckServiceUpdatesJob.scheduleNightly()
// Graceful shutdown for all workers
process.on('SIGTERM', async () => {
@ -97,12 +99,14 @@ export default class QueueWork extends BaseCommand {
handlers.set(RunBenchmarkJob.key, new RunBenchmarkJob())
handlers.set(EmbedFileJob.key, new EmbedFileJob())
handlers.set(CheckUpdateJob.key, new CheckUpdateJob())
handlers.set(CheckServiceUpdatesJob.key, new CheckServiceUpdatesJob())
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)
queues.set(CheckServiceUpdatesJob.key, CheckServiceUpdatesJob.queue)
return [handlers, queues]
}

View File

@ -3,4 +3,5 @@ export const BROADCAST_CHANNELS = {
BENCHMARK_PROGRESS: 'benchmark-progress',
OLLAMA_MODEL_DOWNLOAD: 'ollama-model-download',
SERVICE_INSTALLATION: 'service-installation',
SERVICE_UPDATES: 'service-updates',
}

View File

@ -0,0 +1,21 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'services'
async up() {
this.schema.alterTable(this.tableName, (table) => {
table.string('source_repo', 255).nullable()
table.string('available_update_version', 50).nullable()
table.timestamp('update_checked_at').nullable()
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('source_repo')
table.dropColumn('available_update_version')
table.dropColumn('update_checked_at')
})
}
}

View File

@ -0,0 +1,61 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'services'
async up() {
this.defer(async (db) => {
// Pin :latest images to specific versions
await db
.from(this.tableName)
.where('container_image', 'ghcr.io/gchq/cyberchef:latest')
.update({ container_image: 'ghcr.io/gchq/cyberchef:10.19.4' })
await db
.from(this.tableName)
.where('container_image', 'dullage/flatnotes:latest')
.update({ container_image: 'dullage/flatnotes:v5.5.4' })
await db
.from(this.tableName)
.where('container_image', 'treehouses/kolibri:latest')
.update({ container_image: 'treehouses/kolibri:0.12.8' })
// Populate source_repo for services whose images lack the OCI source label
const sourceRepos: Record<string, string> = {
nomad_kiwix_server: 'https://github.com/kiwix/kiwix-tools',
nomad_ollama: 'https://github.com/ollama/ollama',
nomad_qdrant: 'https://github.com/qdrant/qdrant',
nomad_cyberchef: 'https://github.com/gchq/CyberChef',
nomad_flatnotes: 'https://github.com/dullage/flatnotes',
nomad_kolibri: 'https://github.com/learningequality/kolibri',
}
for (const [serviceName, repoUrl] of Object.entries(sourceRepos)) {
await db
.from(this.tableName)
.where('service_name', serviceName)
.update({ source_repo: repoUrl })
}
})
}
async down() {
this.defer(async (db) => {
await db
.from(this.tableName)
.where('container_image', 'ghcr.io/gchq/cyberchef:10.19.4')
.update({ container_image: 'ghcr.io/gchq/cyberchef:latest' })
await db
.from(this.tableName)
.where('container_image', 'dullage/flatnotes:v5.5.4')
.update({ container_image: 'dullage/flatnotes:latest' })
await db
.from(this.tableName)
.where('container_image', 'treehouses/kolibri:0.12.8')
.update({ container_image: 'treehouses/kolibri:latest' })
})
}
}

View File

@ -12,7 +12,7 @@ export default class ServiceSeeder extends BaseSeeder {
)
private static DEFAULT_SERVICES: Omit<
ModelAttributes<Service>,
'created_at' | 'updated_at' | 'metadata' | 'id'
'created_at' | 'updated_at' | 'metadata' | 'id' | 'available_update_version' | 'update_checked_at'
>[] = [
{
service_name: SERVICE_NAMES.KIWIX,
@ -23,6 +23,7 @@ export default class ServiceSeeder extends BaseSeeder {
'Offline access to Wikipedia, medical references, how-to guides, and encyclopedias',
icon: 'IconBooks',
container_image: 'ghcr.io/kiwix/kiwix-serve:3.8.1',
source_repo: 'https://github.com/kiwix/kiwix-tools',
container_command: '*.zim --address=all',
container_config: JSON.stringify({
HostConfig: {
@ -46,6 +47,7 @@ export default class ServiceSeeder extends BaseSeeder {
description: 'Vector database for storing and searching embeddings',
icon: 'IconRobot',
container_image: 'qdrant/qdrant:v1.16',
source_repo: 'https://github.com/qdrant/qdrant',
container_command: null,
container_config: JSON.stringify({
HostConfig: {
@ -69,6 +71,7 @@ export default class ServiceSeeder extends BaseSeeder {
description: 'Local AI chat that runs entirely on your hardware - no internet required',
icon: 'IconWand',
container_image: 'ollama/ollama:0.15.2',
source_repo: 'https://github.com/ollama/ollama',
container_command: 'serve',
container_config: JSON.stringify({
HostConfig: {
@ -91,7 +94,8 @@ export default class ServiceSeeder extends BaseSeeder {
display_order: 11,
description: 'Swiss Army knife for data encoding, encryption, and analysis',
icon: 'IconChefHat',
container_image: 'ghcr.io/gchq/cyberchef:latest',
container_image: 'ghcr.io/gchq/cyberchef:10.19.4',
source_repo: 'https://github.com/gchq/CyberChef',
container_command: null,
container_config: JSON.stringify({
HostConfig: {
@ -113,7 +117,8 @@ export default class ServiceSeeder extends BaseSeeder {
display_order: 10,
description: 'Simple note-taking app with local storage',
icon: 'IconNotes',
container_image: 'dullage/flatnotes:latest',
container_image: 'dullage/flatnotes:v5.5.4',
source_repo: 'https://github.com/dullage/flatnotes',
container_command: null,
container_config: JSON.stringify({
HostConfig: {
@ -137,7 +142,8 @@ export default class ServiceSeeder extends BaseSeeder {
display_order: 2,
description: 'Interactive learning platform with video courses and exercises',
icon: 'IconSchool',
container_image: 'treehouses/kolibri:latest',
container_image: 'treehouses/kolibri:0.12.8',
source_repo: 'https://github.com/learningequality/kolibri',
container_command: null,
container_config: JSON.stringify({
HostConfig: {

View File

@ -1,4 +1,4 @@
import { IconCircleCheck } from '@tabler/icons-react'
import { IconCircleCheck, IconCircleX } from '@tabler/icons-react'
import classNames from '~/lib/classNames'
export type InstallActivityFeedProps = {
@ -16,6 +16,12 @@ export type InstallActivityFeedProps = {
| 'started'
| 'finalizing'
| 'completed'
| 'update-pulling'
| 'update-stopping'
| 'update-creating'
| 'update-starting'
| 'update-complete'
| 'update-rollback'
timestamp: string
message: string
}>
@ -40,8 +46,10 @@ const InstallActivityFeed: React.FC<InstallActivityFeedProps> = ({ activity, cla
</div>
<>
<div className="relative flex size-6 flex-none items-center justify-center bg-transparent">
{activityItem.type === 'completed' ? (
{activityItem.type === 'completed' || activityItem.type === 'update-complete' ? (
<IconCircleCheck aria-hidden="true" className="size-6 text-indigo-600" />
) : activityItem.type === 'update-rollback' ? (
<IconCircleX aria-hidden="true" className="size-6 text-red-500" />
) : (
<div className="size-1.5 rounded-full bg-gray-100 ring-1 ring-gray-300" />
)}

View File

@ -0,0 +1,145 @@
import { useState } from "react"
import { ServiceSlim } from "../../types/services"
import StyledModal from "./StyledModal"
import { IconArrowUp } from "@tabler/icons-react"
import api from "~/lib/api"
interface UpdateServiceModalProps {
record: ServiceSlim
currentTag: string
latestVersion: string
onCancel: () => void
onUpdate: (version: string) => void
showError: (msg: string) => void
}
export default function UpdateServiceModal({
record,
currentTag,
latestVersion,
onCancel,
onUpdate,
showError,
}: UpdateServiceModalProps) {
const [selectedVersion, setSelectedVersion] = useState(latestVersion)
const [showAdvanced, setShowAdvanced] = useState(false)
const [versions, setVersions] = useState<Array<{ tag: string; isLatest: boolean; releaseUrl?: string }>>([])
const [loadingVersions, setLoadingVersions] = useState(false)
async function loadVersions() {
if (versions.length > 0) return
setLoadingVersions(true)
try {
const result = await api.getAvailableVersions(record.service_name)
if (result?.versions) {
setVersions(result.versions)
}
} catch (error) {
showError('Failed to load available versions')
} finally {
setLoadingVersions(false)
}
}
function handleToggleAdvanced() {
const next = !showAdvanced
setShowAdvanced(next)
if (next) loadVersions()
}
return (
<StyledModal
title="Update Service"
onConfirm={() => onUpdate(selectedVersion)}
onCancel={onCancel}
open={true}
confirmText="Update"
cancelText="Cancel"
confirmVariant="primary"
icon={<IconArrowUp className="h-12 w-12 text-desert-green" />}
>
<div className="space-y-4">
<p className="text-gray-700">
Update <strong>{record.friendly_name || record.service_name}</strong> from{' '}
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-sm">{currentTag}</code> to{' '}
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-sm">{selectedVersion}</code>?
</p>
<p className="text-sm text-gray-500">
Your data and configuration will be preserved during the update.
{versions.find((v) => v.tag === selectedVersion)?.releaseUrl && (
<>
{' '}
<a
href={versions.find((v) => v.tag === selectedVersion)!.releaseUrl}
target="_blank"
rel="noopener noreferrer"
className="text-desert-green hover:underline"
>
View release notes
</a>
</>
)}
</p>
<div>
<button
type="button"
onClick={handleToggleAdvanced}
className="text-sm text-desert-green hover:underline font-medium"
>
{showAdvanced ? 'Hide' : 'Show'} available versions
</button>
{showAdvanced && (
<>
<div className="mt-3 max-h-48 overflow-y-auto border rounded-lg divide-y">
{loadingVersions ? (
<div className="p-4 text-center text-gray-500 text-sm">Loading versions...</div>
) : versions.length === 0 ? (
<div className="p-4 text-center text-gray-500 text-sm">No other versions available</div>
) : (
versions.map((v) => (
<label
key={v.tag}
className="flex items-center gap-3 px-4 py-2.5 hover:bg-gray-50 cursor-pointer"
>
<input
type="radio"
name="version"
value={v.tag}
checked={selectedVersion === v.tag}
onChange={() => setSelectedVersion(v.tag)}
className="text-desert-green focus:ring-desert-green"
/>
<span className="text-sm font-medium text-gray-900">{v.tag}</span>
{v.isLatest && (
<span className="text-xs bg-desert-green/10 text-desert-green px-2 py-0.5 rounded-full">
Latest
</span>
)}
{v.releaseUrl && (
<a
href={v.releaseUrl}
target="_blank"
rel="noopener noreferrer"
className="ml-auto text-xs text-desert-green hover:underline"
onClick={(e) => e.stopPropagation()}
>
Release notes
</a>
)}
</label>
))
)}
</div>
<p className="mt-2 text-sm text-gray-500">
It's not recommended to upgrade to a new major version (e.g. 1.8.2 &rarr; 2.0.0) unless you have verified compatibility with your current configuration. Always review the release notes and test in a staging environment if possible.
</p>
</>
)}
</div>
</div>
</StyledModal>
)
}

View File

@ -163,6 +163,34 @@ class API {
})()
}
async checkServiceUpdates() {
return catchInternal(async () => {
const response = await this.client.post<{ success: boolean; message: string }>(
'/system/services/check-updates'
)
return response.data
})()
}
async getAvailableVersions(serviceName: string) {
return catchInternal(async () => {
const response = await this.client.get<{
versions: Array<{ tag: string; isLatest: boolean; releaseUrl?: string }>
}>(`/system/services/${serviceName}/available-versions`)
return response.data
})()
}
async updateService(serviceName: string, targetVersion: string) {
return catchInternal(async () => {
const response = await this.client.post<{ success: boolean; message: string }>(
'/system/services/update',
{ service_name: serviceName, target_version: targetVersion }
)
return response.data
})()
}
async forceReinstallService(service_name: string) {
return catchInternal(async () => {
const response = await this.client.post<{ success: boolean; message: string }>(

View File

@ -13,27 +13,68 @@ import LoadingSpinner from '~/components/LoadingSpinner'
import useErrorNotification from '~/hooks/useErrorNotification'
import useInternetStatus from '~/hooks/useInternetStatus'
import useServiceInstallationActivity from '~/hooks/useServiceInstallationActivity'
import { IconCheck, IconDownload } from '@tabler/icons-react'
import { useTransmit } from 'react-adonis-transmit'
import { BROADCAST_CHANNELS } from '../../../constants/broadcast'
import { IconArrowUp, IconCheck, IconDownload } from '@tabler/icons-react'
import UpdateServiceModal from '~/components/UpdateServiceModal'
function extractTag(containerImage: string): string {
if (!containerImage) return ''
const parts = containerImage.split(':')
return parts.length > 1 ? parts[parts.length - 1] : 'latest'
}
export default function SettingsPage(props: { system: { services: ServiceSlim[] } }) {
const { openModal, closeAllModals } = useModals()
const { showError } = useErrorNotification()
const { isOnline } = useInternetStatus()
const { subscribe } = useTransmit()
const installActivity = useServiceInstallationActivity()
const [isInstalling, setIsInstalling] = useState(false)
const [loading, setLoading] = useState(false)
const [checkingUpdates, setCheckingUpdates] = useState(false)
useEffect(() => {
if (installActivity.length === 0) return
if (installActivity.some((activity) => activity.type === 'completed')) {
// If any activity is completed, we can clear the installActivity state
if (
installActivity.some(
(activity) => activity.type === 'completed' || activity.type === 'update-complete'
)
) {
setTimeout(() => {
window.location.reload() // Reload the page to reflect changes
}, 3000) // Clear after 3 seconds
window.location.reload()
}, 3000)
}
}, [installActivity])
// Listen for service update check completion
useEffect(() => {
const unsubscribe = subscribe(BROADCAST_CHANNELS.SERVICE_UPDATES, () => {
setCheckingUpdates(false)
window.location.reload()
})
return () => { unsubscribe() }
}, [])
async function handleCheckUpdates() {
try {
if (!isOnline) {
showError('You must have an internet connection to check for updates.')
return
}
setCheckingUpdates(true)
const response = await api.checkServiceUpdates()
if (!response?.success) {
throw new Error('Failed to dispatch update check')
}
} catch (error) {
console.error('Error checking for updates:', error)
showError(`Failed to check for updates: ${error.message || 'Unknown error'}`)
setCheckingUpdates(false)
}
}
const handleInstallService = (service: ServiceSlim) => {
openModal(
<StyledModal
@ -97,8 +138,8 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
setTimeout(() => {
setLoading(false)
window.location.reload() // Reload the page to reflect changes
}, 3000) // Add small delay to allow for the action to complete
window.location.reload()
}, 3000)
} catch (error) {
console.error(`Error affecting service ${record.service_name}:`, error)
showError(`Failed to ${action} service: ${error.message || 'Unknown error'}`)
@ -120,14 +161,44 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
setTimeout(() => {
setLoading(false)
window.location.reload() // Reload the page to reflect changes
}, 3000) // Add small delay to allow for the action to complete
window.location.reload()
}, 3000)
} catch (error) {
console.error(`Error force reinstalling service ${record.service_name}:`, error)
showError(`Failed to force reinstall service: ${error.message || 'Unknown error'}`)
}
}
function handleUpdateService(record: ServiceSlim) {
const currentTag = extractTag(record.container_image)
const latestVersion = record.available_update_version!
openModal(
<UpdateServiceModal
record={record}
currentTag={currentTag}
latestVersion={latestVersion}
onCancel={closeAllModals}
onUpdate={async (targetVersion: string) => {
closeAllModals()
try {
setLoading(true)
const response = await api.updateService(record.service_name, targetVersion)
if (!response?.success) {
throw new Error(response?.message || 'Update failed')
}
} catch (error) {
console.error(`Error updating service ${record.service_name}:`, error)
showError(`Failed to update service: ${error.message || 'Unknown error'}`)
setLoading(false)
}
}}
showError={showError}
/>,
`${record.service_name}-update-modal`
)
}
const AppActions = ({ record }: { record: ServiceSlim }) => {
const ForceReinstallButton = () => (
<StyledButton
@ -162,7 +233,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
if (!record) return null
if (!record.installed) {
return (
<div className="flex space-x-2">
<div className="flex flex-wrap gap-2">
<StyledButton
icon={'IconDownload'}
variant="primary"
@ -178,7 +249,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
}
return (
<div className="flex space-x-2">
<div className="flex flex-wrap gap-2">
<StyledButton
icon={'IconExternalLink'}
onClick={() => {
@ -187,6 +258,16 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
>
Open
</StyledButton>
{record.available_update_version && (
<StyledButton
icon="IconArrowUp"
variant="primary"
onClick={() => handleUpdateService(record)}
disabled={isInstalling || !isOnline}
>
Update
</StyledButton>
)}
{record.status && record.status !== 'unknown' && (
<>
<StyledButton
@ -254,14 +335,26 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
<Head title="App Settings" />
<div className="xl:pl-72 w-full">
<main className="px-12 py-6">
<h1 className="text-4xl font-semibold mb-4">Apps</h1>
<p className="text-gray-500 mb-4">
Manage the applications that are available in your Project N.O.M.A.D. instance.
</p>
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-4xl font-semibold">Apps</h1>
<p className="text-gray-500 mt-1">
Manage the applications that are available in your Project N.O.M.A.D. instance. Nightly update checks will automatically detect when new versions of these apps are available.
</p>
</div>
<StyledButton
icon="IconRefreshAlert"
onClick={handleCheckUpdates}
disabled={checkingUpdates || !isOnline}
loading={checkingUpdates}
>
Check for Updates
</StyledButton>
</div>
{loading && <LoadingSpinner fullscreen />}
{!loading && (
<StyledTable<ServiceSlim & { actions?: any }>
className="font-semibold"
className="font-semibold !overflow-x-auto"
rowLines={true}
columns={[
{
@ -296,9 +389,30 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
render: (record) =>
record.installed ? <IconCheck className="h-6 w-6 text-desert-green" /> : '',
},
{
accessor: 'container_image',
title: 'Version',
render: (record) => {
if (!record.installed) return null
const currentTag = extractTag(record.container_image)
if (record.available_update_version) {
return (
<div className="flex items-center gap-1.5">
<span className="text-gray-500">{currentTag}</span>
<IconArrowUp className="h-4 w-4 text-desert-green" />
<span className="text-desert-green font-semibold">
{record.available_update_version}
</span>
</div>
)
}
return <span className="text-gray-600">{currentTag}</span>
},
},
{
accessor: 'actions',
title: 'Actions',
className: '!whitespace-normal',
render: (record) => <AppActions record={record} />,
},
]}
@ -313,3 +427,4 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
</SettingsLayout>
)
}

View File

@ -141,6 +141,9 @@ router
router.post('/services/affect', [SystemController, 'affectService'])
router.post('/services/install', [SystemController, 'installService'])
router.post('/services/force-reinstall', [SystemController, 'forceReinstallService'])
router.post('/services/check-updates', [SystemController, 'checkServiceUpdates'])
router.get('/services/:name/available-versions', [SystemController, 'getAvailableVersions'])
router.post('/services/update', [SystemController, 'updateService'])
router.post('/subscribe-release-notes', [SystemController, 'subscribeToReleaseNotes'])
router.get('/latest-version', [SystemController, 'checkLatestVersion'])
router.post('/update', [SystemController, 'requestSystemUpdate'])

View File

@ -12,4 +12,6 @@ export type ServiceSlim = Pick<
| 'icon'
| 'powered_by'
| 'display_order'
| 'container_image'
| 'available_update_version'
> & { status?: string }