diff --git a/src/client/api.ts b/src/client/api.ts index d79851d..9f8dcb5 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -13,8 +13,17 @@ export const shareApp = (name: string) => export const unshareApp = (name: string) => 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 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 stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { method: 'POST' }) diff --git a/src/client/components/SettingsPage.tsx b/src/client/components/SettingsPage.tsx index 9b844a1..84bee13 100644 --- a/src/client/components/SettingsPage.tsx +++ b/src/client/components/SettingsPage.tsx @@ -1,9 +1,13 @@ 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 { Button, DashboardInstallCmd, + FormField, + FormLabel, + FormSelect, HeaderActions, Main, MainContent, @@ -13,9 +17,28 @@ import { SectionTitle, } 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 }) { const [version, setVersion] = useState('') const [sha, setSha] = useState('') + const [themeChoice, setThemeChoice] = useState(localStorage.getItem('theme') || 'system') + const [restarting, setRestarting] = useState(false) + const [updateInfo, setUpdateInfo] = useState(null) + const [checking, setChecking] = useState(false) + const [updating, setUpdating] = useState(false) useEffect(() => { getSystemInfo().then(info => { @@ -28,6 +51,57 @@ export function SettingsPage({ render }: { render: () => void }) { 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 (
@@ -37,11 +111,22 @@ export function SettingsPage({ render }: { render: () => void }) { +
+ Theme + + Appearance + + + + + + +
About
Version: {version} - SHA: {sha} + SHA: {sha}
@@ -50,6 +135,41 @@ export function SettingsPage({ render }: { render: () => void }) { curl -fsSL {location.origin}/install | bash
+
+ Update + {updating ? ( +
Updating... server will restart shortly.
+ ) : updateInfo?.available ? ( +
+
+ {updateInfo.commits.length} update{updateInfo.commits.length !== 1 ? 's' : ''} available ({updateInfo.current} → {updateInfo.latest}) +
+
+ {updateInfo.commits.map(c =>
{c}
)} +
+
+ +
+
+ ) : updateInfo ? ( +
+ Up to date + +
+ ) : ( + + )} +
+
+ Server + +
) diff --git a/src/client/index.tsx b/src/client/index.tsx index a4460a7..bd46081 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -23,13 +23,18 @@ const render = () => { initUpdate(render) initToolIframes() -// Set theme based on system preference -const setTheme = () => { +// Set theme based on localStorage preference or system preference +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 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', () => { setTheme() render() diff --git a/src/server/api/system.ts b/src/server/api/system.ts index d41794b..251deb8 100644 --- a/src/server/api/system.ts +++ b/src/server/api/system.ts @@ -266,4 +266,55 @@ onChange(collectLogs) // Subscribe to host-level log messages 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