Compare commits

..

2 Commits

Author SHA1 Message Date
21c6c27c92 Add SSH CLI access and update install docs 2026-03-16 16:35:19 -07:00
195be426f1 Convert getAppMetrics to async and replace spawnSync with async Bun.spawn
spawnSync blocks the event loop while waiting for ps and du, which
stalls the SSE metrics stream and other requests. Running these
concurrently with async spawn (and Promise.all for du) keeps the
server responsive under load.
2026-03-16 16:32:17 -07:00
3 changed files with 55 additions and 18 deletions

View File

@ -34,6 +34,18 @@ Once complete, visit `http://<hostname>.local` on your local network.
- https://toes.local web UI for managing your projects.
- `toes` CLI for managing your projects.
## ssh cli
You can manage your toes server from any machine on your network over SSH — no install required.
```bash
ssh cli@toes.local # interactive shell with tab completion
ssh cli@toes.local list # run a single command
ssh cli@toes.local logs fog # stream logs for an app
```
The `cli` user's login shell is the `toes` binary itself. No password is needed. With no arguments, you get an interactive REPL. With arguments, it runs the command and exits.
## cli configuration
by default, the CLI connects to `localhost:3000` in dev and `toes.local:80` in production.

View File

@ -112,6 +112,15 @@ for app_dir in "$DEST"/apps/*/; do
rm -rf "$APPS_DIR/$app/.git"
done
# ── CLI + SSH ────────────────────────────────────────────
info "Installing CLI"
quiet bun run cli:build
sudo cp dist/toes /usr/local/bin/toes
info "Setting up SSH access"
quiet sudo bash "$DEST/scripts/setup-ssh.sh"
# ── Systemd ──────────────────────────────────────────────
info "Installing toes service"
@ -131,6 +140,7 @@ echo " ${b}${g}🐾 toes $VERSION is up!${r}"
echo " ${d}─────────────────────────────${r}"
echo ""
echo " Dashboard: ${c}http://$(hostname).local${r}"
echo " SSH CLI: ${c}ssh cli@$(hostname).local${r}"
echo ""
echo " ${d}Grab the CLI:${r}"
echo " ${c}curl -fsSL http://$(hostname).local/install | bash${r}"

View File

@ -103,7 +103,7 @@ let _appDiskCache: Record<string, number> = {}
let _appDiskLastUpdate = 0
const DISK_CACHE_TTL = 30000
function getAppMetrics(): Record<string, AppMetrics> {
async function getAppMetrics(): Promise<Record<string, AppMetrics>> {
const apps = allApps()
const running = apps.filter(a => a.proc?.pid)
const result: Record<string, AppMetrics> = {}
@ -117,8 +117,10 @@ function getAppMetrics(): Record<string, AppMetrics> {
if (pidToName.size > 0) {
try {
const pids = [...pidToName.keys()].join(',')
const ps = Bun.spawnSync(['ps', '-o', 'pid=,%cpu=,rss=', '-p', pids])
for (const line of ps.stdout.toString().split('\n')) {
const proc = Bun.spawn(['ps', '-o', 'pid=,%cpu=,rss=', '-p', pids], { stdout: 'pipe', stderr: 'ignore' })
const output = await new Response(proc.stdout).text()
await proc.exited
for (const line of output.split('\n')) {
const parts = line.trim().split(/\s+/)
if (parts.length < 3) continue
const pid = parseInt(parts[0]!, 10)
@ -135,12 +137,21 @@ function getAppMetrics(): Record<string, AppMetrics> {
if (now - _appDiskLastUpdate > DISK_CACHE_TTL) {
_appDiskLastUpdate = now
_appDiskCache = {}
for (const app of apps) {
try {
const du = Bun.spawnSync(['du', '-sk', join(APPS_DIR, app.name)])
const kb = parseInt(du.stdout.toString().trim().split('\t')[0]!, 10)
if (kb) _appDiskCache[app.name] = kb * 1024
} catch {}
const duResults = await Promise.all(
apps.map(async app => {
try {
const proc = Bun.spawn(['du', '-sk', join(APPS_DIR, app.name)], { stdout: 'pipe', stderr: 'ignore' })
const output = await new Response(proc.stdout).text()
await proc.exited
const kb = parseInt(output.trim().split('\t')[0]!, 10)
return { name: app.name, bytes: kb ? kb * 1024 : 0 }
} catch {
return { name: app.name, bytes: 0 }
}
})
)
for (const { name, bytes } of duResults) {
if (bytes) _appDiskCache[name] = bytes
}
}
@ -154,26 +165,30 @@ function getAppMetrics(): Record<string, AppMetrics> {
}
// Get current system metrics
router.get('/metrics', c => {
router.get('/metrics', async c => {
const metrics: SystemMetrics = {
cpu: getCpuUsage(),
ram: getMemoryUsage(),
disk: getDiskUsage(),
apps: getAppMetrics(),
apps: await getAppMetrics(),
}
return c.json(metrics)
})
// SSE stream for real-time metrics (updates every 2s)
router.sse('/metrics/stream', (send) => {
let queue = Promise.resolve()
const sendMetrics = () => {
const metrics: SystemMetrics = {
cpu: getCpuUsage(),
ram: getMemoryUsage(),
disk: getDiskUsage(),
apps: getAppMetrics(),
}
send(metrics)
queue = queue.then(async () => {
const metrics: SystemMetrics = {
cpu: getCpuUsage(),
ram: getMemoryUsage(),
disk: getDiskUsage(),
apps: await getAppMetrics(),
}
await send(metrics)
})
}
// Initial send