Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Dependencies and build artifacts
node_modules
dist
.git
.gitignore

# IDE and OS
.vscode
.idea
*.log
.DS_Store
Thumbs.db

# Env and secrets (mount at runtime)
.env
.env.local
.env.*.local

# Tauri build outputs (not needed for Docker image)
src-tauri/target

# Tests and dev
e2e
tests
*.spec.ts
*.test.mjs
playwright
playwright.config.*

# Docs and meta (optional: remove if you want them in image)
docs
*.md
!README.md

# Desktop packaging
scripts/desktop-package.mjs
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

All notable changes to World Monitor are documented here.

## [Unreleased]

### Added

- **Self-hosted Docker image**: Single container runs the full dashboard (SPA + 45+ API handlers). `Dockerfile` and `docker-compose.yml` with optional env file for API keys. Sidecar supports `LOCAL_API_MODE=standalone` to serve static `dist/` and listen on `0.0.0.0` for in-container use. README updated with Option 4 (Self-hosted Docker) and roadmap item marked done.
- **Static serving security**: Standalone mode uses `path.relative()` to enforce in-directory resolution and prevent path traversal when serving files from `dist/`.

## [2.3.4] - 2026-02-16

### Fixed
Expand Down
35 changes: 35 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# World Monitor — self-hosted Docker image
# Serves the built SPA + local API (45+ handlers). Optional API keys via env.
# See README "Self-hosting with Docker" and .env.example.

FROM node:20-alpine AS builder

WORKDIR /app

COPY package.json package-lock.json* ./
RUN npm ci --omit=dev

COPY . .
ENV VITE_VARIANT=full
RUN npm run build:full

# Runner stage: same Node, no devDependencies
FROM node:20-alpine

WORKDIR /app

COPY package.json package-lock.json* ./
RUN npm ci --omit=dev

COPY api ./api
COPY src-tauri/sidecar ./src-tauri/sidecar
COPY --from=builder /app/dist ./dist

ENV NODE_ENV=production
ENV LOCAL_API_MODE=standalone
ENV LOCAL_API_PORT=3000
ENV LOCAL_API_CLOUD_FALLBACK=true

EXPOSE 3000

CMD ["node", "src-tauri/sidecar/local-api-server.mjs"]
36 changes: 33 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -904,15 +904,45 @@ npm run dev # Vite dev server on http://localhost:5173

This runs the frontend without the API layer. Panels that require server-side proxying will show "No data available". The interactive map, static data layers (bases, cables, pipelines), and browser-side ML models still work.

### Option 4: Self-hosted Docker

Run the full dashboard (SPA + 45+ API handlers) in a single container. The image serves the built frontend and the same Node-based API layer used by the desktop app, with optional cloud fallback when handlers fail.

```bash
# Build and run (no API keys required; panels without keys stay empty or use cloud fallback)
docker build -t worldmonitor .
docker run -p 3000:3000 worldmonitor
```

