A self-hosted dashboard for a personal Mac mini server: watch system load, reach the services running on it, browse files locally or over FTP, check cloud functions, keep bookmarks, and run a small set of maintenance commands.
Built with Next.js 16 (App Router), React 19, Tailwind 4, and TypeScript. It
runs in production mode on port 8096 under a macOS launchd agent.
Captured against a throwaway instance seeded with fake data — the services, bookmarks, cloud projects, hostname and paths below are all placeholders.
Every self-hosted app with a live reachability probe and round-trip latency.
CPU per core, memory, load average, network throughput, top processes and volumes.
Browse the local filesystem or connect to a remote host over FTP.
Hosted backends across Supabase, Firebase and Cloudflare, probed the same way.
Saved links grouped by category, with metadata filled in automatically.
Predefined maintenance commands plus an arbitrary bash escape hatch.
- Services — the self-hosted apps on the box, each with a local and tailnet URL and a live reachability dot.
- System — CPU (per-core), memory, disk, network throughput, and the top
processes by CPU, polled from
systeminformation. - Files — a browser for the local filesystem plus an FTP client (upload,
download, navigate) backed by
basic-ftp. - Serverless — the deployed Firebase and Supabase functions and their state.
- Bookmarks — saved links whose title/description/favicon are filled in automatically by a Supabase edge function, with direct scraping as a fallback.
- Actions — one-click shell commands (restart the gateway, run backups, clear caches) with stdout and stderr streamed back into the page. The command list is operator config, not source — see below.
npm install
cp .env.example .env.local # then fill it in
npm run dev # http://localhost:3000Every variable in .env.example is optional. With none of them set the panel
runs entirely off the gitignored data/*.json fallback store, which is enough to
boot it and click around.
Operator-specific data — service hostnames, tailnet names, cloud project refs, bookmarks, credential paths — is deliberately kept out of the repository. It lives in one of two places:
- Supabase, in the tables
panel_services,panel_serverless,panel_bookmarksandpanel_actions(schema undersupabase/migrations/). Used when bothSUPABASE_URLandSUPABASE_SERVICE_ROLE_KEYare set. data/*.json, a gitignored local fallback used when Supabase is not configured or is unreachable, so the panel still renders offline.
Both are read through src/lib/panel-store.ts. The files under src/config/
define types only — adding a service or a bookmark means inserting a row, not
editing source. Seed a fresh Supabase project with:
SUPABASE_URL=... SUPABASE_SERVICE_ROLE_KEY=... node scripts/seed-supabase.mjs| Route | Purpose |
|---|---|
GET /api/system |
Host metrics: CPU, memory, disk, network, processes |
GET /api/services |
Configured services and their reachability |
GET /api/serverless |
Deployed cloud functions |
GET/POST/DELETE /api/bookmarks |
Bookmark CRUD |
POST /api/webhook/bookmark |
Create a bookmark with generated metadata |
GET /api/actions |
List the configured action buttons |
POST /api/actions |
Execute a shell command (requires auth) |
/api/fs/* |
Local filesystem list, upload, download |
/api/ftp/* |
FTP connect, list, upload, download, disconnect |
The panel runs under launchd as com.openclaw.admin-panel, defined in
~/Library/LaunchAgents/com.openclaw.admin-panel.plist. To ship a change:
npm run build
launchctl kickstart -k gui/$(id -u)/com.openclaw.admin-panel
curl -sI http://127.0.0.1:8096/With Basic auth configured that last check returns 401, which still proves the
server is up. Pass -u "$PANEL_AUTH_USER:$PANEL_AUTH_PASSWORD" for a 200.
Logs go to logs/admin.log and logs/admin.err.log.
One gotcha worth knowing: launchd does not inherit your shell PATH. The
plist's PATH must include /usr/sbin:/sbin, because systeminformation
shells out to diskutil, ioreg and sysctl — without them the System tab
silently returns empty disk and hardware data.
POST /api/actions executes shell commands, so the panel is protected by HTTP
Basic auth. Set both PANEL_AUTH_USER and PANEL_AUTH_PASSWORD in .env.local
and every route — pages and API alike — requires credentials.
The panel has no login screen, so any token the browser holds would also be readable by anyone who can load the page. Basic auth is the only boundary that actually keeps a tailnet or LAN neighbour out of the command executor.
While those two variables are unset the panel stays browsable but the executor
fails closed: POST /api/actions returns 503 and runs nothing. Execution
is never silently unauthenticated.
The executor is additionally bounded — commands are killed after 60 seconds and captured output is capped at 1 MB — but it still runs arbitrary shell as the panel's user. Treat access to this panel as shell access to the host.
.env*, .firebaserc and data/ are gitignored and must stay that way — see
.env.example and .firebaserc.example for the shape of each.





