real settings

This commit is contained in:
Chris Wanstrath 2026-03-04 19:03:24 -08:00
parent b152e0d3e8
commit f54cc401dc
4 changed files with 190 additions and 5 deletions

View File

@ -13,8 +13,17 @@ export const shareApp = (name: string) =>
export const unshareApp = (name: string) => export const unshareApp = (name: string) =>
fetch(`/api/apps/${name}/tunnel`, { method: 'DELETE' }) fetch(`/api/apps/${name}/tunnel`, { method: 'DELETE' })
export const applyUpdate = () =>
fetch('/api/system/update', { method: 'POST' }).then(r => r.json())
export const checkForUpdate = (): Promise<{ available: boolean, current: string, latest: string, commits: string[] }> =>
fetch('/api/system/update').then(r => r.json())
export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' }) export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })
export const restartServer = () =>
fetch('/api/system/restart', { method: 'POST' }).then(r => r.json())
export const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' }) export const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' })
export const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { method: 'POST' }) export const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { method: 'POST' })

View File

@ -1,9 +1,13 @@
import { useEffect, useState } from 'hono/jsx' import { useEffect, useState } from 'hono/jsx'
import { getSystemInfo } from '../api' import { applyUpdate, checkForUpdate, getSystemInfo, restartServer } from '../api'
import { setTheme } from '../index'
import { navigate } from '../router' import { navigate } from '../router'
import { import {
Button, Button,
DashboardInstallCmd, DashboardInstallCmd,
FormField,
FormLabel,
FormSelect,
HeaderActions, HeaderActions,
Main, Main,
MainContent, MainContent,
@ -13,9 +17,28 @@ import {
SectionTitle, SectionTitle,
} from '../styles' } from '../styles'
type UpdateInfo = { available: boolean, current: string, latest: string, commits: string[] }
function pollUntilBack(onBack: () => void) {
const poll = setInterval(async () => {
try {
const res = await fetch('/api/system/info')
if (res.ok) {
clearInterval(poll)
onBack()
}
} catch {}
}, 2000)
}
export function SettingsPage({ render }: { render: () => void }) { export function SettingsPage({ render }: { render: () => void }) {
const [version, setVersion] = useState('') const [version, setVersion] = useState('')
const [sha, setSha] = useState('') const [sha, setSha] = useState('')
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(() => { useEffect(() => {
getSystemInfo().then(info => { getSystemInfo().then(info => {
@ -28,6 +51,57 @@ export function SettingsPage({ render }: { render: () => void }) {
navigate('/') 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 handleRestart = () => {
if (!confirm('Are you sure you want to restart the server?')) return
setRestarting(true)
restartServer().catch(() => {})
pollUntilBack(() => {
setRestarting(false)
getSystemInfo().then(info => {
setVersion(info.version)
setSha(info.sha)
})
})
}
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()
} catch {}
pollUntilBack(() => {
setUpdating(false)
getSystemInfo().then(info => {
setVersion(info.version)
setSha(info.sha)
})
setUpdateInfo(null)
})
}
return ( return (
<Main> <Main>
<MainHeader centered> <MainHeader centered>
@ -37,11 +111,22 @@ export function SettingsPage({ render }: { render: () => void }) {
</HeaderActions> </HeaderActions>
</MainHeader> </MainHeader>
<MainContent centered> <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> <Section>
<SectionTitle>About</SectionTitle> <SectionTitle>About</SectionTitle>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, fontSize: 14 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8, fontSize: 14 }}>
<span>Version: {version}</span> <span>Version: {version}</span>
<span>SHA: {sha}</span> <span>SHA: <a href={`https://git.nose.space/defunkt/toes/commit/${sha}`} target="_blank">{sha}</a></span>
</div> </div>
</Section> </Section>
<Section> <Section>
@ -50,6 +135,41 @@ export function SettingsPage({ render }: { render: () => void }) {
curl -fsSL {location.origin}/install | bash curl -fsSL {location.origin}/install | bash
</DashboardInstallCmd> </DashboardInstallCmd>
</Section> </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>
<Button variant="danger" onClick={handleRestart} disabled={restarting}>
{restarting ? 'Restarting...' : 'Restart Server'}
</Button>
</Section>
</MainContent> </MainContent>
</Main> </Main>
) )

View File

@ -23,13 +23,18 @@ const render = () => {
initUpdate(render) initUpdate(render)
initToolIframes() initToolIframes()
// Set theme based on system preference // Set theme based on localStorage preference or system preference
const setTheme = () => { export const setTheme = () => {
const stored = localStorage.getItem('theme')
if (stored === 'light' || stored === 'dark') {
document.documentElement.setAttribute('data-theme', stored)
return
}
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light') document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light')
} }
// Listen for system theme changes // Listen for system theme changes (only applies when using system theme)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
setTheme() setTheme()
render() render()

View File

@ -266,4 +266,55 @@ onChange(collectLogs)
// Subscribe to host-level log messages // Subscribe to host-level log messages
onHostLog(pushHostLog) onHostLog(pushHostLog)
// Restart server (systemd brings it back)
router.post('/restart', c => {
setTimeout(() => process.exit(0), 100)
return c.json({ ok: true })
})
// Check for updates
router.get('/update', async c => {
const cwd = join(import.meta.dir, '../../..')
try {
const fetch = Bun.spawnSync(['git', 'fetch', 'origin', 'main'], { cwd })
if (fetch.exitCode !== 0) return c.json({ available: false })
const log = Bun.spawnSync(['git', 'log', 'HEAD..origin/main', '--oneline'], { cwd })
const output = log.stdout.toString().trim()
const commits = output ? output.split('\n') : []
const latest = Bun.spawnSync(['git', 'rev-parse', '--short', 'origin/main'], { cwd })
.stdout.toString().trim()
return c.json({
available: commits.length > 0,
current: sha,
latest: commits.length > 0 ? latest : sha,
commits,
})
} catch {
return c.json({ available: false })
}
})
// Apply update and restart
router.post('/update', async c => {
const cwd = join(import.meta.dir, '../../..')
try {
const pull = Bun.spawnSync(['git', 'pull', 'origin', 'main'], { cwd })
if (pull.exitCode !== 0) return c.json({ ok: false, error: 'git pull failed' }, 500)
const install = Bun.spawnSync(['bun', 'install'], { cwd })
if (install.exitCode !== 0) return c.json({ ok: false, error: 'bun install failed' }, 500)
const build = Bun.spawnSync(['bun', 'run', 'build'], { cwd })
if (build.exitCode !== 0) return c.json({ ok: false, error: 'build failed' }, 500)
setTimeout(() => process.exit(0), 100)
return c.json({ ok: true })
} catch {
return c.json({ ok: false, error: 'update failed' }, 500)
}
})
export default router export default router