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 Switch from '~/components/inputs/Switch' import { useMutation } from '@tanstack/react-query' import { useNotifications } from '~/context/NotificationContext' import { useSystemSetting } from '~/hooks/useSystemSetting' type Props = { updateAvailable: boolean latestVersion: string currentVersion: string earlyAccess: boolean } function ContentUpdatesSection() { const { addNotification } = useNotifications() const [checkResult, setCheckResult] = useState(null) const [isChecking, setIsChecking] = useState(false) const [applyingIds, setApplyingIds] = useState>(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 (

Check if newer versions of your installed ZIM files and maps are available.

Check for Content Updates
{checkResult?.error && ( )} {checkResult && !checkResult.error && checkResult.updates.length === 0 && ( )} {checkResult && checkResult.updates.length > 0 && (

{checkResult.updates.length} update(s) available

Update All ({checkResult.updates.length})
( {record.resource_id} ), }, { accessor: 'resource_type', title: 'Type', render: (record) => ( {record.resource_type === 'zim' ? 'ZIM' : 'Map'} ), }, { accessor: 'installed_version', title: 'Version', render: (record) => ( {record.installed_version} → {record.latest_version} ), }, { accessor: 'resource_id', title: '', render: (record) => ( handleApply(record)} loading={applyingIds.has(record.resource_id)} > Update ), }, ]} />
)} {checkResult?.checked_at && (

Last checked: {new Date(checkResult.checked_at).toLocaleString()}

)}
) } export default function SystemUpdatePage(props: { system: Props }) { const { addNotification } = useNotifications() const [isUpdating, setIsUpdating] = useState(false) const [updateStatus, setUpdateStatus] = useState(null) const [error, setError] = useState(null) const [showLogs, setShowLogs] = useState(false) const [logs, setLogs] = useState('') const [email, setEmail] = useState('') const [versionInfo, setVersionInfo] = useState>(props.system) const [showConnectionLostNotice, setShowConnectionLostNotice] = useState(false) const earlyAccessSetting = useSystemSetting({ key: 'system.earlyAccess', initialData: { key: 'system.earlyAccess', value: props.system.earlyAccess, } }) 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') { // Re-check version so the KV store clears the stale "update available" flag // before we reload, otherwise the banner shows "current → current" try { await api.checkLatestVersion(true) } catch { // Non-critical - page reload will still work } 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]) 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 if (updateStatus?.stage === 'error') return if (isUpdating) return if (props.system.updateAvailable) return return } const updateSettingMutation = useMutation({ mutationFn: async ({ key, value }: { key: string; value: boolean }) => { return await api.updateSetting(key, value) }, onSuccess: () => { addNotification({ message: 'Setting updated successfully.', type: 'success' }) earlyAccessSetting.refetch() }, onError: (error) => { console.error('Error updating setting:', error) addNotification({ message: 'There was an error updating the setting. Please try again.', type: 'error' }) }, }) 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 (

System Update

Keep your Project N.O.M.A.D. instance up to date with the latest features and improvements.

{error && (
setError(null)} />
)} {isUpdating && updateStatus?.stage === 'recreating' && (
)} {isUpdating && showConnectionLostNotice && (
)}
{getStatusIcon()}
{!isUpdating && ( <>

{props.system.updateAvailable ? 'Update Available' : 'System Up to Date'}

{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!'}

)} {isUpdating && updateStatus && ( <>

{updateStatus.stage === 'idle' ? 'Preparing Update' : updateStatus.stage}

{updateStatus.message}

)}

Current Version

{versionInfo.currentVersion}

{versionInfo.updateAvailable && ( <>

Latest Version

{versionInfo.latestVersion}

)}
{isUpdating && updateStatus && (

{updateStatus.progress}% complete

)} {!isUpdating && (
{versionInfo.updateAvailable ? 'Start Update' : 'No Update Available'} checkVersionMutation.mutate()} loading={checkVersionMutation.isPending} > Check Again
)}

What happens during an update?

1

Pull Latest Images

Downloads the newest Docker images for all core containers

2

Recreate Containers

Safely stops and recreates all core containers with the new images

3

Automatic Reload

This page will automatically reload when the update is complete

{isUpdating && (
View Update Logs
)}
{ updateSettingMutation.mutate({ key: 'system.earlyAccess', value: newVal }) }} disabled={updateSettingMutation.isPending} label="Enable Early Access" description="Receive release candidate (RC) versions before they are officially released. Note: RC versions may contain bugs and are not recommended for environments where stability and data integrity are critical." />

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.

setEmail(e.target.value)} className="w-full" containerClassName="!mt-0" /> subscribeToReleaseNotesMutation.mutateAsync(email)} loading={subscribeToReleaseNotesMutation.isPending} > Subscribe

We care about your privacy. Project N.O.M.A.D. will never share your email with third parties or send you spam.

{showLogs && (

Update Logs

                    {logs || 'No logs available yet...'}
                  
setShowLogs(false)} fullWidth> Close
)}
) }