dashboard
This commit is contained in:
parent
071f1a02b5
commit
971ebef21c
|
|
@ -4,6 +4,16 @@ export const getLogDates = (name: string): Promise<string[]> =>
|
||||||
export const getLogsForDate = (name: string, date: string): Promise<string[]> =>
|
export const getLogsForDate = (name: string, date: string): Promise<string[]> =>
|
||||||
fetch(`/api/apps/${name}/logs?date=${date}`).then(r => r.json())
|
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) =>
|
export const shareApp = (name: string) =>
|
||||||
fetch(`/api/apps/${name}/tunnel`, { method: 'POST' })
|
fetch(`/api/apps/${name}/tunnel`, { method: 'POST' })
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,25 @@
|
||||||
import { Styles } from '@because/forge'
|
import { Styles } from '@because/forge'
|
||||||
import { apps, isNarrow, selectedApp } from '../state'
|
import { apps, currentView, isNarrow, selectedApp } from '../state'
|
||||||
import { Layout } from '../styles'
|
import { Layout } from '../styles'
|
||||||
import { AppDetail } from './AppDetail'
|
import { AppDetail } from './AppDetail'
|
||||||
import { DashboardLanding } from './DashboardLanding'
|
import { DashboardLanding } from './DashboardLanding'
|
||||||
import { Modal } from './modal'
|
import { Modal } from './modal'
|
||||||
|
import { SettingsPage } from './SettingsPage'
|
||||||
import { Sidebar } from './Sidebar'
|
import { Sidebar } from './Sidebar'
|
||||||
|
|
||||||
export function Dashboard({ render }: { render: () => void }) {
|
function MainContent({ render }: { render: () => void }) {
|
||||||
const selected = apps.find(a => a.name === selectedApp)
|
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 (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<Styles />
|
<Styles />
|
||||||
{!isNarrow && <Sidebar render={render} />}
|
{!isNarrow && <Sidebar render={render} />}
|
||||||
{selected ? (
|
<MainContent render={render} />
|
||||||
<AppDetail app={selected} render={render} />
|
|
||||||
) : (
|
|
||||||
<DashboardLanding render={render} />
|
|
||||||
)}
|
|
||||||
<Modal />
|
<Modal />
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { useEffect } from 'hono/jsx'
|
import { useEffect } from 'hono/jsx'
|
||||||
import { openAppSelectorModal } from '../modals'
|
import { openAppSelectorModal } from '../modals'
|
||||||
import { apps, isNarrow, setSelectedApp } from '../state'
|
import { apps, isNarrow, setCurrentView, setSelectedApp } from '../state'
|
||||||
import {
|
import {
|
||||||
AppSelectorChevron,
|
AppSelectorChevron,
|
||||||
DashboardContainer,
|
DashboardContainer,
|
||||||
DashboardHeader,
|
DashboardHeader,
|
||||||
DashboardInstallCmd,
|
DashboardInstallCmd,
|
||||||
DashboardTitle,
|
DashboardTitle,
|
||||||
|
SettingsGear,
|
||||||
StatusDot,
|
StatusDot,
|
||||||
StatusDotLink,
|
StatusDotLink,
|
||||||
StatusDotsRow,
|
StatusDotsRow,
|
||||||
|
|
@ -25,8 +26,21 @@ export function DashboardLanding({ render }: { render: () => void }) {
|
||||||
|
|
||||||
const narrow = isNarrow || undefined
|
const narrow = isNarrow || undefined
|
||||||
|
|
||||||
|
const openSettings = () => {
|
||||||
|
setSelectedApp(null)
|
||||||
|
setCurrentView('settings')
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardContainer narrow={narrow}>
|
<DashboardContainer narrow={narrow} relative>
|
||||||
|
<SettingsGear
|
||||||
|
onClick={openSettings}
|
||||||
|
title="Settings"
|
||||||
|
style={{ position: 'absolute', top: 16, right: 16 }}
|
||||||
|
>
|
||||||
|
⚙️
|
||||||
|
</SettingsGear>
|
||||||
<DashboardHeader>
|
<DashboardHeader>
|
||||||
<DashboardTitle narrow={narrow}>
|
<DashboardTitle narrow={narrow}>
|
||||||
🐾 Toes
|
🐾 Toes
|
||||||
|
|
|
||||||
87
src/client/components/SettingsPage.tsx
Normal file
87
src/client/components/SettingsPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { openNewAppModal } from '../modals'
|
import { openNewAppModal } from '../modals'
|
||||||
import {
|
import {
|
||||||
|
setCurrentView,
|
||||||
setSelectedApp,
|
setSelectedApp,
|
||||||
setSidebarCollapsed,
|
setSidebarCollapsed,
|
||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
|
|
@ -18,6 +19,7 @@ import { AppSelector } from './AppSelector'
|
||||||
export function Sidebar({ render }: { render: () => void }) {
|
export function Sidebar({ render }: { render: () => void }) {
|
||||||
const goToDashboard = () => {
|
const goToDashboard = () => {
|
||||||
setSelectedApp(null)
|
setSelectedApp(null)
|
||||||
|
setCurrentView('dashboard')
|
||||||
render()
|
render()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { App } from '../shared/types'
|
import type { App } from '../shared/types'
|
||||||
|
|
||||||
// UI state (survives re-renders)
|
// UI state (survives re-renders)
|
||||||
|
export let currentView: 'dashboard' | 'settings' = 'dashboard'
|
||||||
export let isNarrow: boolean = window.matchMedia('(max-width: 768px)').matches
|
export let isNarrow: boolean = window.matchMedia('(max-width: 768px)').matches
|
||||||
export let selectedApp: string | null = localStorage.getItem('selectedApp')
|
export let selectedApp: string | null = localStorage.getItem('selectedApp')
|
||||||
export let sidebarCollapsed: boolean = localStorage.getItem('sidebarCollapsed') === 'true'
|
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') || '{}')
|
export let appTabs: Record<string, string> = JSON.parse(localStorage.getItem('appTabs') || '{}')
|
||||||
|
|
||||||
// State setters
|
// State setters
|
||||||
|
export function setCurrentView(view: 'dashboard' | 'settings') {
|
||||||
|
currentView = view
|
||||||
|
}
|
||||||
|
|
||||||
export function setSelectedApp(name: string | null) {
|
export function setSelectedApp(name: string | null) {
|
||||||
selectedApp = name
|
selectedApp = name
|
||||||
if (name) {
|
if (name) {
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ export {
|
||||||
SectionLabel,
|
SectionLabel,
|
||||||
SectionSwitcher,
|
SectionSwitcher,
|
||||||
SectionTab,
|
SectionTab,
|
||||||
|
SettingsGear,
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
StatCard,
|
StatCard,
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,9 @@ export const DashboardContainer = define('DashboardContainer', {
|
||||||
padding: 20,
|
padding: 20,
|
||||||
gap: 24,
|
gap: 24,
|
||||||
},
|
},
|
||||||
|
relative: {
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -264,6 +267,23 @@ export const StatValue = define('StatValue', {
|
||||||
marginBottom: 4,
|
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', {
|
export const StatLabel = define('StatLabel', {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: theme('colors-textMuted'),
|
color: theme('colors-textMuted'),
|
||||||
|
|
|
||||||
|
|
@ -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 { onHostLog } from '../tui'
|
||||||
import { Hype } from '@because/hype'
|
import { Hype } from '@because/hype'
|
||||||
import { cpus, platform, totalmem } from 'os'
|
import { cpus, platform, totalmem } from 'os'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { readFileSync, statfsSync } from 'fs'
|
import { existsSync, mkdirSync, readFileSync, statfsSync, writeFileSync } from 'fs'
|
||||||
|
|
||||||
export interface AppMetrics {
|
export interface AppMetrics {
|
||||||
cpu: number
|
cpu: number
|
||||||
|
|
@ -18,6 +18,11 @@ export interface SystemMetrics {
|
||||||
apps: Record<string, AppMetrics>
|
apps: Record<string, AppMetrics>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WifiConfig {
|
||||||
|
network: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface UnifiedLogLine {
|
export interface UnifiedLogLine {
|
||||||
time: number
|
time: number
|
||||||
app: string
|
app: string
|
||||||
|
|
@ -199,6 +204,38 @@ router.sse('/metrics/stream', (send) => {
|
||||||
return () => clearInterval(interval)
|
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
|
// Get recent unified logs
|
||||||
router.get('/logs', c => {
|
router.get('/logs', c => {
|
||||||
const tail = c.req.query('tail')
|
const tail = c.req.query('tail')
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user