mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-03-28 03:29:25 +01:00
424 lines
17 KiB
TypeScript
424 lines
17 KiB
TypeScript
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'
|
|
import Input from '~/components/inputs/Input'
|
|
import { useMutation } from '@tanstack/react-query'
|
|
import { useNotifications } from '~/context/NotificationContext'
|
|
|
|
export default function SystemUpdatePage(props: {
|
|
system: {
|
|
updateAvailable: boolean
|
|
latestVersion: string
|
|
currentVersion: string
|
|
}
|
|
}) {
|
|
const { addNotification } = useNotifications()
|
|
|
|
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>('')
|
|
const [email, setEmail] = useState('')
|
|
|
|
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" />
|
|
}
|
|
|
|
const subscribeToReleaseNotesMutation = useMutation({
|
|
mutationKey: ['subscribeToReleaseNotes'],
|
|
mutationFn: (email: string) => api.subscribeToReleaseNotes(email),
|
|
onSuccess: (data) => {
|
|
if (data && data.success) {
|
|
addNotification({ type: 'success', message: 'Successfully subscribed to release notes!' })
|
|
setEmail('')
|
|
} else {
|
|
addNotification({
|
|
type: 'error',
|
|
message: `Failed to subscribe: ${data?.message || 'Unknown error'}`,
|
|
})
|
|
}
|
|
},
|
|
onError: (error: any) => {
|
|
addNotification({
|
|
type: 'error',
|
|
message: `Error subscribing to release notes: ${error.message || 'Unknown error'}`,
|
|
})
|
|
},
|
|
})
|
|
|
|
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="IconDownload"
|
|
onClick={handleStartUpdate}
|
|
disabled={!props.system.updateAvailable}
|
|
>
|
|
{props.system.updateAvailable ? 'Start Update' : 'No Update Available'}
|
|
</StyledButton>
|
|
<StyledButton
|
|
variant="ghost"
|
|
size="lg"
|
|
icon="IconRefresh"
|
|
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="IconLogs"
|
|
onClick={handleViewLogs}
|
|
fullWidth
|
|
>
|
|
View Update Logs
|
|
</StyledButton>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="bg-white rounded-lg border shadow-md overflow-hidden py-6 mt-6">
|
|
<div className="flex flex-col md:flex-row justify-between items-center p-8 gap-y-8 md:gap-y-0 gap-x-8">
|
|
<div>
|
|
<h2 className="max-w-xl text-lg font-bold text-desert-green sm:text-xl lg:col-span-7">
|
|
Want to stay updated with the latest from Project N.O.M.A.D.? Subscribe to receive
|
|
release notes directly to your inbox. Unsubscribe anytime.
|
|
</h2>
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<div className="flex gap-x-3">
|
|
<Input
|
|
name="email"
|
|
label=""
|
|
type="email"
|
|
placeholder="Your email address"
|
|
disabled={false}
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
className="w-full"
|
|
containerClassName="!mt-0"
|
|
/>
|
|
<StyledButton
|
|
variant="primary"
|
|
disabled={!email}
|
|
onClick={() => subscribeToReleaseNotesMutation.mutateAsync(email)}
|
|
loading={subscribeToReleaseNotesMutation.isPending}
|
|
>
|
|
Subscribe
|
|
</StyledButton>
|
|
</div>
|
|
<p className="mt-2 text-sm text-desert-stone-dark">
|
|
We care about your privacy. Project N.O.M.A.D. will never share your email with
|
|
third parties or send you spam.
|
|
</p>
|
|
</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>
|
|
)
|
|
}
|