dashboard

This commit is contained in:
Chris Wanstrath 2026-02-19 09:28:15 -08:00
parent 071f1a02b5
commit 971ebef21c
9 changed files with 189 additions and 11 deletions

View File

@ -4,6 +4,16 @@ export const getLogDates = (name: string): Promise<string[]> =>
export const getLogsForDate = (name: string, date: string): Promise<string[]> =>
fetch(`/api/apps/${name}/logs?date=${date}`).then(r => r.json())
export const getWifiConfig = (): Promise<{ network: string, password: string }> =>
fetch('/api/system/wifi').then(r => r.json())
export const saveWifiConfig = (config: { network: string, password: string }) =>
fetch('/api/system/wifi', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
}).then(r => r.json())
export const shareApp = (name: string) =>
fetch(`/api/apps/${name}/tunnel`, { method: 'POST' })

View File

@ -1,23 +1,25 @@
import { Styles } from '@because/forge'
import { apps, isNarrow, selectedApp } from '../state'
import { apps, currentView, isNarrow, selectedApp } from '../state'
import { Layout } from '../styles'
import { AppDetail } from './AppDetail'
import { DashboardLanding } from './DashboardLanding'
import { Modal } from './modal'
import { SettingsPage } from './SettingsPage'
import { Sidebar } from './Sidebar'
export function Dashboard({ render }: { render: () => void }) {
function MainContent({ render }: { render: () => void }) {
const selected = apps.find(a => a.name === selectedApp)
if (selected) return <AppDetail app={selected} render={render} />
if (currentView === 'settings') return <SettingsPage render={render} />
return <DashboardLanding render={render} />
}
export function Dashboard({ render }: { render: () => void }) {
return (
<Layout>
<Styles />
{!isNarrow && <Sidebar render={render} />}
{selected ? (
<AppDetail app={selected} render={render} />
) : (
<DashboardLanding render={render} />
)}
<MainContent render={render} />
<Modal />
</Layout>
)

View File

@ -1,12 +1,13 @@
import { useEffect } from 'hono/jsx'
import { openAppSelectorModal } from '../modals'
import { apps, isNarrow, setSelectedApp } from '../state'
import { apps, isNarrow, setCurrentView, setSelectedApp } from '../state'
import {
AppSelectorChevron,
DashboardContainer,
DashboardHeader,
DashboardInstallCmd,
DashboardTitle,
SettingsGear,
StatusDot,
StatusDotLink,
StatusDotsRow,
@ -25,8 +26,21 @@ export function DashboardLanding({ render }: { render: () => void }) {
const narrow = isNarrow || undefined
const openSettings = () => {
setSelectedApp(null)
setCurrentView('settings')
render()
}
return (
<DashboardContainer narrow={narrow}>
<DashboardContainer narrow={narrow} relative>
<SettingsGear
onClick={openSettings}
title="Settings"
style={{ position: 'absolute', top: 16, right: 16 }}
>
</SettingsGear>
<DashboardHeader>
<DashboardTitle narrow={narrow}>
🐾 Toes

View File

@ -0,0 +1,87 @@
import { useEffect, useState } from 'hono/jsx'
import { getWifiConfig, saveWifiConfig } from '../api'
import { setCurrentView } from '../state'
import {
Button,
FormActions,
FormField,
FormInput,
FormLabel,
HeaderActions,
Main,
MainContent,
MainHeader,
MainTitle,
Section,
SectionTitle,
} from '../styles'
export function SettingsPage({ render }: { render: () => void }) {
const [network, setNetwork] = useState('')
const [password, setPassword] = useState('')
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
useEffect(() => {
getWifiConfig().then(config => {
setNetwork(config.network)
setPassword(config.password)
})
}, [])
const goBack = () => {
setCurrentView('dashboard')
render()
}
const handleSave = async (e: Event) => {
e.preventDefault()
setSaving(true)
setSaved(false)
await saveWifiConfig({ network, password })
setSaving(false)
setSaved(true)
}
return (
<Main>
<MainHeader>
<MainTitle>Settings</MainTitle>
<HeaderActions>
<Button onClick={goBack}>Back</Button>
</HeaderActions>
</MainHeader>
<MainContent>
<Section>
<SectionTitle>WiFi</SectionTitle>
<form onSubmit={handleSave} style={{ display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 400 }}>
<FormField>
<FormLabel>Network</FormLabel>
<FormInput
type="text"
value={network}
onInput={(e: Event) => setNetwork((e.target as HTMLInputElement).value)}
placeholder="SSID"
/>
</FormField>
<FormField>
<FormLabel>Password</FormLabel>
<FormInput
type="password"
value={password}
onInput={(e: Event) => setPassword((e.target as HTMLInputElement).value)}
placeholder="Password"
/>
</FormField>
<FormActions>
{saved && <span style={{ fontSize: 13, color: '#888', alignSelf: 'center' }}>Saved</span>}
<Button variant="primary" type="submit" disabled={saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</FormActions>
</form>
</Section>
</MainContent>
</Main>
)
}

View File

@ -1,5 +1,6 @@
import { openNewAppModal } from '../modals'
import {
setCurrentView,
setSelectedApp,
setSidebarCollapsed,
sidebarCollapsed,
@ -18,6 +19,7 @@ import { AppSelector } from './AppSelector'
export function Sidebar({ render }: { render: () => void }) {
const goToDashboard = () => {
setSelectedApp(null)
setCurrentView('dashboard')
render()
}

View File

@ -1,6 +1,7 @@
import type { App } from '../shared/types'
// UI state (survives re-renders)
export let currentView: 'dashboard' | 'settings' = 'dashboard'
export let isNarrow: boolean = window.matchMedia('(max-width: 768px)').matches
export let selectedApp: string | null = localStorage.getItem('selectedApp')
export let sidebarCollapsed: boolean = localStorage.getItem('sidebarCollapsed') === 'true'
@ -13,6 +14,10 @@ export let apps: App[] = []
export let appTabs: Record<string, string> = JSON.parse(localStorage.getItem('appTabs') || '{}')
// State setters
export function setCurrentView(view: 'dashboard' | 'settings') {
currentView = view
}
export function setSelectedApp(name: string | null) {
selectedApp = name
if (name) {

View File

@ -43,6 +43,7 @@ export {
SectionLabel,
SectionSwitcher,
SectionTab,
SettingsGear,
Sidebar,
SidebarFooter,
StatCard,

View File

@ -211,6 +211,9 @@ export const DashboardContainer = define('DashboardContainer', {
padding: 20,
gap: 24,
},
relative: {
position: 'relative',
},
},
})
@ -264,6 +267,23 @@ export const StatValue = define('StatValue', {
marginBottom: 4,
})
export const SettingsGear = define('SettingsGear', {
base: 'button',
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 18,
color: theme('colors-textMuted'),
padding: '4px 8px',
borderRadius: theme('radius-md'),
selectors: {
'&:hover': {
color: theme('colors-text'),
background: theme('colors-bgHover'),
},
},
})
export const StatLabel = define('StatLabel', {
fontSize: 14,
color: theme('colors-textMuted'),

View File

@ -1,9 +1,9 @@
import { allApps, APPS_DIR, onChange } from '$apps'
import { allApps, APPS_DIR, onChange, TOES_DIR } from '$apps'
import { onHostLog } from '../tui'
import { Hype } from '@because/hype'
import { cpus, platform, totalmem } from 'os'
import { join } from 'path'
import { readFileSync, statfsSync } from 'fs'
import { existsSync, mkdirSync, readFileSync, statfsSync, writeFileSync } from 'fs'
export interface AppMetrics {
cpu: number
@ -18,6 +18,11 @@ export interface SystemMetrics {
apps: Record<string, AppMetrics>
}
export interface WifiConfig {
network: string
password: string
}
export interface UnifiedLogLine {
time: number
app: string
@ -199,6 +204,38 @@ router.sse('/metrics/stream', (send) => {
return () => clearInterval(interval)
})
// WiFi config
const CONFIG_DIR = join(TOES_DIR, 'config')
const WIFI_PATH = join(CONFIG_DIR, 'wifi.json')
function readWifiConfig(): WifiConfig {
try {
if (existsSync(WIFI_PATH)) {
return JSON.parse(readFileSync(WIFI_PATH, 'utf-8'))
}
} catch {}
return { network: '', password: '' }
}
function writeWifiConfig(config: WifiConfig) {
if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true })
writeFileSync(WIFI_PATH, JSON.stringify(config, null, 2))
}
router.get('/wifi', c => {
return c.json(readWifiConfig())
})
router.put('/wifi', async c => {
const body = await c.req.json<WifiConfig>()
const config: WifiConfig = {
network: String(body.network ?? ''),
password: String(body.password ?? ''),
}
writeWifiConfig(config)
return c.json(config)
})
// Get recent unified logs
router.get('/logs', c => {
const tail = c.req.query('tail')