toes/src/client/components/SettingsPage.tsx

214 lines
6.7 KiB
TypeScript

import { useEffect, useState } from 'hono/jsx'
import { applyUpdate, checkForUpdate, getSystemInfo, restartServer } from '../api'
import { setTheme } from '../index'
import { navigate } from '../router'
import {
Button,
DashboardInstallCmd,
FormField,
FormLabel,
FormSelect,
HeaderActions,
Main,
MainContent,
MainHeader,
MainTitle,
Section,
SectionTitle,
} from '../styles'
type UpdateInfo = { available: boolean, current: string, latest: string, commits: string[] }
function formatUptime(ms: number): string {
const seconds = Math.floor(ms / 1000)
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
const parts: string[] = []
if (days > 0) parts.push(`${days}d`)
if (hours > 0) parts.push(`${hours}h`)
if (minutes > 0) parts.push(`${minutes}m`)
parts.push(`${secs}s`)
return parts.join(' ')
}
function pollUntilBack(onBack: () => void, onTimeout?: () => void) {
let elapsed = 0
const poll = setInterval(async () => {
elapsed += 2000
if (elapsed > 60000) {
clearInterval(poll)
onTimeout?.()
return
}
try {
const res = await fetch('/api/system/info')
if (res.ok) {
clearInterval(poll)
onBack()
}
} catch {}
}, 2000)
}
export function SettingsPage({ render }: { render: () => void }) {
const [version, setVersion] = useState('')
const [sha, setSha] = useState('')
const [uptime, setUptime] = useState(0)
const [themeChoice, setThemeChoice] = useState(localStorage.getItem('theme') || 'system')
const [restarting, setRestarting] = useState(false)
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
const [checking, setChecking] = useState(false)
const [updating, setUpdating] = useState(false)
useEffect(() => {
getSystemInfo().then(info => {
setVersion(info.version)
setSha(info.sha)
setUptime(info.uptime)
})
}, [])
// Tick uptime every second
useEffect(() => {
const interval = setInterval(() => setUptime(u => u + 1000), 1000)
return () => clearInterval(interval)
}, [])
const goBack = () => {
navigate('/')
}
const handleThemeChange = (e: Event) => {
const value = (e.target as HTMLSelectElement).value
setThemeChoice(value)
if (value === 'system') {
localStorage.removeItem('theme')
} else {
localStorage.setItem('theme', value)
}
setTheme()
}
const refreshSystemInfo = () => {
getSystemInfo().then(info => {
setVersion(info.version)
setSha(info.sha)
setUptime(info.uptime)
})
}
const handleRestart = () => {
if (!confirm('Are you sure you want to restart the server?')) return
setRestarting(true)
restartServer().catch(() => {})
pollUntilBack(
() => { setRestarting(false); refreshSystemInfo() },
() => { setRestarting(false) },
)
}
const handleCheckUpdate = async () => {
setChecking(true)
try {
const info = await checkForUpdate()
setUpdateInfo(info)
} catch {
setUpdateInfo(null)
}
setChecking(false)
}
const handleApplyUpdate = async () => {
if (!confirm('This will update and restart the server. Continue?')) return
setUpdating(true)
try {
await applyUpdate()
pollUntilBack(
() => { setUpdating(false); refreshSystemInfo(); setUpdateInfo(null) },
() => { setUpdating(false) },
)
} catch {
setUpdating(false)
}
}
return (
<Main>
<MainHeader centered>
<MainTitle>Settings</MainTitle>
<HeaderActions>
<Button onClick={goBack}>Back</Button>
</HeaderActions>
</MainHeader>
<MainContent centered>
<Section>
<SectionTitle>Theme</SectionTitle>
<FormField>
<FormLabel>Appearance</FormLabel>
<FormSelect onChange={handleThemeChange}>
<option value="system" selected={themeChoice === 'system'}>System</option>
<option value="light" selected={themeChoice === 'light'}>Light</option>
<option value="dark" selected={themeChoice === 'dark'}>Dark</option>
</FormSelect>
</FormField>
</Section>
<Section>
<SectionTitle>About</SectionTitle>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, fontSize: 14 }}>
<span>Version: {version}</span>
<span>SHA: <a href={`https://git.nose.space/defunkt/toes/commit/${sha}`} target="_blank">{sha}</a></span>
</div>
</Section>
<Section>
<SectionTitle>Install CLI</SectionTitle>
<DashboardInstallCmd>
curl -fsSL {location.origin}/install | bash
</DashboardInstallCmd>
</Section>
<Section>
<SectionTitle>Update</SectionTitle>
{updating ? (
<div style={{ fontSize: 14 }}>Updating... server will restart shortly.</div>
) : updateInfo?.available ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: 14 }}>
{updateInfo.commits.length} update{updateInfo.commits.length !== 1 ? 's' : ''} available ({updateInfo.current} {updateInfo.latest})
</div>
<div style={{ fontSize: 13, color: 'var(--colors-textMuted)', display: 'flex', flexDirection: 'column', gap: 2 }}>
{updateInfo.commits.map(c => <div>{c}</div>)}
</div>
<div>
<Button variant="primary" onClick={handleApplyUpdate}>Update & Restart</Button>
</div>
</div>
) : updateInfo ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 14 }}>Up to date</span>
<Button onClick={handleCheckUpdate} disabled={checking}>
{checking ? 'Checking...' : 'Check Again'}
</Button>
</div>
) : (
<Button onClick={handleCheckUpdate} disabled={checking}>
{checking ? 'Checking...' : 'Check for Updates'}
</Button>
)}
</Section>
<Section>
<SectionTitle>Server</SectionTitle>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, fontSize: 14 }}>
<span>Uptime: {formatUptime(uptime)}</span>
</div>
<div style={{ marginTop: 12 }}>
<Button variant="danger" onClick={handleRestart} disabled={restarting}>
{restarting ? 'Restarting...' : 'Restart Server'}
</Button>
</div>
</Section>
</MainContent>
</Main>
)
}