Compare commits

...

24 Commits

Author SHA1 Message Date
d2b0eb410f fix tool iframes 2026-02-28 12:58:21 -08:00
ffe1df22e6 domain 2026-02-28 08:29:55 -08:00
7f82a37c63 toes.dev 2026-02-28 08:12:38 -08:00
6055b9798d Replace quickstart with detailed setup docs 2026-02-28 08:04:23 -08:00
f7397dc060 install 2026-02-27 20:40:30 -08:00
d69dc6ae9d Merge branch 'ssh-install' 2026-02-27 19:35:11 -08:00
4853ee4f7a Skip bundled app install if already exists 2026-02-27 19:35:06 -08:00
Chris Wanstrath
74f9062a89 fix reconnect 2026-02-27 15:35:49 -08:00
Chris Wanstrath
55316027c0 heartbeat 2026-02-27 15:14:43 -08:00
cfba207077 no no no 2026-02-27 07:40:19 -08:00
702019279a no 2026-02-27 07:29:23 -08:00
141622f86f Add test123 app and support tunnelUrl in Urls 2026-02-27 07:28:58 -08:00
526678e87a Add active variant flex column styles 2026-02-27 07:26:15 -08:00
d29e306e61 Merge branch 'mobile' 2026-02-26 20:37:52 -08:00
671f51ca0c Replace app selector modal with mobile sidebar state 2026-02-26 20:37:50 -08:00
d082af4e33 Add width 100% to active style 2026-02-26 20:21:51 -08:00
9bce15b871 Add flex layout to LogsSection container 2026-02-26 19:59:37 -08:00
7ab27f2767 Replace chevron with hamburger menu for app selector 2026-02-26 19:58:42 -08:00
45b1903e6b Use URL-based routing instead of local state 2026-02-26 19:43:18 -08:00
68274d8651 Intercept link clicks for client-side routing 2026-02-26 18:49:48 -08:00
98a1c1ad97 Add client-side router, use URLs for navigation 2026-02-26 11:40:50 -08:00
6d02f1db3f Make stopped tiles link to app page instead of nowhere 2026-02-26 07:28:05 -08:00
1a71656508 app tiles 2026-02-25 20:33:02 -08:00
363a82a845 Add icon span and conditional URL/name display 2026-02-25 19:58:01 -08:00
31 changed files with 420 additions and 200 deletions

View File

@ -4,11 +4,28 @@ Toes is a personal web server you run in your home.
Plug it in, turn it on, and forget about the cloud. Plug it in, turn it on, and forget about the cloud.
## quickstart ## setup
1. Plug in and turn on your Toes computer. Toes runs on a Raspberry Pi. You'll need:
2. Tell Toes about your WiFi by <using dark @probablycorey magick>.
3. Visit https://toes.local to get started! - A Raspberry Pi running Raspberry Pi OS
- A `toes` user with passwordless sudo
SSH into your Pi as the `toes` user and run:
```bash
curl -fsSL https://toes.dev/install | bash
```
This will:
1. Install system dependencies (git, fish shell, networking tools)
2. Install Bun and grant it network binding capabilities
3. Clone and build the toes server
4. Set up bundled apps (clock, code, cron, env, stats, versions)
5. Install and enable a systemd service for auto-start
Once complete, visit `http://<hostname>.local` on your local network.
## features ## features
- Hosts bun/hono/hype webapps - both SSR and SPA. - Hosts bun/hono/hype webapps - both SSR and SPA.

26
install/bun.lock Normal file
View File

@ -0,0 +1,26 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "toes-install",
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5.9.3",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.9", "https://npm.nose.space/@types/bun/-/bun-1.3.9.tgz", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/node": ["@types/node@25.3.2", "https://npm.nose.space/@types/node/-/node-25.3.2.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="],
"bun-types": ["bun-types@1.3.9", "https://npm.nose.space/bun-types/-/bun-types-1.3.9.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.18.2", "https://npm.nose.space/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}

View File

@ -3,7 +3,7 @@ set -euo pipefail
## ##
# toes installer # toes installer
# Usage: curl -sSL https://toes.space/install | bash # Usage: curl -sSL https://toes.dev/install | bash
# Must be run as the 'toes' user. # Must be run as the 'toes' user.
DEST=~/toes DEST=~/toes
@ -91,12 +91,16 @@ echo ">> Installing bundled apps"
BUNDLED_APPS="clock code cron env stats versions" BUNDLED_APPS="clock code cron env stats versions"
for app in $BUNDLED_APPS; do for app in $BUNDLED_APPS; do
if [ -d "$DEST/apps/$app" ]; then if [ -d "$DEST/apps/$app" ]; then
if [ -d ~/apps/"$app" ]; then
echo " $app (exists, skipping)"
continue
fi
echo " $app" echo " $app"
cp -r "$DEST/apps/$app" ~/apps/ cp -r "$DEST/apps/$app" ~/apps/
version_dir=$(ls -1 ~/apps/$app | grep -E '^[0-9]{8}-[0-9]{6}$' | sort -r | head -1) version_dir=$(ls -1 ~/apps/"$app" | grep -E '^[0-9]{8}-[0-9]{6}$' | sort -r | head -1)
if [ -n "$version_dir" ]; then if [ -n "$version_dir" ]; then
ln -sfn "$version_dir" ~/apps/$app/current ln -sfn "$version_dir" ~/apps/"$app"/current
(cd ~/apps/$app/current && bun install --frozen-lockfile) > /dev/null 2>&1 || true (cd ~/apps/"$app"/current && bun install --frozen-lockfile) > /dev/null 2>&1 || true
fi fi
fi fi
done done

16
install/package.json Normal file
View File

@ -0,0 +1,16 @@
{
"name": "toes-install",
"version": "0.0.1",
"description": "install toes",
"module": "server.ts",
"type": "module",
"scripts": {
"start": "bun run server.ts"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.9.3"
}
}

View File

@ -10,7 +10,7 @@ Bun.serve({
headers: { "content-type": "text/plain" }, headers: { "content-type": "text/plain" },
}) })
} }
return new Response("toes", { status: 404 }) return new Response("404 Not Found", { status: 404 })
}, },
}) })