Open [http://localhost:3000](http://localhost:3000). To enable AI, markets, or other features, pass env vars (see `.env.example`):

```bash
docker run -p 3000:3000 \
-e GROQ_API_KEY=your_groq_key \
-e UPSTASH_REDIS_REST_URL=... \
-e UPSTASH_REDIS_REST_TOKEN=... \
worldmonitor
```

Or use a compose file and an env file:

```bash
cp .env.example .env
# Edit .env with your keys (optional)
docker compose up -d
```

The container listens on `0.0.0.0:3000`. With `LOCAL_API_CLOUD_FALLBACK=true` (default), any handler that errors or is missing will transparently use the cloud API (worldmonitor.app).

### Platform Notes

| Platform | Status | Notes |
|----------|--------|-------|
| **Vercel** | Full support | Recommended deployment target |
| **Linux x86_64** | Works with `vercel dev` | Full local development |
| **macOS** | Works with `vercel dev` | Full local development |
| **Raspberry Pi / ARM** | Partial | `vercel dev` edge runtime emulation may not work on ARM. Use Option 1 (deploy to Vercel) or Option 3 (static frontend) instead |
| **Docker** | Planned | See [Roadmap](#roadmap) |
| **Raspberry Pi / ARM** | Partial | `vercel dev` edge runtime emulation may not work on ARM. Use Option 1 (deploy to Vercel), Option 3 (static frontend), or Option 4 (Docker on ARM if Node image supports it) |
| **Docker** | Supported | Single image: SPA + API. See [Option 4](#option-4-self-hosted-docker) above. |

### Railway Relay (Optional)

Expand Down Expand Up @@ -1009,7 +1039,7 @@ Desktop release details, signing hooks, variant outputs, and clean-machine valid
- [x] Entity index with cross-source correlation and confidence scoring
- [ ] Mobile-optimized views
- [ ] Push notifications for critical alerts
- [ ] Self-hosted Docker image
- [x] Self-hosted Docker image

See [full roadmap](./docs/DOCUMENTATION.md#roadmap).

Expand Down
16 changes: 16 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# World Monitor — self-hosted stack
# Usage: docker compose up -d
# Optional: cp .env.example .env, add API keys, then uncomment env_file below.

services:
app:
build: .
ports:
- "3000:3000"
# env_file: [ .env ]
environment:
NODE_ENV: production
LOCAL_API_MODE: standalone
LOCAL_API_PORT: 3000
LOCAL_API_CLOUD_FALLBACK: "true"
restart: unless-stopped
62 changes: 59 additions & 3 deletions src-tauri/sidecar/local-api-server.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import http, { createServer } from 'node:http';
import https from 'node:https';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { existsSync, readFileSync, writeFileSync, createReadStream, statSync } from 'node:fs';
import { readdir } from 'node:fs/promises';
import { gzipSync } from 'node:zlib';
import path from 'node:path';
Expand Down Expand Up @@ -312,6 +312,8 @@ function resolveConfig(options = {}) {
const mode = String(options.mode ?? process.env.LOCAL_API_MODE ?? 'desktop-sidecar');
const cloudFallback = String(options.cloudFallback ?? process.env.LOCAL_API_CLOUD_FALLBACK ?? '') === 'true';
const logger = options.logger ?? console;
const distDir = options.distDir ?? (mode === 'standalone' ? path.join(resourceDir, 'dist') : null);
const host = options.host ?? (mode === 'standalone' ? '0.0.0.0' : '127.0.0.1');

return {
port,
Expand All @@ -321,6 +323,8 @@ function resolveConfig(options = {}) {
mode,
cloudFallback,
logger,
distDir: distDir && existsSync(distDir) ? distDir : null,
host,
};
}

Expand Down Expand Up @@ -833,10 +837,61 @@ export async function createLocalApiServer(options = {}) {
loadVerboseState(context.resourceDir);
const routes = await buildRouteTable(context.apiDir);

const MIME_TYPES = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json',
'.ico': 'image/x-icon',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.woff2': 'font/woff2',
'.woff': 'font/woff',
'.map': 'application/json',
};

function serveStatic(requestUrl, res, req) {
if (!context.distDir || (req.method !== 'GET' && req.method !== 'HEAD')) return false;
const safePath = requestUrl.pathname.replace(/^\/+/, '').replace(/\/+/g, '/');
if (safePath.includes('..') || path.isAbsolute(safePath)) return false;
const filePath = path.join(context.distDir, safePath || 'index.html');
const resolved = path.resolve(filePath);
const rel = path.relative(context.distDir, resolved);
if (rel.startsWith('..') || path.isAbsolute(rel)) return false;
let target = resolved;
if (!existsSync(target)) {
if (!safePath || safePath.endsWith('/')) {
target = path.join(resolved, 'index.html');
} else {
target = path.join(context.distDir, 'index.html');
}
if (!existsSync(target)) return false;
}
const stat = statSync(target);
if (stat.isDirectory()) {
target = path.join(target, 'index.html');
if (!existsSync(target)) return false;
}
const ext = path.extname(target);
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
const stat2 = statSync(target);
const headers = { 'content-type': contentType, ...makeCorsHeaders(req) };
if (req.method === 'HEAD') {
headers['content-length'] = String(stat2.size);
res.writeHead(200, headers);
res.end();
} else {
res.writeHead(200, headers);
createReadStream(target).pipe(res);
}
return true;
}

const server = createServer(async (req, res) => {
const requestUrl = new URL(req.url || '/', `http://127.0.0.1:${context.port}`);

if (!requestUrl.pathname.startsWith('/api/')) {
if (serveStatic(requestUrl, res, req)) return;
res.writeHead(404, { 'content-type': 'application/json', ...makeCorsHeaders(req) });
res.end(JSON.stringify({ error: 'Not found' }));
return;
Expand Down Expand Up @@ -913,12 +968,13 @@ export async function createLocalApiServer(options = {}) {

server.once('listening', onListening);
server.once('error', onError);
server.listen(context.port, '127.0.0.1');
server.listen(context.port, context.host);
});

const address = server.address();
const boundPort = typeof address === 'object' && address?.port ? address.port : context.port;
context.logger.log(`[local-api] listening on http://127.0.0.1:${boundPort} (apiDir=${context.apiDir}, routes=${routes.length}, cloudFallback=${context.cloudFallback})`);
const bindHost = context.host === '0.0.0.0' ? '0.0.0.0' : '127.0.0.1';
context.logger.log(`[local-api] listening on http://${bindHost}:${boundPort} (apiDir=${context.apiDir}, routes=${routes.length}, cloudFallback=${context.cloudFallback}${context.distDir ? ', static=' + context.distDir : ''})`);
return { port: boundPort };
},
async close() {
Expand Down