import { Head } from '@inertiajs/react' import { useTranslation } from 'react-i18next' 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 { t } = useTranslation() 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: t('update.failedToCheckUpdates'), }) } 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: t('update.updateStarted', { id: 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 || t('update.failedToStartUpdate') }) } } catch { addNotification({ type: 'error', message: t('update.failedToStartUpdateFor', { id: 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: t('update.startedUpdates', { count: succeeded }) }) } if (failed > 0) { addNotification({ type: 'error', message: t('update.failedUpdates', { count: failed }) }) } // 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: t('update.failedToApplyUpdates') }) } finally { setIsApplyingAll(false) } } return (

{t('update.contentUpdatesDescription')}

{t('update.checkForContentUpdates')}
{checkResult?.error && ( )} {checkResult && !checkResult.error && checkResult.updates.length === 0 && ( )} {checkResult && checkResult.updates.length > 0 && (

{t('update.updatesAvailable', { count: checkResult.updates.length })}

{t('update.updateAll', { count: checkResult.updates.length })}
( {record.resource_id} ), }, { accessor: 'resource_type', title: t('update.columns.type'), render: (record) => ( {record.resource_type === 'zim' ? t('update.zim') : t('update.map')} ), }, { accessor: 'installed_version', title: t('update.columns.version'), render: (record) => ( {record.installed_version} → {record.latest_version} ), }, { accessor: 'resource_id', title: '', render: (record) => ( handleApply(record)} loading={applyingIds.has(record.resource_id)} > {t('update.updateButton')} ), }, ]} />
)} {checkResult?.checked_at && (

{t('update.lastChecked')} {new Date(checkResult.checked_at).toLocaleString()}

)}
) } export default function SystemUpdatePage(props: { system: Props }) { const { t } = useTranslation() 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: t('update.updateAvailableNotification', { version: data.latestVersion }), }) } else { addNotification({ type: 'success', message: t('update.systemUpToDateNotification') }) } setError(null) } }, onError: (error: any) => { const errorMessage = error?.message || t('update.failedToCheckForUpdates') 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: t('update.settingUpdated'), type: 'success' }) earlyAccessSetting.refetch() }, onError: (error) => { console.error('Error updating setting:', error) addNotification({ message: t('update.settingUpdateError'), type: 'error' }) }, }) const subscribeToReleaseNotesMutation = useMutation({ mutationKey: ['subscribeToReleaseNotes'], mutationFn: (email: string) => api.subscribeToReleaseNotes(email), onSuccess: (data) => { if (data && data.success) { addNotification({ type: 'success', message: t('update.subscribeSuccess') }) setEmail('') } else { addNotification({ type: 'error', message: t('update.subscribeFailed', { error: data?.message || 'Unknown error' }), }) } }, onError: (error: any) => { addNotification({ type: 'error', message: t('update.subscribeError', { error: error.message || 'Unknown error' }), }) }, }) return (

{t('update.heading')}

{t('update.description')}

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

{props.system.updateAvailable ? t('update.updateAvailable') : t('update.systemUpToDate')}

{props.system.updateAvailable ? t('update.newVersionAvailable', { version: props.system.latestVersion }) : t('update.runningLatest')}

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

{updateStatus.stage === 'idle' ? t('update.preparingUpdate') : updateStatus.stage}

{updateStatus.message}

)}

{t('update.currentVersion')}

{versionInfo.currentVersion}

{versionInfo.updateAvailable && ( <>

{t('update.latestVersion')}

{versionInfo.latestVersion}

)}
{isUpdating && updateStatus && (

{t('update.percentComplete', { percent: updateStatus.progress })}

)} {!isUpdating && (
{versionInfo.updateAvailable ? t('update.startUpdate') : t('update.noUpdateAvailable')} checkVersionMutation.mutate()} loading={checkVersionMutation.isPending} > {t('update.checkAgain')}
)}

{t('update.whatHappens')}

1

{t('update.step1Title')}

{t('update.step1Description')}

2

{t('update.step2Title')}

{t('update.step2Description')}

3

{t('update.step3Title')}

{t('update.step3Description')}

{isUpdating && (
{t('update.viewUpdateLogs')}
)}
{ updateSettingMutation.mutate({ key: 'system.earlyAccess', value: newVal }) }} disabled={updateSettingMutation.isPending} label={t('update.enableEarlyAccess')} description={t('update.enableEarlyAccessDescription')} />

{t('update.subscribeHeading')}

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

{t('update.privacyNote')}

{showLogs && (

{t('update.updateLogs')}

                    {logs || t('update.noLogsAvailable')}
                  
setShowLogs(false)} fullWidth> {t('update.close')}
)}
) }