project-nomad/admin/inertia/pages/settings/update.tsx

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>
)
}