Compare commits

..

No commits in common. "main" and "back-button" have entirely different histories.

25 changed files with 87 additions and 382 deletions

View File

@ -4,28 +4,11 @@ Toes is a personal web server you run in your home.
Plug it in, turn it on, and forget about the cloud.
## setup
## quickstart
Toes runs on a Raspberry Pi. You'll need:
- 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.
1. Plug in and turn on your Toes computer.
2. Tell Toes about your WiFi by <using dark @probablycorey magick>.
3. Visit https://toes.local to get started!
## features
- Hosts bun/hono/hype webapps - both SSR and SPA.

View File

@ -1,26 +0,0 @@
{
"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

@ -1,123 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
##
# toes installer
# Usage: curl -sSL https://toes.dev/install | bash
# Must be run as the 'toes' user.
DEST=~/toes
REPO="https://git.nose.space/defunkt/toes"
quiet() { "$@" > /dev/null 2>&1; }
echo ""
echo " ╔══════════════════════════════════╗"
echo " ║ 🐾 toes - personal web appliance ║"
echo " ╚══════════════════════════════════╝"
echo ""
# Must be running as toes
if [ "$(whoami)" != "toes" ]; then
echo "ERROR: This script must be run as the 'toes' user."
echo "Create the user during Raspberry Pi OS setup."
exit 1
fi
# Must have passwordless sudo (can't prompt when piped from curl)
if ! sudo -n true 2>/dev/null; then
echo "ERROR: This script requires passwordless sudo."
echo "On Raspberry Pi OS, the default user has this already."
exit 1
fi
# -- System packages --
echo ">> Updating system packages"
quiet sudo apt-get update
quiet sudo apt-get install -y git libcap2-bin avahi-utils fish unzip
echo ">> Setting fish as default shell"
if [ "$(getent passwd toes | cut -d: -f7)" != "/usr/bin/fish" ]; then
quiet sudo chsh -s /usr/bin/fish toes
fi
# -- Bun --
BUN_REAL="$HOME/.bun/bin/bun"
BUN_SYMLINK="/usr/local/bin/bun"
if [ ! -x "$BUN_REAL" ]; then
echo ">> Installing bun"
curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1
if [ ! -x "$BUN_REAL" ]; then
echo "ERROR: bun installation failed"
exit 1
fi
fi
if [ ! -x "$BUN_SYMLINK" ]; then
sudo ln -sf "$BUN_REAL" "$BUN_SYMLINK"
fi
echo ">> Setting CAP_NET_BIND_SERVICE on bun"
sudo setcap 'cap_net_bind_service=+ep' "$BUN_REAL"
# -- Clone --
if [ ! -d "$DEST" ]; then
echo ">> Cloning toes"
git clone "$REPO" "$DEST"
else
echo ">> Updating toes"
cd "$DEST" && git pull origin main
fi
# -- Directories --
mkdir -p ~/data ~/apps
# -- Dependencies & build --
echo ">> Installing dependencies"
cd "$DEST" && bun install
echo ">> Building client"
cd "$DEST" && bun run build
# -- Bundled apps --
echo ">> Installing bundled apps"
BUNDLED_APPS="clock code cron env stats versions"
for app in $BUNDLED_APPS; do
if [ -d "$DEST/apps/$app" ]; then
if [ -d ~/apps/"$app" ]; then
echo " $app (exists, skipping)"
continue
fi
echo " $app"
cp -r "$DEST/apps/$app" ~/apps/
version_dir=$(ls -1 ~/apps/"$app" | grep -E '^[0-9]{8}-[0-9]{6}$' | sort -r | head -1)
if [ -n "$version_dir" ]; then
ln -sfn "$version_dir" ~/apps/"$app"/current
(cd ~/apps/"$app"/current && bun install --frozen-lockfile) > /dev/null 2>&1 || true
fi
fi
done
# -- Systemd --
echo ">> Installing toes service"
sudo install -m 644 -o root -g root "$DEST/scripts/toes.service" /etc/systemd/system/toes.service
sudo systemctl daemon-reload
sudo systemctl enable toes
echo ">> Starting toes"
sudo systemctl restart toes
# -- Done --
echo ""
echo " toes is installed and running!"
echo " Visit: http://$(hostname).local"
echo ""

View File

@ -1,16 +0,0 @@
{
"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

@ -1,17 +0,0 @@
import { resolve } from "path"
const script = await Bun.file(resolve(import.meta.dir, "install.sh")).text()
Bun.serve({
port: parseInt(process.env.PORT || "3000"),
fetch(req) {
if (new URL(req.url).pathname === "/install") {
return new Response(script, {
headers: { "content-type": "text/plain" },
})
}
return new Response("404 Not Found", { status: 404 })
},
})
console.log(`Serving /install on :${Bun.env.PORT || 3000}`)

View File

@ -1,43 +0,0 @@
{
"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}"
SSH_HOST="$TOES_USER@$HOST"
URL="${URL:-http://$HOST}"
DEST="${DEST:-$HOME/toes}"
DATA_DIR="${DATA_DIR:-$HOME/data}"
APPS_DIR="${APPS_DIR:-$HOME/apps}"
DEST="${DEST:-~/toes}"
DATA_DIR="${DATA_DIR:-~/data}"
APPS_DIR="${APPS_DIR:-~/apps}"
mkdir -p "$DEST" "$DATA_DIR" "$APPS_DIR"

View File

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

View File

@ -17,12 +17,11 @@ interface AppSelectorProps {
render: () => void
onSelect?: () => void
collapsed?: boolean
large?: boolean
switcherStyle?: CSSProperties
listStyle?: CSSProperties
}
export function AppSelector({ render, onSelect, collapsed, large, switcherStyle, listStyle }: AppSelectorProps) {
export function AppSelector({ render, onSelect, collapsed, switcherStyle, listStyle }: AppSelectorProps) {
const switchSection = (section: 'apps' | 'tools') => {
setSidebarSection(section)
render()
@ -36,10 +35,10 @@ export function AppSelector({ render, onSelect, collapsed, large, switcherStyle,
<>
{!collapsed && toolApps.length > 0 && (
<SectionSwitcher style={switcherStyle}>
<SectionTab active={sidebarSection === 'apps' ? true : undefined} large={large || undefined} onClick={() => switchSection('apps')}>
<SectionTab active={sidebarSection === 'apps' ? true : undefined} onClick={() => switchSection('apps')}>
Apps
</SectionTab>
<SectionTab active={sidebarSection === 'tools' ? true : undefined} large={large || undefined} onClick={() => switchSection('tools')}>
<SectionTab active={sidebarSection === 'tools' ? true : undefined} onClick={() => switchSection('tools')}>
Tools
</SectionTab>
</SectionSwitcher>
@ -60,7 +59,6 @@ export function AppSelector({ render, onSelect, collapsed, large, switcherStyle,
key={app.name}
href={`/app/${app.name}`}
onClick={onSelect}
large={large || undefined}
selected={app.name === selectedApp ? true : undefined}
style={collapsed ? { justifyContent: 'center', padding: '10px 12px' } : undefined}
title={collapsed ? app.name : undefined}
@ -69,7 +67,7 @@ export function AppSelector({ render, onSelect, collapsed, large, switcherStyle,
<span style={{ fontSize: 18 }}>{app.icon}</span>
) : (
<>
<span style={{ fontSize: large ? 20 : 14 }}>{app.icon}</span>
<span style={{ fontSize: 14 }}>{app.icon}</span>
{app.name}
<StatusDot state={app.state} data-app={app.name} data-state={app.state} style={{ marginLeft: 'auto' }} />
</>

View File

@ -1,48 +1,13 @@
import { Styles } from '@because/forge'
import { openNewAppModal } from '../modals'
import { apps, currentView, isNarrow, mobileSidebar, selectedApp, setMobileSidebar } from '../state'
import {
HamburgerButton,
HamburgerLine,
Layout,
Main,
MainContent as MainContentContainer,
MainHeader,
MainTitle,
NewAppButton,
} from '../styles'
import { apps, currentView, isNarrow, selectedApp } from '../state'
import { Layout } from '../styles'
import { AppDetail } from './AppDetail'
import { AppSelector } from './AppSelector'
import { DashboardLanding } from './DashboardLanding'
import { Modal } from './modal'
import { SettingsPage } from './SettingsPage'
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 }) {
if (isNarrow && mobileSidebar) return <MobileSidebar render={render} />
const selected = apps.find(a => a.name === selectedApp)
if (selected) return <AppDetail app={selected} render={render} />
if (currentView === 'settings') return <SettingsPage render={render} />

View File

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

View File

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

View File

@ -1,12 +1,13 @@
import { buildAppUrl } from '../../shared/urls'
import { navigate } from '../router'
import { apps, isNarrow } from '../state'
import { apps } from '../state'
import {
EmptyState,
Tile,
TileGrid,
TileIcon,
TileName,
TilePort,
TileStatus,
} from '../styles'
@ -18,9 +19,9 @@ export function Urls({ render }: { render: () => void }) {
}
return (
<TileGrid narrow={isNarrow || undefined}>
<TileGrid>
{nonTools.map(app => {
const url = app.tunnelUrl || buildAppUrl(app.name, location.origin)
const url = buildAppUrl(app.name, location.origin)
const running = app.state === 'running'
const appPage = `/app/${app.name}`
@ -35,11 +36,11 @@ export function Urls({ render }: { render: () => void }) {
key={app.name}
href={running ? url : appPage}
target={running ? '_blank' : undefined}
narrow={isNarrow || undefined}
>
<TileStatus state={app.state} onClick={openAppPage} />
<TileIcon>{app.icon}</TileIcon>
<TileName>{app.name}</TileName>
<TilePort>{app.port ? `:${app.port}` : '\u2014'}</TilePort>
</Tile>
)
})}

View File

@ -0,0 +1,17 @@
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,3 +1,4 @@
export { openAppSelectorModal } from './AppSelector'
export { openDeleteAppModal } from './DeleteApp'
export { openNewAppModal } from './NewApp'
export { openRenameAppModal } from './RenameApp'

View File

@ -1,4 +1,4 @@
import { setCurrentView, setDashboardTab, setMobileSidebar, setSelectedApp, setSelectedTab } from './state'
import { setCurrentView, setSelectedApp } from './state'
let _render: () => void
@ -14,7 +14,6 @@ export function initRouter(render: () => void) {
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()
@ -28,31 +27,17 @@ export function initRouter(render: () => void) {
}
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)
const name = decodeURIComponent(path.slice(5))
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')
}

View File

@ -7,19 +7,19 @@ export let currentView: 'dashboard' | 'settings' = 'dashboard'
export let isNarrow: boolean = window.matchMedia('(max-width: 768px)').matches
export let selectedApp: string | null = null
export let sidebarCollapsed: boolean = localStorage.getItem('sidebarCollapsed') === 'true'
export let dashboardTab: DashboardTab = 'urls'
export let mobileSidebar: boolean = false
export let dashboardTab: DashboardTab = (localStorage.getItem('dashboardTab') as DashboardTab) || 'urls'
export let sidebarSection: 'apps' | 'tools' = (localStorage.getItem('sidebarSection') as 'apps' | 'tools') || 'apps'
// Server state (from SSE)
export let apps: App[] = []
// Tab state
export let appTabs: Record<string, string> = {}
export let appTabs: Record<string, string> = JSON.parse(localStorage.getItem('appTabs') || '{}')
// State setters
export function setDashboardTab(tab: DashboardTab) {
dashboardTab = tab
localStorage.setItem('dashboardTab', tab)
}
export function setCurrentView(view: 'dashboard' | 'settings') {
@ -34,10 +34,6 @@ export function setIsNarrow(narrow: boolean) {
isNarrow = narrow
}
export function setMobileSidebar(open: boolean) {
mobileSidebar = open
}
export function setSidebarCollapsed(collapsed: boolean) {
sidebarCollapsed = collapsed
localStorage.setItem('sidebarCollapsed', String(collapsed))
@ -58,4 +54,5 @@ export const getSelectedTab = (appName: string | null) =>
export function setSelectedTab(appName: string | null, tab: string) {
if (!appName) return
appTabs[appName] = tab
localStorage.setItem('appTabs', JSON.stringify(appTabs))
}

View File

@ -65,6 +65,7 @@ export const GaugeValue = define('GaugeValue', {
// Unified Logs Section
export const LogsSection = define('LogsSection', {
width: '100%',
maxWidth: 800,
flex: 1,
minHeight: 0,
display: 'flex',
@ -206,13 +207,9 @@ export const LogStatus = define('LogStatus', {
export const TileGrid = define('TileGrid', {
width: '100%',
maxWidth: 900,
margin: '0 auto',
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
gap: 20,
variants: {
narrow: { gridTemplateColumns: '1fr' },
},
})
export const Tile = define('Tile', {
@ -234,14 +231,6 @@ export const Tile = define('Tile', {
borderColor: theme('colors-textFaint'),
},
},
variants: {
narrow: {
flexDirection: 'row',
alignItems: 'center',
padding: '16px 20px',
gap: 16,
},
},
})
export const TileIcon = define('TileIcon', {

View File

@ -29,6 +29,7 @@ export { Form, FormActions, FormCheckbox, FormCheckboxField, FormCheckboxLabel,
export {
AppItem,
AppList,
AppSelectorChevron,
ClickableAppName,
DashboardContainer,
DashboardHeader,

View File

@ -106,7 +106,6 @@ export const SectionTab = define('SectionTab', {
background: theme('colors-bgSelected'),
color: theme('colors-text'),
},
large: { fontSize: 14, padding: '8px 12px' },
},
})
@ -130,7 +129,6 @@ export const AppItem = define('AppItem', {
'&:hover': { background: theme('colors-bgHover'), color: theme('colors-text') },
},
variants: {
large: { fontSize: 18, padding: '12px 16px', gap: 12 },
selected: { background: theme('colors-bgSelected'), color: theme('colors-text'), fontWeight: 500 },
},
})
@ -172,6 +170,20 @@ export const MainTitle = define('MainTitle', {
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', {
cursor: 'pointer',
borderRadius: theme('radius-md'),
@ -225,7 +237,6 @@ export const DashboardContainer = define('DashboardContainer', {
export const DashboardHeader = define('DashboardHeader', {
textAlign: 'center',
width: '100%',
})
export const DashboardTitle = define('DashboardTitle', {

View File

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

View File

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

View File

@ -115,10 +115,7 @@ 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()

View File

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

View File

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