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

673 lines
25 KiB
TypeScript

import { Head } from '@inertiajs/react'
import SettingsLayout from '~/layouts/SettingsLayout'
import StyledButton from '~/components/StyledButton'
import StyledTable from '~/components/StyledTable'
import StyledSectionHeader from '~/components/StyledSectionHeader'
import ActiveDownloads from '~/components/ActiveDownloads'
import Alert from '~/components/Alert'
import { useEffect, useState } from 'react'
import { IconAlertCircle, IconArrowBigUpLines, IconCheck, IconCircleCheck, IconReload } from '@tabler/icons-react'
import { SystemUpdateStatus } from '../../../types/system'
import type { ContentUpdateCheckResult, ResourceUpdateInfo } from '../../../types/collections'
import api from '~/lib/api'
import Input from '~/components/inputs/Input'
import { useMutation } from '@tanstack/react-query'
import { useNotifications } from '~/context/NotificationContext'
function ContentUpdatesSection() {
const { addNotification } = useNotifications()
const [checkResult, setCheckResult] = useState<ContentUpdateCheckResult | null>(null)
const [isChecking, setIsChecking] = useState(false)
const [applyingIds, setApplyingIds] = useState<Set<string>>(new Set())
const [isApplyingAll, setIsApplyingAll] = useState(false)
const handleCheck = async () => {
setIsChecking(true)
try {
const result = await api.checkForContentUpdates()
if (result) {
setCheckResult(result)
}
} catch {
setCheckResult({
updates: [],
checked_at: new Date().toISOString(),
error: 'Failed to check for content updates',
})
} finally {
setIsChecking(false)
}
}
const handleApply = async (update: ResourceUpdateInfo) => {
setApplyingIds((prev) => new Set(prev).add(update.resource_id))
try {
const result = await api.applyContentUpdate(update)
if (result?.success) {
addNotification({ type: 'success', message: `Update started for ${update.resource_id}` })
// Remove from the updates list
setCheckResult((prev) =>
prev
? { ...prev, updates: prev.updates.filter((u) => u.resource_id !== update.resource_id) }
: prev
)
} else {
addNotification({ type: 'error', message: result?.error || 'Failed to start update' })
}
} catch {
addNotification({ type: 'error', message: `Failed to start update for ${update.resource_id}` })
} finally {
setApplyingIds((prev) => {
const next = new Set(prev)
next.delete(update.resource_id)
return next
})
}
}
const handleApplyAll = async () => {
if (!checkResult?.updates.length) return
setIsApplyingAll(true)
try {
const result = await api.applyAllContentUpdates(checkResult.updates)
if (result?.results) {
const succeeded = result.results.filter((r) => r.success).length
const failed = result.results.filter((r) => !r.success).length
if (succeeded > 0) {
addNotification({ type: 'success', message: `Started ${succeeded} update(s)` })
}
if (failed > 0) {
addNotification({ type: 'error', message: `${failed} update(s) could not be started` })
}
// Remove successful updates from the list
const successIds = new Set(result.results.filter((r) => r.success).map((r) => r.resource_id))
setCheckResult((prev) =>
prev
? { ...prev, updates: prev.updates.filter((u) => !successIds.has(u.resource_id)) }
: prev
)
}
} catch {
addNotification({ type: 'error', message: 'Failed to apply updates' })
} finally {
setIsApplyingAll(false)
}
}
return (
<div className="mt-8">
<StyledSectionHeader title="Content Updates" />
<div className="bg-white rounded-lg border shadow-md overflow-hidden p-6">
<div className="flex items-center justify-between mb-4">
<p className="text-desert-stone-dark">
Check if newer versions of your installed ZIM files and maps are available.
</p>
<StyledButton
variant="primary"
icon="IconRefresh"
onClick={handleCheck}
loading={isChecking}
>
Check for Content Updates
</StyledButton>
</div>
{checkResult?.error && (
<Alert
type="warning"
title="Update Check Issue"
message={checkResult.error}
variant="bordered"
className="mb-4"
/>
)}
{checkResult && !checkResult.error && checkResult.updates.length === 0 && (
<Alert
type="success"
title="All Content Up to Date"
message="All your installed content is running the latest available version."
variant="bordered"
className="mb-4"
/>
)}
{checkResult && checkResult.updates.length > 0 && (
<div className="mt-4">
<div className="flex items-center justify-between mb-3">
<p className="text-sm text-desert-stone-dark">
{checkResult.updates.length} update(s) available
</p>
<StyledButton
variant="primary"
size="sm"
icon="IconDownload"
onClick={handleApplyAll}
loading={isApplyingAll}
>
Update All ({checkResult.updates.length})
</StyledButton>
</div>
<StyledTable
data={checkResult.updates}
columns={[
{
accessor: 'resource_id',
title: 'Title',
render: (record) => (
<span className="font-medium text-desert-green">{record.resource_id}</span>
),
},
{
accessor: 'resource_type',
title: 'Type',
render: (record) => (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
record.resource_type === 'zim'
? 'bg-blue-100 text-blue-800'
: 'bg-emerald-100 text-emerald-800'
}`}
>
{record.resource_type === 'zim' ? 'ZIM' : 'Map'}
</span>
),
},
{
accessor: 'installed_version',
title: 'Version',
render: (record) => (
<span className="text-desert-stone-dark">
{record.installed_version} {record.latest_version}
</span>
),
},
{
accessor: 'resource_id',
title: '',
render: (record) => (
<StyledButton
variant="secondary"
size="sm"
icon="IconDownload"
onClick={() => handleApply(record)}
loading={applyingIds.has(record.resource_id)}
>
Update
</StyledButton>
),
},
]}
/>
</div>
)}
{checkResult?.checked_at && (
<p className="text-xs text-desert-stone mt-3">
Last checked: {new Date(checkResult.checked_at).toLocaleString()}
</p>
)}
</div>
<ActiveDownloads withHeader />
</div>
)
}
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('')
const [versionInfo, setVersionInfo] = useState(props.system)
const [showConnectionLostNotice, setShowConnectionLostNotice] = useState(false)
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)
// If we can connect again, hide the connection lost notice
setShowConnectionLostNotice(false)
// 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
// Show a notice to inform the user that this is normal
setShowConnectionLostNotice(true)
// 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 checkVersionMutation = useMutation({
mutationKey: ['checkLatestVersion'],
mutationFn: () => api.checkLatestVersion(true),
onSuccess: (data) => {
if (data) {
setVersionInfo({
updateAvailable: data.updateAvailable,
latestVersion: data.latestVersion,
currentVersion: data.currentVersion,
})
if (data.updateAvailable) {
addNotification({
type: 'success',
message: `Update available: ${data.latestVersion}`,
})
} else {
addNotification({ type: 'success', message: 'System is up to date' })
}
setError(null)
}
},
onError: (error: any) => {
const errorMessage = error?.message || 'Failed to check for updates'
setError(errorMessage)
addNotification({ type: 'error', message: errorMessage })
},
})
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 <IconReload 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>
)}
{isUpdating && showConnectionLostNotice && (
<div className="mb-6">
<Alert
type="info"
title="Connection Temporarily Lost (Expected)"
message="You may see error notifications while the backend restarts during the update. This is completely normal and expected. Connection should be restored momentarily."
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">
{versionInfo.currentVersion}
</p>
</div>
{versionInfo.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">
{versionInfo.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={!versionInfo.updateAvailable}
>
{versionInfo.updateAvailable ? 'Start Update' : 'No Update Available'}
</StyledButton>
<StyledButton
variant="ghost"
size="lg"
icon="IconRefresh"
onClick={() => checkVersionMutation.mutate()}
loading={checkVersionMutation.isPending}
>
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>
<ContentUpdatesSection />
{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>
)
}