mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-03-28 03:29:25 +01:00
feat: support for updating services
This commit is contained in:
parent
7db8568e19
commit
58b106f388
|
|
@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
134
admin/app/jobs/check_service_updates_job.ts
Normal file
134
admin/app/jobs/check_service_updates_job.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
484
admin/app/services/container_registry_service.ts
Normal file
484
admin/app/services/container_registry_service.ts
Normal 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`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
49
admin/app/utils/version.ts
Normal file
49
admin/app/utils/version.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
}
|
}
|
||||||
|
|
@ -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')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
145
admin/inertia/components/UpdateServiceModal.tsx
Normal file
145
admin/inertia/components/UpdateServiceModal.tsx
Normal 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 → 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 }>(
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'])
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user