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 { 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, 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 { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http' import type { HttpContext } from '@adonisjs/core/http'
@ -10,7 +12,8 @@ export default class SystemController {
constructor( constructor(
private systemService: SystemService, private systemService: SystemService,
private dockerService: DockerService, private dockerService: DockerService,
private systemUpdateService: SystemUpdateService private systemUpdateService: SystemUpdateService,
private containerRegistryService: ContainerRegistryService
) { } ) { }
async getInternetStatus({ }: HttpContext) { async getInternetStatus({ }: HttpContext) {
@ -104,9 +107,70 @@ export default class SystemController {
response.send({ logs }); response.send({ logs });
} }
async subscribeToReleaseNotes({ request }: HttpContext) { async subscribeToReleaseNotes({ request }: HttpContext) {
const reqData = await request.validateUsing(subscribeToReleaseNotesValidator); const reqData = await request.validateUsing(subscribeToReleaseNotesValidator);
return await this.systemService.subscribeToReleaseNotes(reqData.email); 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() @column()
declare metadata: string | null 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 }) @column.dateTime({ autoCreate: true })
declare created_at: DateTime 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 containers = await this.docker.listContainers({ all: true })
const containerMap = new Map<string, Docker.ContainerInfo>() const containerMap = new Map<string, Docker.ContainerInfo>()
containers.forEach((container) => { containers.forEach((container) => {
const name = container.Names[0].replace('/', '') const name = container.Names[0]?.replace('/', '')
if (name.startsWith('nomad_')) { if (name && name.startsWith('nomad_')) {
containerMap.set(name, container) 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) { private _broadcast(service: string, status: string, message: string) {
transmit.broadcast(BROADCAST_CHANNELS.SERVICE_INSTALLATION, { transmit.broadcast(BROADCAST_CHANNELS.SERVICE_INSTALLATION, {
service_name: service, service_name: service,

View File

@ -13,6 +13,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 { KV_STORE_SCHEMA, KVStoreKey } from '../../types/kv_store.js' import { KV_STORE_SCHEMA, KVStoreKey } from '../../types/kv_store.js'
import { isNewerVersion } from '../utils/version.js'
@inject() @inject()
@ -142,7 +143,9 @@ export class SystemService {
'description', 'description',
'icon', 'icon',
'powered_by', 'powered_by',
'display_order' 'display_order',
'container_image',
'available_update_version'
) )
.where('is_dependency_service', false) .where('is_dependency_service', false)
if (installedOnly) { if (installedOnly) {
@ -172,6 +175,8 @@ export class SystemService {
ui_location: service.ui_location || '', ui_location: service.ui_location || '',
powered_by: service.powered_by, powered_by: service.powered_by,
display_order: service.display_order, 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' const updateAvailable = process.env.NODE_ENV === 'development'
? false ? false
: this.isNewerVersion(latestVersion, currentVersion.trim()) : isNewerVersion(latestVersion, currentVersion.trim(), earlyAccess)
// Cache the results in KVStore for frontend checks // Cache the results in KVStore for frontend checks
await KVStore.setValue('system.updateAvailable', updateAvailable) 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 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 { 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' import { CheckUpdateJob } from '#jobs/check_update_job'
import { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job'
export default class QueueWork extends BaseCommand { export default class QueueWork extends BaseCommand {
static commandName = 'queue:work' static commandName = 'queue:work'
@ -76,8 +77,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) // Schedule nightly update checks (idempotent, will persist over restarts)
await CheckUpdateJob.scheduleNightly() await CheckUpdateJob.scheduleNightly()
await CheckServiceUpdatesJob.scheduleNightly()
// Graceful shutdown for all workers // Graceful shutdown for all workers
process.on('SIGTERM', async () => { process.on('SIGTERM', async () => {
@ -97,12 +99,14 @@ export default class QueueWork extends BaseCommand {
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()) handlers.set(CheckUpdateJob.key, new CheckUpdateJob())
handlers.set(CheckServiceUpdatesJob.key, new CheckServiceUpdatesJob())
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) queues.set(CheckUpdateJob.key, CheckUpdateJob.queue)
queues.set(CheckServiceUpdatesJob.key, CheckServiceUpdatesJob.queue)
return [handlers, queues] return [handlers, queues]
} }

View File

@ -3,4 +3,5 @@ export const BROADCAST_CHANNELS = {
BENCHMARK_PROGRESS: 'benchmark-progress', BENCHMARK_PROGRESS: 'benchmark-progress',
OLLAMA_MODEL_DOWNLOAD: 'ollama-model-download', OLLAMA_MODEL_DOWNLOAD: 'ollama-model-download',
SERVICE_INSTALLATION: 'service-installation', 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< private static DEFAULT_SERVICES: Omit<
ModelAttributes<Service>, 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, 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', 'Offline access to Wikipedia, medical references, how-to guides, and encyclopedias',
icon: 'IconBooks', icon: 'IconBooks',
container_image: 'ghcr.io/kiwix/kiwix-serve:3.8.1', container_image: 'ghcr.io/kiwix/kiwix-serve:3.8.1',
source_repo: 'https://github.com/kiwix/kiwix-tools',
container_command: '*.zim --address=all', container_command: '*.zim --address=all',
container_config: JSON.stringify({ container_config: JSON.stringify({
HostConfig: { HostConfig: {
@ -46,6 +47,7 @@ export default class ServiceSeeder extends BaseSeeder {
description: 'Vector database for storing and searching embeddings', description: 'Vector database for storing and searching embeddings',
icon: 'IconRobot', icon: 'IconRobot',
container_image: 'qdrant/qdrant:v1.16', container_image: 'qdrant/qdrant:v1.16',
source_repo: 'https://github.com/qdrant/qdrant',
container_command: null, container_command: null,
container_config: JSON.stringify({ container_config: JSON.stringify({
HostConfig: { HostConfig: {
@ -69,6 +71,7 @@ export default class ServiceSeeder extends BaseSeeder {
description: 'Local AI chat that runs entirely on your hardware - no internet required', description: 'Local AI chat that runs entirely on your hardware - no internet required',
icon: 'IconWand', icon: 'IconWand',
container_image: 'ollama/ollama:0.15.2', container_image: 'ollama/ollama:0.15.2',
source_repo: 'https://github.com/ollama/ollama',
container_command: 'serve', container_command: 'serve',
container_config: JSON.stringify({ container_config: JSON.stringify({
HostConfig: { HostConfig: {
@ -91,7 +94,8 @@ export default class ServiceSeeder extends BaseSeeder {
display_order: 11, display_order: 11,
description: 'Swiss Army knife for data encoding, encryption, and analysis', description: 'Swiss Army knife for data encoding, encryption, and analysis',
icon: 'IconChefHat', 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_command: null,
container_config: JSON.stringify({ container_config: JSON.stringify({
HostConfig: { HostConfig: {
@ -113,7 +117,8 @@ export default class ServiceSeeder extends BaseSeeder {
display_order: 10, display_order: 10,
description: 'Simple note-taking app with local storage', description: 'Simple note-taking app with local storage',
icon: 'IconNotes', 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_command: null,
container_config: JSON.stringify({ container_config: JSON.stringify({
HostConfig: { HostConfig: {
@ -137,7 +142,8 @@ export default class ServiceSeeder extends BaseSeeder {
display_order: 2, display_order: 2,
description: 'Interactive learning platform with video courses and exercises', description: 'Interactive learning platform with video courses and exercises',
icon: 'IconSchool', 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_command: null,
container_config: JSON.stringify({ container_config: JSON.stringify({
HostConfig: { 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' import classNames from '~/lib/classNames'
export type InstallActivityFeedProps = { export type InstallActivityFeedProps = {
@ -16,6 +16,12 @@ export type InstallActivityFeedProps = {
| 'started' | 'started'
| 'finalizing' | 'finalizing'
| 'completed' | 'completed'
| 'update-pulling'
| 'update-stopping'
| 'update-creating'
| 'update-starting'
| 'update-complete'
| 'update-rollback'
timestamp: string timestamp: string
message: string message: string
}> }>
@ -40,8 +46,10 @@ const InstallActivityFeed: React.FC<InstallActivityFeedProps> = ({ activity, cla
</div> </div>
<> <>
<div className="relative flex size-6 flex-none items-center justify-center bg-transparent"> <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" /> <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" /> <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) { async forceReinstallService(service_name: string) {
return catchInternal(async () => { return catchInternal(async () => {
const response = await this.client.post<{ success: boolean; message: string }>( 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 useErrorNotification from '~/hooks/useErrorNotification'
import useInternetStatus from '~/hooks/useInternetStatus' import useInternetStatus from '~/hooks/useInternetStatus'
import useServiceInstallationActivity from '~/hooks/useServiceInstallationActivity' 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[] } }) { export default function SettingsPage(props: { system: { services: ServiceSlim[] } }) {
const { openModal, closeAllModals } = useModals() const { openModal, closeAllModals } = useModals()
const { showError } = useErrorNotification() const { showError } = useErrorNotification()
const { isOnline } = useInternetStatus() const { isOnline } = useInternetStatus()
const { subscribe } = useTransmit()
const installActivity = useServiceInstallationActivity() const installActivity = useServiceInstallationActivity()
const [isInstalling, setIsInstalling] = useState(false) const [isInstalling, setIsInstalling] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [checkingUpdates, setCheckingUpdates] = useState(false)
useEffect(() => { useEffect(() => {
if (installActivity.length === 0) return if (installActivity.length === 0) return
if (installActivity.some((activity) => activity.type === 'completed')) { if (
// If any activity is completed, we can clear the installActivity state installActivity.some(
(activity) => activity.type === 'completed' || activity.type === 'update-complete'
)
) {
setTimeout(() => { setTimeout(() => {
window.location.reload() // Reload the page to reflect changes window.location.reload()
}, 3000) // Clear after 3 seconds }, 3000)
} }
}, [installActivity]) }, [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) => { const handleInstallService = (service: ServiceSlim) => {
openModal( openModal(
<StyledModal <StyledModal
@ -97,8 +138,8 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
setTimeout(() => { setTimeout(() => {
setLoading(false) setLoading(false)
window.location.reload() // Reload the page to reflect changes window.location.reload()
}, 3000) // Add small delay to allow for the action to complete }, 3000)
} catch (error) { } catch (error) {
console.error(`Error affecting service ${record.service_name}:`, error) console.error(`Error affecting service ${record.service_name}:`, error)
showError(`Failed to ${action} service: ${error.message || 'Unknown error'}`) showError(`Failed to ${action} service: ${error.message || 'Unknown error'}`)
@ -120,14 +161,44 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
setTimeout(() => { setTimeout(() => {
setLoading(false) setLoading(false)
window.location.reload() // Reload the page to reflect changes window.location.reload()
}, 3000) // Add small delay to allow for the action to complete }, 3000)
} catch (error) { } catch (error) {
console.error(`Error force reinstalling service ${record.service_name}:`, error) console.error(`Error force reinstalling service ${record.service_name}:`, error)
showError(`Failed to force reinstall service: ${error.message || 'Unknown 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 AppActions = ({ record }: { record: ServiceSlim }) => {
const ForceReinstallButton = () => ( const ForceReinstallButton = () => (
<StyledButton <StyledButton
@ -162,7 +233,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
if (!record) return null if (!record) return null
if (!record.installed) { if (!record.installed) {
return ( return (
<div className="flex space-x-2"> <div className="flex flex-wrap gap-2">
<StyledButton <StyledButton
icon={'IconDownload'} icon={'IconDownload'}
variant="primary" variant="primary"
@ -178,7 +249,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
} }
return ( return (
<div className="flex space-x-2"> <div className="flex flex-wrap gap-2">
<StyledButton <StyledButton
icon={'IconExternalLink'} icon={'IconExternalLink'}
onClick={() => { onClick={() => {
@ -187,6 +258,16 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
> >
Open Open
</StyledButton> </StyledButton>
{record.available_update_version && (
<StyledButton
icon="IconArrowUp"
variant="primary"
onClick={() => handleUpdateService(record)}
disabled={isInstalling || !isOnline}
>
Update
</StyledButton>
)}
{record.status && record.status !== 'unknown' && ( {record.status && record.status !== 'unknown' && (
<> <>
<StyledButton <StyledButton
@ -254,14 +335,26 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
<Head title="App Settings" /> <Head title="App Settings" />
<div className="xl:pl-72 w-full"> <div className="xl:pl-72 w-full">
<main className="px-12 py-6"> <main className="px-12 py-6">
<h1 className="text-4xl font-semibold mb-4">Apps</h1> <div className="flex items-center justify-between mb-4">
<p className="text-gray-500 mb-4"> <div>
Manage the applications that are available in your Project N.O.M.A.D. instance. <h1 className="text-4xl font-semibold">Apps</h1>
</p> <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 && <LoadingSpinner fullscreen />}
{!loading && ( {!loading && (
<StyledTable<ServiceSlim & { actions?: any }> <StyledTable<ServiceSlim & { actions?: any }>
className="font-semibold" className="font-semibold !overflow-x-auto"
rowLines={true} rowLines={true}
columns={[ columns={[
{ {
@ -296,9 +389,30 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
render: (record) => render: (record) =>
record.installed ? <IconCheck className="h-6 w-6 text-desert-green" /> : '', 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', accessor: 'actions',
title: 'Actions', title: 'Actions',
className: '!whitespace-normal',
render: (record) => <AppActions record={record} />, render: (record) => <AppActions record={record} />,
}, },
]} ]}
@ -313,3 +427,4 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
</SettingsLayout> </SettingsLayout>
) )
} }

View File

@ -141,6 +141,9 @@ router
router.post('/services/affect', [SystemController, 'affectService']) router.post('/services/affect', [SystemController, 'affectService'])
router.post('/services/install', [SystemController, 'installService']) router.post('/services/install', [SystemController, 'installService'])
router.post('/services/force-reinstall', [SystemController, 'forceReinstallService']) 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.post('/subscribe-release-notes', [SystemController, 'subscribeToReleaseNotes'])
router.get('/latest-version', [SystemController, 'checkLatestVersion']) router.get('/latest-version', [SystemController, 'checkLatestVersion'])
router.post('/update', [SystemController, 'requestSystemUpdate']) router.post('/update', [SystemController, 'requestSystemUpdate'])

View File

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