Progressive web app to manage all your odio instances from one place.
Part of the odio project — full documentation.
Progressive Web App for discovering and controlling odio-api instances on your local network.
Manage multiple odio-api endpoints from a single interface — add instances manually, monitor their status, and connect to their embedded web UI via iframe with one-tap switching.
- Instance management — add, edit, delete odio-api instances (IP/hostname + port), persisted in localStorage
- Deep linking — open an instance directly via
#/i/<host>/<port>?label=<name>(port and label optional). First-time visits stay in-memory only and prompt to save on exit, so QR codes and shared links don't silently pollute the user's list - Real-time status — live updates via SSE (
/events), up to 6 concurrent connections; additional instances fall back to HTTP polling every 30 s - Smart reconnect — exponential backoff on connection loss (1 s → 30 s cap); gives up after 90 s of consecutive failures
- Power event handling — reboot shows a waiting screen and auto-reloads when the server comes back; poweroff offers to wait or go back to the list immediately; servers without SSE support are detected automatically and use polling only
- Embedded UI — loads each instance's
/uiin a full-screen iframe - Quick switch — compact top bar with dropdown to jump between online instances
- PWA — installable, offline-capable app shell, service worker caching
- Dark theme — responsive layout (single column on mobile, grid on desktop)
- In-app diagnostics: Firefox / Safari desktop on HTTPS get a persistent banner and a disabled "+ Add Instance" button; instance cards distinguish Server unreachable, Server reachable: missing CORS headers, and Browser blocked (mixed content) so the user knows what to fix
git clone https://github.com/b0bbywan/odio-pwa.git
cd odio-pwa
npm installStart the dev server:
npm run devOpens at http://localhost:5173 by default.
Run type checking:
npm run checksrc/
├── lib/
│ ├── types.ts # OdioServerInfo, OdioInstance, PowerEvent
│ ├── api.ts # probeInstance(), getInstanceUiUrl()
│ ├── sse.ts # connectSSE() — thin EventSource wrapper
│ ├── connection.ts # createConnection() — backoff, give-up, SSE/polling
│ └── state.svelte.ts # Reactive state (Svelte 5 runes), instancePath() helper
├── components/
│ ├── InstanceList.svelte # Discovery screen with card grid
│ ├── InstanceCard.svelte # Status indicator, server info, actions
│ ├── AddInstanceForm.svelte # Add/edit form (host, port, label)
│ ├── InstanceTopBar.svelte # Navigation bar with instance switcher + Save button
│ ├── PowerScreen.svelte # Full-screen reboot/poweroff state display
│ ├── InstanceView.svelte # Iframe embed, power event orchestration, save prompt
│ └── ReloadPrompt.svelte # PWA update toast
├── App.svelte # Hash routing (svelte-spa-router): / and /i/:host/:port?
├── app.css # Global styles, dark theme
└── main.ts # App bootstrap
- Svelte 5 with runes (
$state,$derived,$effect) - svelte-spa-router for hash-based deep linking
- Vite
- vite-plugin-pwa with Workbox
- Vitest + @testing-library/svelte for unit and component tests
Run tests:
npm run testnpm run build
npm run previewThe production build outputs to dist/. The preview server serves it at http://localhost:4173.
- Open the app in your browser
- Tap + Add Instance and enter the IP/hostname and port (default
8018) of an odio-api instance - The app probes
/serverto check if the instance is online - Tap Connect on an online instance to load its UI in an iframe
- Use the switch dropdown in the top bar to jump between instances
You can also open an instance straight from a URL:
https://pwa.odio.love/#/i/<host>[/<port>][?label=<name>]
hostis required,portdefaults to8018,labelis optional- Examples:
#/i/192.168.1.10,#/i/odio.local/9000?label=Living%20Room - The instance is held in-memory only; a Save button appears in the top bar to persist it, and leaving without saving (in-app or browser back) prompts to Save / Don't save / Cancel
odio-pwa uses Server-Sent Events (/events) for real-time status updates when the odio-api version supports it. If the endpoint is not available (404 or connection error before the stream opens), the app automatically falls back to HTTP polling every 30 s — no configuration required.
The status probe (fetch /server) and the SSE stream (fetch /events) are cross-origin requests. Your odio-api instances must respond with:
Access-Control-Allow-Origin: *
For those using https://odio-pwa.vercel.app, odio-api already support the appropriate header
The iframe loading /ui does not require CORS headers.
The service worker caches only the app shell (HTML, CSS, JS, icons). It does not cache cross-origin requests to odio-api instances. The app is installable on supported browsers and works offline (showing the cached instance list — instances will appear as offline until the network is available).
A Docker image is published to GHCR for each v* tag, alongside a zip of the static build attached to the GitHub Release.
docker run -d -p 8080:80 --restart unless-stopped \
--name odio-pwa ghcr.io/b0bbywan/odio-pwa:latestThe image is multi-arch (linux/amd64, linux/arm64) and ships an nginx configured for SPA routing and PWA cache headers.
Download odio-pwa-<version>.zip from the Releases page and serve the extracted files with any static web server. Make sure to:
- rewrite unknown routes to
/index.html(SPA fallback) - serve
/index.html,/sw.js,/registerSW.jswithCache-Control: no-cacheso PWA updates propagate
The PWA makes HTTP calls to your odio-api instances on the LAN. When the PWA itself is served over HTTPS (e.g. behind a public reverse proxy), behavior varies by browser:
- Chrome 142+ shows a Local Network Access permission prompt on the first HTTP LAN call; granting it bypasses the mixed-content block for private IPs,
.localdomains, and loopback. - Safari (iOS/macOS) blocks mixed-content fetches strictly — no LAN exemption in WebKit.
- Firefox exempts
localhost/.localhost, but private IPs are still blocked (MDN: Mixed content).
Serving the PWA over plain HTTP on the LAN avoids all of this.
When the PWA detects Firefox or Safari desktop on an HTTPS context, it shows a persistent banner pointing back at this guide and disables the "+ Add Instance" button (no point in adding nodes that cannot be reached).
When a probe fails for an existing instance, the card distinguishes three cases by re-probing with mode: 'no-cors':
- Server unreachable (red): the server is genuinely down, or the request was blocked at the network layer.
- Server reachable: missing CORS headers (amber): the request round-tripped but the response lacks
Access-Control-Allow-Origin. Add the PWA's origin to the node'sapi.cors.origins. - Browser blocked (mixed content) (amber): both the regular and
no-corsprobes failed on an HTTPS PWA, so a browser-level block is the most likely cause.
The app checks GitHub for a newer stable release and shows an arrow next to the version when one is available. By default prereleases are ignored. To also surface prereleases (useful when testing an RC image), run this in the browser devtools:
localStorage.setItem('odio-include-prereleases', 'true'); location.reload();To revert: localStorage.removeItem('odio-include-prereleases'); location.reload().