43
install/tsconfig.json Normal file
View File

@ -0,0 +1,43 @@
{
"exclude": ["apps", "templates"],
"compilerOptions": {
// Environment setup & latest features
"lib": [
"ESNext",
"DOM"
],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
"baseUrl": ".",
"paths": {
"$*": [
"./src/server/*"
],
"@*": [
"./src/shared/*"
],
"%*": [
"./src/lib/*"
]
}
}
}

View File

@ -6,8 +6,8 @@ TOES_USER="${TOES_USER:-toes}"
HOST="${HOST:-toes.local}" HOST="${HOST:-toes.local}"
SSH_HOST="$TOES_USER@$HOST" SSH_HOST="$TOES_USER@$HOST"
URL="${URL:-http://$HOST}" URL="${URL:-http://$HOST}"
DEST="${DEST:-~/toes}" DEST="${DEST:-$HOME/toes}"
DATA_DIR="${DATA_DIR:-~/data}" DATA_DIR="${DATA_DIR:-$HOME/data}"
APPS_DIR="${APPS_DIR:-~/apps}" APPS_DIR="${APPS_DIR:-$HOME/apps}"
mkdir -p "$DEST" "$DATA_DIR" "$APPS_DIR" mkdir -p "$DEST" "$DATA_DIR" "$APPS_DIR"

View File

