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 { SystemService } from '#services/system_service'
|
||||
import { SystemUpdateService } from '#services/system_update_service'
|
||||
import { affectServiceValidator, checkLatestVersionValidator, installServiceValidator, subscribeToReleaseNotesValidator } from '#validators/system';
|
||||
import { ContainerRegistryService } from '#services/container_registry_service'
|
||||
import { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job'
|
||||
import { affectServiceValidator, checkLatestVersionValidator, installServiceValidator, subscribeToReleaseNotesValidator, updateServiceValidator } from '#validators/system';
|
||||
import { inject } from '@adonisjs/core'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
|
||||
|
|
@ -10,7 +12,8 @@ export default class SystemController {
|
|||
constructor(
|
||||
private systemService: SystemService,
|
||||
private dockerService: DockerService,
|
||||
private systemUpdateService: SystemUpdateService
|
||||
private systemUpdateService: SystemUpdateService,
|
||||
private containerRegistryService: ContainerRegistryService
|
||||
) { }
|
||||
|
||||
async getInternetStatus({ }: HttpContext) {
|
||||
|
|
@ -109,4 +112,65 @@ export default class SystemController {
|
|||
const reqData = await request.validateUsing(subscribeToReleaseNotesValidator);
|
||||
return await this.systemService.subscribeToReleaseNotes(reqData.email);
|
||||
}
|
||||
|
||||
async checkServiceUpdates({ response }: HttpContext) {
|
||||
await CheckServiceUpdatesJob.dispatch()
|
||||
response.send({ success: true, message: 'Service update check dispatched' })
|
||||
}
|
||||
|
||||
async getAvailableVersions({ params, response }: HttpContext) {
|
||||
const serviceName = params.name
|
||||
const service = await (await import('#models/service')).default
|
||||
.query()
|
||||
.where('service_name', serviceName)
|
||||
.where('installed', true)
|
||||
.first()
|
||||
|
||||
if (!service) {
|
||||
return response.status(404).send({ error: `Service ${serviceName} not found or not installed` })
|
||||
}
|
||||
|
||||
try {
|
||||
const hostArch = await this.getHostArch()
|
||||
const updates = await this.containerRegistryService.getAvailableUpdates(
|
||||
service.container_image,
|
||||
hostArch,
|
||||
service.source_repo
|
||||
)
|
||||
response.send({ versions: updates })
|
||||
} catch (error) {
|
||||
response.status(500).send({ error: `Failed to fetch versions: ${error.message}` })
|
||||
}
|
||||
}
|
||||
|
||||
async updateService({ request, response }: HttpContext) {
|
||||
const payload = await request.validateUsing(updateServiceValidator)
|
||||
const result = await this.dockerService.updateContainer(
|
||||
payload.service_name,
|
||||
payload.target_version
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
response.send({ success: true, message: result.message })
|
||||
} else {
|
||||
response.status(400).send({ error: result.message })
|
||||
}
|
||||
}
|
||||
|
||||
private async getHostArch(): Promise<string> {
|
||||
try {
|
||||
const info = await this.dockerService.docker.info()
|
||||
const arch = info.Architecture || ''
|
||||
const archMap: Record<string, string> = {
|
||||
x86_64: 'amd64',
|
||||
aarch64: 'arm64',
|
||||
armv7l: 'arm',
|
||||
amd64: 'amd64',
|
||||
arm64: 'arm64',
|
||||
}
|
||||
return archMap[arch] || arch.toLowerCase()
|
||||
} catch {
|
||||
return 'amd64'
|
||||
}
|
||||
}
|
||||
}
|
||||
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()
|
||||
declare metadata: string | null
|
||||
|
||||
@column()
|
||||
declare source_repo: string | null
|
||||
|
||||
@column()
|
||||
declare available_update_version: string | null
|
||||
|
||||
@column.dateTime()
|
||||
declare update_checked_at: DateTime | null
|
||||
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare created_at: DateTime
|
||||
|
||||
|
|
|
|||
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 containerMap = new Map<string, Docker.ContainerInfo>()
|
||||
containers.forEach((container) => {
|
||||
const name = container.Names[0].replace('/', '')
|
||||
if (name.startsWith('nomad_')) {
|
||||
const name = container.Names[0]?.replace('/', '')
|
||||
if (name && name.startsWith('nomad_')) {
|
||||
containerMap.set(name, container)
|
||||
}
|
||||
})
|
||||
|
|
@ -792,6 +792,186 @@ export class DockerService {
|
|||
// }
|
||||
// }
|
||||
|
||||
/**
|
||||
* Update a service container to a new image version while preserving volumes and data.
|
||||
* Includes automatic rollback if the new container fails health checks.
|
||||
*/
|
||||
async updateContainer(
|
||||
serviceName: string,
|
||||
targetVersion: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const service = await Service.query().where('service_name', serviceName).first()
|
||||
if (!service) {
|
||||
return { success: false, message: `Service ${serviceName} not found` }
|
||||
}
|
||||
if (!service.installed) {
|
||||
return { success: false, message: `Service ${serviceName} is not installed` }
|
||||
}
|
||||
if (this.activeInstallations.has(serviceName)) {
|
||||
return { success: false, message: `Service ${serviceName} already has an operation in progress` }
|
||||
}
|
||||
|
||||
this.activeInstallations.add(serviceName)
|
||||
|
||||
// Compute new image string
|
||||
const currentImage = service.container_image
|
||||
const imageBase = currentImage.includes(':')
|
||||
? currentImage.substring(0, currentImage.lastIndexOf(':'))
|
||||
: currentImage
|
||||
const newImage = `${imageBase}:${targetVersion}`
|
||||
|
||||
// Step 1: Pull new image
|
||||
this._broadcast(serviceName, 'update-pulling', `Pulling image ${newImage}...`)
|
||||
const pullStream = await this.docker.pull(newImage)
|
||||
await new Promise((res) => this.docker.modem.followProgress(pullStream, res))
|
||||
|
||||
// Step 2: Find and stop existing container
|
||||
this._broadcast(serviceName, 'update-stopping', `Stopping current container...`)
|
||||
const containers = await this.docker.listContainers({ all: true })
|
||||
const existingContainer = containers.find((c) => c.Names.includes(`/${serviceName}`))
|
||||
|
||||
if (!existingContainer) {
|
||||
this.activeInstallations.delete(serviceName)
|
||||
return { success: false, message: `Container for ${serviceName} not found` }
|
||||
}
|
||||
|
||||
const oldContainer = this.docker.getContainer(existingContainer.Id)
|
||||
|
||||
// Inspect to capture full config before stopping
|
||||
const inspectData = await oldContainer.inspect()
|
||||
|
||||
if (existingContainer.State === 'running') {
|
||||
await oldContainer.stop({ t: 15 })
|
||||
}
|
||||
|
||||
// Step 3: Rename old container as safety net
|
||||
const oldName = `${serviceName}_old`
|
||||
await oldContainer.rename({ name: oldName })
|
||||
|
||||
// Step 4: Create new container with inspected config + new image
|
||||
this._broadcast(serviceName, 'update-creating', `Creating updated container...`)
|
||||
|
||||
const hostConfig = inspectData.HostConfig || {}
|
||||
const newContainerConfig: any = {
|
||||
Image: newImage,
|
||||
name: serviceName,
|
||||
Env: inspectData.Config?.Env || undefined,
|
||||
Cmd: inspectData.Config?.Cmd || undefined,
|
||||
ExposedPorts: inspectData.Config?.ExposedPorts || undefined,
|
||||
WorkingDir: inspectData.Config?.WorkingDir || undefined,
|
||||
User: inspectData.Config?.User || undefined,
|
||||
HostConfig: {
|
||||
Binds: hostConfig.Binds || undefined,
|
||||
PortBindings: hostConfig.PortBindings || undefined,
|
||||
RestartPolicy: hostConfig.RestartPolicy || undefined,
|
||||
DeviceRequests: hostConfig.DeviceRequests || undefined,
|
||||
Devices: hostConfig.Devices || undefined,
|
||||
},
|
||||
NetworkingConfig: inspectData.NetworkSettings?.Networks
|
||||
? {
|
||||
EndpointsConfig: Object.fromEntries(
|
||||
Object.keys(inspectData.NetworkSettings.Networks).map((net) => [net, {}])
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
|
||||
// Remove undefined values from HostConfig
|
||||
Object.keys(newContainerConfig.HostConfig).forEach((key) => {
|
||||
if (newContainerConfig.HostConfig[key] === undefined) {
|
||||
delete newContainerConfig.HostConfig[key]
|
||||
}
|
||||
})
|
||||
|
||||
let newContainer: any
|
||||
try {
|
||||
newContainer = await this.docker.createContainer(newContainerConfig)
|
||||
} catch (createError) {
|
||||
// Rollback: rename old container back
|
||||
this._broadcast(serviceName, 'update-rollback', `Failed to create new container: ${createError.message}. Rolling back...`)
|
||||
const rollbackContainer = this.docker.getContainer((await this.docker.listContainers({ all: true })).find((c) => c.Names.includes(`/${oldName}`))!.Id)
|
||||
await rollbackContainer.rename({ name: serviceName })
|
||||
await rollbackContainer.start()
|
||||
this.activeInstallations.delete(serviceName)
|
||||
return { success: false, message: `Failed to create updated container: ${createError.message}` }
|
||||
}
|
||||
|
||||
// Step 5: Start new container
|
||||
this._broadcast(serviceName, 'update-starting', `Starting updated container...`)
|
||||
await newContainer.start()
|
||||
|
||||
// Step 6: Health check — verify container stays running for 5 seconds
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000))
|
||||
const newContainerInfo = await newContainer.inspect()
|
||||
|
||||
if (newContainerInfo.State?.Running) {
|
||||
// Healthy — clean up old container
|
||||
try {
|
||||
const oldContainerRef = this.docker.getContainer(
|
||||
(await this.docker.listContainers({ all: true })).find((c) =>
|
||||
c.Names.includes(`/${oldName}`)
|
||||
)?.Id || ''
|
||||
)
|
||||
await oldContainerRef.remove({ force: true })
|
||||
} catch {
|
||||
// Old container may already be gone
|
||||
}
|
||||
|
||||
// Update DB
|
||||
service.container_image = newImage
|
||||
service.available_update_version = null
|
||||
await service.save()
|
||||
|
||||
this.activeInstallations.delete(serviceName)
|
||||
this._broadcast(
|
||||
serviceName,
|
||||
'update-complete',
|
||||
`Successfully updated ${serviceName} to ${targetVersion}`
|
||||
)
|
||||
return { success: true, message: `Service ${serviceName} updated to ${targetVersion}` }
|
||||
} else {
|
||||
// Unhealthy — rollback
|
||||
this._broadcast(
|
||||
serviceName,
|
||||
'update-rollback',
|
||||
`New container failed health check. Rolling back to previous version...`
|
||||
)
|
||||
|
||||
try {
|
||||
await newContainer.stop({ t: 5 }).catch(() => {})
|
||||
await newContainer.remove({ force: true })
|
||||
} catch {
|
||||
// Best effort cleanup
|
||||
}
|
||||
|
||||
// Restore old container
|
||||
const oldContainers = await this.docker.listContainers({ all: true })
|
||||
const oldRef = oldContainers.find((c) => c.Names.includes(`/${oldName}`))
|
||||
if (oldRef) {
|
||||
const rollbackContainer = this.docker.getContainer(oldRef.Id)
|
||||
await rollbackContainer.rename({ name: serviceName })
|
||||
await rollbackContainer.start()
|
||||
}
|
||||
|
||||
this.activeInstallations.delete(serviceName)
|
||||
return {
|
||||
success: false,
|
||||
message: `Update failed: new container did not stay running. Rolled back to previous version.`,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.activeInstallations.delete(serviceName)
|
||||
this._broadcast(
|
||||
serviceName,
|
||||
'update-rollback',
|
||||
`Update failed: ${error.message}`
|
||||
)
|
||||
logger.error(`[DockerService] Update failed for ${serviceName}: ${error.message}`)
|
||||
return { success: false, message: `Update failed: ${error.message}` }
|
||||
}
|
||||
}
|
||||
|
||||
private _broadcast(service: string, status: string, message: string) {
|
||||
transmit.broadcast(BROADCAST_CHANNELS.SERVICE_INSTALLATION, {
|
||||
service_name: service,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import axios from 'axios'
|
|||
import env from '#start/env'
|
||||
import KVStore from '#models/kv_store'
|
||||
import { KV_STORE_SCHEMA, KVStoreKey } from '../../types/kv_store.js'
|
||||
import { isNewerVersion } from '../utils/version.js'
|
||||
|
||||
|
||||
@inject()
|
||||
|
|
@ -142,7 +143,9 @@ export class SystemService {
|
|||
'description',
|
||||
'icon',
|
||||
'powered_by',
|
||||
'display_order'
|
||||
'display_order',
|
||||
'container_image',
|
||||
'available_update_version'
|
||||
)
|
||||
.where('is_dependency_service', false)
|
||||
if (installedOnly) {
|
||||
|
|
@ -172,6 +175,8 @@ export class SystemService {
|
|||
ui_location: service.ui_location || '',
|
||||
powered_by: service.powered_by,
|
||||
display_order: service.display_order,
|
||||
container_image: service.container_image,
|
||||
available_update_version: service.available_update_version,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -353,7 +358,7 @@ export class SystemService {
|
|||
|
||||
const updateAvailable = process.env.NODE_ENV === 'development'
|
||||
? false
|
||||
: this.isNewerVersion(latestVersion, currentVersion.trim())
|
||||
: isNewerVersion(latestVersion, currentVersion.trim(), earlyAccess)
|
||||
|
||||
// Cache the results in KVStore for frontend checks
|
||||
await KVStore.setValue('system.updateAvailable', updateAvailable)
|
||||
|
|
@ -494,35 +499,4 @@ export class SystemService {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two semantic version strings to determine if the first is newer than the second.
|
||||
* @param version1 - The version to check (e.g., "1.25.0")
|
||||
* @param version2 - The current version (e.g., "1.24.0")
|
||||
* @returns true if version1 is newer than version2
|
||||
*/
|
||||
private isNewerVersion(version1: string, version2: string): boolean {
|
||||
const [base1, pre1] = version1.split('-')
|
||||
const [base2, pre2] = version2.split('-')
|
||||
|
||||
const v1Parts = base1.split('.').map((p) => parseInt(p, 10) || 0)
|
||||
const v2Parts = base2.split('.').map((p) => parseInt(p, 10) || 0)
|
||||
|
||||
const maxLen = Math.max(v1Parts.length, v2Parts.length)
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const a = v1Parts[i] || 0
|
||||
const b = v2Parts[i] || 0
|
||||
if (a > b) return true
|
||||
if (a < b) return false
|
||||
}
|
||||
|
||||
// Base versions equal — GA > RC, RC.n+1 > RC.n
|
||||
if (!pre1 && pre2) return true // v1 is GA, v2 is RC → v1 is newer
|
||||
if (pre1 && !pre2) return false // v1 is RC, v2 is GA → v2 is newer
|
||||
if (!pre1 && !pre2) return false // both GA, equal
|
||||
|
||||
// Both prerelease: compare numeric suffix (e.g. "rc.2" vs "rc.1")
|
||||
const pre1Num = parseInt(pre1.split('.')[1], 10) || 0
|
||||
const pre2Num = parseInt(pre2.split('.')[1], 10) || 0
|
||||
return pre1Num > pre2Num
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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
|
||||
})
|
||||
)
|
||||
|
||||
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 { EmbedFileJob } from '#jobs/embed_file_job'
|
||||
import { CheckUpdateJob } from '#jobs/check_update_job'
|
||||
import { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job'
|
||||
|
||||
export default class QueueWork extends BaseCommand {
|
||||
static commandName = 'queue:work'
|
||||
|
|
@ -76,8 +77,9 @@ export default class QueueWork extends BaseCommand {
|
|||
this.logger.info(`Worker started for queue: ${queueName}`)
|
||||
}
|
||||
|
||||
// Schedule nightly update check (idempotent, will persist over restarts)
|
||||
// Schedule nightly update checks (idempotent, will persist over restarts)
|
||||
await CheckUpdateJob.scheduleNightly()
|
||||
await CheckServiceUpdatesJob.scheduleNightly()
|
||||
|
||||
// Graceful shutdown for all workers
|
||||
process.on('SIGTERM', async () => {
|
||||
|
|
@ -97,12 +99,14 @@ export default class QueueWork extends BaseCommand {
|
|||
handlers.set(RunBenchmarkJob.key, new RunBenchmarkJob())
|
||||
handlers.set(EmbedFileJob.key, new EmbedFileJob())
|
||||
handlers.set(CheckUpdateJob.key, new CheckUpdateJob())
|
||||
handlers.set(CheckServiceUpdatesJob.key, new CheckServiceUpdatesJob())
|
||||
|
||||
queues.set(RunDownloadJob.key, RunDownloadJob.queue)
|
||||
queues.set(DownloadModelJob.key, DownloadModelJob.queue)
|
||||
queues.set(RunBenchmarkJob.key, RunBenchmarkJob.queue)
|
||||
queues.set(EmbedFileJob.key, EmbedFileJob.queue)
|
||||
queues.set(CheckUpdateJob.key, CheckUpdateJob.queue)
|
||||
queues.set(CheckServiceUpdatesJob.key, CheckServiceUpdatesJob.queue)
|
||||
|
||||
return [handlers, queues]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,4 +3,5 @@ export const BROADCAST_CHANNELS = {
|
|||
BENCHMARK_PROGRESS: 'benchmark-progress',
|
||||
OLLAMA_MODEL_DOWNLOAD: 'ollama-model-download',
|
||||
SERVICE_INSTALLATION: 'service-installation',
|
||||
SERVICE_UPDATES: 'service-updates',
|
||||
}
|
||||
|
|
@ -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<
|
||||
ModelAttributes<Service>,
|
||||
'created_at' | 'updated_at' | 'metadata' | 'id'
|
||||
'created_at' | 'updated_at' | 'metadata' | 'id' | 'available_update_version' | 'update_checked_at'
|
||||
>[] = [
|
||||
{
|
||||
service_name: SERVICE_NAMES.KIWIX,
|
||||
|
|
@ -23,6 +23,7 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
'Offline access to Wikipedia, medical references, how-to guides, and encyclopedias',
|
||||
icon: 'IconBooks',
|
||||
container_image: 'ghcr.io/kiwix/kiwix-serve:3.8.1',
|
||||
source_repo: 'https://github.com/kiwix/kiwix-tools',
|
||||
container_command: '*.zim --address=all',
|
||||
container_config: JSON.stringify({
|
||||
HostConfig: {
|
||||
|
|
@ -46,6 +47,7 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
description: 'Vector database for storing and searching embeddings',
|
||||
icon: 'IconRobot',
|
||||
container_image: 'qdrant/qdrant:v1.16',
|
||||
source_repo: 'https://github.com/qdrant/qdrant',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
HostConfig: {
|
||||
|
|
@ -69,6 +71,7 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
description: 'Local AI chat that runs entirely on your hardware - no internet required',
|
||||
icon: 'IconWand',
|
||||
container_image: 'ollama/ollama:0.15.2',
|
||||
source_repo: 'https://github.com/ollama/ollama',
|
||||
container_command: 'serve',
|
||||
container_config: JSON.stringify({
|
||||
HostConfig: {
|
||||
|
|
@ -91,7 +94,8 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
display_order: 11,
|
||||
description: 'Swiss Army knife for data encoding, encryption, and analysis',
|
||||
icon: 'IconChefHat',
|
||||
container_image: 'ghcr.io/gchq/cyberchef:latest',
|
||||
container_image: 'ghcr.io/gchq/cyberchef:10.19.4',
|
||||
source_repo: 'https://github.com/gchq/CyberChef',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
HostConfig: {
|
||||
|
|
@ -113,7 +117,8 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
display_order: 10,
|
||||
description: 'Simple note-taking app with local storage',
|
||||
icon: 'IconNotes',
|
||||
container_image: 'dullage/flatnotes:latest',
|
||||
container_image: 'dullage/flatnotes:v5.5.4',
|
||||
source_repo: 'https://github.com/dullage/flatnotes',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
HostConfig: {
|
||||
|
|
@ -137,7 +142,8 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
display_order: 2,
|
||||
description: 'Interactive learning platform with video courses and exercises',
|
||||
icon: 'IconSchool',
|
||||
container_image: 'treehouses/kolibri:latest',
|
||||
container_image: 'treehouses/kolibri:0.12.8',
|
||||
source_repo: 'https://github.com/learningequality/kolibri',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
HostConfig: {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { IconCircleCheck } from '@tabler/icons-react'
|
||||
import { IconCircleCheck, IconCircleX } from '@tabler/icons-react'
|
||||
import classNames from '~/lib/classNames'
|
||||
|
||||
export type InstallActivityFeedProps = {
|
||||
|
|
@ -16,6 +16,12 @@ export type InstallActivityFeedProps = {
|
|||
| 'started'
|
||||
| 'finalizing'
|
||||
| 'completed'
|
||||
| 'update-pulling'
|
||||
| 'update-stopping'
|
||||
| 'update-creating'
|
||||
| 'update-starting'
|
||||
| 'update-complete'
|
||||
| 'update-rollback'
|
||||
timestamp: string
|
||||
message: string
|
||||
}>
|
||||
|
|
@ -40,8 +46,10 @@ const InstallActivityFeed: React.FC<InstallActivityFeedProps> = ({ activity, cla
|
|||
</div>
|
||||
<>
|
||||
<div className="relative flex size-6 flex-none items-center justify-center bg-transparent">
|
||||
{activityItem.type === 'completed' ? (
|
||||
{activityItem.type === 'completed' || activityItem.type === 'update-complete' ? (
|
||||
<IconCircleCheck aria-hidden="true" className="size-6 text-indigo-600" />
|
||||
) : activityItem.type === 'update-rollback' ? (
|
||||
<IconCircleX aria-hidden="true" className="size-6 text-red-500" />
|
||||
) : (
|
||||
<div className="size-1.5 rounded-full bg-gray-100 ring-1 ring-gray-300" />
|
||||
)}
|
||||
|
|
|
|||
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) {
|
||||
return catchInternal(async () => {
|
||||
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 useInternetStatus from '~/hooks/useInternetStatus'
|
||||
import useServiceInstallationActivity from '~/hooks/useServiceInstallationActivity'
|
||||
import { IconCheck, IconDownload } from '@tabler/icons-react'
|
||||
import { useTransmit } from 'react-adonis-transmit'
|
||||
import { BROADCAST_CHANNELS } from '../../../constants/broadcast'
|
||||
import { IconArrowUp, IconCheck, IconDownload } from '@tabler/icons-react'
|
||||
import UpdateServiceModal from '~/components/UpdateServiceModal'
|
||||
|
||||
function extractTag(containerImage: string): string {
|
||||
if (!containerImage) return ''
|
||||
const parts = containerImage.split(':')
|
||||
return parts.length > 1 ? parts[parts.length - 1] : 'latest'
|
||||
}
|
||||
|
||||
export default function SettingsPage(props: { system: { services: ServiceSlim[] } }) {
|
||||
const { openModal, closeAllModals } = useModals()
|
||||
const { showError } = useErrorNotification()
|
||||
const { isOnline } = useInternetStatus()
|
||||
const { subscribe } = useTransmit()
|
||||
const installActivity = useServiceInstallationActivity()
|
||||
|
||||
const [isInstalling, setIsInstalling] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [checkingUpdates, setCheckingUpdates] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (installActivity.length === 0) return
|
||||
if (installActivity.some((activity) => activity.type === 'completed')) {
|
||||
// If any activity is completed, we can clear the installActivity state
|
||||
if (
|
||||
installActivity.some(
|
||||
(activity) => activity.type === 'completed' || activity.type === 'update-complete'
|
||||
)
|
||||
) {
|
||||
setTimeout(() => {
|
||||
window.location.reload() // Reload the page to reflect changes
|
||||
}, 3000) // Clear after 3 seconds
|
||||
window.location.reload()
|
||||
}, 3000)
|
||||
}
|
||||
}, [installActivity])
|
||||
|
||||
// Listen for service update check completion
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribe(BROADCAST_CHANNELS.SERVICE_UPDATES, () => {
|
||||
setCheckingUpdates(false)
|
||||
window.location.reload()
|
||||
})
|
||||
return () => { unsubscribe() }
|
||||
}, [])
|
||||
|
||||
async function handleCheckUpdates() {
|
||||
try {
|
||||
if (!isOnline) {
|
||||
showError('You must have an internet connection to check for updates.')
|
||||
return
|
||||
}
|
||||
setCheckingUpdates(true)
|
||||
const response = await api.checkServiceUpdates()
|
||||
if (!response?.success) {
|
||||
throw new Error('Failed to dispatch update check')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking for updates:', error)
|
||||
showError(`Failed to check for updates: ${error.message || 'Unknown error'}`)
|
||||
setCheckingUpdates(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInstallService = (service: ServiceSlim) => {
|
||||
openModal(
|
||||
<StyledModal
|
||||
|
|
@ -97,8 +138,8 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
|
|||
|
||||
setTimeout(() => {
|
||||
setLoading(false)
|
||||
window.location.reload() // Reload the page to reflect changes
|
||||
}, 3000) // Add small delay to allow for the action to complete
|
||||
window.location.reload()
|
||||
}, 3000)
|
||||
} catch (error) {
|
||||
console.error(`Error affecting service ${record.service_name}:`, error)
|
||||
showError(`Failed to ${action} service: ${error.message || 'Unknown error'}`)
|
||||
|
|
@ -120,14 +161,44 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
|
|||
|
||||
setTimeout(() => {
|
||||
setLoading(false)
|
||||
window.location.reload() // Reload the page to reflect changes
|
||||
}, 3000) // Add small delay to allow for the action to complete
|
||||
window.location.reload()
|
||||
}, 3000)
|
||||
} catch (error) {
|
||||
console.error(`Error force reinstalling service ${record.service_name}:`, error)
|
||||
showError(`Failed to force reinstall service: ${error.message || 'Unknown error'}`)
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdateService(record: ServiceSlim) {
|
||||
const currentTag = extractTag(record.container_image)
|
||||
const latestVersion = record.available_update_version!
|
||||
|
||||
openModal(
|
||||
<UpdateServiceModal
|
||||
record={record}
|
||||
currentTag={currentTag}
|
||||
latestVersion={latestVersion}
|
||||
onCancel={closeAllModals}
|
||||
onUpdate={async (targetVersion: string) => {
|
||||
closeAllModals()
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await api.updateService(record.service_name, targetVersion)
|
||||
if (!response?.success) {
|
||||
throw new Error(response?.message || 'Update failed')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error updating service ${record.service_name}:`, error)
|
||||
showError(`Failed to update service: ${error.message || 'Unknown error'}`)
|
||||
setLoading(false)
|
||||
}
|
||||
}}
|
||||
showError={showError}
|
||||
/>,
|
||||
`${record.service_name}-update-modal`
|
||||
)
|
||||
}
|
||||
|
||||
const AppActions = ({ record }: { record: ServiceSlim }) => {
|
||||
const ForceReinstallButton = () => (
|
||||
<StyledButton
|
||||
|
|
@ -162,7 +233,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
|
|||
if (!record) return null
|
||||
if (!record.installed) {
|
||||
return (
|
||||
<div className="flex space-x-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<StyledButton
|
||||
icon={'IconDownload'}
|
||||
variant="primary"
|
||||
|
|
@ -178,7 +249,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex space-x-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<StyledButton
|
||||
icon={'IconExternalLink'}
|
||||
onClick={() => {
|
||||
|
|
@ -187,6 +258,16 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
|
|||
>
|
||||
Open
|
||||
</StyledButton>
|
||||
{record.available_update_version && (
|
||||
<StyledButton
|
||||
icon="IconArrowUp"
|
||||
variant="primary"
|
||||
onClick={() => handleUpdateService(record)}
|
||||
disabled={isInstalling || !isOnline}
|
||||
>
|
||||
Update
|
||||
</StyledButton>
|
||||
)}
|
||||
{record.status && record.status !== 'unknown' && (
|
||||
<>
|
||||
<StyledButton
|
||||
|
|
@ -254,14 +335,26 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
|
|||
<Head title="App Settings" />
|
||||
<div className="xl:pl-72 w-full">
|
||||
<main className="px-12 py-6">
|
||||
<h1 className="text-4xl font-semibold mb-4">Apps</h1>
|
||||
<p className="text-gray-500 mb-4">
|
||||
Manage the applications that are available in your Project N.O.M.A.D. instance.
|
||||
</p>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Apps</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
Manage the applications that are available in your Project N.O.M.A.D. instance. Nightly update checks will automatically detect when new versions of these apps are available.
|
||||
</p>
|
||||
</div>
|
||||
<StyledButton
|
||||
icon="IconRefreshAlert"
|
||||
onClick={handleCheckUpdates}
|
||||
disabled={checkingUpdates || !isOnline}
|
||||
loading={checkingUpdates}
|
||||
>
|
||||
Check for Updates
|
||||
</StyledButton>
|
||||
</div>
|
||||
{loading && <LoadingSpinner fullscreen />}
|
||||
{!loading && (
|
||||
<StyledTable<ServiceSlim & { actions?: any }>
|
||||
className="font-semibold"
|
||||
className="font-semibold !overflow-x-auto"
|
||||
rowLines={true}
|
||||
columns={[
|
||||
{
|
||||
|
|
@ -296,9 +389,30 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
|
|||
render: (record) =>
|
||||
record.installed ? <IconCheck className="h-6 w-6 text-desert-green" /> : '',
|
||||
},
|
||||
{
|
||||
accessor: 'container_image',
|
||||
title: 'Version',
|
||||
render: (record) => {
|
||||
if (!record.installed) return null
|
||||
const currentTag = extractTag(record.container_image)
|
||||
if (record.available_update_version) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-500">{currentTag}</span>
|
||||
<IconArrowUp className="h-4 w-4 text-desert-green" />
|
||||
<span className="text-desert-green font-semibold">
|
||||
{record.available_update_version}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <span className="text-gray-600">{currentTag}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
accessor: 'actions',
|
||||
title: 'Actions',
|
||||
className: '!whitespace-normal',
|
||||
render: (record) => <AppActions record={record} />,
|
||||
},
|
||||
]}
|
||||
|
|
@ -313,3 +427,4 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
|
|||
</SettingsLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -141,6 +141,9 @@ router
|
|||
router.post('/services/affect', [SystemController, 'affectService'])
|
||||
router.post('/services/install', [SystemController, 'installService'])
|
||||
router.post('/services/force-reinstall', [SystemController, 'forceReinstallService'])
|
||||
router.post('/services/check-updates', [SystemController, 'checkServiceUpdates'])
|
||||
router.get('/services/:name/available-versions', [SystemController, 'getAvailableVersions'])
|
||||
router.post('/services/update', [SystemController, 'updateService'])
|
||||
router.post('/subscribe-release-notes', [SystemController, 'subscribeToReleaseNotes'])
|
||||
router.get('/latest-version', [SystemController, 'checkLatestVersion'])
|
||||
router.post('/update', [SystemController, 'requestSystemUpdate'])
|
||||
|
|
|
|||
|
|
@ -12,4 +12,6 @@ export type ServiceSlim = Pick<
|
|||
| 'icon'
|
||||
| 'powered_by'
|
||||
| 'display_order'
|
||||
| 'container_image'
|
||||
| 'available_update_version'
|
||||
> & { status?: string }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user