mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-03-28 03:29:25 +01:00
feat: [wip] self updates
This commit is contained in:
parent
b6ac6b1e84
commit
393c177af1
|
|
@ -43,6 +43,17 @@ export default class SettingsController {
|
|||
});
|
||||
}
|
||||
|
||||
async update({ inertia }: HttpContext) {
|
||||
const updateInfo = await this.systemService.checkLatestVersion();
|
||||
return inertia.render('settings/update', {
|
||||
system: {
|
||||
updateAvailable: updateInfo.updateAvailable,
|
||||
latestVersion: updateInfo.latestVersion,
|
||||
currentVersion: updateInfo.currentVersion
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async zim({ inertia }: HttpContext) {
|
||||
return inertia.render('settings/zim/index')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { DockerService } from '#services/docker_service';
|
||||
import { SystemService } from '#services/system_service'
|
||||
import { SystemUpdateService } from '#services/system_update_service'
|
||||
import { affectServiceValidator, installServiceValidator } from '#validators/system';
|
||||
import { inject } from '@adonisjs/core'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
|
|
@ -8,7 +9,8 @@ import type { HttpContext } from '@adonisjs/core/http'
|
|||
export default class SystemController {
|
||||
constructor(
|
||||
private systemService: SystemService,
|
||||
private dockerService: DockerService
|
||||
private dockerService: DockerService,
|
||||
private systemUpdateService: SystemUpdateService
|
||||
) { }
|
||||
|
||||
async getInternetStatus({ }: HttpContext) {
|
||||
|
|
@ -43,4 +45,51 @@ export default class SystemController {
|
|||
}
|
||||
response.send({ success: result.success, message: result.message });
|
||||
}
|
||||
|
||||
async checkLatestVersion({ }: HttpContext) {
|
||||
return await this.systemService.checkLatestVersion();
|
||||
}
|
||||
|
||||
async requestSystemUpdate({ response }: HttpContext) {
|
||||
if (!this.systemUpdateService.isSidecarAvailable()) {
|
||||
response.status(503).send({
|
||||
success: false,
|
||||
error: 'Update sidecar is not available. Ensure the updater container is running.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.systemUpdateService.requestUpdate();
|
||||
|
||||
if (result.success) {
|
||||
response.send({
|
||||
success: true,
|
||||
message: result.message,
|
||||
note: 'Monitor update progress via GET /api/system/update/status. The connection may drop during container restart.',
|
||||
});
|
||||
} else {
|
||||
response.status(409).send({
|
||||
success: false,
|
||||
error: result.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getSystemUpdateStatus({ response }: HttpContext) {
|
||||
const status = this.systemUpdateService.getUpdateStatus();
|
||||
|
||||
if (!status) {
|
||||
response.status(500).send({
|
||||
error: 'Failed to retrieve update status',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
response.send(status);
|
||||
}
|
||||
|
||||
async getSystemUpdateLogs({ response }: HttpContext) {
|
||||
const logs = this.systemUpdateService.getUpdateLogs();
|
||||
response.send({ logs });
|
||||
}
|
||||
}
|
||||
|
|
@ -62,7 +62,16 @@ export class SystemService {
|
|||
|
||||
const query = Service.query()
|
||||
.orderBy('friendly_name', 'asc')
|
||||
.select('id', 'service_name', 'installed', 'installation_status', 'ui_location', 'friendly_name', 'description', 'icon')
|
||||
.select(
|
||||
'id',
|
||||
'service_name',
|
||||
'installed',
|
||||
'installation_status',
|
||||
'ui_location',
|
||||
'friendly_name',
|
||||
'description',
|
||||
'icon'
|
||||
)
|
||||
.where('is_dependency_service', false)
|
||||
if (installedOnly) {
|
||||
query.where('installed', true)
|
||||
|
|
@ -166,6 +175,52 @@ export class SystemService {
|
|||
}
|
||||
}
|
||||
|
||||
async checkLatestVersion(): Promise<{
|
||||
success: boolean
|
||||
updateAvailable: boolean
|
||||
currentVersion: string
|
||||
latestVersion: string
|
||||
message?: string
|
||||
}> {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
'https://api.github.com/repos/Crosstalk-Solutions/project-nomad/releases/latest',
|
||||
{
|
||||
headers: { Accept: 'application/vnd.github+json' },
|
||||
timeout: 5000,
|
||||
}
|
||||
)
|
||||
|
||||
if (!response || !response.data?.tag_name) {
|
||||
throw new Error('Invalid response from GitHub API')
|
||||
}
|
||||
|
||||
const latestVersion = response.data.tag_name.replace(/^v/, '') // Remove leading 'v' if present
|
||||
const currentVersion = SystemService.getAppVersion()
|
||||
|
||||
logger.info(`Current version: ${currentVersion}, Latest version: ${latestVersion}`)
|
||||
|
||||
// NOTE: this will always return true in dev environment! See getAppVersion()
|
||||
const updateAvailable = latestVersion !== currentVersion
|
||||
|
||||
return {
|
||||
success: true,
|
||||
updateAvailable,
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error checking latest version:', error)
|
||||
return {
|
||||
success: false,
|
||||
updateAvailable: false,
|
||||
currentVersion: '',
|
||||
latestVersion: '',
|
||||
message: `Failed to check latest version: ${error instanceof Error ? error.message : error}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the current state of Docker containers against the database records and updates the database accordingly.
|
||||
* It will mark services as not installed if their corresponding containers are not running, and can also handle cleanup of any orphaned records.
|
||||
|
|
|
|||
95
admin/app/services/system_update_service.ts
Normal file
95
admin/app/services/system_update_service.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import logger from '@adonisjs/core/services/logger'
|
||||
import { readFileSync, existsSync } from 'fs'
|
||||
import { writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
|
||||
interface UpdateStatus {
|
||||
stage: 'idle' | 'starting' | 'pulling' | 'pulled' | 'recreating' | 'complete' | 'error'
|
||||
progress: number
|
||||
message: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export class SystemUpdateService {
|
||||
private static SHARED_DIR = '/app/update-shared'
|
||||
private static REQUEST_FILE = join(SystemUpdateService.SHARED_DIR, 'update-request')
|
||||
private static STATUS_FILE = join(SystemUpdateService.SHARED_DIR, 'update-status')
|
||||
private static LOG_FILE = join(SystemUpdateService.SHARED_DIR, 'update-log')
|
||||
|
||||
/**
|
||||
* Requests a system update by creating a request file that the sidecar will detect
|
||||
*/
|
||||
async requestUpdate(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const currentStatus = this.getUpdateStatus()
|
||||
if (currentStatus && !['idle', 'complete', 'error'].includes(currentStatus.stage)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Update already in progress (stage: ${currentStatus.stage})`,
|
||||
}
|
||||
}
|
||||
|
||||
const requestData = {
|
||||
requested_at: new Date().toISOString(),
|
||||
requester: 'admin-api',
|
||||
}
|
||||
|
||||
await writeFile(SystemUpdateService.REQUEST_FILE, JSON.stringify(requestData, null, 2))
|
||||
logger.info('[SystemUpdateService]: System update requested - sidecar will process shortly')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'System update initiated. The admin container will restart during the process.',
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[SystemUpdateService]: Failed to request system update:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to request update: ${error.message}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getUpdateStatus(): UpdateStatus | null {
|
||||
try {
|
||||
if (!existsSync(SystemUpdateService.STATUS_FILE)) {
|
||||
return {
|
||||
stage: 'idle',
|
||||
progress: 0,
|
||||
message: 'No update in progress',
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
const statusContent = readFileSync(SystemUpdateService.STATUS_FILE, 'utf-8')
|
||||
return JSON.parse(statusContent) as UpdateStatus
|
||||
} catch (error) {
|
||||
logger.error('[SystemUpdateService]: Failed to read update status:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
getUpdateLogs(): string {
|
||||
try {
|
||||
if (!existsSync(SystemUpdateService.LOG_FILE)) {
|
||||
return 'No update logs available'
|
||||
}
|
||||
|
||||
return readFileSync(SystemUpdateService.LOG_FILE, 'utf-8')
|
||||
} catch (error) {
|
||||
logger.error('[SystemUpdateService]: Failed to read update logs:', error)
|
||||
return `Error reading logs: ${error.message}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the update sidecar is reachable (i.e. shared volume is mounted)
|
||||
*/
|
||||
isSidecarAvailable(): boolean {
|
||||
try {
|
||||
return existsSync(SystemUpdateService.SHARED_DIR)
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import {
|
|||
FolderIcon,
|
||||
MagnifyingGlassIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import { IconDashboard, IconGavel, IconMapRoute } from '@tabler/icons-react'
|
||||
import { IconArrowBigUpLines, IconDashboard, IconGavel, IconMapRoute } from '@tabler/icons-react'
|
||||
import StyledSidebar from '~/components/StyledSidebar'
|
||||
import { getServiceLink } from '~/lib/navigation'
|
||||
|
||||
|
|
@ -26,6 +26,12 @@ const navigation = [
|
|||
icon: MagnifyingGlassIcon,
|
||||
current: false,
|
||||
},
|
||||
{
|
||||
name: 'Check for Updates',
|
||||
href: '/settings/update',
|
||||
icon: IconArrowBigUpLines,
|
||||
current: false,
|
||||
},
|
||||
{ name: 'System', href: '/settings/system', icon: Cog6ToothIcon, current: true },
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import axios, { AxiosInstance } from 'axios'
|
|||
import { ListRemoteZimFilesResponse, ListZimFilesResponse } from '../../types/zim'
|
||||
import { ServiceSlim } from '../../types/services'
|
||||
import { FileEntry } from '../../types/files'
|
||||
import { SystemInformationResponse } from '../../types/system'
|
||||
import { SystemInformationResponse, SystemUpdateStatus } from '../../types/system'
|
||||
import { CuratedCollectionWithStatus, DownloadJobWithProgress } from '../../types/downloads'
|
||||
import { catchInternal } from './util'
|
||||
|
||||
|
|
@ -116,6 +116,29 @@ class API {
|
|||
})()
|
||||
}
|
||||
|
||||
async getSystemUpdateStatus() {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.get<SystemUpdateStatus>('/system/update/status')
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async getSystemUpdateLogs() {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.get<{ logs: string }>('/system/update/logs')
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async healthCheck() {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.get<{ status: string }>('/health', {
|
||||
timeout: 5000,
|
||||
})
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async installService(service_name: string) {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.post<{ success: boolean; message: string }>(
|
||||
|
|
@ -198,6 +221,15 @@ class API {
|
|||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async startSystemUpdate() {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.post<{ success: boolean; message: string }>(
|
||||
'/system/update'
|
||||
)
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
}
|
||||
|
||||
export default new API()
|
||||
|
|
|
|||
358
admin/inertia/pages/settings/update.tsx
Normal file
358
admin/inertia/pages/settings/update.tsx
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
import { Head } from '@inertiajs/react'
|
||||
import IconArrowBigUpLines from '@tabler/icons-react/dist/esm/icons/IconArrowBigUpLines'
|
||||
import IconCheck from '@tabler/icons-react/dist/esm/icons/IconCheck'
|
||||
import IconAlertCircle from '@tabler/icons-react/dist/esm/icons/IconAlertCircle'
|
||||
import IconRefresh from '@tabler/icons-react/dist/esm/icons/IconRefresh'
|
||||
import SettingsLayout from '~/layouts/SettingsLayout'
|
||||
import StyledButton from '~/components/StyledButton'
|
||||
import Alert from '~/components/Alert'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { IconCircleCheck } from '@tabler/icons-react'
|
||||
import { SystemUpdateStatus } from '../../../types/system'
|
||||
import api from '~/lib/api'
|
||||
|
||||
export default function SystemUpdatePage(props: {
|
||||
system: {
|
||||
updateAvailable: boolean
|
||||
latestVersion: string
|
||||
currentVersion: string
|
||||
}
|
||||
}) {
|
||||
const [isUpdating, setIsUpdating] = useState(false)
|
||||
const [updateStatus, setUpdateStatus] = useState<SystemUpdateStatus | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showLogs, setShowLogs] = useState(false)
|
||||
const [logs, setLogs] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUpdating) return
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const response = await api.getSystemUpdateStatus()
|
||||
if (!response) {
|
||||
throw new Error('Failed to fetch update status')
|
||||
}
|
||||
setUpdateStatus(response)
|
||||
|
||||
// Check if update is complete or errored
|
||||
if (response.stage === 'complete') {
|
||||
// Give a moment for the new container to fully start
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 2000)
|
||||
} else if (response.stage === 'error') {
|
||||
setIsUpdating(false)
|
||||
setError(response.message)
|
||||
}
|
||||
} catch (err) {
|
||||
// During container restart, we'll lose connection - this is expected
|
||||
// Continue polling to detect when the container comes back up
|
||||
console.log('Polling update status (container may be restarting)...')
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [isUpdating])
|
||||
|
||||
// Poll health endpoint when update is in recreating stage
|
||||
useEffect(() => {
|
||||
if (updateStatus?.stage !== 'recreating') return
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const response = await api.healthCheck()
|
||||
if (!response) {
|
||||
throw new Error('Health check failed')
|
||||
}
|
||||
if (response.status === 'ok') {
|
||||
// Reload page when container is back up
|
||||
window.location.reload()
|
||||
}
|
||||
} catch (err) {
|
||||
// Still restarting, continue polling...
|
||||
}
|
||||
}, 3000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [updateStatus?.stage])
|
||||
|
||||
const handleStartUpdate = async () => {
|
||||
try {
|
||||
setError(null)
|
||||
setIsUpdating(true)
|
||||
const response = await api.startSystemUpdate()
|
||||
if (!response || !response.success) {
|
||||
throw new Error('Failed to start update')
|
||||
}
|
||||
} catch (err: any) {
|
||||
setIsUpdating(false)
|
||||
setError(err.response?.data?.error || err.message || 'Failed to start update')
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewLogs = async () => {
|
||||
try {
|
||||
const response = await api.getSystemUpdateLogs()
|
||||
if (!response) {
|
||||
throw new Error('Failed to fetch update logs')
|
||||
}
|
||||
setLogs(response.logs)
|
||||
setShowLogs(true)
|
||||
} catch (err) {
|
||||
setError('Failed to fetch update logs')
|
||||
}
|
||||
}
|
||||
|
||||
const getProgressBarColor = () => {
|
||||
if (updateStatus?.stage === 'error') return 'bg-desert-red'
|
||||
if (updateStatus?.stage === 'complete') return 'bg-desert-olive'
|
||||
return 'bg-desert-green'
|
||||
}
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (updateStatus?.stage === 'complete')
|
||||
return <IconCheck className="h-12 w-12 text-desert-olive" />
|
||||
if (updateStatus?.stage === 'error')
|
||||
return <IconAlertCircle className="h-12 w-12 text-desert-red" />
|
||||
if (isUpdating) return <IconRefresh className="h-12 w-12 text-desert-green animate-spin" />
|
||||
if (props.system.updateAvailable) return <IconArrowBigUpLines className="h-16 w-16 text-desert-green" />
|
||||
return <IconCircleCheck className="h-16 w-16 text-desert-olive" />
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<Head title="System Update" />
|
||||
<div className="xl:pl-72 w-full">
|
||||
<main className="px-6 lg:px-12 py-6 lg:py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold text-desert-green mb-2">System Update</h1>
|
||||
<p className="text-desert-stone-dark">
|
||||
Keep your Project N.O.M.A.D. instance up to date with the latest features and improvements.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6">
|
||||
<Alert
|
||||
type="error"
|
||||
title="Update Failed"
|
||||
message={error}
|
||||
variant="bordered"
|
||||
dismissible
|
||||
onDismiss={() => setError(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isUpdating && updateStatus?.stage === 'recreating' && (
|
||||
<div className="mb-6">
|
||||
<Alert
|
||||
type="info"
|
||||
title="Container Restarting"
|
||||
message="The admin container is restarting. This page will reload automatically when the update is complete."
|
||||
variant="solid"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-white rounded-lg border shadow-md overflow-hidden">
|
||||
<div className="p-8 text-center">
|
||||
<div className="flex justify-center mb-4">{getStatusIcon()}</div>
|
||||
|
||||
{!isUpdating && (
|
||||
<>
|
||||
<h2 className="text-2xl font-bold text-desert-green mb-2">
|
||||
{props.system.updateAvailable
|
||||
? 'Update Available'
|
||||
: 'System Up to Date'}
|
||||
</h2>
|
||||
<p className="text-desert-stone-dark mb-6">
|
||||
{props.system.updateAvailable
|
||||
? `A new version (${props.system.latestVersion}) is available for your Project N.O.M.A.D. instance.`
|
||||
: 'Your system is running the latest version!'}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isUpdating && updateStatus && (
|
||||
<>
|
||||
<h2 className="text-2xl font-bold text-desert-green mb-2 capitalize">
|
||||
{updateStatus.stage === 'idle' ? 'Preparing Update' : updateStatus.stage}
|
||||
</h2>
|
||||
<p className="text-desert-stone-dark mb-6">{updateStatus.message}</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center gap-8 mb-6">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-desert-stone mb-1">Current Version</p>
|
||||
<p className="text-xl font-bold text-desert-green">
|
||||
{props.system.currentVersion}
|
||||
</p>
|
||||
</div>
|
||||
{props.system.updateAvailable && (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className="h-6 w-6 text-desert-stone"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 7l5 5m0 0l-5 5m5-5H6"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-desert-stone mb-1">Latest Version</p>
|
||||
<p className="text-xl font-bold text-desert-olive">
|
||||
{props.system.latestVersion}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isUpdating && updateStatus && (
|
||||
<div className="mb-4">
|
||||
<div className="w-full bg-desert-stone-light rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className={`${getProgressBarColor()} h-full transition-all duration-500 ease-out`}
|
||||
style={{ width: `${updateStatus.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-desert-stone mt-2">
|
||||
{updateStatus.progress}% complete
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!isUpdating && (
|
||||
<div className="flex justify-center gap-4">
|
||||
<StyledButton
|
||||
variant="primary"
|
||||
size="lg"
|
||||
icon="ArrowDownTrayIcon"
|
||||
onClick={handleStartUpdate}
|
||||
disabled={!props.system.updateAvailable}
|
||||
>
|
||||
{props.system.updateAvailable ? 'Start Update' : 'No Update Available'}
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
icon="ArrowPathIcon"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Check Again
|
||||
</StyledButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t bg-white p-6">
|
||||
<h3 className="text-lg font-semibold text-desert-green mb-4">
|
||||
What happens during an update?
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-desert-green text-white flex items-center justify-center text-sm font-bold">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-desert-stone-dark">Pull Latest Images</p>
|
||||
<p className="text-sm text-desert-stone">
|
||||
Downloads the newest Docker images for all core containers
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-desert-green text-white flex items-center justify-center text-sm font-bold">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-desert-stone-dark">Recreate Containers</p>
|
||||
<p className="text-sm text-desert-stone">
|
||||
Safely stops and recreates all core containers with the new images
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-desert-green text-white flex items-center justify-center text-sm font-bold">
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-desert-stone-dark">Automatic Reload</p>
|
||||
<p className="text-sm text-desert-stone">
|
||||
This page will automatically reload when the update is complete
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isUpdating && (
|
||||
<div className="mt-6 pt-6 border-t border-desert-stone-light">
|
||||
<StyledButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="DocumentTextIcon"
|
||||
onClick={handleViewLogs}
|
||||
fullWidth
|
||||
>
|
||||
View Update Logs
|
||||
</StyledButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Alert
|
||||
type="info"
|
||||
title="Backup Reminder"
|
||||
message="While updates are designed to be safe, it's always recommended to backup any critical data before proceeding."
|
||||
variant="solid"
|
||||
/>
|
||||
<Alert
|
||||
type="warning"
|
||||
title="Temporary Downtime"
|
||||
message="Services will be briefly unavailable during the update process. This typically takes 2-5 minutes depending on your internet connection."
|
||||
variant="solid"
|
||||
/>
|
||||
</div>
|
||||
{showLogs && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg shadow-2xl max-w-4xl w-full max-h-[80vh] flex flex-col">
|
||||
<div className="p-6 border-b border-desert-stone-light flex justify-between items-center">
|
||||
<h3 className="text-xl font-bold text-desert-green">Update Logs</h3>
|
||||
<button
|
||||
onClick={() => setShowLogs(false)}
|
||||
className="text-desert-stone hover:text-desert-green transition-colors"
|
||||
>
|
||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 overflow-auto flex-1">
|
||||
<pre className="bg-black text-green-400 p-4 rounded text-xs font-mono whitespace-pre-wrap">
|
||||
{logs || 'No logs available yet...'}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="p-6 border-t border-desert-stone-light">
|
||||
<StyledButton variant="secondary" onClick={() => setShowLogs(false)} fullWidth>
|
||||
Close
|
||||
</StyledButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
)
|
||||
}
|
||||
|
|
@ -32,6 +32,7 @@ router
|
|||
router.get('/apps', [SettingsController, 'apps'])
|
||||
router.get('/legal', [SettingsController, 'legal'])
|
||||
router.get('/maps', [SettingsController, 'maps'])
|
||||
router.get('/update', [SettingsController, 'update'])
|
||||
router.get('/zim', [SettingsController, 'zim'])
|
||||
router.get('/zim/remote-explorer', [SettingsController, 'zimRemote'])
|
||||
})
|
||||
|
|
@ -90,6 +91,10 @@ router
|
|||
router.get('/services', [SystemController, 'getServices'])
|
||||
router.post('/services/affect', [SystemController, 'affectService'])
|
||||
router.post('/services/install', [SystemController, 'installService'])
|
||||
router.get('/latest-version', [SystemController, 'checkLatestVersion'])
|
||||
router.post('/update', [SystemController, 'requestSystemUpdate'])
|
||||
router.get('/update/status', [SystemController, 'getSystemUpdateStatus'])
|
||||
router.get('/update/logs', [SystemController, 'getSystemUpdateLogs'])
|
||||
})
|
||||
.prefix('/api/system')
|
||||
|
||||
|
|
|
|||
|
|
@ -60,3 +60,10 @@ export type NomadDiskInfo = {
|
|||
percentUsed: number
|
||||
}[]
|
||||
}
|
||||
|
||||
export type SystemUpdateStatus = {
|
||||
stage: 'idle' | 'starting' | 'pulling' | 'pulled' | 'recreating' | 'complete' | 'error'
|
||||
progress: number
|
||||
message: string
|
||||
timestamp: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ WHIPTAIL_TITLE="Project N.O.M.A.D Installation"
|
|||
NOMAD_DIR="/opt/project-nomad"
|
||||
MANAGEMENT_COMPOSE_FILE_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/install/management_compose.yaml"
|
||||
ENTRYPOINT_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/install/entrypoint.sh"
|
||||
SIDECAR_UPDATER_DOCKERFILE_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/install/sidecar-updater/Dockerfile"
|
||||
SIDECAR_UPDATER_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/install/sidecar-updater/update-watcher.sh"
|
||||
START_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/install/start_nomad.sh"
|
||||
STOP_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/install/stop_nomad.sh"
|
||||
UPDATE_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/install/update_nomad.sh"
|
||||
|
|
@ -293,6 +295,32 @@ download_entrypoint_script() {
|
|||
echo -e "${GREEN}#${RESET} entrypoint script downloaded successfully to $entrypoint_script_path.\\n"
|
||||
}
|
||||
|
||||
download_sidecar_files() {
|
||||
# Create sidecar-updater directory if it doesn't exist
|
||||
if [[ ! -d "${NOMAD_DIR}/sidecar-updater" ]]; then
|
||||
sudo mkdir -p "${NOMAD_DIR}/sidecar-updater"
|
||||
sudo chown "$(whoami):$(whoami)" "${NOMAD_DIR}/sidecar-updater"
|
||||
fi
|
||||
|
||||
local sidecar_dockerfile_path="${NOMAD_DIR}/sidecar-updater/Dockerfile"
|
||||
local sidecar_script_path="${NOMAD_DIR}/sidecar-updater/update-watcher.sh"
|
||||
|
||||
echo -e "${YELLOW}#${RESET} Downloading sidecar updater Dockerfile...\\n"
|
||||
if ! curl -fsSL "$SIDECAR_UPDATER_DOCKERFILE_URL" -o "$sidecar_dockerfile_path"; then
|
||||
echo -e "${RED}#${RESET} Failed to download the sidecar updater Dockerfile. Please check the URL and try again."
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}#${RESET} Sidecar updater Dockerfile downloaded successfully to $sidecar_dockerfile_path.\\n"
|
||||
|
||||
echo -e "${YELLOW}#${RESET} Downloading sidecar updater script...\\n"
|
||||
if ! curl -fsSL "$SIDECAR_UPDATER_SCRIPT_URL" -o "$sidecar_script_path"; then
|
||||
echo -e "${RED}#${RESET} Failed to download the sidecar updater script. Please check the URL and try again."
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "$sidecar_script_path"
|
||||
echo -e "${GREEN}#${RESET} Sidecar updater script downloaded successfully to $sidecar_script_path.\\n"
|
||||
}
|
||||
|
||||
download_and_start_collect_disk_info_script() {
|
||||
local collect_disk_info_script_path="${NOMAD_DIR}/collect_disk_info.sh"
|
||||
|
||||
|
|
@ -340,7 +368,7 @@ download_helper_scripts() {
|
|||
|
||||
start_management_containers() {
|
||||
echo -e "${YELLOW}#${RESET} Starting management containers using docker compose...\\n"
|
||||
if ! sudo docker compose -f "${NOMAD_DIR}/compose.yml" up -d; then
|
||||
if ! sudo docker compose -p project-nomad -f "${NOMAD_DIR}/compose.yml" up -d; then
|
||||
echo -e "${RED}#${RESET} Failed to start management containers. Please check the logs and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
|
@ -383,6 +411,7 @@ get_local_ip
|
|||
create_nomad_directory
|
||||
download_wait_for_it_script
|
||||
download_entrypoint_script
|
||||
download_sidecar_files
|
||||
download_helper_scripts
|
||||
download_and_start_collect_disk_info_script
|
||||
download_management_compose_file
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
name: project-nomad
|
||||
services:
|
||||
admin:
|
||||
image: ghcr.io/crosstalk-solutions/project-nomad:latest
|
||||
|
|
@ -12,6 +13,7 @@ services:
|
|||
- /var/run/docker.sock:/var/run/docker.sock # Allows the admin service to communicate with the Host's Docker daemon
|
||||
- ./entrypoint.sh:/usr/local/bin/entrypoint.sh
|
||||
- ./wait-for-it.sh:/usr/local/bin/wait-for-it.sh
|
||||
- nomad-update-shared:/app/update-shared # Shared volume for update communication
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=8080
|
||||
|
|
@ -80,4 +82,18 @@ services:
|
|||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
retries: 3
|
||||
updater:
|
||||
build:
|
||||
context: ./sidecar-updater
|
||||
dockerfile: Dockerfile
|
||||
container_name: nomad_updater
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock # Allows communication with the Host's Docker daemon
|
||||
- /opt/project-nomad/compose.yml:/opt/project-nomad/compose.yml:ro
|
||||
- nomad-update-shared:/shared # Shared volume for communication with admin container
|
||||
|
||||
volumes:
|
||||
nomad-update-shared:
|
||||
driver: local
|
||||
15
install/sidecar-updater/Dockerfile
Normal file
15
install/sidecar-updater/Dockerfile
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
FROM alpine:3.20
|
||||
|
||||
# Install Docker CLI for compose operations
|
||||
RUN apk add --no-cache docker-cli docker-cli-compose bash
|
||||
|
||||
# Copy the update watcher script
|
||||
COPY update-watcher.sh /usr/local/bin/update-watcher.sh
|
||||
RUN chmod +x /usr/local/bin/update-watcher.sh
|
||||
|
||||
# Create shared communication directory
|
||||
RUN mkdir -p /shared
|
||||
|
||||
WORKDIR /shared
|
||||
|
||||
CMD ["/usr/local/bin/update-watcher.sh"]
|
||||
134
install/sidecar-updater/update-watcher.sh
Normal file
134
install/sidecar-updater/update-watcher.sh
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Project N.O.M.A.D. Update Sidecar - Polls for update requests and executes them
|
||||
|
||||
SHARED_DIR="/shared"
|
||||
REQUEST_FILE="${SHARED_DIR}/update-request"
|
||||
STATUS_FILE="${SHARED_DIR}/update-status"
|
||||
LOG_FILE="${SHARED_DIR}/update-log"
|
||||
COMPOSE_FILE="/opt/project-nomad/compose.yml"
|
||||
COMPOSE_PROJECT_NAME="project-nomad"
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
write_status() {
|
||||
local stage="$1"
|
||||
local progress="$2"
|
||||
local message="$3"
|
||||
|
||||
cat > "$STATUS_FILE" <<EOF
|
||||
{
|
||||
"stage": "$stage",
|
||||
"progress": $progress,
|
||||
"message": "$message",
|
||||
"timestamp": "$(date -Iseconds)"
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
perform_update() {
|
||||
log "Update request received - starting system update"
|
||||
|
||||
# Clear old logs
|
||||
> "$LOG_FILE"
|
||||
|
||||
# Stage 1: Starting
|
||||
write_status "starting" 0 "System update initiated"
|
||||
log "System update initiated"
|
||||
sleep 1
|
||||
|
||||
# Stage 2: Pulling images
|
||||
write_status "pulling" 20 "Pulling latest Docker images..."
|
||||
log "Pulling latest Docker images..."
|
||||
|
||||
if docker compose -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" pull >> "$LOG_FILE" 2>&1; then
|
||||
log "Successfully pulled latest images"
|
||||
write_status "pulled" 60 "Images pulled successfully"
|
||||
else
|
||||
log "ERROR: Failed to pull images"
|
||||
write_status "error" 0 "Failed to pull Docker images - check logs"
|
||||
return 1
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
|
||||
# Stage 3: Recreating containers individually (excluding updater)
|
||||
write_status "recreating" 65 "Recreating containers individually..."
|
||||
log "Recreating containers individually (excluding updater)..."
|
||||
|
||||
# List of services to update (excluding updater)
|
||||
SERVICES_TO_UPDATE="admin mysql redis dozzle"
|
||||
|
||||
local current_progress=65
|
||||
local progress_per_service=8 # (95 - 65) / 4 services ≈ 8% per service
|
||||
|
||||
for service in $SERVICES_TO_UPDATE; do
|
||||
log "Updating service: $service"
|
||||
write_status "recreating" $current_progress "Recreating $service..."
|
||||
|
||||
# Stop the service
|
||||
log " Stopping $service..."
|
||||
docker compose -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" stop "$service" >> "$LOG_FILE" 2>&1 || log " WARNING: Failed to stop $service"
|
||||
|
||||
# Remove the container
|
||||
log " Removing old $service container..."
|
||||
docker compose -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" rm -f "$service" >> "$LOG_FILE" 2>&1 || log " WARNING: Failed to remove $service"
|
||||
|
||||
# Recreate and start with new image
|
||||
log " Starting new $service container..."
|
||||
if docker compose -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" up -d --no-deps "$service" >> "$LOG_FILE" 2>&1; then
|
||||
log " ✓ Successfully recreated $service"
|
||||
else
|
||||
log " ERROR: Failed to recreate $service"
|
||||
write_status "error" $current_progress "Failed to recreate $service - check logs"
|
||||
return 1
|
||||
fi
|
||||
|
||||
current_progress=$((current_progress + progress_per_service))
|
||||
done
|
||||
|
||||
log "Successfully recreated all containers"
|
||||
write_status "complete" 100 "System update completed successfully"
|
||||
log "System update completed successfully"
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
log "Update sidecar shutting down"
|
||||
exit 0
|
||||
}
|
||||
|
||||
trap cleanup SIGTERM SIGINT
|
||||
|
||||
# Main watch loop
|
||||
log "Update sidecar started - watching for update requests"
|
||||
write_status "idle" 0 "Ready for update requests"
|
||||
|
||||
while true; do
|
||||
# Check if an update request file exists
|
||||
if [ -f "$REQUEST_FILE" ]; then
|
||||
log "Found update request file"
|
||||
|
||||
# Read request details (could contain metadata like requester, timestamp, etc.)
|
||||
REQUEST_DATA=$(cat "$REQUEST_FILE" 2>/dev/null || echo "{}")
|
||||
log "Request data: $REQUEST_DATA"
|
||||
|
||||
# Remove the request file to prevent re-processing
|
||||
rm -f "$REQUEST_FILE"
|
||||
|
||||
if perform_update; then
|
||||
log "Update completed successfully"
|
||||
else
|
||||
log "Update failed - see logs for details"
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
write_status "idle" 0 "Ready for update requests"
|
||||
fi
|
||||
|
||||
# Sleep before next check (1 second polling)
|
||||
sleep 1
|
||||
done
|
||||
|
|
@ -97,7 +97,7 @@ try_remove_disk_info_file() {
|
|||
|
||||
uninstall_nomad() {
|
||||
echo "Stopping and removing Project N.O.M.A.D. management containers..."
|
||||
docker compose -f "${MANAGEMENT_COMPOSE_FILE}" down
|
||||
docker compose -p project-nomad -f "${MANAGEMENT_COMPOSE_FILE}" down
|
||||
echo "Allowing some time for management containers to stop..."
|
||||
sleep 5
|
||||
|
||||
|
|
|
|||
|
|
@ -103,13 +103,13 @@ ensure_docker_compose_file_exists() {
|
|||
|
||||
force_recreate() {
|
||||
echo -e "${YELLOW}#${RESET} Pulling the latest Docker images..."
|
||||
if ! docker compose -f /opt/project-nomad/compose.yml pull; then
|
||||
if ! docker compose -p project-nomad -f /opt/project-nomad/compose.yml pull; then
|
||||
echo -e "${RED}#${RESET} Failed to pull the latest Docker images. Please check your network connection and the Docker registry status, then try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}#${RESET} Forcing recreation of containers..."
|
||||
if ! docker compose -f /opt/project-nomad/compose.yml up -d --force-recreate; then
|
||||
if ! docker compose -p project-nomad -f /opt/project-nomad/compose.yml up -d --force-recreate; then
|
||||
echo -e "${RED}#${RESET} Failed to recreate containers. Please check the Docker logs for more details."
|
||||
exit 1
|
||||
fi
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user