Compare commits
33 Commits
wifi-setup
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d2b0eb410f | |||
| ffe1df22e6 | |||
| 7f82a37c63 | |||
| 6055b9798d | |||
| f7397dc060 | |||
| d69dc6ae9d | |||
| 4853ee4f7a | |||
|
|
74f9062a89 | ||
|
|
55316027c0 | ||
| cfba207077 | |||
| 702019279a | |||
| 141622f86f | |||
| 526678e87a | |||
| d29e306e61 | |||
| 671f51ca0c | |||
| 604ac96b30 | |||
| d082af4e33 | |||
| 9bce15b871 | |||
| 7ab27f2767 | |||
| 45b1903e6b | |||
| 68274d8651 | |||
| 98a1c1ad97 | |||
| 6d02f1db3f | |||
| b0c5a11cde | |||
| 029e349c5b | |||
| 1a71656508 | |||
| 363a82a845 | |||
| 271bf018b8 | |||
|
|
488c643342 | ||
|
|
8fc54bd349 | ||
|
|
3cbb25a82a | ||
| 87d0ff50c1 | |||
| 0499060676 |
25
README.md
25
README.md
|
|
@ -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.
|
||||
|
||||
## quickstart
|
||||
## setup
|
||||
|
||||
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!
|
||||
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.
|
||||
|
||||
## features
|
||||
- Hosts bun/hono/hype webapps - both SSR and SPA.
|
||||
|
|
|
|||
26
install/bun.lock
Normal file
26
install/bun.lock
Normal 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=="],
|
||||
}
|
||||
}
|
||||
123
install/install.sh
Normal file
123
install/install.sh
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
#!/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 ""
|
||||
16
install/package.json
Normal file
16
install/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
17
install/server.ts
Normal file
17
install/server.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
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}`)
|
||||
43
install/tsconfig.json
Normal file
43
install/tsconfig.json
Normal 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/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,8 +2,12 @@
|
|||
|
||||
# It isn't enough to modify this yet.
|
||||
# You also need to manually update the toes.service file.
|
||||
HOST="${HOST:-toes@toes.local}"
|
||||
URL="${URL:-http://toes.local}"
|
||||
DEST="${DEST:-~/toes}"
|
||||
DATA_DIR="${DATA_DIR:-~/data}"
|
||||
APPS_DIR="${APPS_DIR:-~/apps}"
|
||||
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}"
|
||||
|
||||
mkdir -p "$DEST" "$DATA_DIR" "$APPS_DIR"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ source "$ROOT_DIR/scripts/config.sh"
|
|||
git push origin main
|
||||
|
||||
# SSH to target: pull, build, sync apps, restart
|
||||
ssh "$HOST" DEST="$DEST" APPS_DIR="$APPS_DIR" bash <<'SCRIPT'
|
||||
ssh "$SSH_HOST" DEST="$DEST" APPS_DIR="$APPS_DIR" bash <<'SCRIPT'
|
||||
set -e
|
||||
|
||||
cd "$DEST" && git checkout -- bun.lock && git pull origin main && bun install && rm -rf dist && bun run build
|
||||
|
|
@ -35,5 +35,5 @@ done
|
|||
sudo systemctl restart toes.service
|
||||
SCRIPT
|
||||
|
||||
echo "=> Deployed to $HOST"
|
||||
echo "=> Deployed to $SSH_HOST"
|
||||
echo "=> Visit $URL"
|
||||
|
|
|
|||
|
|
@ -9,4 +9,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
|
|||
source "$ROOT_DIR/scripts/config.sh"
|
||||
|
||||
# Run remote install on the target
|
||||
ssh "$HOST" "git clone https://git.nose.space/defunkt/toes $DEST && cd $DEST && ./scripts/install.sh"
|
||||
ssh "$SSH_HOST" "git clone https://git.nose.space/defunkt/toes $DEST && cd $DEST && ./scripts/install.sh"
|
||||
|
|
|
|||
|
|
@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
|
|||
|
||||
source "$ROOT_DIR/scripts/config.sh"
|
||||
|
||||
ssh "$HOST" "journalctl -u toes -n 100"
|
||||
ssh "$SSH_HOST" "journalctl -u toes -n 100"
|
||||
|
|
|
|||
|
|
@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
|
|||
|
||||
source "$ROOT_DIR/scripts/config.sh"
|
||||
|
||||
ssh "$HOST" "sudo systemctl restart toes.service"
|
||||
ssh "$SSH_HOST" "sudo systemctl restart toes.service"
|
||||
|
|
|
|||
|
|
@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
|
|||
|
||||
source "$ROOT_DIR/scripts/config.sh"
|
||||
|
||||
ssh "$HOST" "sudo systemctl start toes.service"
|
||||
ssh "$SSH_HOST" "sudo systemctl start toes.service"
|
||||
|
|
|
|||
|
|
@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
|
|||
|
||||
source "$ROOT_DIR/scripts/config.sh"
|
||||
|
||||
ssh "$HOST" "sudo systemctl stop toes.service"
|
||||
ssh "$SSH_HOST" "sudo systemctl stop toes.service"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { Manifest } from '@types'
|
||||
|
||||
const DEFAULT_HOST = process.env.DEV ? 'http://localhost:3000' : 'http://toes.local'
|
||||
|
||||
const normalizeUrl = (url: string) =>
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@ 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 { openAppSelectorModal, openDeleteAppModal, openRenameAppModal } from '../modals'
|
||||
import { apps, getSelectedTab, isNarrow } from '../state'
|
||||
import { openDeleteAppModal, openRenameAppModal } from '../modals'
|
||||
import { apps, getSelectedTab, isNarrow, setMobileSidebar } from '../state'
|
||||
import {
|
||||
ActionBar,
|
||||
AppSelectorChevron,
|
||||
Button,
|
||||
ClickableAppName,
|
||||
HamburgerButton,
|
||||
HamburgerLine,
|
||||
HeaderActions,
|
||||
InfoLabel,
|
||||
InfoRow,
|
||||
|
|
@ -52,14 +53,15 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
|
|||
<Main>
|
||||
<MainHeader>
|
||||
<MainTitle>
|
||||
<OpenEmojiPicker app={app} render={render}>{app.icon}</OpenEmojiPicker>
|
||||
|
||||
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
|
||||
{isNarrow && (
|
||||
<AppSelectorChevron onClick={() => openAppSelectorModal(render)}>
|
||||
▼
|
||||
</AppSelectorChevron>
|
||||
<HamburgerButton onClick={() => { setMobileSidebar(true); render() }} title="Show apps">
|
||||
<HamburgerLine />
|
||||
<HamburgerLine />
|
||||
<HamburgerLine />
|
||||
</HamburgerButton>
|
||||
)}
|
||||
<OpenEmojiPicker app={app} render={render}>{app.icon}</OpenEmojiPicker>
|
||||
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
|
||||
</MainTitle>
|
||||
<HeaderActions>
|
||||
{!app.tool && (
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import type { CSSProperties } from 'hono/jsx'
|
|||
import {
|
||||
apps,
|
||||
selectedApp,
|
||||
setSelectedApp,
|
||||
setSidebarSection,
|
||||
sidebarSection,
|
||||
} from '../state'
|
||||
|
|
@ -17,19 +16,13 @@ import {
|
|||
interface AppSelectorProps {
|
||||
render: () => void
|
||||
onSelect?: () => void
|
||||
onDashboard?: () => void
|
||||
collapsed?: boolean
|
||||
large?: boolean
|
||||
switcherStyle?: CSSProperties
|
||||
listStyle?: CSSProperties
|
||||
}
|
||||
|
||||
export function AppSelector({ render, onSelect, onDashboard, collapsed, switcherStyle, listStyle }: AppSelectorProps) {
|
||||
const selectApp = (name: string) => {
|
||||
setSelectedApp(name)
|
||||
onSelect?.()
|
||||
render()
|
||||
}
|
||||
|
||||
export function AppSelector({ render, onSelect, collapsed, large, switcherStyle, listStyle }: AppSelectorProps) {
|
||||
const switchSection = (section: 'apps' | 'tools') => {
|
||||
setSidebarSection(section)
|
||||
render()
|
||||
|
|
@ -43,18 +36,18 @@ export function AppSelector({ render, onSelect, onDashboard, collapsed, switcher
|
|||
<>
|
||||
{!collapsed && toolApps.length > 0 && (
|
||||
<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
|
||||
</SectionTab>
|
||||
<SectionTab active={sidebarSection === 'tools' ? true : undefined} onClick={() => switchSection('tools')}>
|
||||
<SectionTab active={sidebarSection === 'tools' ? true : undefined} large={large || undefined} onClick={() => switchSection('tools')}>
|
||||
Tools
|
||||
</SectionTab>
|
||||
</SectionSwitcher>
|
||||
)}
|
||||
<AppList style={listStyle}>
|
||||
{collapsed && onDashboard && (
|
||||
{collapsed && (
|
||||
<AppItem
|
||||
onClick={onDashboard}
|
||||
href="/"
|
||||
selected={!selectedApp ? true : undefined}
|
||||
style={{ justifyContent: 'center', padding: '10px 12px' }}
|
||||
title="Toes"
|
||||
|
|
@ -65,7 +58,9 @@ export function AppSelector({ render, onSelect, onDashboard, collapsed, switcher
|
|||
{activeApps.map(app => (
|
||||
<AppItem
|
||||
key={app.name}
|
||||
onClick={() => selectApp(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}
|
||||
|
|
@ -74,7 +69,7 @@ export function AppSelector({ render, onSelect, onDashboard, collapsed, switcher
|
|||
<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}
|
||||
<StatusDot state={app.state} data-app={app.name} data-state={app.state} style={{ marginLeft: 'auto' }} />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,48 @@
|
|||
import { Styles } from '@because/forge'
|
||||
import { apps, currentView, isNarrow, selectedApp } from '../state'
|
||||
import { Layout } from '../styles'
|
||||
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 { 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} />
|
||||
|
|
|
|||
|
|
@ -1,34 +1,37 @@
|
|||
import { useEffect } from 'hono/jsx'
|
||||
import { openAppSelectorModal } from '../modals'
|
||||
import { apps, isNarrow, setCurrentView, setSelectedApp } from '../state'
|
||||
import { navigate } from '../router'
|
||||
import { dashboardTab, isNarrow, setMobileSidebar } from '../state'
|
||||
import {
|
||||
AppSelectorChevron,
|
||||
HamburgerButton,
|
||||
HamburgerLine,
|
||||
DashboardContainer,
|
||||
DashboardHeader,
|
||||
DashboardTitle,
|
||||
SettingsGear,
|
||||
StatusDot,
|
||||
StatusDotLink,
|
||||
StatusDotsRow,
|
||||
Tab,
|
||||
TabBar,
|
||||
TabContent,
|
||||
} from '../styles'
|
||||
import { update } from '../update'
|
||||
import { UnifiedLogs, initUnifiedLogs } from './UnifiedLogs'
|
||||
import { UnifiedLogs, initUnifiedLogs, scrollLogsToBottom } from './UnifiedLogs'
|
||||
import { Urls } from './Urls'
|
||||
import { Vitals, initVitals } from './Vitals'
|
||||
|
||||
let activeTooltip: string | null = null
|
||||
|
||||
export function DashboardLanding({ render }: { render: () => void }) {
|
||||
useEffect(() => {
|
||||
initUnifiedLogs()
|
||||
initVitals()
|
||||
if (dashboardTab === 'logs') scrollLogsToBottom()
|
||||
}, [])
|
||||
|
||||
const narrow = isNarrow || undefined
|
||||
|
||||
const openSettings = () => {
|
||||
setSelectedApp(null)
|
||||
setCurrentView('settings')
|
||||
render()
|
||||
navigate('/settings')
|
||||
}
|
||||
|
||||
const switchTab = (tab: typeof dashboardTab) => {
|
||||
navigate(tab === 'urls' ? '/' : `/${tab}`)
|
||||
if (tab === 'logs') scrollLogsToBottom()
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -40,43 +43,40 @@ 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>
|
||||
|
||||
<StatusDotsRow>
|
||||
{[...apps.filter(a => !a.tool), ...apps.filter(a => a.tool)].map(app => (
|
||||
<StatusDotLink
|
||||
key={app.name}
|
||||
data-tooltip={app.name}
|
||||
tooltipVisible={activeTooltip === app.name || undefined}
|
||||
onClick={(e: Event) => {
|
||||
e.preventDefault()
|
||||
if (isNarrow && activeTooltip !== app.name) {
|
||||
activeTooltip = app.name
|
||||
render()
|
||||
return
|
||||
}
|
||||
activeTooltip = null
|
||||
setSelectedApp(app.name)
|
||||
update()
|
||||
}}
|
||||
>
|
||||
<StatusDot state={app.state} data-app={app.name} />
|
||||
</StatusDotLink>
|
||||
))}
|
||||
</StatusDotsRow>
|
||||
<TabBar centered>
|
||||
<Tab active={dashboardTab === 'urls' || undefined} onClick={() => switchTab('urls')}>URLs</Tab>
|
||||
<Tab active={dashboardTab === 'logs' || undefined} onClick={() => switchTab('logs')}>Logs</Tab>
|
||||
<Tab active={dashboardTab === 'metrics' || undefined} onClick={() => switchTab('metrics')}>Metrics</Tab>
|
||||
</TabBar>
|
||||
|
||||
<Vitals />
|
||||
<TabContent active={dashboardTab === 'urls' || undefined}>
|
||||
<Urls render={render} />
|
||||
</TabContent>
|
||||
|
||||
<TabContent active={dashboardTab === 'logs' || undefined}>
|
||||
<UnifiedLogs />
|
||||
</TabContent>
|
||||
|
||||
<TabContent active={dashboardTab === 'metrics' || undefined}>
|
||||
<Vitals />
|
||||
</TabContent>
|
||||
</DashboardContainer>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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 { resetToolIframe } from '../tool-iframes'
|
||||
|
||||
|
|
@ -12,8 +13,7 @@ export function Nav({ app, render }: { app: App; render: () => void }) {
|
|||
resetToolIframe(tab, app.name)
|
||||
return
|
||||
}
|
||||
setSelectedTab(app.name, tab)
|
||||
render()
|
||||
navigate(tab === 'overview' ? `/app/${app.name}` : `/app/${app.name}/${tab}`)
|
||||
}
|
||||
|
||||
// Find all tools
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState } from 'hono/jsx'
|
||||
import { getWifiConfig, saveWifiConfig } from '../api'
|
||||
import { setCurrentView } from '../state'
|
||||
import { navigate } from '../router'
|
||||
import {
|
||||
Button,
|
||||
DashboardInstallCmd,
|
||||
|
|
@ -31,8 +31,7 @@ export function SettingsPage({ render }: { render: () => void }) {
|
|||
}, [])
|
||||
|
||||
const goBack = () => {
|
||||
setCurrentView('dashboard')
|
||||
render()
|
||||
navigate('/')
|
||||
}
|
||||
|
||||
const handleSave = async (e: Event) => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import { openNewAppModal } from '../modals'
|
||||
import {
|
||||
setCurrentView,
|
||||
setSelectedApp,
|
||||
setSidebarCollapsed,
|
||||
sidebarCollapsed,
|
||||
} from '../state'
|
||||
|
|
@ -17,12 +15,6 @@ import {
|
|||
import { AppSelector } from './AppSelector'
|
||||
|
||||
export function Sidebar({ render }: { render: () => void }) {
|
||||
const goToDashboard = () => {
|
||||
setSelectedApp(null)
|
||||
setCurrentView('dashboard')
|
||||
render()
|
||||
}
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setSidebarCollapsed(!sidebarCollapsed)
|
||||
render()
|
||||
|
|
@ -40,7 +32,7 @@ export function Sidebar({ render }: { render: () => void }) {
|
|||
</div>
|
||||
) : (
|
||||
<Logo>
|
||||
<LogoLink onClick={goToDashboard} title="Go to dashboard">
|
||||
<LogoLink href="/" title="Go to dashboard">
|
||||
🐾 Toes
|
||||
</LogoLink>
|
||||
<HamburgerButton onClick={toggleSidebar} title="Hide sidebar">
|
||||
|
|
@ -50,7 +42,7 @@ export function Sidebar({ render }: { render: () => void }) {
|
|||
</HamburgerButton>
|
||||
</Logo>
|
||||
)}
|
||||
<AppSelector render={render} collapsed={sidebarCollapsed} onDashboard={goToDashboard} />
|
||||
<AppSelector render={render} collapsed={sidebarCollapsed} />
|
||||
{!sidebarCollapsed && (
|
||||
<SidebarFooter>
|
||||
<NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton>
|
||||
|
|
|
|||
|
|
@ -105,6 +105,13 @@ function renderLogs() {
|
|||
})
|
||||
}
|
||||
|
||||
export function scrollLogsToBottom() {
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.getElementById('unified-logs-body')
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
})
|
||||
}
|
||||
|
||||
export function initUnifiedLogs() {
|
||||
if (_source) return
|
||||
_source = new EventSource('/api/system/logs/stream')
|
||||
|
|
|
|||
48
src/client/components/Urls.tsx
Normal file
48
src/client/components/Urls.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { buildAppUrl } from '../../shared/urls'
|
||||
import { navigate } from '../router'
|
||||
import { apps, isNarrow } from '../state'
|
||||
import {
|
||||
EmptyState,
|
||||
Tile,
|
||||
TileGrid,
|
||||
TileIcon,
|
||||
TileName,
|
||||
TileStatus,
|
||||
} from '../styles'
|
||||
|
||||
export function Urls({ render }: { render: () => void }) {
|
||||
const nonTools = apps.filter(a => !a.tool)
|
||||
|
||||
if (nonTools.length === 0) {
|
||||
return <EmptyState>No apps installed</EmptyState>
|
||||
}
|
||||
|
||||
return (
|
||||
<TileGrid narrow={isNarrow || undefined}>
|
||||
{nonTools.map(app => {
|
||||
const url = app.tunnelUrl || buildAppUrl(app.name, location.origin)
|
||||
const running = app.state === 'running'
|
||||
const appPage = `/app/${app.name}`
|
||||
|
||||
const openAppPage = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
navigate(appPage)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tile
|
||||
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>
|
||||
</Tile>
|
||||
)
|
||||
})}
|
||||
</TileGrid>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import { render as renderApp } from 'hono/jsx/dom'
|
||||
import { Dashboard } from './components'
|
||||
import { apps, getSelectedTab, selectedApp, setApps, setIsNarrow, setSelectedApp } from './state'
|
||||
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 { initUpdate } from './update'
|
||||
|
||||
|
|
@ -41,14 +42,16 @@ narrowQuery.addEventListener('change', e => {
|
|||
render()
|
||||
})
|
||||
|
||||
// Initialize router (sets initial state from URL and renders)
|
||||
initRouter(render)
|
||||
|
||||
// SSE connection
|
||||
const events = new EventSource('/api/apps/stream')
|
||||
events.onmessage = e => {
|
||||
const prev = apps
|
||||
setApps(JSON.parse(e.data))
|
||||
|
||||
if (selectedApp && !apps.some(a => a.name === selectedApp)) {
|
||||
setSelectedApp(null)
|
||||
navigate('/')
|
||||
}
|
||||
|
||||
render()
|
||||
|
|
|
|||
|
|
@ -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' }}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import type { App } from '../../shared/types'
|
||||
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 { theme } from '../themes'
|
||||
|
||||
|
|
@ -32,11 +33,11 @@ async function deleteApp(input: HTMLInputElement) {
|
|||
throw new Error(`Failed to delete app: ${res.statusText}`)
|
||||
}
|
||||
|
||||
// Success - close modal and clear selection
|
||||
if (selectedApp === deleteAppTarget.name) {
|
||||
setSelectedApp(null)
|
||||
}
|
||||
// Success - close modal and navigate to dashboard
|
||||
closeModal()
|
||||
if (selectedApp === deleteAppTarget.name) {
|
||||
navigate('/')
|
||||
}
|
||||
} catch (err) {
|
||||
deleteAppError = err instanceof Error ? err.message : 'Failed to delete app'
|
||||
deleteAppDeleting = false
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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'
|
||||
|
||||
type TemplateType = 'ssr' | 'spa' | 'bare'
|
||||
|
|
@ -48,9 +49,9 @@ async function createNewApp() {
|
|||
throw new Error(data.error || 'Failed to create app')
|
||||
}
|
||||
|
||||
// Success - close modal and select the new app
|
||||
setSelectedApp(name)
|
||||
// Success - close modal and navigate to the new app
|
||||
closeModal()
|
||||
navigate(`/app/${name}`)
|
||||
} catch (err) {
|
||||
newAppError = err instanceof Error ? err.message : 'Failed to create app'
|
||||
newAppCreating = false
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { App } from '../../shared/types'
|
||||
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'
|
||||
|
||||
let renameAppError = ''
|
||||
|
|
@ -58,9 +59,9 @@ async function doRenameApp(input: HTMLInputElement) {
|
|||
throw new Error(data.error || 'Failed to rename app')
|
||||
}
|
||||
|
||||
// Success - update selection and close modal
|
||||
setSelectedApp(data.name || newName)
|
||||
// Success - close modal and navigate to renamed app
|
||||
closeModal()
|
||||
navigate(`/app/${data.name || newName}`)
|
||||
} catch (err) {
|
||||
renameAppError = err instanceof Error ? err.message : 'Failed to rename app'
|
||||
renameAppRenaming = false
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
export { openAppSelectorModal } from './AppSelector'
|
||||
export { openDeleteAppModal } from './DeleteApp'
|
||||
export { openNewAppModal } from './NewApp'
|
||||
export { openRenameAppModal } from './RenameApp'
|
||||
|
|
|
|||
60
src/client/router.ts
Normal file
60
src/client/router.ts
Normal 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()
|
||||
}
|
||||
|
|
@ -1,36 +1,43 @@
|
|||
import type { App } from '../shared/types'
|
||||
|
||||
export type DashboardTab = 'urls' | 'logs' | 'metrics'
|
||||
|
||||
// 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 selectedApp: string | null = null
|
||||
export let sidebarCollapsed: boolean = localStorage.getItem('sidebarCollapsed') === 'true'
|
||||
export let dashboardTab: DashboardTab = 'urls'
|
||||
export let mobileSidebar: boolean = false
|
||||
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> = JSON.parse(localStorage.getItem('appTabs') || '{}')
|
||||
export let appTabs: Record<string, string> = {}
|
||||
|
||||
// State setters
|
||||
export function setDashboardTab(tab: DashboardTab) {
|
||||
dashboardTab = tab
|
||||
}
|
||||
|
||||
export function setCurrentView(view: 'dashboard' | 'settings') {
|
||||
currentView = view
|
||||
}
|
||||
|
||||
export function setSelectedApp(name: string | null) {
|
||||
selectedApp = name
|
||||
if (name) {
|
||||
localStorage.setItem('selectedApp', name)
|
||||
} else {
|
||||
localStorage.removeItem('selectedApp')
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
|
|
@ -51,5 +58,4 @@ 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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,6 @@ export const GaugeValue = define('GaugeValue', {
|
|||
// Unified Logs Section
|
||||
export const LogsSection = define('LogsSection', {
|
||||
width: '100%',
|
||||
maxWidth: 800,
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
|
|
@ -202,3 +201,86 @@ export const LogStatus = define('LogStatus', {
|
|||
warning: { color: '#f59e0b' },
|
||||
},
|
||||
})
|
||||
|
||||
// App Tiles Grid
|
||||
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', {
|
||||
base: 'a',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '28px 20px 24px',
|
||||
background: theme('colors-bgElement'),
|
||||
border: `1px solid ${theme('colors-border')}`,
|
||||
borderRadius: theme('radius-md'),
|
||||
textDecoration: 'none',
|
||||
cursor: 'pointer',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
background: theme('colors-bgHover'),
|
||||
borderColor: theme('colors-textFaint'),
|
||||
},
|
||||
},
|
||||
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') },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ export {
|
|||
LogStatus,
|
||||
LogText,
|
||||
LogTimestamp,
|
||||
Tile,
|
||||
TileGrid,
|
||||
TileIcon,
|
||||
TileName,
|
||||
TilePort,
|
||||
TileStatus,
|
||||
VitalCard,
|
||||
VitalLabel,
|
||||
VitalsSection,
|
||||
|
|
@ -23,7 +29,6 @@ export { Form, FormActions, FormCheckbox, FormCheckboxField, FormCheckboxLabel,
|
|||
export {
|
||||
AppItem,
|
||||
AppList,
|
||||
AppSelectorChevron,
|
||||
ClickableAppName,
|
||||
DashboardContainer,
|
||||
DashboardHeader,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,10 @@ export const Logo = define('Logo', {
|
|||
})
|
||||
|
||||
export const LogoLink = define('LogoLink', {
|
||||
base: 'a',
|
||||
cursor: 'pointer',
|
||||
color: 'inherit',
|
||||
textDecoration: 'none',
|
||||
borderRadius: theme('radius-md'),
|
||||
padding: '4px 8px',
|
||||
margin: '-4px -8px',
|
||||
|
|
@ -103,6 +106,7 @@ export const SectionTab = define('SectionTab', {
|
|||
background: theme('colors-bgSelected'),
|
||||
color: theme('colors-text'),
|
||||
},
|
||||
large: { fontSize: 14, padding: '8px 12px' },
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -112,6 +116,7 @@ export const AppList = define('AppList', {
|
|||
})
|
||||
|
||||
export const AppItem = define('AppItem', {
|
||||
base: 'a',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
|
@ -125,6 +130,7 @@ 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 },
|
||||
},
|
||||
})
|
||||
|
|
@ -166,20 +172,6 @@ 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'),
|
||||
|
|
@ -233,6 +225,7 @@ export const DashboardContainer = define('DashboardContainer', {
|
|||
|
||||
export const DashboardHeader = define('DashboardHeader', {
|
||||
textAlign: 'center',
|
||||
width: '100%',
|
||||
})
|
||||
|
||||
export const DashboardTitle = define('DashboardTitle', {
|
||||
|
|
|
|||
|
|
@ -127,6 +127,12 @@ export const TabBar = define('TabBar', {
|
|||
display: 'flex',
|
||||
gap: 24,
|
||||
marginBottom: 20,
|
||||
variants: {
|
||||
centered: {
|
||||
justifyContent: 'center',
|
||||
marginBottom: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const Tab = define('Tab', {
|
||||
|
|
@ -159,6 +165,7 @@ export const TabContent = define('TabContent', {
|
|||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
4
src/lib/config.ts
Normal file
4
src/lib/config.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { hostname } from 'os'
|
||||
|
||||
export const HOSTNAME = hostname()
|
||||
export const LOCAL_HOST = `${HOSTNAME}.local`
|
||||
|
|
@ -8,7 +8,11 @@ const router = Hype.router()
|
|||
// individual events so apps can react to specific lifecycle changes.
|
||||
router.sse('/stream', (send) => {
|
||||
const unsub = onEvent(event => send(event))
|
||||
return unsub
|
||||
const heartbeat = setInterval(() => send('', 'ping'), 60_000)
|
||||
return () => {
|
||||
clearInterval(heartbeat)
|
||||
unsub()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { Subprocess } from 'bun'
|
|||
import { DEFAULT_EMOJI } from '@types'
|
||||
import { buildAppUrl, toSubdomain } from '@urls'
|
||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from 'fs'
|
||||
import { hostname } from 'os'
|
||||
import { LOCAL_HOST } from '%config'
|
||||
import { join, resolve } from 'path'
|
||||
import { loadAppEnv } from '../tools/env'
|
||||
import { publishApp, unpublishAll, unpublishApp } from './mdns'
|
||||
|
|
@ -16,7 +16,7 @@ export type { AppState } from '@types'
|
|||
export const APPS_DIR = process.env.APPS_DIR ?? resolve(join(process.env.DATA_DIR ?? '.', 'apps'))
|
||||
export const TOES_DIR = process.env.TOES_DIR ?? join(process.env.DATA_DIR ?? '.', 'toes')
|
||||
|
||||
const defaultHost = process.env.NODE_ENV === 'production' ? `${hostname()}.local` : 'localhost'
|
||||
const defaultHost = process.env.NODE_ENV === 'production' ? LOCAL_HOST : 'localhost'
|
||||
export const TOES_URL = process.env.TOES_URL ?? `http://${defaultHost}:${process.env.PORT || 3000}`
|
||||
|
||||
const HEALTH_CHECK_FAILURES_BEFORE_RESTART = 3
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import systemRouter from './api/system'
|
|||
import { Hype } from '@because/hype'
|
||||
import { cleanupStalePublishers } from './mdns'
|
||||
import { extractSubdomain, proxySubdomain, proxyWebSocket, websocket } from './proxy'
|
||||
import { Shell } from './shell'
|
||||
import type { Server } from 'bun'
|
||||
import type { WsData } from './proxy'
|
||||
|
||||
|
|
@ -89,7 +90,7 @@ async function buildBinary(name: string): Promise<boolean> {
|
|||
return promise
|
||||
}
|
||||
|
||||
// Install script: curl -fsSL http://toes.local/install | bash
|
||||
// Install script: curl -fsSL http://<hostname>.local/install | bash
|
||||
app.get('/install', c => {
|
||||
if (!TOES_URL) return c.text('TOES_URL is not configured', 500)
|
||||
const script = INSTALL_SCRIPT.replace('__TOES_URL__', TOES_URL)
|
||||
|
|
@ -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()
|
||||
await initApps()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Subprocess } from 'bun'
|
||||
import { toSubdomain } from '@urls'
|
||||
import { LOCAL_HOST } from '%config'
|
||||
import { networkInterfaces } from 'os'
|
||||
import { hostLog } from './tui'
|
||||
|
||||
|
|
@ -24,7 +25,7 @@ export function cleanupStalePublishers() {
|
|||
if (!isEnabled) return
|
||||
|
||||
try {
|
||||
const result = Bun.spawnSync(['pkill', '-f', 'avahi-publish.*toes\\.local'])
|
||||
const result = Bun.spawnSync(['pkill', '-f', `avahi-publish.*${LOCAL_HOST}`])
|
||||
if (result.exitCode === 0) {
|
||||
hostLog('mDNS: cleaned up stale avahi-publish processes')
|
||||
}
|
||||
|
|
@ -41,22 +42,22 @@ export function publishApp(name: string) {
|
|||
return
|
||||
}
|
||||
|
||||
const hostname = `${toSubdomain(name)}.toes.local`
|
||||
const host = `${toSubdomain(name)}.${LOCAL_HOST}`
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(['avahi-publish', '-a', hostname, '-R', ip], {
|
||||
const proc = Bun.spawn(['avahi-publish', '-a', host, '-R', ip], {
|
||||
stdout: 'ignore',
|
||||
stderr: 'ignore',
|
||||
})
|
||||
|
||||
_publishers.set(name, proc)
|
||||
hostLog(`mDNS: published ${hostname} -> ${ip}`)
|
||||
hostLog(`mDNS: published ${host} -> ${ip}`)
|
||||
|
||||
proc.exited.then(() => {
|
||||
_publishers.delete(name)
|
||||
})
|
||||
} catch {
|
||||
hostLog(`mDNS: failed to publish ${hostname}`)
|
||||
hostLog(`mDNS: failed to publish ${host}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -68,7 +69,7 @@ export function unpublishApp(name: string) {
|
|||
|
||||
proc.kill()
|
||||
_publishers.delete(name)
|
||||
hostLog(`mDNS: unpublished ${toSubdomain(name)}.toes.local`)
|
||||
hostLog(`mDNS: unpublished ${toSubdomain(name)}.${LOCAL_HOST}`)
|
||||
}
|
||||
|
||||
export function unpublishAll() {
|
||||
|
|
@ -76,7 +77,7 @@ export function unpublishAll() {
|
|||
|
||||
for (const [name, proc] of _publishers) {
|
||||
proc.kill()
|
||||
hostLog(`mDNS: unpublished ${toSubdomain(name)}.toes.local`)
|
||||
hostLog(`mDNS: unpublished ${toSubdomain(name)}.${LOCAL_HOST}`)
|
||||
}
|
||||
_publishers.clear()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,18 +146,19 @@ export function renameTunnelConfig(oldName: string, newName: string) {
|
|||
saveConfig(config)
|
||||
}
|
||||
|
||||
function cancelReconnect(appName: string) {
|
||||
function cancelReconnect(appName: string, resetAttempts = true) {
|
||||
const timer = _reconnectTimers.get(appName)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
_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
|
||||
cancelReconnect(appName)
|
||||
// but preserve attempts counter during reconnection so backoff works
|
||||
cancelReconnect(appName, !isReconnect)
|
||||
|
||||
// Close existing tunnel if any
|
||||
const existing = _tunnels.get(appName)
|
||||
|
|
@ -232,7 +233,7 @@ function openTunnel(appName: string, port: number, subdomain?: string) {
|
|||
const config = loadConfig()
|
||||
if (!config[appName]) return
|
||||
hostLog(`Tunnel reconnecting: ${appName}`)
|
||||
openTunnel(appName, port, config[appName]?.subdomain)
|
||||
openTunnel(appName, port, config[appName]?.subdomain, true)
|
||||
}, delay)
|
||||
|
||||
_reconnectTimers.set(appName, timer)
|
||||
|
|
|
|||
|
|
@ -35,8 +35,10 @@ 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(line.slice(6))
|
||||
const event: ToesEvent = JSON.parse(payload)
|
||||
_listeners.forEach(l => {
|
||||
if (l.types.includes(event.type)) l.callback(event)
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user