Compare commits
6 Commits
071f1a02b5
...
5510432b42
| Author | SHA1 | Date | |
|---|---|---|---|
| 5510432b42 | |||
| 881517a88f | |||
| 5477470551 | |||
| 5b1a970da1 | |||
| 09e21c738b | |||
| 971ebef21c |
846
docs/GUIDE.md
Normal file
846
docs/GUIDE.md
Normal file
|
|
@ -0,0 +1,846 @@
|
||||||
|
# Toes User Guide
|
||||||
|
|
||||||
|
Toes is a personal web appliance that runs multiple web apps on your home network. Plug it in, turn it on, and forget about the cloud.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Quick Start](#quick-start)
|
||||||
|
- [Creating an App](#creating-an-app)
|
||||||
|
- [App Templates](#app-templates)
|
||||||
|
- [App Structure](#app-structure)
|
||||||
|
- [The Bare Minimum](#the-bare-minimum)
|
||||||
|
- [Using Hype](#using-hype)
|
||||||
|
- [Using Forge](#using-forge)
|
||||||
|
- [Creating a Tool](#creating-a-tool)
|
||||||
|
- [What's a Tool?](#whats-a-tool)
|
||||||
|
- [Tool Setup](#tool-setup)
|
||||||
|
- [Theme Tokens](#theme-tokens)
|
||||||
|
- [Accessing App Data](#accessing-app-data)
|
||||||
|
- [CLI Reference](#cli-reference)
|
||||||
|
- [App Management](#app-management)
|
||||||
|
- [Lifecycle](#lifecycle)
|
||||||
|
- [Syncing Code](#syncing-code)
|
||||||
|
- [Environment Variables](#environment-variables)
|
||||||
|
- [Versioning](#versioning)
|
||||||
|
- [Cron Jobs](#cron-jobs-1)
|
||||||
|
- [Metrics](#metrics)
|
||||||
|
- [Sharing](#sharing)
|
||||||
|
- [Configuration](#configuration)
|
||||||
|
- [Environment Variables](#environment-variables-1)
|
||||||
|
- [Health Checks](#health-checks)
|
||||||
|
- [App Lifecycle](#app-lifecycle)
|
||||||
|
- [Cron Jobs](#cron-jobs)
|
||||||
|
- [Data Persistence](#data-persistence)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install the CLI
|
||||||
|
curl -fsSL http://toes.local/install | bash
|
||||||
|
|
||||||
|
# Create a new app
|
||||||
|
toes new my-app
|
||||||
|
|
||||||
|
# Enter the directory, install deps, and develop locally
|
||||||
|
cd my-app
|
||||||
|
bun install
|
||||||
|
bun dev
|
||||||
|
|
||||||
|
# Push to the server
|
||||||
|
toes push
|
||||||
|
|
||||||
|
# Open in browser
|
||||||
|
toes open
|
||||||
|
```
|
||||||
|
|
||||||
|
Your app is now running at `http://my-app.toes.local`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Creating an App
|
||||||
|
|
||||||
|
### App Templates
|
||||||
|
|
||||||
|
Toes ships with three templates. Pick one when creating an app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes new my-app # SSR (default)
|
||||||
|
toes new my-app --bare # Minimal
|
||||||
|
toes new my-app --spa # Single-page app
|
||||||
|
```
|
||||||
|
|
||||||
|
**SSR** — Server-side rendered with a pages directory. Best for most apps. Uses Hype's built-in layout and page routing.
|
||||||
|
|
||||||
|
**Bare** — Just an `index.tsx` with a single route. Good when you want to start from scratch.
|
||||||
|
|
||||||
|
**SPA** — Client-side rendering with `hono/jsx/dom`. Hype serves the HTML shell and static files; the browser handles routing and rendering.
|
||||||
|
|
||||||
|
### App Structure
|
||||||
|
|
||||||
|
A generated SSR app looks like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
my-app/
|
||||||
|
.npmrc # Points to the private registry
|
||||||
|
.toesignore # Files to exclude from sync (like .gitignore)
|
||||||
|
package.json # Must have scripts.toes
|
||||||
|
tsconfig.json # TypeScript config
|
||||||
|
index.tsx # Entry point (re-exports from src/server)
|
||||||
|
src/
|
||||||
|
server/
|
||||||
|
index.tsx # Hype app with routes
|
||||||
|
pages/
|
||||||
|
index.tsx # Page components
|
||||||
|
```
|
||||||
|
|
||||||
|
### The Bare Minimum
|
||||||
|
|
||||||
|
Every app needs three things:
|
||||||
|
|
||||||
|
1. **`package.json`** with a `scripts.toes` entry
|
||||||
|
2. **`index.tsx`** that exports `app.defaults`
|
||||||
|
3. **A `GET /ok` route** that returns 200 (health check)
|
||||||
|
|
||||||
|
**package.json:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-app",
|
||||||
|
"module": "index.tsx",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"toes": "bun run --watch index.tsx"
|
||||||
|
},
|
||||||
|
"toes": {
|
||||||
|
"icon": "🎨"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@because/hype": "*",
|
||||||
|
"@because/forge": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `scripts.toes` field is how Toes discovers your app. The `toes.icon` field sets the emoji shown in the dashboard.
|
||||||
|
|
||||||
|
**.npmrc:**
|
||||||
|
|
||||||
|
```
|
||||||
|
registry=https://npm.nose.space
|
||||||
|
```
|
||||||
|
|
||||||
|
Required for installing `@because/*` packages.
|
||||||
|
|
||||||
|
**index.tsx:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Hype } from '@because/hype'
|
||||||
|
|
||||||
|
const app = new Hype()
|
||||||
|
|
||||||
|
app.get('/', c => c.html(<h1>Hello World</h1>))
|
||||||
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
|
||||||
|
export default app.defaults
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it. Push to the server and it runs.
|
||||||
|
|
||||||
|
### Using Hype
|
||||||
|
|
||||||
|
Hype wraps [Hono](https://hono.dev). Everything you know from Hono works here. Hype adds a few extras:
|
||||||
|
|
||||||
|
**Basic routing:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Hype } from '@because/hype'
|
||||||
|
|
||||||
|
const app = new Hype()
|
||||||
|
|
||||||
|
app.get('/', c => c.html(<h1>Home</h1>))
|
||||||
|
app.get('/about', c => c.html(<h1>About</h1>))
|
||||||
|
app.post('/api/items', async c => {
|
||||||
|
const body = await c.req.json()
|
||||||
|
return c.json({ ok: true })
|
||||||
|
})
|
||||||
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
|
||||||
|
export default app.defaults
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sub-routers:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const api = Hype.router()
|
||||||
|
api.get('/items', c => c.json([]))
|
||||||
|
api.post('/items', async c => {
|
||||||
|
const body = await c.req.json()
|
||||||
|
return c.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.route('/api', api) // mounts at /api/items
|
||||||
|
```
|
||||||
|
|
||||||
|
**Server-Sent Events:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
app.sse('/stream', (send, c) => {
|
||||||
|
send({ hello: 'world' })
|
||||||
|
const interval = setInterval(() => send({ time: Date.now() }), 1000)
|
||||||
|
return () => clearInterval(interval) // cleanup on disconnect
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Constructor options:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const app = new Hype({
|
||||||
|
layout: true, // Wraps pages in an HTML layout (default: true)
|
||||||
|
prettyHTML: true, // Pretty-print HTML output (default: true)
|
||||||
|
logging: true, // Log requests to stdout (default: true)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Forge
|
||||||
|
|
||||||
|
Forge is a CSS-in-JS library that creates styled JSX components. Define a component once, use it everywhere.
|
||||||
|
|
||||||
|
**Basic usage:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { define, stylesToCSS } from '@because/forge'
|
||||||
|
|
||||||
|
const Box = define('Box', {
|
||||||
|
padding: 20,
|
||||||
|
borderRadius: '6px',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
})
|
||||||
|
// <Box>content</Box> renders <div class="Box">content</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Numbers auto-convert to `px` (except `flex`, `opacity`, `zIndex`, `fontWeight`).
|
||||||
|
|
||||||
|
**Set the HTML element:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const Button = define('Button', { base: 'button', padding: '8px 16px' })
|
||||||
|
const Link = define('Link', { base: 'a', textDecoration: 'none' })
|
||||||
|
const Input = define('Input', { base: 'input', padding: 8, border: '1px solid #ccc' })
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pseudo-classes (`states`):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const Item = define('Item', {
|
||||||
|
padding: 12,
|
||||||
|
states: {
|
||||||
|
':hover': { backgroundColor: '#eee' },
|
||||||
|
':last-child': { borderBottom: 'none' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nested selectors:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const List = define('List', {
|
||||||
|
selectors: {
|
||||||
|
'& > li:last-child': { borderBottom: 'none' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Variants:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const Button = define('Button', {
|
||||||
|
base: 'button',
|
||||||
|
padding: '8px 16px',
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
primary: { backgroundColor: '#2563eb', color: 'white' },
|
||||||
|
danger: { backgroundColor: '#dc2626', color: 'white' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// <Button variant="primary">Save</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Serving CSS:**
|
||||||
|
|
||||||
|
Forge generates CSS at runtime. Serve it from a route:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { stylesToCSS } from '@because/forge'
|
||||||
|
|
||||||
|
app.get('/styles.css', c =>
|
||||||
|
c.text(stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' })
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then link it in your HTML:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Creating a Tool
|
||||||
|
|
||||||
|
### What's a Tool?
|
||||||
|
|
||||||
|
A tool is an app that appears as a tab inside the Toes dashboard instead of in the sidebar. Tools render in an iframe and receive the currently selected app as a `?app=` query parameter. Good for things like a code editor, log viewer, env manager, or cron scheduler.
|
||||||
|
|
||||||
|
From the server's perspective, a tool is identical to an app — same lifecycle, same health checks, same port allocation. The only differences are in `package.json` and how you render.
|
||||||
|
|
||||||
|
### Tool Setup
|
||||||
|
|
||||||
|
A tool needs three extra things compared to a regular app:
|
||||||
|
|
||||||
|
1. Set `"tool": true` in `package.json`
|
||||||
|
2. Include `<ToolScript />` in the HTML body
|
||||||
|
3. Prepend `baseStyles` to CSS output
|
||||||
|
|
||||||
|
**package.json:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-tool",
|
||||||
|
"module": "index.tsx",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"toes": "bun run --watch index.tsx"
|
||||||
|
},
|
||||||
|
"toes": {
|
||||||
|
"icon": "🔧",
|
||||||
|
"tool": true
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@because/forge": "*",
|
||||||
|
"@because/hype": "*",
|
||||||
|
"@because/toes": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `"tool"` to `true` for a tab labeled with the app name, or to a string for a custom label (e.g., `"tool": ".env"`).
|
||||||
|
|
||||||
|
**index.tsx:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Hype } from '@because/hype'
|
||||||
|
import { define, stylesToCSS } from '@because/forge'
|
||||||
|
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
|
||||||
|
import type { Child } from 'hono/jsx'
|
||||||
|
|
||||||
|
const app = new Hype({ prettyHTML: false })
|
||||||
|
|
||||||
|
const Container = define('Container', {
|
||||||
|
fontFamily: theme('fonts-sans'),
|
||||||
|
padding: '20px',
|
||||||
|
paddingTop: 0,
|
||||||
|
maxWidth: '800px',
|
||||||
|
margin: '0 auto',
|
||||||
|
color: theme('colors-text'),
|
||||||
|
})
|
||||||
|
|
||||||
|
function Layout({ title, children }: { title: string; children: Child }) {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{title}</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<ToolScript />
|
||||||
|
<Container>{children}</Container>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
app.get('/styles.css', c =>
|
||||||
|
c.text(baseStyles + stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' })
|
||||||
|
)
|
||||||
|
|
||||||
|
app.get('/', async c => {
|
||||||
|
const appName = c.req.query('app')
|
||||||
|
if (!appName) {
|
||||||
|
return c.html(<Layout title="My Tool"><p>No app selected</p></Layout>)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.html(
|
||||||
|
<Layout title="My Tool">
|
||||||
|
<h2>{appName}</h2>
|
||||||
|
<p>Tool content for {appName}</p>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app.defaults
|
||||||
|
```
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
|
||||||
|
- `<ToolScript />` handles dark/light mode syncing and iframe height communication with the dashboard.
|
||||||
|
- `baseStyles` sets the body background to match the dashboard theme.
|
||||||
|
- `prettyHTML: false` is recommended for tools since their output is inside an iframe.
|
||||||
|
- The `?app=` query parameter tells you which app the user has selected in the sidebar.
|
||||||
|
|
||||||
|
### Theme Tokens
|
||||||
|
|
||||||
|
Tools should use theme tokens to match the dashboard's look. Import `theme` from `@because/toes/tools`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { theme } from '@because/toes/tools'
|
||||||
|
|
||||||
|
const Card = define('Card', {
|
||||||
|
color: theme('colors-text'),
|
||||||
|
backgroundColor: theme('colors-bgElement'),
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
padding: theme('spacing-lg'),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Available tokens:
|
||||||
|
|
||||||
|
| Token | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `colors-bg` | Page background |
|
||||||
|
| `colors-bgSubtle` | Subtle background |
|
||||||
|
| `colors-bgElement` | Element background (cards, inputs) |
|
||||||
|
| `colors-bgHover` | Hover background |
|
||||||
|
| `colors-text` | Primary text |
|
||||||
|
| `colors-textMuted` | Secondary text |
|
||||||
|
| `colors-textFaint` | Tertiary/disabled text |
|
||||||
|
| `colors-border` | Borders |
|
||||||
|
| `colors-link` | Link text |
|
||||||
|
| `colors-primary` | Primary action color |
|
||||||
|
| `colors-primaryText` | Text on primary color |
|
||||||
|
| `colors-error` | Error color |
|
||||||
|
| `colors-dangerBorder` | Danger state border |
|
||||||
|
| `colors-dangerText` | Danger text |
|
||||||
|
| `colors-success` | Success color |
|
||||||
|
| `colors-successBg` | Success background |
|
||||||
|
| `colors-statusRunning` | Running indicator |
|
||||||
|
| `colors-statusStopped` | Stopped indicator |
|
||||||
|
| `fonts-sans` | Sans-serif font stack |
|
||||||
|
| `fonts-mono` | Monospace font stack |
|
||||||
|
| `spacing-xs` | 4px |
|
||||||
|
| `spacing-sm` | 8px |
|
||||||
|
| `spacing-md` | 12px |
|
||||||
|
| `spacing-lg` | 16px |
|
||||||
|
| `spacing-xl` | 24px |
|
||||||
|
| `radius-md` | 6px |
|
||||||
|
|
||||||
|
### Accessing App Data
|
||||||
|
|
||||||
|
**Reading app files:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
const APPS_DIR = process.env.APPS_DIR!
|
||||||
|
|
||||||
|
app.get('/', c => {
|
||||||
|
const appName = c.req.query('app')
|
||||||
|
if (!appName) return c.html(<p>No app selected</p>)
|
||||||
|
|
||||||
|
const appPath = join(APPS_DIR, appName, 'current')
|
||||||
|
// Read files from appPath...
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Always go through the `current` symlink — never access version directories directly.
|
||||||
|
|
||||||
|
**Calling the Toes API:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const TOES_URL = process.env.TOES_URL!
|
||||||
|
|
||||||
|
// List all apps
|
||||||
|
const apps = await fetch(`${TOES_URL}/api/apps`).then(r => r.json())
|
||||||
|
|
||||||
|
// Get a specific app
|
||||||
|
const app = await fetch(`${TOES_URL}/api/apps/${name}`).then(r => r.json())
|
||||||
|
```
|
||||||
|
|
||||||
|
**Linking between tools:**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<a href="/tool/code?app=my-app&file=index.tsx">Edit in Code</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
Tool URLs go through `/tool/:name` which redirects to the tool's subdomain with query params preserved.
|
||||||
|
|
||||||
|
**Listening to lifecycle events:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { on } from '@because/toes/tools'
|
||||||
|
|
||||||
|
const unsub = on('app:start', event => {
|
||||||
|
console.log(`${event.app} started at ${event.time}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Event types: 'app:start', 'app:stop', 'app:create', 'app:delete', 'app:activate'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI Reference
|
||||||
|
|
||||||
|
The CLI connects to your Toes server over HTTP. By default it connects to `http://toes.local`. Set `TOES_URL` to point elsewhere, or set `DEV=1` to use `http://localhost:3000`.
|
||||||
|
|
||||||
|
Most commands accept an optional app name. If omitted, the CLI uses the current directory's `package.json` name.
|
||||||
|
|
||||||
|
### App Management
|
||||||
|
|
||||||
|
**`toes list`** — List all apps and their status.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes list # Show apps and tools
|
||||||
|
toes list --apps # Apps only (exclude tools)
|
||||||
|
toes list --tools # Tools only
|
||||||
|
```
|
||||||
|
|
||||||
|
**`toes new [name]`** — Create a new app from a template.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes new my-app # SSR template (default)
|
||||||
|
toes new my-app --bare # Minimal template
|
||||||
|
toes new my-app --spa # SPA template
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates the app locally, then pushes it to the server. If run without a name, scaffolds the current directory.
|
||||||
|
|
||||||
|
**`toes info [name]`** — Show details for an app (state, URL, port, PID, uptime).
|
||||||
|
|
||||||
|
**`toes get <name>`** — Download an app from the server to your local machine.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes get my-app # Creates ./my-app/ with all files
|
||||||
|
cd my-app
|
||||||
|
bun install
|
||||||
|
bun dev # Develop locally
|
||||||
|
```
|
||||||
|
|
||||||
|
**`toes open [name]`** — Open a running app in your browser.
|
||||||
|
|
||||||
|
**`toes rename [name] <new-name>`** — Rename an app. Requires typing a confirmation.
|
||||||
|
|
||||||
|
**`toes rm [name]`** — Permanently delete an app from the server. Requires typing a confirmation.
|
||||||
|
|
||||||
|
### Lifecycle
|
||||||
|
|
||||||
|
**`toes start [name]`** — Start a stopped app.
|
||||||
|
|
||||||
|
**`toes stop [name]`** — Stop a running app.
|
||||||
|
|
||||||
|
**`toes restart [name]`** — Stop and start an app.
|
||||||
|
|
||||||
|
**`toes logs [name]`** — View logs for an app.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes logs my-app # Today's logs
|
||||||
|
toes logs my-app -f # Follow (tail) logs in real-time
|
||||||
|
toes logs my-app -d 2026-01-15 # Logs from a specific date
|
||||||
|
toes logs my-app -s 2d # Logs from the last 2 days
|
||||||
|
toes logs my-app -g error # Filter logs by pattern
|
||||||
|
toes logs my-app -f -g error # Follow and filter
|
||||||
|
```
|
||||||
|
|
||||||
|
Duration formats for `--since`: `1h` (hours), `2d` (days), `1w` (weeks), `1m` (months).
|
||||||
|
|
||||||
|
### Syncing Code
|
||||||
|
|
||||||
|
Toes uses a manifest-based sync protocol. Each file is tracked by SHA-256 hash. The server stores versioned snapshots with timestamps.
|
||||||
|
|
||||||
|
**`toes push`** — Push local changes to the server.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes push # Push changes (fails if server changed)
|
||||||
|
toes push --force # Overwrite server changes
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates a new version on the server, uploads changed files, deletes removed files, then activates the new version. The app auto-restarts.
|
||||||
|
|
||||||
|
**`toes pull`** — Pull changes from the server.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes pull # Pull changes (fails if you have local changes)
|
||||||
|
toes pull --force # Overwrite local changes
|
||||||
|
```
|
||||||
|
|
||||||
|
**`toes status`** — Show what would be pushed or pulled.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes status
|
||||||
|
# Changes to push:
|
||||||
|
# * index.tsx
|
||||||
|
# + new-file.ts
|
||||||
|
# - removed-file.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
**`toes diff`** — Show a line-by-line diff of changed files.
|
||||||
|
|
||||||
|
**`toes sync`** — Watch for changes and sync bidirectionally in real-time. Useful during development when editing on the server.
|
||||||
|
|
||||||
|
**`toes clean`** — Remove local files that don't exist on the server.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes clean # Interactive confirmation
|
||||||
|
toes clean --force # No confirmation
|
||||||
|
toes clean --dry-run # Show what would be removed
|
||||||
|
```
|
||||||
|
|
||||||
|
**`toes stash`** — Stash local changes (like `git stash`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes stash # Save local changes
|
||||||
|
toes stash pop # Restore stashed changes
|
||||||
|
toes stash list # List all stashes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
**`toes env [name]`** — List environment variables for an app.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes env my-app # List app vars
|
||||||
|
toes env -g # List global vars
|
||||||
|
```
|
||||||
|
|
||||||
|
**`toes env set [name] <KEY> [value]`** — Set a variable.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes env set my-app API_KEY sk-123 # Set for an app
|
||||||
|
toes env set my-app API_KEY=sk-123 # KEY=value format also works
|
||||||
|
toes env set -g API_KEY sk-123 # Set globally (shared by all apps)
|
||||||
|
```
|
||||||
|
|
||||||
|
Setting a variable automatically restarts the app.
|
||||||
|
|
||||||
|
**`toes env rm [name] <KEY>`** — Remove a variable.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes env rm my-app API_KEY # Remove from an app
|
||||||
|
toes env rm -g API_KEY # Remove global var
|
||||||
|
```
|
||||||
|
|
||||||
|
### Versioning
|
||||||
|
|
||||||
|
Every push creates a timestamped version. The server keeps the last 5 versions.
|
||||||
|
|
||||||
|
**`toes versions [name]`** — List deployed versions.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes versions my-app
|
||||||
|
# Versions for my-app:
|
||||||
|
#
|
||||||
|
# → 20260219-143022 2/19/2026, 2:30:22 PM (current)
|
||||||
|
# 20260218-091500 2/18/2026, 9:15:00 AM
|
||||||
|
# 20260217-160845 2/17/2026, 4:08:45 PM
|
||||||
|
```
|
||||||
|
|
||||||
|
**`toes history [name]`** — Show file changes between versions.
|
||||||
|
|
||||||
|
**`toes rollback [name]`** — Rollback to a previous version.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes rollback my-app # Interactive version picker
|
||||||
|
toes rollback my-app -v 20260218-091500 # Rollback to specific version
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cron Jobs
|
||||||
|
|
||||||
|
Cron commands talk to the cron tool running on your Toes server.
|
||||||
|
|
||||||
|
**`toes cron [app]`** — List all cron jobs, or jobs for a specific app.
|
||||||
|
|
||||||
|
**`toes cron status <app:name>`** — Show details for a specific job.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes cron status my-app:backup
|
||||||
|
# my-app:backup ok
|
||||||
|
#
|
||||||
|
# Schedule: day
|
||||||
|
# State: idle
|
||||||
|
# Last run: 2h ago
|
||||||
|
# Duration: 3s
|
||||||
|
# Exit code: 0
|
||||||
|
# Next run: in 22h
|
||||||
|
```
|
||||||
|
|
||||||
|
**`toes cron run <app:name>`** — Trigger a job immediately.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes cron run my-app:backup
|
||||||
|
```
|
||||||
|
|
||||||
|
**`toes cron log [target]`** — View cron logs.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes cron log # All cron logs
|
||||||
|
toes cron log my-app # Cron logs for an app
|
||||||
|
toes cron log my-app:backup # Logs for a specific job
|
||||||
|
toes cron log -f # Follow logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Metrics
|
||||||
|
|
||||||
|
**`toes metrics [name]`** — Show CPU, memory, and disk usage.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes metrics # All apps
|
||||||
|
toes metrics my-app # Single app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sharing
|
||||||
|
|
||||||
|
**`toes share [name]`** — Create a public tunnel to share an app over the internet.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes share my-app
|
||||||
|
# ↗ Sharing my-app... https://abc123.trycloudflare.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**`toes unshare [name]`** — Stop sharing an app.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
**`toes config`** — Show the current server URL and sync state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Toes injects these variables into every app process automatically:
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `PORT` | Assigned port (3001-3100). Your app must listen on this port. |
|
||||||
|
| `APPS_DIR` | Path to the apps directory on the server. |
|
||||||
|
| `DATA_DIR` | Per-app data directory for persistent storage. |
|
||||||
|
| `TOES_URL` | Base URL of the Toes server (e.g., `http://toes.local:3000`). |
|
||||||
|
| `TOES_DIR` | Path to the Toes config directory. |
|
||||||
|
|
||||||
|
You can set custom variables per-app or globally. Global variables are inherited by all apps. Per-app variables override globals.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set per-app
|
||||||
|
toes env set my-app OPENAI_API_KEY sk-123
|
||||||
|
|
||||||
|
# Set globally (shared by all apps)
|
||||||
|
toes env set -g DATABASE_URL postgres://localhost/mydb
|
||||||
|
```
|
||||||
|
|
||||||
|
Access them in your app:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const apiKey = process.env.OPENAI_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Health Checks
|
||||||
|
|
||||||
|
Toes checks `GET /ok` on every app every 30 seconds. Your app must return a 2xx response.
|
||||||
|
|
||||||
|
Three consecutive failures trigger an automatic restart with exponential backoff (1s, 2s, 4s, 8s, 16s, 32s). After 5 restart failures, the app is marked as errored and restart is disabled.
|
||||||
|
|
||||||
|
The simplest health check:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## App Lifecycle
|
||||||
|
|
||||||
|
Apps move through these states:
|
||||||
|
|
||||||
|
```
|
||||||
|
invalid → stopped → starting → running → stopping → stopped
|
||||||
|
↓
|
||||||
|
error
|
||||||
|
```
|
||||||
|
|
||||||
|
- **invalid** — Missing `package.json` or `scripts.toes`. Fix the config and start manually.
|
||||||
|
- **stopped** — Not running. Start with `toes start` or the dashboard.
|
||||||
|
- **starting** — Process spawned, waiting for `/ok` to return 200. Times out after 30 seconds.
|
||||||
|
- **running** — Healthy and serving requests.
|
||||||
|
- **stopping** — SIGTERM sent, waiting for process to exit. Escalates to SIGKILL after 10 seconds.
|
||||||
|
- **error** — Crashed too many times. Start manually to retry.
|
||||||
|
|
||||||
|
On startup, `bun install` runs automatically before the app's `scripts.toes` command.
|
||||||
|
|
||||||
|
Apps are accessed via subdomain: `http://my-app.toes.local` or `http://my-app.localhost`. The Toes server proxies requests to the app's assigned port.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cron Jobs
|
||||||
|
|
||||||
|
Place TypeScript files in a `cron/` directory inside your app:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// cron/daily-cleanup.ts
|
||||||
|
export const schedule = "day"
|
||||||
|
|
||||||
|
export default async function() {
|
||||||
|
console.log("Running daily cleanup")
|
||||||
|
// Your job logic here
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The cron tool auto-discovers jobs by scanning `cron/*.ts` in all apps. New jobs are picked up within 60 seconds.
|
||||||
|
|
||||||
|
### Schedules
|
||||||
|
|
||||||
|
| Value | When |
|
||||||
|
|-------|------|
|
||||||
|
| `1 minute` | Every minute |
|
||||||
|
| `5 minutes` | Every 5 minutes |
|
||||||
|
| `15 minutes` | Every 15 minutes |
|
||||||
|
| `30 minutes` | Every 30 minutes |
|
||||||
|
| `hour` | Top of every hour |
|
||||||
|
| `noon` | 12:00 daily |
|
||||||
|
| `midnight` / `day` | 00:00 daily |
|
||||||
|
| `week` / `sunday` | 00:00 Sunday |
|
||||||
|
| `monday` - `saturday` | 00:00 on that day |
|
||||||
|
|
||||||
|
Jobs inherit the app's working directory and all environment variables.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Persistence
|
||||||
|
|
||||||
|
Use the filesystem for data storage. The `DATA_DIR` environment variable points to a per-app directory that persists across deployments and restarts:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { join } from 'path'
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
||||||
|
|
||||||
|
const DATA_DIR = process.env.DATA_DIR!
|
||||||
|
|
||||||
|
function loadData(): MyData {
|
||||||
|
const path = join(DATA_DIR, 'data.json')
|
||||||
|
if (!existsSync(path)) return { items: [] }
|
||||||
|
return JSON.parse(readFileSync(path, 'utf-8'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveData(data: MyData) {
|
||||||
|
writeFileSync(join(DATA_DIR, 'data.json'), JSON.stringify(data, null, 2))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`DATA_DIR` is separate from your app's code directory, so pushes and rollbacks won't affect stored data.
|
||||||
|
|
@ -4,6 +4,16 @@ export const getLogDates = (name: string): Promise<string[]> =>
|
||||||
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 }> =>
|
||||||
|
fetch('/api/system/wifi').then(r => r.json())
|
||||||
|
|
||||||
|
export const saveWifiConfig = (config: { network: string, password: string }) =>
|
||||||
|
fetch('/api/system/wifi', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/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' })
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,25 @@
|
||||||
import { Styles } from '@because/forge'
|
import { Styles } from '@because/forge'
|
||||||
import { apps, isNarrow, selectedApp } from '../state'
|
import { apps, currentView, isNarrow, selectedApp } from '../state'
|
||||||
import { Layout } from '../styles'
|
import { Layout } from '../styles'
|
||||||
import { AppDetail } from './AppDetail'
|
import { AppDetail } from './AppDetail'
|
||||||
import { DashboardLanding } from './DashboardLanding'
|
import { DashboardLanding } from './DashboardLanding'
|
||||||
import { Modal } from './modal'
|
import { Modal } from './modal'
|
||||||
|
import { SettingsPage } from './SettingsPage'
|
||||||
import { Sidebar } from './Sidebar'
|
import { Sidebar } from './Sidebar'
|
||||||
|
|
||||||
export function Dashboard({ render }: { render: () => void }) {
|
function MainContent({ render }: { render: () => void }) {
|
||||||
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 (currentView === 'settings') return <SettingsPage render={render} />
|
||||||
|
return <DashboardLanding render={render} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dashboard({ render }: { render: () => void }) {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<Styles />
|
<Styles />
|
||||||
{!isNarrow && <Sidebar render={render} />}
|
{!isNarrow && <Sidebar render={render} />}
|
||||||
{selected ? (
|
<MainContent render={render} />
|
||||||
<AppDetail app={selected} render={render} />
|
|
||||||
) : (
|
|
||||||
<DashboardLanding render={render} />
|
|
||||||
)}
|
|
||||||
<Modal />
|
<Modal />
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { useEffect } from 'hono/jsx'
|
import { useEffect } from 'hono/jsx'
|
||||||
import { openAppSelectorModal } from '../modals'
|
import { openAppSelectorModal } from '../modals'
|
||||||
import { apps, isNarrow, setSelectedApp } from '../state'
|
import { apps, isNarrow, setCurrentView, setSelectedApp } from '../state'
|
||||||
import {
|
import {
|
||||||
AppSelectorChevron,
|
AppSelectorChevron,
|
||||||
DashboardContainer,
|
DashboardContainer,
|
||||||
DashboardHeader,
|
DashboardHeader,
|
||||||
DashboardInstallCmd,
|
DashboardInstallCmd,
|
||||||
DashboardTitle,
|
DashboardTitle,
|
||||||
|
SettingsGear,
|
||||||
StatusDot,
|
StatusDot,
|
||||||
StatusDotLink,
|
StatusDotLink,
|
||||||
StatusDotsRow,
|
StatusDotsRow,
|
||||||
|
|
@ -25,8 +26,21 @@ export function DashboardLanding({ render }: { render: () => void }) {
|
||||||
|
|
||||||
const narrow = isNarrow || undefined
|
const narrow = isNarrow || undefined
|
||||||
|
|
||||||
|
const openSettings = () => {
|
||||||
|
setSelectedApp(null)
|
||||||
|
setCurrentView('settings')
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardContainer narrow={narrow}>
|
<DashboardContainer narrow={narrow} relative>
|
||||||
|
<SettingsGear
|
||||||
|
onClick={openSettings}
|
||||||
|
title="Settings"
|
||||||
|
style={{ position: 'absolute', top: 16, right: 16 }}
|
||||||
|
>
|
||||||
|
⚙️
|
||||||
|
</SettingsGear>
|
||||||
<DashboardHeader>
|
<DashboardHeader>
|
||||||
<DashboardTitle narrow={narrow}>
|
<DashboardTitle narrow={narrow}>
|
||||||
🐾 Toes
|
🐾 Toes
|
||||||
|
|
|
||||||
87
src/client/components/SettingsPage.tsx
Normal file
87
src/client/components/SettingsPage.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { useEffect, useState } from 'hono/jsx'
|
||||||
|
import { getWifiConfig, saveWifiConfig } from '../api'
|
||||||
|
import { setCurrentView } from '../state'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FormActions,
|
||||||
|
FormField,
|
||||||
|
FormInput,
|
||||||
|
FormLabel,
|
||||||
|
HeaderActions,
|
||||||
|
Main,
|
||||||
|
MainContent,
|
||||||
|
MainHeader,
|
||||||
|
MainTitle,
|
||||||
|
Section,
|
||||||
|
SectionTitle,
|
||||||
|
} from '../styles'
|
||||||
|
|
||||||
|
export function SettingsPage({ render }: { render: () => void }) {
|
||||||
|
const [network, setNetwork] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getWifiConfig().then(config => {
|
||||||
|
setNetwork(config.network)
|
||||||
|
setPassword(config.password)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
setCurrentView('dashboard')
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async (e: Event) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
setSaved(false)
|
||||||
|
await saveWifiConfig({ network, password })
|
||||||
|
setSaving(false)
|
||||||
|
setSaved(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Main>
|
||||||
|
<MainHeader>
|
||||||
|
<MainTitle>Settings</MainTitle>
|
||||||
|
<HeaderActions>
|
||||||
|
<Button onClick={goBack}>Back</Button>
|
||||||
|
</HeaderActions>
|
||||||
|
</MainHeader>
|
||||||
|
<MainContent>
|
||||||
|
<Section>
|
||||||
|
<SectionTitle>WiFi</SectionTitle>
|
||||||
|
<form onSubmit={handleSave} style={{ display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 400 }}>
|
||||||
|
<FormField>
|
||||||
|
<FormLabel>Network</FormLabel>
|
||||||
|
<FormInput
|
||||||
|
type="text"
|
||||||
|
value={network}
|
||||||
|
onInput={(e: Event) => setNetwork((e.target as HTMLInputElement).value)}
|
||||||
|
placeholder="SSID"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<FormInput
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onInput={(e: Event) => setPassword((e.target as HTMLInputElement).value)}
|
||||||
|
placeholder="Password"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormActions>
|
||||||
|
{saved && <span style={{ fontSize: 13, color: '#888', alignSelf: 'center' }}>Saved</span>}
|
||||||
|
<Button variant="primary" type="submit" disabled={saving}>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</FormActions>
|
||||||
|
</form>
|
||||||
|
</Section>
|
||||||
|
</MainContent>
|
||||||
|
</Main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { openNewAppModal } from '../modals'
|
import { openNewAppModal } from '../modals'
|
||||||
import {
|
import {
|
||||||
|
setCurrentView,
|
||||||
setSelectedApp,
|
setSelectedApp,
|
||||||
setSidebarCollapsed,
|
setSidebarCollapsed,
|
||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
|
|
@ -18,6 +19,7 @@ import { AppSelector } from './AppSelector'
|
||||||
export function Sidebar({ render }: { render: () => void }) {
|
export function Sidebar({ render }: { render: () => void }) {
|
||||||
const goToDashboard = () => {
|
const goToDashboard = () => {
|
||||||
setSelectedApp(null)
|
setSelectedApp(null)
|
||||||
|
setCurrentView('dashboard')
|
||||||
render()
|
render()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,11 +31,9 @@ export function Sidebar({ render }: { render: () => void }) {
|
||||||
return (
|
return (
|
||||||
<SidebarContainer style={sidebarCollapsed ? { width: 'auto' } : undefined}>
|
<SidebarContainer style={sidebarCollapsed ? { width: 'auto' } : undefined}>
|
||||||
<Logo>
|
<Logo>
|
||||||
{!sidebarCollapsed && (
|
<LogoLink onClick={goToDashboard} title="Go to dashboard">
|
||||||
<LogoLink onClick={goToDashboard} title="Go to dashboard">
|
{sidebarCollapsed ? '🐾' : '🐾 Toes'}
|
||||||
🐾 Toes
|
</LogoLink>
|
||||||
</LogoLink>
|
|
||||||
)}
|
|
||||||
<HamburgerButton onClick={toggleSidebar} title={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}>
|
<HamburgerButton onClick={toggleSidebar} title={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}>
|
||||||
<HamburgerLine />
|
<HamburgerLine />
|
||||||
<HamburgerLine />
|
<HamburgerLine />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { App } from '../shared/types'
|
import type { App } from '../shared/types'
|
||||||
|
|
||||||
// UI state (survives re-renders)
|
// UI state (survives re-renders)
|
||||||
|
export let currentView: 'dashboard' | 'settings' = 'dashboard'
|
||||||
export let isNarrow: boolean = window.matchMedia('(max-width: 768px)').matches
|
export let isNarrow: boolean = window.matchMedia('(max-width: 768px)').matches
|
||||||
export let selectedApp: string | null = localStorage.getItem('selectedApp')
|
export let selectedApp: string | null = localStorage.getItem('selectedApp')
|
||||||
export let sidebarCollapsed: boolean = localStorage.getItem('sidebarCollapsed') === 'true'
|
export let sidebarCollapsed: boolean = localStorage.getItem('sidebarCollapsed') === 'true'
|
||||||
|
|
@ -13,6 +14,10 @@ export let apps: App[] = []
|
||||||
export let appTabs: Record<string, string> = JSON.parse(localStorage.getItem('appTabs') || '{}')
|
export let appTabs: Record<string, string> = JSON.parse(localStorage.getItem('appTabs') || '{}')
|
||||||
|
|
||||||
// State setters
|
// State setters
|
||||||
|
export function setCurrentView(view: 'dashboard' | 'settings') {
|
||||||
|
currentView = view
|
||||||
|
}
|
||||||
|
|
||||||
export function setSelectedApp(name: string | null) {
|
export function setSelectedApp(name: string | null) {
|
||||||
selectedApp = name
|
selectedApp = name
|
||||||
if (name) {
|
if (name) {
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ export {
|
||||||
SectionLabel,
|
SectionLabel,
|
||||||
SectionSwitcher,
|
SectionSwitcher,
|
||||||
SectionTab,
|
SectionTab,
|
||||||
|
SettingsGear,
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
StatCard,
|
StatCard,
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,9 @@ export const DashboardContainer = define('DashboardContainer', {
|
||||||
padding: 20,
|
padding: 20,
|
||||||
gap: 24,
|
gap: 24,
|
||||||
},
|
},
|
||||||
|
relative: {
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -264,6 +267,23 @@ export const StatValue = define('StatValue', {
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const SettingsGear = define('SettingsGear', {
|
||||||
|
base: 'button',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 18,
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
color: theme('colors-text'),
|
||||||
|
background: theme('colors-bgHover'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export const StatLabel = define('StatLabel', {
|
export const StatLabel = define('StatLabel', {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: theme('colors-textMuted'),
|
color: theme('colors-textMuted'),
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { allApps, APPS_DIR, onChange } from '$apps'
|
import { allApps, APPS_DIR, onChange, TOES_DIR } from '$apps'
|
||||||
import { onHostLog } from '../tui'
|
import { onHostLog } from '../tui'
|
||||||
import { Hype } from '@because/hype'
|
import { Hype } from '@because/hype'
|
||||||
import { cpus, platform, totalmem } from 'os'
|
import { cpus, platform, totalmem } from 'os'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { readFileSync, statfsSync } from 'fs'
|
import { existsSync, mkdirSync, readFileSync, statfsSync, writeFileSync } from 'fs'
|
||||||
|
|
||||||
export interface AppMetrics {
|
export interface AppMetrics {
|
||||||
cpu: number
|
cpu: number
|
||||||
|
|
@ -18,6 +18,11 @@ 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
|
||||||
|
|
@ -199,6 +204,38 @@ 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')
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user