@ -2,13 +2,14 @@ import { define } from '@because/forge'
import type { App } from '../../shared/types' import type { App } from '../../shared/types'
import { buildAppUrl } from '../../shared/urls' import { buildAppUrl } from '../../shared/urls'
import { restartApp, shareApp, startApp, stopApp, unshareApp } from '../api' import { restartApp, shareApp, startApp, stopApp, unshareApp } from '../api'
import { openAppSelectorModal, openDeleteAppModal, openRenameAppModal } from '../modals' import { openDeleteAppModal, openRenameAppModal } from '../modals'
import { apps, getSelectedTab, isNarrow } from '../state' import { apps, getSelectedTab, isNarrow, setMobileSidebar } from '../state'
import { import {
ActionBar, ActionBar,
AppSelectorChevron,
Button, Button,
ClickableAppName, ClickableAppName,
HamburgerButton,
HamburgerLine,
HeaderActions, HeaderActions,
InfoLabel, InfoLabel,
InfoRow, InfoRow,
@ -52,14 +53,15 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
<Main> <Main>
<MainHeader> <MainHeader>
<MainTitle> <MainTitle>
<OpenEmojiPicker app={app} render={render}>{app.icon}</OpenEmojiPicker>
&nbsp;
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
{isNarrow && ( {isNarrow && (
<AppSelectorChevron onClick={() => openAppSelectorModal(render)}> <HamburgerButton onClick={() => { setMobileSidebar(true); render() }} title="Show apps">
<HamburgerLine />
</AppSelectorChevron> <HamburgerLine />
<HamburgerLine />
</HamburgerButton>
)} )}
<OpenEmojiPicker app={app} render={render}>{app.icon}</OpenEmojiPicker>
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
</MainTitle> </MainTitle>
<HeaderActions> <HeaderActions>
{!app.tool && ( {!app.tool && (

View File

@ -2,7 +2,6 @@ import type { CSSProperties } from 'hono/jsx'
import { import {
apps, apps,
selectedApp, selectedApp,
setSelectedApp,
setSidebarSection, setSidebarSection,
sidebarSection, sidebarSection,
} from '../state' } from '../state'
@ -17,19 +16,13 @@ import {
interface AppSelectorProps { interface AppSelectorProps {
render: () => void render: () => void
onSelect?: () => void onSelect?: () => void
onDashboard?: () => void
collapsed?: boolean collapsed?: boolean
large?: boolean
switcherStyle?: CSSProperties switcherStyle?: CSSProperties
listStyle?: CSSProperties listStyle?: CSSProperties
} }
export function AppSelector({ render, onSelect, onDashboard, collapsed, switcherStyle, listStyle }: AppSelectorProps) { export function AppSelector({ render, onSelect, collapsed, large, switcherStyle, listStyle }: AppSelectorProps) {
const selectApp = (name: string) => {
setSelectedApp(name)
onSelect?.()
render()
}
const switchSection = (section: 'apps' | 'tools') => { const switchSection = (section: 'apps' | 'tools') => {
setSidebarSection(section) setSidebarSection(section)
render() render()
@ -43,18 +36,18 @@ export function AppSelector({ render, onSelect, onDashboard, collapsed, switcher
<> <>
{!collapsed && toolApps.length > 0 && ( {!collapsed && toolApps.length > 0 && (
<SectionSwitcher style={switcherStyle}> <SectionSwitcher style={switcherStyle}>
<SectionTab active={sidebarSection === 'apps' ? true : undefined} onClick={() => switchSection('apps')}> <SectionTab active={sidebarSection === 'apps' ? true : undefined} large={large || undefined} onClick={() => switchSection('apps')}>
Apps Apps
</SectionTab> </SectionTab>
<SectionTab active={sidebarSection === 'tools' ? true : undefined} onClick={() => switchSection('tools')}> <SectionTab active={sidebarSection === 'tools' ? true : undefined} large={large || undefined} onClick={() => switchSection('tools')}>
Tools Tools
</SectionTab> </SectionTab>
</SectionSwitcher> </SectionSwitcher>
)} )}
<AppList style={listStyle}> <AppList style={listStyle}>
{collapsed && onDashboard && ( {collapsed && (
<AppItem <AppItem
onClick={onDashboard} href="/"
selected={!selectedApp ? true : undefined} selected={!selectedApp ? true : undefined}
style={{ justifyContent: 'center', padding: '10px 12px' }} style={{ justifyContent: 'center', padding: '10px 12px' }}
title="Toes" title="Toes"
@ -65,7 +58,9 @@ export function AppSelector({ render, onSelect, onDashboard, collapsed, switcher
{activeApps.map(app => ( {activeApps.map(app => (
<AppItem <AppItem
key={app.name} key={app.name}
onClick={() => selectApp(app.name)} href={`/app/${app.name}`}
onClick={onSelect}
large={large || undefined}
selected={app.name === selectedApp ? true : undefined} selected={app.name === selectedApp ? true : undefined}
style={collapsed ? { justifyContent: 'center', padding: '10px 12px' } : undefined} style={collapsed ? { justifyContent: 'center', padding: '10px 12px' } : undefined}
title={collapsed ? app.name : undefined} title={collapsed ? app.name : undefined}
@ -74,7 +69,7 @@ export function AppSelector({ render, onSelect, onDashboard, collapsed, switcher
<span style={{ fontSize: 18 }}>{app.icon}</span> <span style={{ fontSize: 18 }}>{app.icon}</span>
) : ( ) : (
<> <>
<span style={{ fontSize: 14 }}>{app.icon}</span> <span style={{ fontSize: large ? 20 : 14 }}>{app.icon}</span>
{app.name} {app.name}
<StatusDot state={app.state} data-app={app.name} data-state={app.state} style={{ marginLeft: 'auto' }} /> <StatusDot state={app.state} data-app={app.name} data-state={app.state} style={{ marginLeft: 'auto' }} />
</> </>

View File

@ -1,13 +1,48 @@
import { Styles } from '@because/forge' import { Styles } from '@because/forge'
import { apps, currentView, isNarrow, selectedApp } from '../state' import { openNewAppModal } from '../modals'
import { Layout } from '../styles' import { apps, currentView, isNarrow, mobileSidebar, selectedApp, setMobileSidebar } from '../state'
import {
HamburgerButton,
HamburgerLine,
Layout,
Main,
MainContent as MainContentContainer,
MainHeader,
MainTitle,
NewAppButton,
} from '../styles'
import { AppDetail } from './AppDetail' import { AppDetail } from './AppDetail'
import { AppSelector } from './AppSelector'
import { DashboardLanding } from './DashboardLanding' import { DashboardLanding } from './DashboardLanding'
import { Modal } from './modal' import { Modal } from './modal'
import { SettingsPage } from './SettingsPage' import { SettingsPage } from './SettingsPage'
import { Sidebar } from './Sidebar' import { Sidebar } from './Sidebar'
function MobileSidebar({ render }: { render: () => void }) {
return (
<Main>
<MainHeader>
<MainTitle>
<HamburgerButton onClick={() => { setMobileSidebar(false); render() }} title="Hide apps">
<HamburgerLine />
<HamburgerLine />
<HamburgerLine />
</HamburgerButton>
<a href="/" style={{ textDecoration: 'none', color: 'inherit' }}>🐾 Toes</a>
</MainTitle>
</MainHeader>
<MainContentContainer>
<AppSelector render={render} large />
<div style={{ padding: '12px 16px' }}>
<NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton>
</div>
</MainContentContainer>
</Main>
)
}
function MainContent({ render }: { render: () => void }) { function MainContent({ render }: { render: () => void }) {
if (isNarrow && mobileSidebar) return <MobileSidebar render={render} />
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 (selected) return <AppDetail app={selected} render={render} />
if (currentView === 'settings') return <SettingsPage render={render} /> if (currentView === 'settings') return <SettingsPage render={render} />

View File

@ -1,8 +1,9 @@
import { useEffect } from 'hono/jsx' import { useEffect } from 'hono/jsx'
import { openAppSelectorModal } from '../modals' import { navigate } from '../router'
import { dashboardTab, isNarrow, setCurrentView, setDashboardTab, setSelectedApp } from '../state' import { dashboardTab, isNarrow, setMobileSidebar } from '../state'
import { import {
AppSelectorChevron, HamburgerButton,
HamburgerLine,
DashboardContainer, DashboardContainer,
DashboardHeader, DashboardHeader,
DashboardTitle, DashboardTitle,
@ -25,14 +26,11 @@ export function DashboardLanding({ render }: { render: () => void }) {
const narrow = isNarrow || undefined const narrow = isNarrow || undefined
const openSettings = () => { const openSettings = () => {
setSelectedApp(null) navigate('/settings')
setCurrentView('settings')
render()
} }
const switchTab = (tab: typeof dashboardTab) => { const switchTab = (tab: typeof dashboardTab) => {
setDashboardTab(tab) navigate(tab === 'urls' ? '/' : `/${tab}`)
render()
if (tab === 'logs') scrollLogsToBottom() if (tab === 'logs') scrollLogsToBottom()
} }
@ -45,14 +43,20 @@ export function DashboardLanding({ render }: { render: () => void }) {
> >
</SettingsGear> </SettingsGear>
{isNarrow && (
<HamburgerButton
onClick={() => { setMobileSidebar(true); render() }}
title="Show apps"
style={{ position: 'absolute', top: 16, left: 16 }}
>
<HamburgerLine />
<HamburgerLine />
<HamburgerLine />
</HamburgerButton>
)}
<DashboardHeader> <DashboardHeader>
<DashboardTitle narrow={narrow}> <DashboardTitle narrow={narrow}>
🐾 Toes 🐾 Toes
{isNarrow && (
<AppSelectorChevron onClick={() => openAppSelectorModal(render)}>
</AppSelectorChevron>
)}
</DashboardTitle> </DashboardTitle>
</DashboardHeader> </DashboardHeader>
@ -63,7 +67,7 @@ export function DashboardLanding({ render }: { render: () => void }) {
</TabBar> </TabBar>
<TabContent active={dashboardTab === 'urls' || undefined}> <TabContent active={dashboardTab === 'urls' || undefined}>
<Urls /> <Urls render={render} />
</TabContent> </TabContent>
<TabContent active={dashboardTab === 'logs' || undefined}> <TabContent active={dashboardTab === 'logs' || undefined}>

View File

@ -1,5 +1,6 @@
import type { App } from '../../shared/types' import type { App } from '../../shared/types'
import { apps, getSelectedTab, setSelectedTab } from '../state' import { navigate } from '../router'
import { apps, getSelectedTab } from '../state'
import { Tab, TabBar } from '../styles' import { Tab, TabBar } from '../styles'
import { resetToolIframe } from '../tool-iframes' import { resetToolIframe } from '../tool-iframes'
@ -12,8 +13,7 @@ export function Nav({ app, render }: { app: App; render: () => void }) {
resetToolIframe(tab, app.name) resetToolIframe(tab, app.name)
return return
} }
setSelectedTab(app.name, tab) navigate(tab === 'overview' ? `/app/${app.name}` : `/app/${app.name}/${tab}`)
render()
} }
// Find all tools // Find all tools

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'hono/jsx' import { useEffect, useState } from 'hono/jsx'
import { getWifiConfig, saveWifiConfig } from '../api' import { getWifiConfig, saveWifiConfig } from '../api'
import { setCurrentView } from '../state' import { navigate } from '../router'
import { import {
Button, Button,
DashboardInstallCmd, DashboardInstallCmd,
@ -31,8 +31,7 @@ export function SettingsPage({ render }: { render: () => void }) {
}, []) }, [])
const goBack = () => { const goBack = () => {
setCurrentView('dashboard') navigate('/')
render()
} }
const handleSave = async (e: Event) => { const handleSave = async (e: Event) => {

View File

@ -1,7 +1,5 @@
import { openNewAppModal } from '../modals' import { openNewAppModal } from '../modals'
import { import {
setCurrentView,
setSelectedApp,
setSidebarCollapsed, setSidebarCollapsed,
sidebarCollapsed, sidebarCollapsed,
} from '../state' } from '../state'
@ -17,12 +15,6 @@ import {
import { AppSelector } from './AppSelector' import { AppSelector } from './AppSelector'
export function Sidebar({ render }: { render: () => void }) { export function Sidebar({ render }: { render: () => void }) {
const goToDashboard = () => {
setSelectedApp(null)
setCurrentView('dashboard')
render()
}
const toggleSidebar = () => { const toggleSidebar = () => {
setSidebarCollapsed(!sidebarCollapsed) setSidebarCollapsed(!sidebarCollapsed)
render() render()
@ -40,7 +32,7 @@ export function Sidebar({ render }: { render: () => void }) {
</div> </div>
) : ( ) : (
<Logo> <Logo>
<LogoLink onClick={goToDashboard} title="Go to dashboard"> <LogoLink href="/" title="Go to dashboard">
🐾 Toes 🐾 Toes
</LogoLink> </LogoLink>
<HamburgerButton onClick={toggleSidebar} title="Hide sidebar"> <HamburgerButton onClick={toggleSidebar} title="Hide sidebar">
@ -50,7 +42,7 @@ export function Sidebar({ render }: { render: () => void }) {
</HamburgerButton> </HamburgerButton>
</Logo> </Logo>
)} )}
<AppSelector render={render} collapsed={sidebarCollapsed} onDashboard={goToDashboard} /> <AppSelector render={render} collapsed={sidebarCollapsed} />
{!sidebarCollapsed && ( {!sidebarCollapsed && (
<SidebarFooter> <SidebarFooter>
<NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton> <NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton>

View File

@ -1,16 +1,16 @@
import { buildAppUrl } from '../../shared/urls' import { buildAppUrl } from '../../shared/urls'
import { apps } from '../state' import { navigate } from '../router'
import { apps, isNarrow } from '../state'
import { import {
EmptyState, EmptyState,
StatusDot, Tile,
UrlLeft, TileGrid,
UrlLink, TileIcon,
UrlList, TileName,
UrlPort, TileStatus,
UrlRow,
} from '../styles' } from '../styles'
export function Urls() { export function Urls({ render }: { render: () => void }) {
const nonTools = apps.filter(a => !a.tool) const nonTools = apps.filter(a => !a.tool)
if (nonTools.length === 0) { if (nonTools.length === 0) {
@ -18,25 +18,31 @@ export function Urls() {
} }
return ( return (
<UrlList> <TileGrid narrow={isNarrow || undefined}>
{nonTools.map(app => { {nonTools.map(app => {
const url = buildAppUrl(app.name, location.origin) const url = app.tunnelUrl || buildAppUrl(app.name, location.origin)
const running = app.state === 'running' const running = app.state === 'running'
const appPage = `/app/${app.name}`
const openAppPage = (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
navigate(appPage)
}
return ( return (
<UrlRow key={app.name}> <Tile
<UrlLeft> key={app.name}
<StatusDot state={app.state} /> href={running ? url : appPage}
{running ? ( target={running ? '_blank' : undefined}
<UrlLink href={url} target="_blank">{url}</UrlLink> narrow={isNarrow || undefined}
) : ( >
<span style={{ color: 'var(--colors-textFaint)' }}>{app.name}</span> <TileStatus state={app.state} onClick={openAppPage} />
)} <TileIcon>{app.icon}</TileIcon>
</UrlLeft> <TileName>{app.name}</TileName>
{app.port ? <UrlPort>:{app.port}</UrlPort> : null} </Tile>
</UrlRow>
) )
})} })}
</UrlList> </TileGrid>
) )
} }

View File

@ -1,7 +1,8 @@
import { render as renderApp } from 'hono/jsx/dom' import { render as renderApp } from 'hono/jsx/dom'
import { Dashboard } from './components' import { Dashboard } from './components'
import { apps, getSelectedTab, selectedApp, setApps, setIsNarrow, setSelectedApp } from './state'
import { initModal } from './components/modal' import { initModal } from './components/modal'
import { initRouter, navigate } from './router'
import { apps, getSelectedTab, selectedApp, setApps, setIsNarrow } from './state'
import { initToolIframes, updateToolIframes } from './tool-iframes' import { initToolIframes, updateToolIframes } from './tool-iframes'
import { initUpdate } from './update' import { initUpdate } from './update'
@ -41,14 +42,16 @@ narrowQuery.addEventListener('change', e => {
render() render()
}) })
// Initialize router (sets initial state from URL and renders)
initRouter(render)
// SSE connection // SSE connection
const events = new EventSource('/api/apps/stream') const events = new EventSource('/api/apps/stream')
events.onmessage = e => { events.onmessage = e => {
const prev = apps
setApps(JSON.parse(e.data)) setApps(JSON.parse(e.data))
if (selectedApp && !apps.some(a => a.name === selectedApp)) { if (selectedApp && !apps.some(a => a.name === selectedApp)) {
setSelectedApp(null) navigate('/')
} }
render() render()

View File

@ -1,17 +0,0 @@
import { closeModal, openModal } from '../components/modal'
import { AppSelector } from '../components/AppSelector'
let renderFn: () => void
export function openAppSelectorModal(render: () => void) {
renderFn = render
openModal('Select App', () => (
<AppSelector
render={renderFn}
onSelect={closeModal}
switcherStyle={{ padding: '0 0 12px', marginLeft: -20, marginRight: -20, paddingLeft: 20, paddingRight: 20, marginBottom: 8 }}
listStyle={{ maxHeight: 300, overflow: 'auto' }}
/>
))
}

View File

@ -1,6 +1,7 @@
import type { App } from '../../shared/types' import type { App } from '../../shared/types'
import { closeModal, openModal, rerenderModal } from '../components/modal' import { closeModal, openModal, rerenderModal } from '../components/modal'
import { selectedApp, setSelectedApp } from '../state' import { navigate } from '../router'
import { selectedApp } from '../state'
import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles' import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles'
import { theme } from '../themes' import { theme } from '../themes'
@ -32,11 +33,11 @@ async function deleteApp(input: HTMLInputElement) {
throw new Error(`Failed to delete app: ${res.statusText}`) throw new Error(`Failed to delete app: ${res.statusText}`)
} }
// Success - close modal and clear selection // Success - close modal and navigate to dashboard
if (selectedApp === deleteAppTarget.name) {
setSelectedApp(null)
}
closeModal() closeModal()
if (selectedApp === deleteAppTarget.name) {
navigate('/')
}
} catch (err) { } catch (err) {
deleteAppError = err instanceof Error ? err.message : 'Failed to delete app' deleteAppError = err instanceof Error ? err.message : 'Failed to delete app'
deleteAppDeleting = false deleteAppDeleting = false

View File

@ -1,5 +1,6 @@
import { closeModal, openModal, rerenderModal } from '../components/modal' import { closeModal, openModal, rerenderModal } from '../components/modal'
import { apps, setSelectedApp } from '../state' import { navigate } from '../router'
import { apps } from '../state'
import { Button, Form, FormActions, FormCheckbox, FormCheckboxField, FormCheckboxLabel, FormError, FormField, FormInput, FormLabel, FormSelect } from '../styles' import { Button, Form, FormActions, FormCheckbox, FormCheckboxField, FormCheckboxLabel, FormError, FormField, FormInput, FormLabel, FormSelect } from '../styles'
type TemplateType = 'ssr' | 'spa' | 'bare' type TemplateType = 'ssr' | 'spa' | 'bare'
@ -48,9 +49,9 @@ async function createNewApp() {
throw new Error(data.error || 'Failed to create app') throw new Error(data.error || 'Failed to create app')
} }
// Success - close modal and select the new app // Success - close modal and navigate to the new app
setSelectedApp(name)
closeModal() closeModal()
navigate(`/app/${name}`)
} catch (err) { } catch (err) {
newAppError = err instanceof Error ? err.message : 'Failed to create app' newAppError = err instanceof Error ? err.message : 'Failed to create app'
newAppCreating = false newAppCreating = false

View File

@ -1,6 +1,7 @@
import type { App } from '../../shared/types' import type { App } from '../../shared/types'
import { closeModal, openModal, rerenderModal } from '../components/modal' import { closeModal, openModal, rerenderModal } from '../components/modal'
import { apps, setSelectedApp } from '../state' import { navigate } from '../router'
import { apps } from '../state'
import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles' import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles'
let renameAppError = '' let renameAppError = ''
@ -58,9 +59,9 @@ async function doRenameApp(input: HTMLInputElement) {
throw new Error(data.error || 'Failed to rename app') throw new Error(data.error || 'Failed to rename app')
} }
// Success - update selection and close modal // Success - close modal and navigate to renamed app
setSelectedApp(data.name || newName)
closeModal() closeModal()
navigate(`/app/${data.name || newName}`)
} catch (err) { } catch (err) {
renameAppError = err instanceof Error ? err.message : 'Failed to rename app' renameAppError = err instanceof Error ? err.message : 'Failed to rename app'
renameAppRenaming = false renameAppRenaming = false

View File

@ -1,4 +1,3 @@
export { openAppSelectorModal } from './AppSelector'
export { openDeleteAppModal } from './DeleteApp' export { openDeleteAppModal } from './DeleteApp'
export { openNewAppModal } from './NewApp' export { openNewAppModal } from './NewApp'
export { openRenameAppModal } from './RenameApp' export { openRenameAppModal } from './RenameApp'

60
src/client/router.ts Normal file
View File

@ -0,0 +1,60 @@
import { setCurrentView, setDashboardTab, setMobileSidebar, setSelectedApp, setSelectedTab } from './state'
let _render: () => void
export function navigate(href: string) {
history.pushState(null, '', href)
route()
}
export function initRouter(render: () => void) {
_render = render
// Intercept link clicks
document.addEventListener('click', e => {
const a = (e.target as Element).closest('a')
if (!a || !a.href || a.origin !== location.origin || a.target === '_blank') return
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
e.preventDefault()
history.pushState(null, '', a.href)
route()
})
// Handle back/forward
window.addEventListener('popstate', route)
// Initial route from URL
route()
}
function route() {
setMobileSidebar(false)
const path = location.pathname
if (path.startsWith('/app/')) {
const rest = decodeURIComponent(path.slice(5))
const slashIdx = rest.indexOf('/')
const name = slashIdx === -1 ? rest : rest.slice(0, slashIdx)
const tab = slashIdx === -1 ? 'overview' : rest.slice(slashIdx + 1)
setSelectedApp(name)
setSelectedTab(name, tab)
setCurrentView('dashboard')
} else if (path === '/settings') {
setSelectedApp(null)
setCurrentView('settings')
} else if (path === '/logs') {
setSelectedApp(null)
setDashboardTab('logs')
setCurrentView('dashboard')
} else if (path === '/metrics') {
setSelectedApp(null)
setDashboardTab('metrics')
setCurrentView('dashboard')
} else {
setSelectedApp(null)
setDashboardTab('urls')
setCurrentView('dashboard')
}
_render()
}

View File

@ -5,21 +5,21 @@ export type DashboardTab = 'urls' | 'logs' | 'metrics'
// UI state (survives re-renders) // UI state (survives re-renders)
export let currentView: 'dashboard' | 'settings' = 'dashboard' 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 = null
export let sidebarCollapsed: boolean = localStorage.getItem('sidebarCollapsed') === 'true' export let sidebarCollapsed: boolean = localStorage.getItem('sidebarCollapsed') === 'true'
export let dashboardTab: DashboardTab = (localStorage.getItem('dashboardTab') as DashboardTab) || 'urls' export let dashboardTab: DashboardTab = 'urls'
export let mobileSidebar: boolean = false
export let sidebarSection: 'apps' | 'tools' = (localStorage.getItem('sidebarSection') as 'apps' | 'tools') || 'apps' export let sidebarSection: 'apps' | 'tools' = (localStorage.getItem('sidebarSection') as 'apps' | 'tools') || 'apps'
// Server state (from SSE) // Server state (from SSE)
export let apps: App[] = [] export let apps: App[] = []
// Tab state // Tab state
export let appTabs: Record<string, string> = JSON.parse(localStorage.getItem('appTabs') || '{}') export let appTabs: Record<string, string> = {}
// State setters // State setters
export function setDashboardTab(tab: DashboardTab) { export function setDashboardTab(tab: DashboardTab) {
dashboardTab = tab dashboardTab = tab
localStorage.setItem('dashboardTab', tab)
} }
export function setCurrentView(view: 'dashboard' | 'settings') { export function setCurrentView(view: 'dashboard' | 'settings') {
@ -28,17 +28,16 @@ export function setCurrentView(view: 'dashboard' | 'settings') {
export function setSelectedApp(name: string | null) { export function setSelectedApp(name: string | null) {
selectedApp = name selectedApp = name
if (name) {
localStorage.setItem('selectedApp', name)
} else {
localStorage.removeItem('selectedApp')
}
} }
export function setIsNarrow(narrow: boolean) { export function setIsNarrow(narrow: boolean) {
isNarrow = narrow isNarrow = narrow
} }
export function setMobileSidebar(open: boolean) {
mobileSidebar = open
}
export function setSidebarCollapsed(collapsed: boolean) { export function setSidebarCollapsed(collapsed: boolean) {
sidebarCollapsed = collapsed sidebarCollapsed = collapsed
localStorage.setItem('sidebarCollapsed', String(collapsed)) localStorage.setItem('sidebarCollapsed', String(collapsed))
@ -59,5 +58,4 @@ export const getSelectedTab = (appName: string | null) =>
export function setSelectedTab(appName: string | null, tab: string) { export function setSelectedTab(appName: string | null, tab: string) {
if (!appName) return if (!appName) return
appTabs[appName] = tab appTabs[appName] = tab
localStorage.setItem('appTabs', JSON.stringify(appTabs))
} }

View File

@ -65,7 +65,6 @@ export const GaugeValue = define('GaugeValue', {
// Unified Logs Section // Unified Logs Section
export const LogsSection = define('LogsSection', { export const LogsSection = define('LogsSection', {
width: '100%', width: '100%',
maxWidth: 800,
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
display: 'flex', display: 'flex',
@ -203,58 +202,85 @@ export const LogStatus = define('LogStatus', {
}, },
}) })
// URL List // App Tiles Grid
export const UrlLeft = define('UrlLeft', { export const TileGrid = define('TileGrid', {
display: 'flex', width: '100%',
alignItems: 'center', maxWidth: 900,
gap: 8, margin: '0 auto',
minWidth: 0, display: 'grid',
}) gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
gap: 20,
export const UrlLink = define('UrlLink', { variants: {
base: 'a', narrow: { gridTemplateColumns: '1fr' },
color: theme('colors-link'),
textDecoration: 'none',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
selectors: {
'&:hover': { textDecoration: 'underline' },
}, },
}) })
export const UrlList = define('UrlList', { export const Tile = define('Tile', {
width: '100%', base: 'a',
minWidth: 400, position: 'relative',
maxWidth: 800,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center',
gap: 8,
padding: '28px 20px 24px',
background: theme('colors-bgElement'), background: theme('colors-bgElement'),
border: `1px solid ${theme('colors-border')}`, border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'), borderRadius: theme('radius-md'),
overflow: 'hidden', textDecoration: 'none',
}) cursor: 'pointer',
export const UrlPort = define('UrlPort', {
fontFamily: theme('fonts-mono'),
fontSize: 12,
color: theme('colors-textFaint'),
flexShrink: 0,
})
export const UrlRow = define('UrlRow', {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '10px 16px',
fontFamily: theme('fonts-mono'),
fontSize: 13,
selectors: { selectors: {
'&:hover': { '&:hover': {
background: theme('colors-bgHover'), background: theme('colors-bgHover'),
borderColor: theme('colors-textFaint'),
}, },
'&:not(:last-child)': { },
borderBottom: `1px solid ${theme('colors-border')}`, variants: {
narrow: {
flexDirection: 'row',
alignItems: 'center',
padding: '16px 20px',
gap: 16,
},
},
})
export const TileIcon = define('TileIcon', {
fontSize: 48,
lineHeight: 1,
userSelect: 'none',
})
export const TileName = define('TileName', {
fontSize: 15,
fontWeight: 600,
color: theme('colors-text'),
textAlign: 'center',
})
export const TilePort = define('TilePort', {
fontFamily: theme('fonts-mono'),
fontSize: 13,
color: theme('colors-textFaint'),
})
export const TileStatus = define('TileStatus', {
position: 'absolute',
top: 8,
right: 8,
width: 2,
height: 2,
borderRadius: '50%',
cursor: 'pointer',
padding: 4,
backgroundClip: 'content-box',
variants: {
state: {
error: { background: theme('colors-statusInvalid') },
invalid: { background: theme('colors-statusInvalid') },
stopped: { background: theme('colors-statusStopped') },
starting: { background: theme('colors-statusStarting') },
running: { background: theme('colors-statusRunning') },
stopping: { background: theme('colors-statusStarting') },
}, },
}, },
}) })

View File

@ -15,11 +15,12 @@ export {
LogStatus, LogStatus,
LogText, LogText,
LogTimestamp, LogTimestamp,
UrlLeft, Tile,
UrlLink, TileGrid,
UrlList, TileIcon,
UrlPort, TileName,
UrlRow, TilePort,
TileStatus,
VitalCard, VitalCard,
VitalLabel, VitalLabel,
VitalsSection, VitalsSection,
@ -28,7 +29,6 @@ export { Form, FormActions, FormCheckbox, FormCheckboxField, FormCheckboxLabel,
export { export {
AppItem, AppItem,
AppList, AppList,
AppSelectorChevron,
ClickableAppName, ClickableAppName,
DashboardContainer, DashboardContainer,
DashboardHeader, DashboardHeader,

View File

@ -29,7 +29,10 @@ export const Logo = define('Logo', {
}) })
export const LogoLink = define('LogoLink', { export const LogoLink = define('LogoLink', {
base: 'a',
cursor: 'pointer', cursor: 'pointer',
color: 'inherit',
textDecoration: 'none',
borderRadius: theme('radius-md'), borderRadius: theme('radius-md'),
padding: '4px 8px', padding: '4px 8px',
margin: '-4px -8px', margin: '-4px -8px',
@ -103,6 +106,7 @@ export const SectionTab = define('SectionTab', {
background: theme('colors-bgSelected'), background: theme('colors-bgSelected'),
color: theme('colors-text'), color: theme('colors-text'),
}, },
large: { fontSize: 14, padding: '8px 12px' },
}, },
}) })
@ -112,6 +116,7 @@ export const AppList = define('AppList', {
}) })
export const AppItem = define('AppItem', { export const AppItem = define('AppItem', {
base: 'a',
display: 'flex', display: 'flex',
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@ -125,6 +130,7 @@ export const AppItem = define('AppItem', {
'&:hover': { background: theme('colors-bgHover'), color: theme('colors-text') }, '&:hover': { background: theme('colors-bgHover'), color: theme('colors-text') },
}, },
variants: { variants: {
large: { fontSize: 18, padding: '12px 16px', gap: 12 },
selected: { background: theme('colors-bgSelected'), color: theme('colors-text'), fontWeight: 500 }, selected: { background: theme('colors-bgSelected'), color: theme('colors-text'), fontWeight: 500 },
}, },
}) })
@ -166,20 +172,6 @@ export const MainTitle = define('MainTitle', {
margin: 0, margin: 0,
}) })
export const AppSelectorChevron = define('AppSelectorChevron', {
base: 'button',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '4px 8px',
marginLeft: 4,
fontSize: 14,
color: theme('colors-textMuted'),
selectors: {
'&:hover': { color: theme('colors-text') },
},
})
export const ClickableAppName = define('ClickableAppName', { export const ClickableAppName = define('ClickableAppName', {
cursor: 'pointer', cursor: 'pointer',
borderRadius: theme('radius-md'), borderRadius: theme('radius-md'),
@ -233,6 +225,7 @@ export const DashboardContainer = define('DashboardContainer', {
export const DashboardHeader = define('DashboardHeader', { export const DashboardHeader = define('DashboardHeader', {
textAlign: 'center', textAlign: 'center',
width: '100%',
}) })
export const DashboardTitle = define('DashboardTitle', { export const DashboardTitle = define('DashboardTitle', {

View File

@ -165,6 +165,7 @@ export const TabContent = define('TabContent', {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
flex: 1, flex: 1,
width: '100%',
} }
} }
}) })

View File

@ -8,7 +8,11 @@ const router = Hype.router()
// individual events so apps can react to specific lifecycle changes. // individual events so apps can react to specific lifecycle changes.
router.sse('/stream', (send) => { router.sse('/stream', (send) => {
const unsub = onEvent(event => send(event)) const unsub = onEvent(event => send(event))
return unsub const heartbeat = setInterval(() => send('', 'ping'), 60_000)
return () => {
clearInterval(heartbeat)
unsub()
}
}) })
export default router export default router

View File

@ -7,6 +7,7 @@ import systemRouter from './api/system'
import { Hype } from '@because/hype' import { Hype } from '@because/hype'
import { cleanupStalePublishers } from './mdns' import { cleanupStalePublishers } from './mdns'
import { extractSubdomain, proxySubdomain, proxyWebSocket, websocket } from './proxy' import { extractSubdomain, proxySubdomain, proxyWebSocket, websocket } from './proxy'
import { Shell } from './shell'
import type { Server } from 'bun' import type { Server } from 'bun'
import type { WsData } from './proxy' import type { WsData } from './proxy'
@ -113,6 +114,13 @@ app.get('/dist/:file', async c => {
}) })
}) })
// SPA routes — serve the shell for all client-side paths
app.get('/app/:name/:tab', c => c.html(<Shell />))
app.get('/app/:name', c => c.html(<Shell />))
app.get('/logs', c => c.html(<Shell />))
app.get('/metrics', c => c.html(<Shell />))
app.get('/settings', c => c.html(<Shell />))
cleanupStalePublishers() cleanupStalePublishers()
await initApps() await initApps()

View File

@ -146,18 +146,19 @@ export function renameTunnelConfig(oldName: string, newName: string) {
saveConfig(config) saveConfig(config)
} }
function cancelReconnect(appName: string) { function cancelReconnect(appName: string, resetAttempts = true) {
const timer = _reconnectTimers.get(appName) const timer = _reconnectTimers.get(appName)
if (timer) { if (timer) {
clearTimeout(timer) clearTimeout(timer)
_reconnectTimers.delete(appName) _reconnectTimers.delete(appName)
} }
_reconnectAttempts.delete(appName) if (resetAttempts) _reconnectAttempts.delete(appName)
} }
function openTunnel(appName: string, port: number, subdomain?: string) { function openTunnel(appName: string, port: number, subdomain?: string, isReconnect = false) {
// Cancel any pending reconnect timer to prevent duplicate loops // Cancel any pending reconnect timer to prevent duplicate loops
cancelReconnect(appName) // but preserve attempts counter during reconnection so backoff works
cancelReconnect(appName, !isReconnect)
// Close existing tunnel if any // Close existing tunnel if any
const existing = _tunnels.get(appName) const existing = _tunnels.get(appName)
@ -232,7 +233,7 @@ function openTunnel(appName: string, port: number, subdomain?: string) {
const config = loadConfig() const config = loadConfig()
if (!config[appName]) return if (!config[appName]) return
hostLog(`Tunnel reconnecting: ${appName}`) hostLog(`Tunnel reconnecting: ${appName}`)
openTunnel(appName, port, config[appName]?.subdomain) openTunnel(appName, port, config[appName]?.subdomain, true)
}, delay) }, delay)
_reconnectTimers.set(appName, timer) _reconnectTimers.set(appName, timer)

View File

@ -35,8 +35,10 @@ function ensureConnection() {
buf = lines.pop()! buf = lines.pop()!
for (const line of lines) { for (const line of lines) {
if (!line.startsWith('data: ')) continue if (!line.startsWith('data: ')) continue
const payload = line.slice(6)
if (!payload) continue
try { try {
const event: ToesEvent = JSON.parse(line.slice(6)) const event: ToesEvent = JSON.parse(payload)
_listeners.forEach(l => { _listeners.forEach(l => {
if (l.types.includes(event.type)) l.callback(event) if (l.types.includes(event.type)) l.callback(event)
}) })