Compare commits
4 Commits
main
...
wifi-setup
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b6d29ef38 | |||
| 7073cab8b5 | |||
| 3d40de5582 | |||
| ab3d379970 |
26
README.md
26
README.md
|
|
@ -4,28 +4,12 @@ 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.
|
||||||
|
|
||||||
## setup
|
## quickstart
|
||||||
|
|
||||||
Toes runs on a Raspberry Pi. You'll need:
|
1. Plug in and turn on your Toes computer.
|
||||||
|
2. Connect to the **Toes Setup** WiFi network (password: **toessetup**).
|
||||||
- A Raspberry Pi running Raspberry Pi OS
|
A setup page will appear — choose your home WiFi and enter its password.
|
||||||
- A `toes` user with passwordless sudo
|
3. Visit https://toes.local to get started!
|
||||||
|
|
||||||
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.
|
||||||
|
|
|
||||||
|
|
@ -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=="],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 ""
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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}`)
|
|
||||||
|
|
@ -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/*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,12 +2,8 @@
|
||||||
|
|
||||||
# It isn't enough to modify this yet.
|
# It isn't enough to modify this yet.
|
||||||
# You also need to manually update the toes.service file.
|
# You also need to manually update the toes.service file.
|
||||||
TOES_USER="${TOES_USER:-toes}"
|
HOST="${HOST:-toes@toes.local}"
|
||||||
HOST="${HOST:-toes.local}"
|
URL="${URL:-http://toes.local}"
|
||||||
SSH_HOST="$TOES_USER@$HOST"
|
DEST="${DEST:-~/toes}"
|
||||||
URL="${URL:-http://$HOST}"
|
DATA_DIR="${DATA_DIR:-~/data}"
|
||||||
DEST="${DEST:-$HOME/toes}"
|
APPS_DIR="${APPS_DIR:-~/apps}"
|
||||||
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
|
git push origin main
|
||||||
|
|
||||||
# SSH to target: pull, build, sync apps, restart
|
# SSH to target: pull, build, sync apps, restart
|
||||||
ssh "$SSH_HOST" DEST="$DEST" APPS_DIR="$APPS_DIR" bash <<'SCRIPT'
|
ssh "$HOST" DEST="$DEST" APPS_DIR="$APPS_DIR" bash <<'SCRIPT'
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
cd "$DEST" && git checkout -- bun.lock && git pull origin main && bun install && rm -rf dist && bun run build
|
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
|
sudo systemctl restart toes.service
|
||||||
SCRIPT
|
SCRIPT
|
||||||
|
|
||||||
echo "=> Deployed to $SSH_HOST"
|
echo "=> Deployed to $HOST"
|
||||||
echo "=> Visit $URL"
|
echo "=> Visit $URL"
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ echo ">> Updating system libraries"
|
||||||
quiet sudo apt-get update
|
quiet sudo apt-get update
|
||||||
quiet sudo apt-get install -y libcap2-bin
|
quiet sudo apt-get install -y libcap2-bin
|
||||||
quiet sudo apt-get install -y avahi-utils
|
quiet sudo apt-get install -y avahi-utils
|
||||||
|
quiet sudo apt-get install -y dnsmasq
|
||||||
quiet sudo apt-get install -y fish
|
quiet sudo apt-get install -y fish
|
||||||
|
|
||||||
echo ">> Setting fish as default shell for toes user"
|
echo ">> Setting fish as default shell for toes user"
|
||||||
|
|
@ -62,14 +63,16 @@ BUNDLED_APPS="clock code cron env stats versions"
|
||||||
for app in $BUNDLED_APPS; do
|
for app in $BUNDLED_APPS; do
|
||||||
if [ -d "apps/$app" ]; then
|
if [ -d "apps/$app" ]; then
|
||||||
echo " Installing $app..."
|
echo " Installing $app..."
|
||||||
# Copy app to ~/apps
|
|
||||||
cp -r "apps/$app" ~/apps/
|
cp -r "apps/$app" ~/apps/
|
||||||
# Find the version directory and create current symlink
|
|
||||||
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
|
||||||
# Install dependencies
|
if ! (cd ~/apps/$app/current && bun install --frozen-lockfile) > /dev/null 2>&1; then
|
||||||
(cd ~/apps/$app/current && bun install --frozen-lockfile) > /dev/null 2>&1
|
echo " WARNING: bun install failed for $app, trying without lockfile..."
|
||||||
|
(cd ~/apps/$app/current && bun install) > /dev/null 2>&1 || echo " ERROR: bun install failed for $app"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " WARNING: no version directory found for $app, skipping"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
@ -118,7 +121,6 @@ EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ">> Done! Rebooting in 5 seconds..."
|
echo ">> Done! Rebooting in 5 seconds..."
|
||||||
quiet systemctl status "$SERVICE_NAME" --no-pager -l || true
|
systemctl status "$SERVICE_NAME" --no-pager -l || true
|
||||||
sleep 5
|
sleep 5
|
||||||
quiet sudo nohup reboot >/dev/null 2>&1 &
|
sudo reboot
|
||||||
exit 0
|
|
||||||
|
|
|
||||||
|
|
@ -9,4 +9,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
|
||||||
source "$ROOT_DIR/scripts/config.sh"
|
source "$ROOT_DIR/scripts/config.sh"
|
||||||
|
|
||||||
# Run remote install on the target
|
# Run remote install on the target
|
||||||
ssh "$SSH_HOST" "git clone https://git.nose.space/defunkt/toes $DEST && cd $DEST && ./scripts/install.sh"
|
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"
|
source "$ROOT_DIR/scripts/config.sh"
|
||||||
|
|
||||||
ssh "$SSH_HOST" "journalctl -u toes -n 100"
|
ssh "$HOST" "journalctl -u toes -n 100"
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
|
||||||
|
|
||||||
source "$ROOT_DIR/scripts/config.sh"
|
source "$ROOT_DIR/scripts/config.sh"
|
||||||
|
|
||||||
ssh "$SSH_HOST" "sudo systemctl restart toes.service"
|
ssh "$HOST" "sudo systemctl restart toes.service"
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
|
||||||
|
|
||||||
source "$ROOT_DIR/scripts/config.sh"
|
source "$ROOT_DIR/scripts/config.sh"
|
||||||
|
|
||||||
ssh "$SSH_HOST" "sudo systemctl start toes.service"
|
ssh "$HOST" "sudo systemctl start toes.service"
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
|
||||||
|
|
||||||
source "$ROOT_DIR/scripts/config.sh"
|
source "$ROOT_DIR/scripts/config.sh"
|
||||||
|
|
||||||
ssh "$SSH_HOST" "sudo systemctl stop toes.service"
|
ssh "$HOST" "sudo systemctl stop toes.service"
|
||||||
|
|
|
||||||
15
scripts/wifi-captive.conf
Normal file
15
scripts/wifi-captive.conf
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# dnsmasq config for Toes WiFi setup captive portal
|
||||||
|
# Redirect ALL DNS queries to the hotspot gateway IP
|
||||||
|
|
||||||
|
# Only listen on the hotspot interface
|
||||||
|
interface=wlan0
|
||||||
|
bind-interfaces
|
||||||
|
|
||||||
|
# Resolve everything to our IP (captive portal)
|
||||||
|
address=/#/10.42.0.1
|
||||||
|
|
||||||
|
# Don't use /etc/resolv.conf
|
||||||
|
no-resolv
|
||||||
|
|
||||||
|
# Don't read /etc/hosts
|
||||||
|
no-hosts
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { Manifest } from '@types'
|
import type { Manifest } from '@types'
|
||||||
|
|
||||||
const DEFAULT_HOST = process.env.DEV ? 'http://localhost:3000' : 'http://toes.local'
|
const DEFAULT_HOST = process.env.DEV ? 'http://localhost:3000' : 'http://toes.local'
|
||||||
|
|
||||||
const normalizeUrl = (url: string) =>
|
const normalizeUrl = (url: string) =>
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,32 @@
|
||||||
|
import type { ConnectResult, WifiNetwork, WifiStatus } from '../shared/types'
|
||||||
|
|
||||||
|
export const connectToWifi = (ssid: string, password?: string): Promise<ConnectResult> =>
|
||||||
|
fetch('/api/wifi/connect', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ssid, password }),
|
||||||
|
}).then(r => r.json())
|
||||||
|
|
||||||
export const getLogDates = (name: string): Promise<string[]> =>
|
export const getLogDates = (name: string): Promise<string[]> =>
|
||||||
fetch(`/api/apps/${name}/logs/dates`).then(r => r.json())
|
fetch(`/api/apps/${name}/logs/dates`).then(r => r.json())
|
||||||
|
|
||||||
export const getLogsForDate = (name: string, date: string): Promise<string[]> =>
|
export const getLogsForDate = (name: string, date: string): Promise<string[]> =>
|
||||||
fetch(`/api/apps/${name}/logs?date=${date}`).then(r => r.json())
|
fetch(`/api/apps/${name}/logs?date=${date}`).then(r => r.json())
|
||||||
|
|
||||||
export const getWifiConfig = (): Promise<{ network: string, password: string }> =>
|
export const getWifiStatus = (): Promise<WifiStatus & { setupMode: boolean, url: string }> =>
|
||||||
fetch('/api/system/wifi').then(r => r.json())
|
fetch('/api/wifi/status').then(r => r.json())
|
||||||
|
|
||||||
export const saveWifiConfig = (config: { network: string, password: string }) =>
|
export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })
|
||||||
fetch('/api/system/wifi', {
|
|
||||||
method: 'PUT',
|
export const scanWifiNetworks = (): Promise<WifiNetwork[]> =>
|
||||||
headers: { 'Content-Type': 'application/json' },
|
fetch('/api/wifi/scan').then(r => r.json())
|
||||||
body: JSON.stringify(config),
|
|
||||||
}).then(r => r.json())
|
|
||||||
|
|
||||||
export const shareApp = (name: string) =>
|
export const shareApp = (name: string) =>
|
||||||
fetch(`/api/apps/${name}/tunnel`, { method: 'POST' })
|
fetch(`/api/apps/${name}/tunnel`, { method: 'POST' })
|
||||||
|
|
||||||
export const unshareApp = (name: string) =>
|
|
||||||
fetch(`/api/apps/${name}/tunnel`, { method: 'DELETE' })
|
|
||||||
|
|
||||||
export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })
|
|
||||||
|
|
||||||
export const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' })
|
export const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' })
|
||||||
|
|
||||||
export const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { method: 'POST' })
|
export const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { method: 'POST' })
|
||||||
|
|
||||||
|
export const unshareApp = (name: string) =>
|
||||||
|
fetch(`/api/apps/${name}/tunnel`, { method: 'DELETE' })
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,13 @@ 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 { openDeleteAppModal, openRenameAppModal } from '../modals'
|
import { openAppSelectorModal, openDeleteAppModal, openRenameAppModal } from '../modals'
|
||||||
import { apps, getSelectedTab, isNarrow, setMobileSidebar } from '../state'
|
import { apps, getSelectedTab, isNarrow } from '../state'
|
||||||
import {
|
import {
|
||||||
ActionBar,
|
ActionBar,
|
||||||
|
AppSelectorChevron,
|
||||||
Button,
|
Button,
|
||||||
ClickableAppName,
|
ClickableAppName,
|
||||||
HamburgerButton,
|
|
||||||
HamburgerLine,
|
|
||||||
HeaderActions,
|
HeaderActions,
|
||||||
InfoLabel,
|
InfoLabel,
|
||||||
InfoRow,
|
InfoRow,
|
||||||
|
|
@ -53,15 +52,14 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
|
||||||
<Main>
|
<Main>
|
||||||
<MainHeader>
|
<MainHeader>
|
||||||
<MainTitle>
|
<MainTitle>
|
||||||
{isNarrow && (
|
|
||||||
<HamburgerButton onClick={() => { setMobileSidebar(true); render() }} title="Show apps">
|
|
||||||
<HamburgerLine />
|
|
||||||
<HamburgerLine />
|
|
||||||
<HamburgerLine />
|
|
||||||
</HamburgerButton>
|
|
||||||
)}
|
|
||||||
<OpenEmojiPicker app={app} render={render}>{app.icon}</OpenEmojiPicker>
|
<OpenEmojiPicker app={app} render={render}>{app.icon}</OpenEmojiPicker>
|
||||||
|
|
||||||
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
|
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
|
||||||
|
{isNarrow && (
|
||||||
|
<AppSelectorChevron onClick={() => openAppSelectorModal(render)}>
|
||||||
|
▼
|
||||||
|
</AppSelectorChevron>
|
||||||
|
)}
|
||||||
</MainTitle>
|
</MainTitle>
|
||||||
<HeaderActions>
|
<HeaderActions>
|
||||||
{!app.tool && (
|
{!app.tool && (
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { CSSProperties } from 'hono/jsx'
|
||||||
import {
|
import {
|
||||||
apps,
|
apps,
|
||||||
selectedApp,
|
selectedApp,
|
||||||
|
setSelectedApp,
|
||||||
setSidebarSection,
|
setSidebarSection,
|
||||||
sidebarSection,
|
sidebarSection,
|
||||||
} from '../state'
|
} from '../state'
|
||||||
|
|
@ -16,13 +17,19 @@ 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, collapsed, large, switcherStyle, listStyle }: AppSelectorProps) {
|
export function AppSelector({ render, onSelect, onDashboard, collapsed, 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()
|
||||||
|
|
@ -36,18 +43,18 @@ export function AppSelector({ render, onSelect, collapsed, large, switcherStyle,
|
||||||
<>
|
<>
|
||||||
{!collapsed && toolApps.length > 0 && (
|
{!collapsed && toolApps.length > 0 && (
|
||||||
<SectionSwitcher style={switcherStyle}>
|
<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
|
Apps
|
||||||
</SectionTab>
|
</SectionTab>
|
||||||
<SectionTab active={sidebarSection === 'tools' ? true : undefined} large={large || undefined} onClick={() => switchSection('tools')}>
|
<SectionTab active={sidebarSection === 'tools' ? true : undefined} onClick={() => switchSection('tools')}>
|
||||||
Tools
|
Tools
|
||||||
</SectionTab>
|
</SectionTab>
|
||||||
</SectionSwitcher>
|
</SectionSwitcher>
|
||||||
)}
|
)}
|
||||||
<AppList style={listStyle}>
|
<AppList style={listStyle}>
|
||||||
{collapsed && (
|
{collapsed && onDashboard && (
|
||||||
<AppItem
|
<AppItem
|
||||||
href="/"
|
onClick={onDashboard}
|
||||||
selected={!selectedApp ? true : undefined}
|
selected={!selectedApp ? true : undefined}
|
||||||
style={{ justifyContent: 'center', padding: '10px 12px' }}
|
style={{ justifyContent: 'center', padding: '10px 12px' }}
|
||||||
title="Toes"
|
title="Toes"
|
||||||
|
|
@ -58,9 +65,7 @@ export function AppSelector({ render, onSelect, collapsed, large, switcherStyle,
|
||||||
{activeApps.map(app => (
|
{activeApps.map(app => (
|
||||||
<AppItem
|
<AppItem
|
||||||
key={app.name}
|
key={app.name}
|
||||||
href={`/app/${app.name}`}
|
onClick={() => selectApp(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}
|
||||||
|
|
@ -69,7 +74,7 @@ export function AppSelector({ render, onSelect, collapsed, large, switcherStyle,
|
||||||
<span style={{ fontSize: 18 }}>{app.icon}</span>
|
<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}
|
{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' }} />
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,13 @@
|
||||||
import { Styles } from '@because/forge'
|
import { Styles } from '@because/forge'
|
||||||
import { openNewAppModal } from '../modals'
|
import { apps, currentView, isNarrow, selectedApp, setupMode } from '../state'
|
||||||
import { apps, currentView, isNarrow, mobileSidebar, selectedApp, setMobileSidebar } from '../state'
|
import { Layout } from '../styles'
|
||||||
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} />
|
||||||
|
|
@ -53,7 +18,7 @@ export function Dashboard({ render }: { render: () => void }) {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<Styles />
|
<Styles />
|
||||||
{!isNarrow && <Sidebar render={render} />}
|
{!isNarrow && !setupMode && <Sidebar render={render} />}
|
||||||
<MainContent render={render} />
|
<MainContent render={render} />
|
||||||
<Modal />
|
<Modal />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,34 @@
|
||||||
import { useEffect } from 'hono/jsx'
|
import { useEffect } from 'hono/jsx'
|
||||||
import { navigate } from '../router'
|
import { openAppSelectorModal } from '../modals'
|
||||||
import { dashboardTab, isNarrow, setMobileSidebar } from '../state'
|
import { apps, isNarrow, setCurrentView, setSelectedApp } from '../state'
|
||||||
import {
|
import {
|
||||||
HamburgerButton,
|
AppSelectorChevron,
|
||||||
HamburgerLine,
|
|
||||||
DashboardContainer,
|
DashboardContainer,
|
||||||
DashboardHeader,
|
DashboardHeader,
|
||||||
DashboardTitle,
|
DashboardTitle,
|
||||||
SettingsGear,
|
SettingsGear,
|
||||||
Tab,
|
StatusDot,
|
||||||
TabBar,
|
StatusDotLink,
|
||||||
TabContent,
|
StatusDotsRow,
|
||||||
} from '../styles'
|
} from '../styles'
|
||||||
import { UnifiedLogs, initUnifiedLogs, scrollLogsToBottom } from './UnifiedLogs'
|
import { update } from '../update'
|
||||||
import { Urls } from './Urls'
|
import { UnifiedLogs, initUnifiedLogs } from './UnifiedLogs'
|
||||||
import { Vitals, initVitals } from './Vitals'
|
import { Vitals, initVitals } from './Vitals'
|
||||||
|
|
||||||
|
let activeTooltip: string | null = null
|
||||||
|
|
||||||
export function DashboardLanding({ render }: { render: () => void }) {
|
export function DashboardLanding({ render }: { render: () => void }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initUnifiedLogs()
|
initUnifiedLogs()
|
||||||
initVitals()
|
initVitals()
|
||||||
if (dashboardTab === 'logs') scrollLogsToBottom()
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const narrow = isNarrow || undefined
|
const narrow = isNarrow || undefined
|
||||||
|
|
||||||
const openSettings = () => {
|
const openSettings = () => {
|
||||||
navigate('/settings')
|
setSelectedApp(null)
|
||||||
}
|
setCurrentView('settings')
|
||||||
|
render()
|
||||||
const switchTab = (tab: typeof dashboardTab) => {
|
|
||||||
navigate(tab === 'urls' ? '/' : `/${tab}`)
|
|
||||||
if (tab === 'logs') scrollLogsToBottom()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -43,40 +40,43 @@ 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>
|
||||||
|
|
||||||
<TabBar centered>
|
<StatusDotsRow>
|
||||||
<Tab active={dashboardTab === 'urls' || undefined} onClick={() => switchTab('urls')}>URLs</Tab>
|
{[...apps.filter(a => !a.tool), ...apps.filter(a => a.tool)].map(app => (
|
||||||
<Tab active={dashboardTab === 'logs' || undefined} onClick={() => switchTab('logs')}>Logs</Tab>
|
<StatusDotLink
|
||||||
<Tab active={dashboardTab === 'metrics' || undefined} onClick={() => switchTab('metrics')}>Metrics</Tab>
|
key={app.name}
|
||||||
</TabBar>
|
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>
|
||||||
|
|
||||||
<TabContent active={dashboardTab === 'urls' || undefined}>
|
|
||||||
<Urls render={render} />
|
|
||||||
</TabContent>
|
|
||||||
|
|
||||||
<TabContent active={dashboardTab === 'logs' || undefined}>
|
|
||||||
<UnifiedLogs />
|
|
||||||
</TabContent>
|
|
||||||
|
|
||||||
<TabContent active={dashboardTab === 'metrics' || undefined}>
|
|
||||||
<Vitals />
|
<Vitals />
|
||||||
</TabContent>
|
|
||||||
|
<UnifiedLogs />
|
||||||
</DashboardContainer>
|
</DashboardContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import type { App } from '../../shared/types'
|
import type { App } from '../../shared/types'
|
||||||
import { navigate } from '../router'
|
import { apps, getSelectedTab, setSelectedTab } from '../state'
|
||||||
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'
|
||||||
|
|
||||||
|
|
@ -13,7 +12,8 @@ export function Nav({ app, render }: { app: App; render: () => void }) {
|
||||||
resetToolIframe(tab, app.name)
|
resetToolIframe(tab, app.name)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
navigate(tab === 'overview' ? `/app/${app.name}` : `/app/${app.name}/${tab}`)
|
setSelectedTab(app.name, tab)
|
||||||
|
render()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find all tools
|
// Find all tools
|
||||||
|
|
|
||||||
|
|
@ -1,92 +1,306 @@
|
||||||
import { useEffect, useState } from 'hono/jsx'
|
import { useEffect, useState } from 'hono/jsx'
|
||||||
import { getWifiConfig, saveWifiConfig } from '../api'
|
import { connectToWifi, getWifiStatus, scanWifiNetworks } from '../api'
|
||||||
import { navigate } from '../router'
|
import { setCurrentView, setupMode } from '../state'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
DashboardInstallCmd,
|
DashboardInstallCmd,
|
||||||
|
ErrorBox,
|
||||||
FormActions,
|
FormActions,
|
||||||
FormField,
|
FormField,
|
||||||
FormInput,
|
FormInput,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
HeaderActions,
|
HeaderActions,
|
||||||
|
InfoLabel,
|
||||||
|
InfoRow,
|
||||||
|
InfoValue,
|
||||||
Main,
|
Main,
|
||||||
MainContent,
|
MainContent,
|
||||||
MainHeader,
|
MainHeader,
|
||||||
MainTitle,
|
MainTitle,
|
||||||
|
NetworkItem,
|
||||||
|
NetworkListWrap,
|
||||||
|
NetworkMeta,
|
||||||
|
NetworkName,
|
||||||
Section,
|
Section,
|
||||||
SectionTitle,
|
SectionTitle,
|
||||||
|
SignalBarSegment,
|
||||||
|
SignalBarsWrap,
|
||||||
|
Spinner,
|
||||||
|
SpinnerWrap,
|
||||||
|
SuccessCheck,
|
||||||
|
WifiColumn,
|
||||||
} from '../styles'
|
} from '../styles'
|
||||||
|
import { theme } from '../themes'
|
||||||
|
import type { WifiNetwork } from '../../shared/types'
|
||||||
|
|
||||||
|
type WifiStep = 'status' | 'scanning' | 'networks' | 'password' | 'connecting' | 'success'
|
||||||
|
|
||||||
|
function signalBars(signal: number) {
|
||||||
|
const level = signal > 75 ? 4 : signal > 50 ? 3 : signal > 25 ? 2 : 1
|
||||||
|
return (
|
||||||
|
<SignalBarsWrap>
|
||||||
|
{[1, 2, 3, 4].map(i => (
|
||||||
|
<SignalBarSegment
|
||||||
|
key={i}
|
||||||
|
level={i <= level ? 'active' : 'inactive'}
|
||||||
|
style={{ height: 3 + i * 3 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SignalBarsWrap>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NetworkList({ networks, onSelect }: { networks: WifiNetwork[], onSelect: (net: WifiNetwork) => void }) {
|
||||||
|
if (networks.length === 0) {
|
||||||
|
return (
|
||||||
|
<SpinnerWrap>
|
||||||
|
No networks found. Try scanning again.
|
||||||
|
</SpinnerWrap>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NetworkListWrap>
|
||||||
|
{networks.map(net => (
|
||||||
|
<NetworkItem key={net.ssid} onClick={() => onSelect(net)}>
|
||||||
|
<NetworkName>{net.ssid}</NetworkName>
|
||||||
|
<NetworkMeta>
|
||||||
|
{net.security && net.security !== '' && net.security !== '--' && <span style={{ fontSize: 12 }}>🔒</span>}
|
||||||
|
{signalBars(net.signal)}
|
||||||
|
</NetworkMeta>
|
||||||
|
</NetworkItem>
|
||||||
|
))}
|
||||||
|
</NetworkListWrap>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsPage({ render }: { render: () => void }) {
|
export function SettingsPage({ render }: { render: () => void }) {
|
||||||
const [network, setNetwork] = useState('')
|
const [step, setStep] = useState<WifiStep>('status')
|
||||||
|
const [connected, setConnected] = useState(false)
|
||||||
|
const [currentSsid, setCurrentSsid] = useState('')
|
||||||
|
const [currentIp, setCurrentIp] = useState('')
|
||||||
|
const [networks, setNetworks] = useState<WifiNetwork[]>([])
|
||||||
|
const [selectedNetwork, setSelectedNetwork] = useState<WifiNetwork | null>(null)
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
const [error, setError] = useState('')
|
||||||
const [saved, setSaved] = useState(false)
|
const [successSsid, setSuccessSsid] = useState('')
|
||||||
|
const [successIp, setSuccessIp] = useState('')
|
||||||
|
const [serverUrl, setServerUrl] = useState('')
|
||||||
|
|
||||||
|
const fetchStatus = () => {
|
||||||
|
getWifiStatus().then(status => {
|
||||||
|
setConnected(status.connected)
|
||||||
|
setCurrentSsid(status.ssid)
|
||||||
|
setCurrentIp(status.ip)
|
||||||
|
if (status.url) setServerUrl(status.url)
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getWifiConfig().then(config => {
|
fetchStatus()
|
||||||
setNetwork(config.network)
|
if (setupMode) doScan()
|
||||||
setPassword(config.password)
|
|
||||||
})
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
navigate('/')
|
setCurrentView('dashboard')
|
||||||
|
render()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async (e: Event) => {
|
const doScan = () => {
|
||||||
e.preventDefault()
|
setStep('scanning')
|
||||||
setSaving(true)
|
setError('')
|
||||||
setSaved(false)
|
scanWifiNetworks()
|
||||||
await saveWifiConfig({ network, password })
|
.then(nets => {
|
||||||
setSaving(false)
|
setNetworks(nets)
|
||||||
setSaved(true)
|
setStep('networks')
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError('Failed to scan networks')
|
||||||
|
setStep('networks')
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSelectNetwork = (net: WifiNetwork) => {
|
||||||
|
setSelectedNetwork(net)
|
||||||
|
setPassword('')
|
||||||
|
setError('')
|
||||||
|
const needsPassword = net.security && net.security !== '' && net.security !== '--'
|
||||||
|
if (needsPassword) {
|
||||||
|
setStep('password')
|
||||||
|
} else {
|
||||||
|
doConnect(net.ssid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const doConnect = (ssid: string, pw?: string) => {
|
||||||
|
setStep('connecting')
|
||||||
|
setError('')
|
||||||
|
connectToWifi(ssid, pw)
|
||||||
|
.then(result => {
|
||||||
|
if (result.ok) {
|
||||||
|
setSuccessSsid(result.ssid || ssid)
|
||||||
|
setSuccessIp(result.ip || '')
|
||||||
|
setStep('success')
|
||||||
|
fetchStatus()
|
||||||
|
} else {
|
||||||
|
setError(result.error || 'Connection failed. Check your password and try again.')
|
||||||
|
setStep('password')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError('Connection failed. Please try again.')
|
||||||
|
setStep('password')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConnect = (e: Event) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!selectedNetwork) return
|
||||||
|
doConnect(selectedNetwork.ssid, password || undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = setupMode ? 'WiFi Setup' : 'Settings'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Main>
|
<Main>
|
||||||
<MainHeader centered>
|
<MainHeader centered>
|
||||||
<MainTitle>Settings</MainTitle>
|
<MainTitle>{title}</MainTitle>
|
||||||
|
{!setupMode && (
|
||||||
<HeaderActions>
|
<HeaderActions>
|
||||||
<Button onClick={goBack}>Back</Button>
|
<Button onClick={goBack}>Back</Button>
|
||||||
</HeaderActions>
|
</HeaderActions>
|
||||||
|
)}
|
||||||
</MainHeader>
|
</MainHeader>
|
||||||
<MainContent centered>
|
<MainContent centered>
|
||||||
<Section>
|
<Section>
|
||||||
<SectionTitle>WiFi</SectionTitle>
|
<SectionTitle>WiFi</SectionTitle>
|
||||||
<form onSubmit={handleSave} style={{ display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 400 }}>
|
|
||||||
|
{/* Status display */}
|
||||||
|
{step === 'status' && (
|
||||||
|
<WifiColumn>
|
||||||
|
<div>
|
||||||
|
<InfoRow>
|
||||||
|
<InfoLabel>Status</InfoLabel>
|
||||||
|
<InfoValue style={{ color: connected ? theme('colors-statusRunning') : theme('colors-error'), fontWeight: 500 }}>
|
||||||
|
{connected ? 'Connected' : 'Disconnected'}
|
||||||
|
</InfoValue>
|
||||||
|
</InfoRow>
|
||||||
|
{connected && currentSsid && (
|
||||||
|
<InfoRow>
|
||||||
|
<InfoLabel>Network</InfoLabel>
|
||||||
|
<InfoValue>{currentSsid}</InfoValue>
|
||||||
|
</InfoRow>
|
||||||
|
)}
|
||||||
|
{connected && currentIp && (
|
||||||
|
<InfoRow>
|
||||||
|
<InfoLabel>IP</InfoLabel>
|
||||||
|
<InfoValue style={{ fontFamily: theme('fonts-mono') }}>{currentIp}</InfoValue>
|
||||||
|
</InfoRow>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<FormActions>
|
||||||
|
<Button onClick={doScan}>Scan Networks</Button>
|
||||||
|
</FormActions>
|
||||||
|
</WifiColumn>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scanning spinner */}
|
||||||
|
{step === 'scanning' && (
|
||||||
|
<SpinnerWrap>
|
||||||
|
<Spinner />
|
||||||
|
<p style={{ color: theme('colors-textMuted') }}>Scanning for networks...</p>
|
||||||
|
</SpinnerWrap>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Network list */}
|
||||||
|
{step === 'networks' && (
|
||||||
|
<WifiColumn style={{ gap: 12 }}>
|
||||||
|
<NetworkList networks={networks} onSelect={handleSelectNetwork} />
|
||||||
|
{error && <ErrorBox>{error}</ErrorBox>}
|
||||||
|
<FormActions>
|
||||||
|
{!setupMode && <Button onClick={() => setStep('status')}>Back</Button>}
|
||||||
|
<Button onClick={doScan}>Rescan</Button>
|
||||||
|
</FormActions>
|
||||||
|
</WifiColumn>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Password entry */}
|
||||||
|
{step === 'password' && (
|
||||||
|
<WifiColumn>
|
||||||
<FormField>
|
<FormField>
|
||||||
<FormLabel>Network</FormLabel>
|
<FormLabel>Network</FormLabel>
|
||||||
<FormInput
|
<div style={{ fontWeight: 500, fontSize: 16 }}>{selectedNetwork?.ssid}</div>
|
||||||
type="text"
|
|
||||||
value={network}
|
|
||||||
onInput={(e: Event) => setNetwork((e.target as HTMLInputElement).value)}
|
|
||||||
placeholder="SSID"
|
|
||||||
/>
|
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<form onSubmit={handleConnect} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
<FormField>
|
<FormField>
|
||||||
<FormLabel>Password</FormLabel>
|
<FormLabel>Password</FormLabel>
|
||||||
<FormInput
|
<FormInput
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onInput={(e: Event) => setPassword((e.target as HTMLInputElement).value)}
|
onInput={(e: Event) => setPassword((e.target as HTMLInputElement).value)}
|
||||||
placeholder="Password"
|
placeholder="Enter WiFi password"
|
||||||
|
autofocus
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
{error && <ErrorBox>{error}</ErrorBox>}
|
||||||
<FormActions>
|
<FormActions>
|
||||||
{saved && <span style={{ fontSize: 13, color: '#888', alignSelf: 'center' }}>Saved</span>}
|
<Button onClick={() => { setError(''); setStep('networks') }}>Back</Button>
|
||||||
<Button variant="primary" type="submit" disabled={saving}>
|
<Button variant="primary" type="submit">Connect</Button>
|
||||||
{saving ? 'Saving...' : 'Save'}
|
|
||||||
</Button>
|
|
||||||
</FormActions>
|
</FormActions>
|
||||||
</form>
|
</form>
|
||||||
|
</WifiColumn>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Connecting spinner */}
|
||||||
|
{step === 'connecting' && (
|
||||||
|
<SpinnerWrap>
|
||||||
|
<Spinner />
|
||||||
|
<p style={{ color: theme('colors-textMuted') }}>Connecting to <strong>{selectedNetwork?.ssid}</strong>...</p>
|
||||||
|
</SpinnerWrap>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success */}
|
||||||
|
{step === 'success' && (
|
||||||
|
<WifiColumn style={{ textAlign: 'center' }}>
|
||||||
|
<SuccessCheck>✓</SuccessCheck>
|
||||||
|
<h3 style={{ fontSize: 20, fontWeight: 600, marginBottom: 8 }}>Connected!</h3>
|
||||||
|
<p style={{ color: theme('colors-textMuted'), marginBottom: 16 }}>
|
||||||
|
Connected to <strong>{successSsid}</strong>
|
||||||
|
{successIp && <span> ({successIp})</span>}
|
||||||
|
</p>
|
||||||
|
{setupMode ? (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 20,
|
||||||
|
padding: 16,
|
||||||
|
background: 'var(--colors-bgSubtle)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: '1px solid var(--colors-border)',
|
||||||
|
}}>
|
||||||
|
<p style={{ marginBottom: 8 }}>Reconnect your device to <strong>{successSsid}</strong> and visit:</p>
|
||||||
|
<a
|
||||||
|
href={serverUrl}
|
||||||
|
style={{ color: theme('colors-statusRunning'), fontSize: 18, fontWeight: 600, textDecoration: 'none' }}
|
||||||
|
>
|
||||||
|
{serverUrl}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FormActions>
|
||||||
|
<Button onClick={() => { fetchStatus(); setStep('status') }}>Done</Button>
|
||||||
|
</FormActions>
|
||||||
|
)}
|
||||||
|
</WifiColumn>
|
||||||
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{!setupMode && (
|
||||||
<Section>
|
<Section>
|
||||||
<SectionTitle>Install CLI</SectionTitle>
|
<SectionTitle>Install CLI</SectionTitle>
|
||||||
<DashboardInstallCmd>
|
<DashboardInstallCmd>
|
||||||
curl -fsSL {location.origin}/install | bash
|
curl -fsSL {location.origin}/install | bash
|
||||||
</DashboardInstallCmd>
|
</DashboardInstallCmd>
|
||||||
</Section>
|
</Section>
|
||||||
|
)}
|
||||||
</MainContent>
|
</MainContent>
|
||||||
</Main>
|
</Main>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { openNewAppModal } from '../modals'
|
import { openNewAppModal } from '../modals'
|
||||||
import {
|
import {
|
||||||
|
setCurrentView,
|
||||||
|
setSelectedApp,
|
||||||
setSidebarCollapsed,
|
setSidebarCollapsed,
|
||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
} from '../state'
|
} from '../state'
|
||||||
|
|
@ -15,6 +17,12 @@ 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()
|
||||||
|
|
@ -32,7 +40,7 @@ export function Sidebar({ render }: { render: () => void }) {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Logo>
|
<Logo>
|
||||||
<LogoLink href="/" title="Go to dashboard">
|
<LogoLink onClick={goToDashboard} title="Go to dashboard">
|
||||||
🐾 Toes
|
🐾 Toes
|
||||||
</LogoLink>
|
</LogoLink>
|
||||||
<HamburgerButton onClick={toggleSidebar} title="Hide sidebar">
|
<HamburgerButton onClick={toggleSidebar} title="Hide sidebar">
|
||||||
|
|
@ -42,7 +50,7 @@ export function Sidebar({ render }: { render: () => void }) {
|
||||||
</HamburgerButton>
|
</HamburgerButton>
|
||||||
</Logo>
|
</Logo>
|
||||||
)}
|
)}
|
||||||
<AppSelector render={render} collapsed={sidebarCollapsed} />
|
<AppSelector render={render} collapsed={sidebarCollapsed} onDashboard={goToDashboard} />
|
||||||
{!sidebarCollapsed && (
|
{!sidebarCollapsed && (
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton>
|
<NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton>
|
||||||
|
|
|
||||||
|
|
@ -105,13 +105,6 @@ function renderLogs() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function scrollLogsToBottom() {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const el = document.getElementById('unified-logs-body')
|
|
||||||
if (el) el.scrollTop = el.scrollHeight
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initUnifiedLogs() {
|
export function initUnifiedLogs() {
|
||||||
if (_source) return
|
if (_source) return
|
||||||
_source = new EventSource('/api/system/logs/stream')
|
_source = new EventSource('/api/system/logs/stream')
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
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,8 +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 { getWifiStatus } from './api'
|
||||||
|
import { apps, getSelectedTab, selectedApp, setApps, setCurrentView, setIsNarrow, setSelectedApp, setSetupMode } 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'
|
||||||
|
|
||||||
|
|
@ -42,16 +42,22 @@ narrowQuery.addEventListener('change', e => {
|
||||||
render()
|
render()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Initialize router (sets initial state from URL and renders)
|
// Check WiFi setup mode on load
|
||||||
initRouter(render)
|
getWifiStatus().then(status => {
|
||||||
|
if (status.setupMode) {
|
||||||
|
setSetupMode(true)
|
||||||
|
setCurrentView('settings')
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
|
||||||
// SSE connection
|
// SSE connection for app state
|
||||||
const events = new EventSource('/api/apps/stream')
|
const events = new EventSource('/api/apps/stream')
|
||||||
events.onmessage = e => {
|
events.onmessage = e => {
|
||||||
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)) {
|
||||||
navigate('/')
|
setSelectedApp(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
render()
|
render()
|
||||||
|
|
|
||||||
17
src/client/modals/AppSelector.tsx
Normal file
17
src/client/modals/AppSelector.tsx
Normal 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' }}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
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 { navigate } from '../router'
|
import { selectedApp, setSelectedApp } from '../state'
|
||||||
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'
|
||||||
|
|
||||||
|
|
@ -33,11 +32,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 navigate to dashboard
|
// Success - close modal and clear selection
|
||||||
closeModal()
|
|
||||||
if (selectedApp === deleteAppTarget.name) {
|
if (selectedApp === deleteAppTarget.name) {
|
||||||
navigate('/')
|
setSelectedApp(null)
|
||||||
}
|
}
|
||||||
|
closeModal()
|
||||||
} 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
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { closeModal, openModal, rerenderModal } from '../components/modal'
|
import { closeModal, openModal, rerenderModal } from '../components/modal'
|
||||||
import { navigate } from '../router'
|
import { apps, setSelectedApp } from '../state'
|
||||||
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'
|
||||||
|
|
@ -49,9 +48,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 navigate to the new app
|
// Success - close modal and select 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
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
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 { navigate } from '../router'
|
import { apps, setSelectedApp } from '../state'
|
||||||
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 = ''
|
||||||
|
|
@ -59,9 +58,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 - close modal and navigate to renamed app
|
// Success - update selection and close modal
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
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'
|
||||||
|
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
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,43 +1,37 @@
|
||||||
import type { App } from '../shared/types'
|
import type { App } from '../shared/types'
|
||||||
|
|
||||||
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 = null
|
export let selectedApp: string | null = localStorage.getItem('selectedApp')
|
||||||
export let sidebarCollapsed: boolean = localStorage.getItem('sidebarCollapsed') === 'true'
|
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'
|
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[] = []
|
||||||
|
export let setupMode: boolean = false
|
||||||
|
|
||||||
// Tab state
|
// Tab state
|
||||||
export let appTabs: Record<string, string> = {}
|
export let appTabs: Record<string, string> = JSON.parse(localStorage.getItem('appTabs') || '{}')
|
||||||
|
|
||||||
// State setters
|
// State setters
|
||||||
export function setDashboardTab(tab: DashboardTab) {
|
|
||||||
dashboardTab = tab
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setCurrentView(view: 'dashboard' | 'settings') {
|
export function setCurrentView(view: 'dashboard' | 'settings') {
|
||||||
currentView = view
|
currentView = view
|
||||||
}
|
}
|
||||||
|
|
||||||
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))
|
||||||
|
|
@ -52,10 +46,15 @@ export function setApps(newApps: App[]) {
|
||||||
apps = newApps
|
apps = newApps
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setSetupMode(mode: boolean) {
|
||||||
|
setupMode = mode
|
||||||
|
}
|
||||||
|
|
||||||
export const getSelectedTab = (appName: string | null) =>
|
export const getSelectedTab = (appName: string | null) =>
|
||||||
appName ? appTabs[appName] || 'overview' : 'overview'
|
appName ? appTabs[appName] || 'overview' : 'overview'
|
||||||
|
|
||||||
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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ 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',
|
||||||
|
|
@ -201,86 +202,3 @@ export const LogStatus = define('LogStatus', {
|
||||||
warning: { color: '#f59e0b' },
|
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,12 +15,6 @@ export {
|
||||||
LogStatus,
|
LogStatus,
|
||||||
LogText,
|
LogText,
|
||||||
LogTimestamp,
|
LogTimestamp,
|
||||||
Tile,
|
|
||||||
TileGrid,
|
|
||||||
TileIcon,
|
|
||||||
TileName,
|
|
||||||
TilePort,
|
|
||||||
TileStatus,
|
|
||||||
VitalCard,
|
VitalCard,
|
||||||
VitalLabel,
|
VitalLabel,
|
||||||
VitalsSection,
|
VitalsSection,
|
||||||
|
|
@ -29,6 +23,7 @@ export { Form, FormActions, FormCheckbox, FormCheckboxField, FormCheckboxLabel,
|
||||||
export {
|
export {
|
||||||
AppItem,
|
AppItem,
|
||||||
AppList,
|
AppList,
|
||||||
|
AppSelectorChevron,
|
||||||
ClickableAppName,
|
ClickableAppName,
|
||||||
DashboardContainer,
|
DashboardContainer,
|
||||||
DashboardHeader,
|
DashboardHeader,
|
||||||
|
|
@ -73,3 +68,16 @@ export {
|
||||||
TabBar,
|
TabBar,
|
||||||
TabContent,
|
TabContent,
|
||||||
} from './misc'
|
} from './misc'
|
||||||
|
export {
|
||||||
|
ErrorBox,
|
||||||
|
NetworkItem,
|
||||||
|
NetworkListWrap,
|
||||||
|
NetworkMeta,
|
||||||
|
NetworkName,
|
||||||
|
SignalBarSegment,
|
||||||
|
SignalBarsWrap,
|
||||||
|
Spinner,
|
||||||
|
SpinnerWrap,
|
||||||
|
SuccessCheck,
|
||||||
|
WifiColumn,
|
||||||
|
} from './wifi'
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,7 @@ 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',
|
||||||
|
|
@ -106,7 +103,6 @@ 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' },
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -116,7 +112,6 @@ 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',
|
||||||
|
|
@ -130,7 +125,6 @@ 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 },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -172,6 +166,20 @@ 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'),
|
||||||
|
|
@ -225,7 +233,6 @@ 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', {
|
||||||
|
|
|
||||||
|
|
@ -127,12 +127,6 @@ export const TabBar = define('TabBar', {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: 24,
|
gap: 24,
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
variants: {
|
|
||||||
centered: {
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginBottom: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const Tab = define('Tab', {
|
export const Tab = define('Tab', {
|
||||||
|
|
@ -165,7 +159,6 @@ export const TabContent = define('TabContent', {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
width: '100%',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
104
src/client/styles/wifi.ts
Normal file
104
src/client/styles/wifi.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { define } from '@because/forge'
|
||||||
|
import { theme } from '../themes'
|
||||||
|
|
||||||
|
export const ErrorBox = define('ErrorBox', {
|
||||||
|
padding: '10px 12px',
|
||||||
|
background: theme('colors-errorBg'),
|
||||||
|
border: `1px solid ${theme('colors-errorBorder')}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
color: theme('colors-error'),
|
||||||
|
fontSize: 14,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const NetworkItem = define('NetworkItem', {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '12px 16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
||||||
|
selectors: {
|
||||||
|
'&:hover': { background: theme('colors-bgHover') },
|
||||||
|
'&:last-child': { borderBottom: 'none' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const NetworkListWrap = define('NetworkListWrap', {
|
||||||
|
maxHeight: 320,
|
||||||
|
overflowY: 'auto',
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const NetworkMeta = define('NetworkMeta', {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
fontSize: 13,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const NetworkName = define('NetworkName', {
|
||||||
|
fontWeight: 500,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SignalBarSegment = define('SignalBarSegment', {
|
||||||
|
width: 3,
|
||||||
|
borderRadius: 1,
|
||||||
|
variants: {
|
||||||
|
level: {
|
||||||
|
active: { background: theme('colors-statusRunning') },
|
||||||
|
inactive: { background: theme('colors-border') },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SignalBarsWrap = define('SignalBarsWrap', {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: 2,
|
||||||
|
height: 14,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Spinner = define('Spinner', {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
border: `3px solid ${theme('colors-border')}`,
|
||||||
|
borderTopColor: theme('colors-statusRunning'),
|
||||||
|
borderRadius: '50%',
|
||||||
|
margin: '0 auto 16px',
|
||||||
|
animation: 'spin 0.8s linear infinite',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SpinnerWrap = define('SpinnerWrap', {
|
||||||
|
padding: '32px 0',
|
||||||
|
textAlign: 'center',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SuccessCheck = define('SuccessCheck', {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
background: theme('colors-statusRunning'),
|
||||||
|
color: theme('colors-bg'),
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
margin: '0 auto 12px',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const WifiColumn = define('WifiColumn', {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 16,
|
||||||
|
maxWidth: 400,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Inject spin keyframes once
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
const style = document.createElement('style')
|
||||||
|
style.textContent = '@keyframes spin { to { transform: rotate(360deg); } }'
|
||||||
|
document.head.appendChild(style)
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,8 @@ export default {
|
||||||
'colors-dangerBorder': '#7f1d1d',
|
'colors-dangerBorder': '#7f1d1d',
|
||||||
'colors-dangerText': '#fca5a5',
|
'colors-dangerText': '#fca5a5',
|
||||||
'colors-error': '#f87171',
|
'colors-error': '#f87171',
|
||||||
|
'colors-errorBg': '#2a1515',
|
||||||
|
'colors-errorBorder': '#4a2020',
|
||||||
|
|
||||||
'colors-statusRunning': '#22c55e',
|
'colors-statusRunning': '#22c55e',
|
||||||
'colors-statusStopped': '#666',
|
'colors-statusStopped': '#666',
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ export default {
|
||||||
'colors-dangerBorder': '#fecaca',
|
'colors-dangerBorder': '#fecaca',
|
||||||
'colors-dangerText': '#dc2626',
|
'colors-dangerText': '#dc2626',
|
||||||
'colors-error': '#dc2626',
|
'colors-error': '#dc2626',
|
||||||
|
'colors-errorBg': '#fef2f2',
|
||||||
|
'colors-errorBorder': '#fecaca',
|
||||||
|
|
||||||
'colors-statusRunning': '#16a34a',
|
'colors-statusRunning': '#16a34a',
|
||||||
'colors-statusStopped': '#9ca3af',
|
'colors-statusStopped': '#9ca3af',
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
import { hostname } from 'os'
|
|
||||||
|
|
||||||
export const HOSTNAME = hostname()
|
|
||||||
export const LOCAL_HOST = `${HOSTNAME}.local`
|
|
||||||
|
|
@ -8,11 +8,7 @@ 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))
|
||||||
const heartbeat = setInterval(() => send('', 'ping'), 60_000)
|
return unsub
|
||||||
return () => {
|
|
||||||
clearInterval(heartbeat)
|
|
||||||
unsub()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { allApps, APPS_DIR, onChange, TOES_DIR } from '$apps'
|
import { allApps, APPS_DIR, onChange } from '$apps'
|
||||||
import { onHostLog } from '../tui'
|
import { onHostLog } from '../tui'
|
||||||
import { Hype } from '@because/hype'
|
import { Hype } from '@because/hype'
|
||||||
import { cpus, freemem, platform, totalmem } from 'os'
|
import { cpus, freemem, platform, totalmem } from 'os'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { existsSync, mkdirSync, readFileSync, statfsSync, writeFileSync } from 'fs'
|
import { readFileSync, statfsSync } from 'fs'
|
||||||
|
|
||||||
export interface AppMetrics {
|
export interface AppMetrics {
|
||||||
cpu: number
|
cpu: number
|
||||||
|
|
@ -18,11 +18,6 @@ export interface SystemMetrics {
|
||||||
apps: Record<string, AppMetrics>
|
apps: Record<string, AppMetrics>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WifiConfig {
|
|
||||||
network: string
|
|
||||||
password: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UnifiedLogLine {
|
export interface UnifiedLogLine {
|
||||||
time: number
|
time: number
|
||||||
app: string
|
app: string
|
||||||
|
|
@ -190,38 +185,6 @@ router.sse('/metrics/stream', (send) => {
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
})
|
})
|
||||||
|
|
||||||
// WiFi config
|
|
||||||
const CONFIG_DIR = join(TOES_DIR, 'config')
|
|
||||||
const WIFI_PATH = join(CONFIG_DIR, 'wifi.json')
|
|
||||||
|
|
||||||
function readWifiConfig(): WifiConfig {
|
|
||||||
try {
|
|
||||||
if (existsSync(WIFI_PATH)) {
|
|
||||||
return JSON.parse(readFileSync(WIFI_PATH, 'utf-8'))
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
return { network: '', password: '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeWifiConfig(config: WifiConfig) {
|
|
||||||
if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true })
|
|
||||||
writeFileSync(WIFI_PATH, JSON.stringify(config, null, 2))
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/wifi', c => {
|
|
||||||
return c.json(readWifiConfig())
|
|
||||||
})
|
|
||||||
|
|
||||||
router.put('/wifi', async c => {
|
|
||||||
const body = await c.req.json<WifiConfig>()
|
|
||||||
const config: WifiConfig = {
|
|
||||||
network: String(body.network ?? ''),
|
|
||||||
password: String(body.password ?? ''),
|
|
||||||
}
|
|
||||||
writeWifiConfig(config)
|
|
||||||
return c.json(config)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Get recent unified logs
|
// Get recent unified logs
|
||||||
router.get('/logs', c => {
|
router.get('/logs', c => {
|
||||||
const tail = c.req.query('tail')
|
const tail = c.req.query('tail')
|
||||||
|
|
|
||||||
31
src/server/api/wifi.ts
Normal file
31
src/server/api/wifi.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { TOES_URL } from '$apps'
|
||||||
|
import { Hype } from '@because/hype'
|
||||||
|
import { connectToWifi, getWifiStatus, isSetupMode, scanNetworks } from '../wifi'
|
||||||
|
|
||||||
|
const router = Hype.router()
|
||||||
|
|
||||||
|
// GET /api/wifi/status - current WiFi state + setup mode flag
|
||||||
|
router.get('/status', async c => {
|
||||||
|
const status = await getWifiStatus()
|
||||||
|
return c.json({ ...status, setupMode: isSetupMode(), url: TOES_URL })
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /api/wifi/scan - list available networks
|
||||||
|
router.get('/scan', async c => {
|
||||||
|
const networks = await scanNetworks()
|
||||||
|
return c.json(networks)
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/wifi/connect - submit WiFi credentials
|
||||||
|
router.post('/connect', async c => {
|
||||||
|
const { ssid, password } = await c.req.json<{ ssid: string, password?: string }>()
|
||||||
|
|
||||||
|
if (!ssid) {
|
||||||
|
return c.json({ ok: false, error: 'SSID is required' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await connectToWifi(ssid, password)
|
||||||
|
return c.json(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
|
|
@ -4,7 +4,7 @@ import type { Subprocess } from 'bun'
|
||||||
import { DEFAULT_EMOJI } from '@types'
|
import { DEFAULT_EMOJI } from '@types'
|
||||||
import { buildAppUrl, toSubdomain } from '@urls'
|
import { buildAppUrl, toSubdomain } from '@urls'
|
||||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from 'fs'
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from 'fs'
|
||||||
import { LOCAL_HOST } from '%config'
|
import { hostname } from 'os'
|
||||||
import { join, resolve } from 'path'
|
import { join, resolve } from 'path'
|
||||||
import { loadAppEnv } from '../tools/env'
|
import { loadAppEnv } from '../tools/env'
|
||||||
import { publishApp, unpublishAll, unpublishApp } from './mdns'
|
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 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')
|
export const TOES_DIR = process.env.TOES_DIR ?? join(process.env.DATA_DIR ?? '.', 'toes')
|
||||||
|
|
||||||
const defaultHost = process.env.NODE_ENV === 'production' ? LOCAL_HOST : 'localhost'
|
const defaultHost = process.env.NODE_ENV === 'production' ? `${hostname()}.local` : 'localhost'
|
||||||
export const TOES_URL = process.env.TOES_URL ?? `http://${defaultHost}:${process.env.PORT || 3000}`
|
export const TOES_URL = process.env.TOES_URL ?? `http://${defaultHost}:${process.env.PORT || 3000}`
|
||||||
|
|
||||||
const HEALTH_CHECK_FAILURES_BEFORE_RESTART = 3
|
const HEALTH_CHECK_FAILURES_BEFORE_RESTART = 3
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,11 @@ import appsRouter from './api/apps'
|
||||||
import eventsRouter from './api/events'
|
import eventsRouter from './api/events'
|
||||||
import syncRouter from './api/sync'
|
import syncRouter from './api/sync'
|
||||||
import systemRouter from './api/system'
|
import systemRouter from './api/system'
|
||||||
|
import wifiRouter from './api/wifi'
|
||||||
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 { initWifi, isSetupMode } from './wifi'
|
||||||
import type { Server } from 'bun'
|
import type { Server } from 'bun'
|
||||||
import type { WsData } from './proxy'
|
import type { WsData } from './proxy'
|
||||||
|
|
||||||
|
|
@ -17,6 +18,7 @@ app.route('/api/apps', appsRouter)
|
||||||
app.route('/api/events', eventsRouter)
|
app.route('/api/events', eventsRouter)
|
||||||
app.route('/api/sync', syncRouter)
|
app.route('/api/sync', syncRouter)
|
||||||
app.route('/api/system', systemRouter)
|
app.route('/api/system', systemRouter)
|
||||||
|
app.route('/api/wifi', wifiRouter)
|
||||||
|
|
||||||
// Tool URLs: /tool/code?app=todo&file=README.md -> redirect to tool subdomain
|
// Tool URLs: /tool/code?app=todo&file=README.md -> redirect to tool subdomain
|
||||||
app.get('/tool/:tool', c => {
|
app.get('/tool/:tool', c => {
|
||||||
|
|
@ -90,7 +92,7 @@ async function buildBinary(name: string): Promise<boolean> {
|
||||||
return promise
|
return promise
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install script: curl -fsSL http://<hostname>.local/install | bash
|
// Install script: curl -fsSL http://toes.local/install | bash
|
||||||
app.get('/install', c => {
|
app.get('/install', c => {
|
||||||
if (!TOES_URL) return c.text('TOES_URL is not configured', 500)
|
if (!TOES_URL) return c.text('TOES_URL is not configured', 500)
|
||||||
const script = INSTALL_SCRIPT.replace('__TOES_URL__', TOES_URL)
|
const script = INSTALL_SCRIPT.replace('__TOES_URL__', TOES_URL)
|
||||||
|
|
@ -114,14 +116,20 @@ app.get('/dist/:file', async c => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// SPA routes — serve the shell for all client-side paths
|
// Captive portal detection paths (iOS, Android, Windows, macOS)
|
||||||
app.get('/app/:name/:tab', c => c.html(<Shell />))
|
const CAPTIVE_PORTAL_PATHS = new Set([
|
||||||
app.get('/app/:name', c => c.html(<Shell />))
|
'/hotspot-detect.html',
|
||||||
app.get('/logs', c => c.html(<Shell />))
|
'/library/test/success.html',
|
||||||
app.get('/metrics', c => c.html(<Shell />))
|
'/generate_204',
|
||||||
app.get('/settings', c => c.html(<Shell />))
|
'/gen_204',
|
||||||
|
'/connecttest.txt',
|
||||||
|
'/ncsi.txt',
|
||||||
|
'/canonical.html',
|
||||||
|
'/success.txt',
|
||||||
|
])
|
||||||
|
|
||||||
cleanupStalePublishers()
|
cleanupStalePublishers()
|
||||||
|
await initWifi()
|
||||||
await initApps()
|
await initApps()
|
||||||
|
|
||||||
const defaults = app.defaults
|
const defaults = app.defaults
|
||||||
|
|
@ -130,7 +138,32 @@ export default {
|
||||||
...defaults,
|
...defaults,
|
||||||
maxRequestBodySize: 1024 * 1024 * 50, // 50MB
|
maxRequestBodySize: 1024 * 1024 * 50, // 50MB
|
||||||
fetch(req: Request, server: Server<WsData>) {
|
fetch(req: Request, server: Server<WsData>) {
|
||||||
|
const url = new URL(req.url)
|
||||||
const subdomain = extractSubdomain(req.headers.get('host') ?? '')
|
const subdomain = extractSubdomain(req.headers.get('host') ?? '')
|
||||||
|
|
||||||
|
// In setup mode, restrict access to WiFi API + client assets
|
||||||
|
if (isSetupMode() && !subdomain) {
|
||||||
|
const path = url.pathname
|
||||||
|
|
||||||
|
// Allow through: WiFi API, app stream (for SSE), client assets, root page
|
||||||
|
const allowed =
|
||||||
|
path.startsWith('/api/wifi') ||
|
||||||
|
path === '/api/apps/stream' ||
|
||||||
|
path.startsWith('/client/') ||
|
||||||
|
path === '/' ||
|
||||||
|
path === ''
|
||||||
|
|
||||||
|
// Captive portal probes → redirect to root
|
||||||
|
if (CAPTIVE_PORTAL_PATHS.has(path)) {
|
||||||
|
return Response.redirect('/', 302)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else → redirect to root
|
||||||
|
if (!allowed) {
|
||||||
|
return Response.redirect('/', 302)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (subdomain) {
|
if (subdomain) {
|
||||||
if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
||||||
return proxyWebSocket(subdomain, req, server)
|
return proxyWebSocket(subdomain, req, server)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import type { Subprocess } from 'bun'
|
import type { Subprocess } from 'bun'
|
||||||
import { toSubdomain } from '@urls'
|
import { toSubdomain } from '@urls'
|
||||||
import { LOCAL_HOST } from '%config'
|
import { hostname, networkInterfaces } from 'os'
|
||||||
import { networkInterfaces } from 'os'
|
|
||||||
import { hostLog } from './tui'
|
import { hostLog } from './tui'
|
||||||
|
|
||||||
const _publishers = new Map<string, Subprocess>()
|
const _publishers = new Map<string, Subprocess>()
|
||||||
|
|
||||||
|
const HOST_DOMAIN = `${hostname()}.local`
|
||||||
const isEnabled = process.env.NODE_ENV === 'production' && process.platform === 'linux'
|
const isEnabled = process.env.NODE_ENV === 'production' && process.platform === 'linux'
|
||||||
|
|
||||||
function getLocalIp(): string | null {
|
function getLocalIp(): string | null {
|
||||||
|
|
@ -25,7 +25,8 @@ export function cleanupStalePublishers() {
|
||||||
if (!isEnabled) return
|
if (!isEnabled) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = Bun.spawnSync(['pkill', '-f', `avahi-publish.*${LOCAL_HOST}`])
|
const pattern = HOST_DOMAIN.replace(/\./g, '\\.')
|
||||||
|
const result = Bun.spawnSync(['pkill', '-f', `avahi-publish.*${pattern}`])
|
||||||
if (result.exitCode === 0) {
|
if (result.exitCode === 0) {
|
||||||
hostLog('mDNS: cleaned up stale avahi-publish processes')
|
hostLog('mDNS: cleaned up stale avahi-publish processes')
|
||||||
}
|
}
|
||||||
|
|
@ -42,22 +43,22 @@ export function publishApp(name: string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = `${toSubdomain(name)}.${LOCAL_HOST}`
|
const hostname = `${toSubdomain(name)}.${HOST_DOMAIN}`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn(['avahi-publish', '-a', host, '-R', ip], {
|
const proc = Bun.spawn(['avahi-publish', '-a', hostname, '-R', ip], {
|
||||||
stdout: 'ignore',
|
stdout: 'ignore',
|
||||||
stderr: 'ignore',
|
stderr: 'ignore',
|
||||||
})
|
})
|
||||||
|
|
||||||
_publishers.set(name, proc)
|
_publishers.set(name, proc)
|
||||||
hostLog(`mDNS: published ${host} -> ${ip}`)
|
hostLog(`mDNS: published ${hostname} -> ${ip}`)
|
||||||
|
|
||||||
proc.exited.then(() => {
|
proc.exited.then(() => {
|
||||||
_publishers.delete(name)
|
_publishers.delete(name)
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
hostLog(`mDNS: failed to publish ${host}`)
|
hostLog(`mDNS: failed to publish ${hostname}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,7 +70,7 @@ export function unpublishApp(name: string) {
|
||||||
|
|
||||||
proc.kill()
|
proc.kill()
|
||||||
_publishers.delete(name)
|
_publishers.delete(name)
|
||||||
hostLog(`mDNS: unpublished ${toSubdomain(name)}.${LOCAL_HOST}`)
|
hostLog(`mDNS: unpublished ${toSubdomain(name)}.${HOST_DOMAIN}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unpublishAll() {
|
export function unpublishAll() {
|
||||||
|
|
@ -77,7 +78,7 @@ export function unpublishAll() {
|
||||||
|
|
||||||
for (const [name, proc] of _publishers) {
|
for (const [name, proc] of _publishers) {
|
||||||
proc.kill()
|
proc.kill()
|
||||||
hostLog(`mDNS: unpublished ${toSubdomain(name)}.${LOCAL_HOST}`)
|
hostLog(`mDNS: unpublished ${toSubdomain(name)}.${HOST_DOMAIN}`)
|
||||||
}
|
}
|
||||||
_publishers.clear()
|
_publishers.clear()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -146,19 +146,18 @@ export function renameTunnelConfig(oldName: string, newName: string) {
|
||||||
saveConfig(config)
|
saveConfig(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelReconnect(appName: string, resetAttempts = true) {
|
function cancelReconnect(appName: string) {
|
||||||
const timer = _reconnectTimers.get(appName)
|
const timer = _reconnectTimers.get(appName)
|
||||||
if (timer) {
|
if (timer) {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
_reconnectTimers.delete(appName)
|
_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
|
// Cancel any pending reconnect timer to prevent duplicate loops
|
||||||
// but preserve attempts counter during reconnection so backoff works
|
cancelReconnect(appName)
|
||||||
cancelReconnect(appName, !isReconnect)
|
|
||||||
|
|
||||||
// Close existing tunnel if any
|
// Close existing tunnel if any
|
||||||
const existing = _tunnels.get(appName)
|
const existing = _tunnels.get(appName)
|
||||||
|
|
@ -233,7 +232,7 @@ function openTunnel(appName: string, port: number, subdomain?: string, isReconne
|
||||||
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, true)
|
openTunnel(appName, port, config[appName]?.subdomain)
|
||||||
}, delay)
|
}, delay)
|
||||||
|
|
||||||
_reconnectTimers.set(appName, timer)
|
_reconnectTimers.set(appName, timer)
|
||||||
|
|
|
||||||
175
src/server/wifi-nmcli.ts
Normal file
175
src/server/wifi-nmcli.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
import { resolve } from 'path'
|
||||||
|
import { hostLog } from './tui'
|
||||||
|
import type { ConnectResult, WifiNetwork, WifiStatus } from '@types'
|
||||||
|
|
||||||
|
const CAPTIVE_CONF = resolve(import.meta.dir, '../../scripts/wifi-captive.conf')
|
||||||
|
const DNSMASQ_PID = '/tmp/toes-dnsmasq.pid'
|
||||||
|
const HOTSPOT_CON = 'toes-hotspot'
|
||||||
|
const HOTSPOT_IFACE = 'wlan0'
|
||||||
|
const HOTSPOT_SSID = 'Toes Setup'
|
||||||
|
|
||||||
|
async function dnsStart(): Promise<void> {
|
||||||
|
await dnsStop()
|
||||||
|
await sudo(['dnsmasq', `--conf-file=${CAPTIVE_CONF}`, `--pid-file=${DNSMASQ_PID}`, '--port=53'])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dnsStop(): Promise<void> {
|
||||||
|
if (await Bun.file(DNSMASQ_PID).exists()) {
|
||||||
|
const pid = (await Bun.file(DNSMASQ_PID).text()).trim()
|
||||||
|
await sudo(['kill', pid]).catch(e => hostLog(`dnsStop: failed to kill pid ${pid}: ${e}`))
|
||||||
|
await sudo(['rm', '-f', DNSMASQ_PID]).catch(e => hostLog(`dnsStop: failed to remove pid file: ${e}`))
|
||||||
|
}
|
||||||
|
await sudo(['pkill', '-f', 'dnsmasq.*wifi-captive.conf']).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nmcli(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||||
|
const proc = Bun.spawn(['nmcli', ...args], { stdout: 'pipe', stderr: 'pipe' })
|
||||||
|
const [stdout, stderr] = await Promise.all([
|
||||||
|
new Response(proc.stdout).text(),
|
||||||
|
new Response(proc.stderr).text(),
|
||||||
|
])
|
||||||
|
const exitCode = await proc.exited
|
||||||
|
if (exitCode !== 0 && stderr.trim()) {
|
||||||
|
hostLog(`nmcli error: ${stderr.trim()}`)
|
||||||
|
}
|
||||||
|
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
||||||
|
|
||||||
|
async function sudo(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||||
|
const proc = Bun.spawn(['sudo', ...args], { stdout: 'pipe', stderr: 'pipe' })
|
||||||
|
const [stdout, stderr] = await Promise.all([
|
||||||
|
new Response(proc.stdout).text(),
|
||||||
|
new Response(proc.stderr).text(),
|
||||||
|
])
|
||||||
|
const exitCode = await proc.exited
|
||||||
|
if (exitCode !== 0 && stderr.trim()) {
|
||||||
|
hostLog(`sudo error: ${stderr.trim()}`)
|
||||||
|
}
|
||||||
|
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectToNetwork(ssid: string, password?: string): Promise<ConnectResult> {
|
||||||
|
// Stop hotspot and DNS first
|
||||||
|
await stopHotspot()
|
||||||
|
await sleep(1000)
|
||||||
|
|
||||||
|
// Connect
|
||||||
|
const args = ['device', 'wifi', 'connect', ssid]
|
||||||
|
if (password) args.push('password', password)
|
||||||
|
args.push('ifname', HOTSPOT_IFACE)
|
||||||
|
|
||||||
|
const result = await nmcli(args)
|
||||||
|
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
// Connection failed — restart hotspot so user can retry
|
||||||
|
try {
|
||||||
|
await startHotspot()
|
||||||
|
} catch (e) {
|
||||||
|
hostLog(`CRITICAL: failed to restart hotspot after connection failure: ${e instanceof Error ? e.message : String(e)}`)
|
||||||
|
}
|
||||||
|
const error = result.stderr || result.stdout || 'Connection failed'
|
||||||
|
return { ok: false, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll for IP (up to 10 seconds)
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const ip = await getIp()
|
||||||
|
if (ip) return { ok: true, ip, ssid }
|
||||||
|
await sleep(1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, ip: '', ssid }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasHardware(): boolean {
|
||||||
|
const proc = Bun.spawnSync(['nmcli', 'device', 'show', HOTSPOT_IFACE])
|
||||||
|
return proc.exitCode === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scanNetworks(): Promise<WifiNetwork[]> {
|
||||||
|
// Force rescan
|
||||||
|
await nmcli(['device', 'wifi', 'rescan', 'ifname', HOTSPOT_IFACE]).catch(() => {})
|
||||||
|
await sleep(1000)
|
||||||
|
|
||||||
|
const { stdout } = await nmcli(['-t', '-f', 'SSID,SIGNAL,SECURITY', 'device', 'wifi', 'list', 'ifname', HOTSPOT_IFACE])
|
||||||
|
if (!stdout) return []
|
||||||
|
|
||||||
|
// Parse colon-delimited output (handle escaped colons with \:)
|
||||||
|
const seen = new Map<string, WifiNetwork>()
|
||||||
|
|
||||||
|
for (const line of stdout.split('\n')) {
|
||||||
|
if (!line.trim()) continue
|
||||||
|
const parts = line.split(/(?<!\\):/)
|
||||||
|
if (parts.length < 3) continue
|
||||||
|
|
||||||
|
const ssid = (parts[0] ?? '').replace(/\\:/g, ':').trim()
|
||||||
|
const signal = parseInt(parts[1] ?? '0', 10) || 0
|
||||||
|
const security = (parts[2] ?? '').trim()
|
||||||
|
|
||||||
|
if (!ssid || ssid === '--') continue
|
||||||
|
|
||||||
|
const existing = seen.get(ssid)
|
||||||
|
if (!existing || signal > existing.signal) {
|
||||||
|
seen.set(ssid, { ssid, signal, security })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...seen.values()].sort((a, b) => b.signal - a.signal)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startHotspot(): Promise<void> {
|
||||||
|
// Delete any existing hotspot connection
|
||||||
|
await nmcli(['connection', 'delete', HOTSPOT_CON]).catch(() => {})
|
||||||
|
|
||||||
|
// Create the hotspot
|
||||||
|
await nmcli([
|
||||||
|
'connection', 'add',
|
||||||
|
'type', 'wifi',
|
||||||
|
'ifname', HOTSPOT_IFACE,
|
||||||
|
'con-name', HOTSPOT_CON,
|
||||||
|
'ssid', HOTSPOT_SSID,
|
||||||
|
'autoconnect', 'no',
|
||||||
|
'wifi.mode', 'ap',
|
||||||
|
'wifi.band', 'bg',
|
||||||
|
'wifi-sec.key-mgmt', 'wpa-psk',
|
||||||
|
'wifi-sec.psk', 'toessetup',
|
||||||
|
'ipv4.method', 'shared',
|
||||||
|
'ipv4.addresses', '10.42.0.1/24',
|
||||||
|
])
|
||||||
|
|
||||||
|
await nmcli(['connection', 'up', HOTSPOT_CON])
|
||||||
|
await dnsStart()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function status(): Promise<WifiStatus> {
|
||||||
|
const [stateResult, ssidResult, ipResult] = await Promise.all([
|
||||||
|
nmcli(['-t', '-f', 'GENERAL.STATE', 'device', 'show', HOTSPOT_IFACE]),
|
||||||
|
nmcli(['-t', '-f', 'GENERAL.CONNECTION', 'device', 'show', HOTSPOT_IFACE]),
|
||||||
|
nmcli(['-t', '-f', 'IP4.ADDRESS', 'device', 'show', HOTSPOT_IFACE]),
|
||||||
|
])
|
||||||
|
|
||||||
|
const state = stateResult.stdout.split(':')[1]?.trim() ?? ''
|
||||||
|
const ssid = ssidResult.stdout.split(':')[1]?.trim() ?? ''
|
||||||
|
const ip = (ipResult.stdout.split('\n')[0] ?? '').split(':')[1]?.split('/')[0]?.trim() ?? ''
|
||||||
|
|
||||||
|
const connected = state.toLowerCase().includes('connected')
|
||||||
|
&& ssid !== HOTSPOT_CON
|
||||||
|
&& ssid !== ''
|
||||||
|
&& ssid !== '--'
|
||||||
|
|
||||||
|
return { connected, ssid, ip }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopHotspot(): Promise<void> {
|
||||||
|
await dnsStop()
|
||||||
|
await nmcli(['connection', 'down', HOTSPOT_CON]).catch(() => {})
|
||||||
|
await nmcli(['connection', 'delete', HOTSPOT_CON]).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getIp(): Promise<string> {
|
||||||
|
const { stdout } = await nmcli(['-t', '-f', 'IP4.ADDRESS', 'device', 'show', HOTSPOT_IFACE])
|
||||||
|
const line = stdout.split('\n')[0] ?? ''
|
||||||
|
return line.split(':')[1]?.split('/')[0]?.trim() ?? ''
|
||||||
|
}
|
||||||
79
src/server/wifi.ts
Normal file
79
src/server/wifi.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import * as nmcli from './wifi-nmcli'
|
||||||
|
import { hostLog } from './tui'
|
||||||
|
import type { ConnectResult, WifiNetwork, WifiStatus } from '@types'
|
||||||
|
|
||||||
|
export type { ConnectResult, WifiNetwork, WifiStatus } from '@types'
|
||||||
|
|
||||||
|
let _setupMode = false
|
||||||
|
|
||||||
|
export const isSetupMode = () => _setupMode
|
||||||
|
|
||||||
|
function setSetupMode(mode: boolean) {
|
||||||
|
if (_setupMode === mode) return
|
||||||
|
_setupMode = mode
|
||||||
|
hostLog(mode ? 'Entering WiFi setup mode' : 'Exiting WiFi setup mode')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWifiStatus(): Promise<WifiStatus> {
|
||||||
|
try {
|
||||||
|
return await nmcli.status()
|
||||||
|
} catch (e) {
|
||||||
|
hostLog(`WiFi status check failed: ${e instanceof Error ? e.message : String(e)}`)
|
||||||
|
return { connected: false, ssid: '', ip: '' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scanNetworks(): Promise<WifiNetwork[]> {
|
||||||
|
try {
|
||||||
|
return await nmcli.scanNetworks()
|
||||||
|
} catch (e) {
|
||||||
|
hostLog(`WiFi scan failed: ${e instanceof Error ? e.message : String(e)}`)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectToWifi(ssid: string, password?: string): Promise<ConnectResult> {
|
||||||
|
try {
|
||||||
|
const result = await nmcli.connectToNetwork(ssid, password)
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
setSetupMode(false)
|
||||||
|
hostLog(`Connected to WiFi: ${ssid} (${result.ip})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initWifi(): Promise<void> {
|
||||||
|
// Skip on non-Linux or when no WiFi hardware
|
||||||
|
if (process.platform !== 'linux') {
|
||||||
|
hostLog('WiFi setup: skipped (not Linux)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nmcli.hasHardware()) {
|
||||||
|
hostLog('WiFi setup: skipped (no WiFi hardware)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await getWifiStatus()
|
||||||
|
|
||||||
|
if (status.connected) {
|
||||||
|
hostLog(`WiFi: connected to ${status.ssid} (${status.ip})`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// No WiFi connection - enter setup mode
|
||||||
|
hostLog('WiFi: not connected, starting setup hotspot...')
|
||||||
|
setSetupMode(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await nmcli.startHotspot()
|
||||||
|
hostLog('WiFi hotspot started: "Toes Setup" (password: toessetup)')
|
||||||
|
} catch (e) {
|
||||||
|
hostLog(`Failed to start hotspot: ${e instanceof Error ? e.message : String(e)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,10 @@ export type LogLine = {
|
||||||
text: string
|
text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ConnectResult = { ok: boolean; ip?: string; ssid?: string; error?: string }
|
||||||
|
export type WifiNetwork = { ssid: string; signal: number; security: string }
|
||||||
|
export type WifiStatus = { connected: boolean; ssid: string; ip: string }
|
||||||
|
|
||||||
export type App = {
|
export type App = {
|
||||||
name: string
|
name: string
|
||||||
state: AppState
|
state: AppState
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,8 @@ 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(payload)
|
const event: ToesEvent = JSON.parse(line.slice(6))
|
||||||
_listeners.forEach(l => {
|
_listeners.forEach(l => {
|
||||||
if (l.types.includes(event.type)) l.callback(event)
|
if (l.types.includes(event.type)) l.callback(event)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user