# JARVIS Web Frontend (Phase 3b) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a React/Vite web frontend (Chat + Dashboard) for JARVIS, protected by a shared-secret gate, served on `jarvis.mbo-tech-it.de` while the API moves to `api.jarvis.mbo-tech-it.de`. **Architecture:** A shared-secret FastAPI dependency (`require_admin_key`) protects the existing API endpoints. A separate React/Vite SPA (new `jarvis-web` Docker service, nginx-served) calls that API from the browser, storing the shared secret in `localStorage` and attaching it as `X-Admin-Key` on every call. **Tech Stack:** FastAPI (existing), React + Vite + TypeScript (new), Vitest (frontend unit tests), pytest + FastAPI TestClient + httpx (backend unit test), nginx:alpine (static serving), Docker multi-stage build. **Spec:** `docs/superpowers/specs/2026-09-12-web-frontend-design.md` ## Global Constraints - No dedicated git repository for this project (see prior plans' Global Constraints) — every "Commit" step below is replaced by "confirm the file is saved"; do not run `git add`/`git commit`. - Runtime is the live VPS at `72.61.186.98`. SSH: `ssh -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key jarvis-core@72.61.186.98`. Local backend files live in `Claude outputs/` (note the space — quote the path), local frontend files go in a new `web/` directory at the project root (`C:\Users\Jonny\Projekte\Claude\JARVIS\web`). - Reuse the existing `API_KEY_ADMIN` secret already generated in the deployed `.env` — do not create a new secret. - Auth header name is `X-Admin-Key` (exact casing as written; HTTP headers are case-insensitive but FastAPI's `Header()` parameter name `x_admin_key` maps to it automatically). - Domain split: `jarvis.mbo-tech-it.de` → frontend, `api.jarvis.mbo-tech-it.de` → API. Both are covered by the existing `*.jarvis.mbo-tech-it.de` wildcard DNS record — no new DNS entries needed. - `GET /health` stays unprotected (monitoring). Every other `/api/v1/*` route gets the auth dependency. - Local toolchain confirmed available: Node v24.14.0 / npm 11.9.0 (frontend, runs natively on this Windows machine); Python 3.14 locally cannot build `psycopg2-binary` (no wheel yet) — backend tests that import `main.py` must run inside a `python:3.11-slim` container, same pattern as the knowledge-base plan: `docker run --rm -v :/app -w /app python:3.11-slim bash -c '...'` (or via SSH on the VPS, which already has this image cached, if local Docker Desktop isn't running). --- ### Task 1: Backend access control **Files:** - Modify: `Claude outputs/main.py` (CORS, `API_KEY_ADMIN` config, `require_admin_key` dependency, route decorators) - Modify: `Claude outputs/docker-compose.yml` (jarvis-api environment) - Create: `Claude outputs/requirements-dev.txt` (add `httpx` if not already present) - Test: `Claude outputs/tests/test_auth.py` **Interfaces:** - Produces: `require_admin_key` FastAPI dependency in `main.py`, applied via `dependencies=[Depends(require_admin_key)]` on every protected route. Later tasks (frontend) rely on the header name `X-Admin-Key` and on `GET /health` remaining open. - [ ] **Step 1: Add `httpx` to the dev requirements** (needed for FastAPI's `TestClient`) Read `Claude outputs/requirements-dev.txt` first (it currently contains `pytest==8.3.3` from the knowledge-base plan). Append: ``` httpx==0.27.2 ``` - [ ] **Step 2: Write the failing tests** Create `Claude outputs/tests/test_auth.py`: ```python import os import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) import pytest from fastapi.testclient import TestClient import main main.API_KEY_ADMIN = "test-secret" @pytest.fixture() def client(): with TestClient(main.app) as c: yield c def test_missing_key_returns_401(client): response = client.get("/api/v1/admin/stats") assert response.status_code == 401 def test_wrong_key_returns_401(client): response = client.get("/api/v1/admin/stats", headers={"X-Admin-Key": "nope"}) assert response.status_code == 401 def test_correct_key_passes_auth_gate(client): response = client.get("/api/v1/admin/stats", headers={"X-Admin-Key": "test-secret"}) assert response.status_code != 401 def test_health_stays_open_without_key(client): response = client.get("/health") assert response.status_code != 401 ``` - [ ] **Step 3: Run tests to verify they fail** Using a local Docker container (fastest path if Docker Desktop is running): ```bash docker run --rm -v "C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs:/app" -w /app python:3.11-slim bash -c "pip install -q -r requirements-dev.txt -r requirements.txt && python -m pytest tests/test_auth.py -v" ``` If Docker Desktop isn't running locally, run the same command over SSH against the VPS instead (copy the files to a scratch dir first, matching the pattern used for the chunking tests): ```bash SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key" ssh $SSHOPTS jarvis-core@72.61.186.98 "mkdir -p /tmp/jarvis-test/tests" OUT="C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs" scp $SSHOPTS "$OUT/main.py" "$OUT/requirements.txt" "$OUT/requirements-dev.txt" jarvis-core@72.61.186.98:/tmp/jarvis-test/ scp $SSHOPTS "$OUT/tests/test_auth.py" jarvis-core@72.61.186.98:/tmp/jarvis-test/tests/ ssh $SSHOPTS jarvis-core@72.61.186.98 "docker run --rm -v /tmp/jarvis-test:/app -w /app python:3.11-slim bash -c 'pip install -q -r requirements-dev.txt -r requirements.txt && python -m pytest tests/test_auth.py -v'" ``` Expected: FAIL — `require_admin_key` doesn't exist yet, or (once it exists but isn't wired up) the 401 assertions fail because routes aren't protected yet. - [ ] **Step 4: Add the config constant and dependency** In `Claude outputs/main.py`, change the fastapi import line: ```python from fastapi import FastAPI, HTTPException ``` to: ```python from fastapi import FastAPI, HTTPException, Depends, Header ``` Add the config constant next to the other `# ============ CONFIG ============` constants: ```python API_KEY_ADMIN = os.getenv("API_KEY_ADMIN") ``` Add the dependency function right after the CORS `app.add_middleware(...)` block: ```python async def require_admin_key(x_admin_key: Optional[str] = Header(default=None)): if not API_KEY_ADMIN or x_admin_key != API_KEY_ADMIN: raise HTTPException(status_code=401, detail="Invalid or missing admin key") ``` - [ ] **Step 5: Tighten CORS** In `Claude outputs/main.py`, change: ```python app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) ``` to: ```python app.add_middleware( CORSMiddleware, allow_origins=["https://jarvis.mbo-tech-it.de"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) ``` - [ ] **Step 6: Protect every `/api/v1/*` route** In `Claude outputs/main.py`, change each of these decorator lines (leave the function bodies untouched): ```python @app.post("/api/v1/chat", response_model=ChatResponse) ``` → ```python @app.post("/api/v1/chat", response_model=ChatResponse, dependencies=[Depends(require_admin_key)]) ``` ```python @app.get("/api/v1/conversations/{conversation_id}") ``` → ```python @app.get("/api/v1/conversations/{conversation_id}", dependencies=[Depends(require_admin_key)]) ``` ```python @app.post("/api/v1/tasks", response_model=TaskResponse) ``` → ```python @app.post("/api/v1/tasks", response_model=TaskResponse, dependencies=[Depends(require_admin_key)]) ``` ```python @app.get("/api/v1/tasks") ``` → ```python @app.get("/api/v1/tasks", dependencies=[Depends(require_admin_key)]) ``` ```python @app.post("/api/v1/documents") ``` → ```python @app.post("/api/v1/documents", dependencies=[Depends(require_admin_key)]) ``` ```python @app.get("/api/v1/documents") ``` → ```python @app.get("/api/v1/documents", dependencies=[Depends(require_admin_key)]) ``` ```python @app.post("/api/v1/workflows/trigger") ``` → ```python @app.post("/api/v1/workflows/trigger", dependencies=[Depends(require_admin_key)]) ``` ```python @app.get("/api/v1/admin/stats") ``` → ```python @app.get("/api/v1/admin/stats", dependencies=[Depends(require_admin_key)]) ``` ```python @app.get("/api/v1/admin/health/detailed") ``` → ```python @app.get("/api/v1/admin/health/detailed", dependencies=[Depends(require_admin_key)]) ``` Leave `@app.get("/health")` exactly as-is (unprotected). - [ ] **Step 7: Run tests to verify they pass** Re-run the same command from Step 3. Expected: 4 passed. - [ ] **Step 8: Wire `API_KEY_ADMIN` into the deployed container environment** In `Claude outputs/docker-compose.yml`, under `jarvis-api: environment:`, add a line next to `CLAUDE_API_KEY`: ```yaml - API_KEY_ADMIN=${API_KEY_ADMIN:-} ``` - [ ] **Step 9: Deploy and verify against the live API** ```bash SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key" OUT="C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs" scp $SSHOPTS "$OUT/main.py" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/api/main.py scp $SSHOPTS "$OUT/docker-compose.yml" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/docker-compose.yml ssh $SSHOPTS jarvis-core@72.61.186.98 "cd /home/jarvis-core/jarvis && docker compose up -d jarvis-api && sleep 8 && docker logs --tail 20 jarvis-api" ``` Expected: log ends with `Application startup complete.` (no traceback). Then confirm the gate is live (replace `` with the real value from the deployed `.env`'s `API_KEY_ADMIN`): ```bash ssh $SSHOPTS jarvis-core@72.61.186.98 "curl -s -o /dev/null -w 'no key: %{http_code}\n' http://localhost:8000/api/v1/admin/stats" ssh $SSHOPTS jarvis-core@72.61.186.98 "curl -s -o /dev/null -w 'health (open): %{http_code}\n' http://localhost:8000/health" ssh $SSHOPTS jarvis-core@72.61.186.98 "grep API_KEY_ADMIN /home/jarvis-core/jarvis/.env" ``` Take the printed `API_KEY_ADMIN` value and confirm it now works: ```bash ssh $SSHOPTS jarvis-core@72.61.186.98 "curl -s -o /dev/null -w 'with key: %{http_code}\n' -H 'X-Admin-Key: ' http://localhost:8000/api/v1/admin/stats" ``` Expected: `no key: 401`, `health (open): 200`, `with key: 200`. - [ ] **Step 10: Confirm files saved** (no git repo for this project — see Global Constraints) --- ### Task 2: Frontend scaffold + API client **Files:** - Create: `web/package.json`, `web/tsconfig.json`, `web/vite.config.ts`, `web/index.html`, `web/.env.production` - Create: `web/src/main.tsx`, `web/src/App.tsx` (placeholder), `web/src/index.css` - Create: `web/src/api.ts` - Test: `web/src/api.test.ts` **Interfaces:** - Produces: `apiFetch(path, options?, keyOverride?)`, `getStoredKey()`, `storeKey(key)`, `clearKey()`, `logout()`, `onUnauthorized(handler)`, `UnauthorizedError` — all exported from `web/src/api.ts`. Task 3 (Login/App) and Tasks 4-5 (Chat/Dashboard) import these. - [ ] **Step 1: Create the project scaffold** Create `web/package.json`: ```json { "name": "jarvis-web", "private": true, "version": "0.1.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", "test": "vitest run" }, "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", "jsdom": "^25.0.1", "typescript": "^5.6.3", "vite": "^5.4.10", "vitest": "^2.1.4" } } ``` Create `web/tsconfig.json`: ```json { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", "strict": true, "types": ["vitest/globals"] }, "include": ["src"] } ``` Create `web/vite.config.ts`: ```typescript /// import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], test: { environment: "jsdom", }, }); ``` Create `web/index.html`: ```html JARVIS
``` Create `web/.env.production`: ``` VITE_API_URL=https://api.jarvis.mbo-tech-it.de ``` Create `web/src/index.css`: ```css body { font-family: system-ui, sans-serif; margin: 0; background: #0f172a; color: #e2e8f0; } .login { display: flex; flex-direction: column; gap: 0.75rem; max-width: 320px; margin: 4rem auto; padding: 2rem; background: #1e293b; border-radius: 8px; } .login input, .login button { padding: 0.5rem; font-size: 1rem; } .error { color: #f87171; } .nav { display: flex; gap: 0.5rem; padding: 1rem; background: #1e293b; } .nav button { padding: 0.5rem 1rem; } .chat { display: flex; flex-direction: column; height: calc(100vh - 64px); padding: 1rem; box-sizing: border-box; } .messages { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 0.5rem; } .message { padding: 0.5rem 0.75rem; border-radius: 8px; max-width: 70%; } .message.user { align-self: flex-end; background: #2563eb; } .message.assistant { align-self: flex-start; background: #334155; } .message.system { align-self: center; background: #7f1d1d; font-size: 0.875rem; } .input-row { display: flex; gap: 0.5rem; margin-top: 1rem; } .input-row input { flex: 1; padding: 0.5rem; } .dashboard { padding: 1rem; } .stat-cards { display: flex; gap: 1rem; margin: 1rem 0; } .stat-card { background: #1e293b; padding: 1rem; border-radius: 8px; flex: 1; text-align: center; } .stat-card span { display: block; font-size: 2rem; font-weight: bold; } .health-table { width: 100%; border-collapse: collapse; } .health-table td, .health-table th { padding: 0.5rem; border-bottom: 1px solid #334155; text-align: left; } .badge-ok { color: #4ade80; } .badge-error { color: #f87171; } ``` Create `web/src/App.tsx` (placeholder, replaced in Task 3): ```tsx export default function App() { return

JARVIS placeholder

; } ``` Create `web/src/main.tsx`: ```tsx import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; import "./index.css"; ReactDOM.createRoot(document.getElementById("root")!).render( ); ``` - [ ] **Step 2: Install dependencies** ```bash cd "C:\Users\Jonny\Projekte\Claude\JARVIS\web" npm install ``` - [ ] **Step 3: Write the failing tests for the API client** Create `web/src/api.test.ts`: ```typescript import { describe, it, expect, beforeEach, vi } from "vitest"; import { apiFetch, storeKey, getStoredKey, UnauthorizedError } from "./api"; describe("apiFetch", () => { beforeEach(() => { localStorage.clear(); vi.restoreAllMocks(); }); it("attaches the stored admin key as X-Admin-Key header", async () => { storeKey("secret123"); const fetchMock = vi.fn().mockResolvedValue(new Response("{}", { status: 200 })); vi.stubGlobal("fetch", fetchMock); await apiFetch("/api/v1/admin/stats"); const [, options] = fetchMock.mock.calls[0]; const headers = options.headers as Headers; expect(headers.get("X-Admin-Key")).toBe("secret123"); }); it("clears the stored key and throws UnauthorizedError on 401", async () => { storeKey("wrong-key"); const fetchMock = vi.fn().mockResolvedValue(new Response("{}", { status: 401 })); vi.stubGlobal("fetch", fetchMock); await expect(apiFetch("/api/v1/admin/stats")).rejects.toBeInstanceOf(UnauthorizedError); expect(getStoredKey()).toBeNull(); }); }); ``` - [ ] **Step 4: Run tests to verify they fail** ```bash cd "C:\Users\Jonny\Projekte\Claude\JARVIS\web" npm test ``` Expected: FAIL — `./api` module doesn't exist yet. - [ ] **Step 5: Implement `api.ts`** Create `web/src/api.ts`: ```typescript const STORAGE_KEY = "jarvis_admin_key"; const UNAUTHORIZED_EVENT = "jarvis:unauthorized"; export class UnauthorizedError extends Error {} function getApiBase(): string { return import.meta.env.VITE_API_URL as string; } export function getStoredKey(): string | null { return localStorage.getItem(STORAGE_KEY); } export function storeKey(key: string): void { localStorage.setItem(STORAGE_KEY, key); } export function clearKey(): void { localStorage.removeItem(STORAGE_KEY); } export function logout(): void { clearKey(); window.dispatchEvent(new Event(UNAUTHORIZED_EVENT)); } export function onUnauthorized(handler: () => void): () => void { window.addEventListener(UNAUTHORIZED_EVENT, handler); return () => window.removeEventListener(UNAUTHORIZED_EVENT, handler); } export async function apiFetch( path: string, options: RequestInit = {}, keyOverride?: string ): Promise { const key = keyOverride ?? getStoredKey(); const headers = new Headers(options.headers); if (key) { headers.set("X-Admin-Key", key); } const response = await fetch(`${getApiBase()}${path}`, { ...options, headers }); if (response.status === 401) { clearKey(); window.dispatchEvent(new Event(UNAUTHORIZED_EVENT)); throw new UnauthorizedError("Invalid or missing admin key"); } return response; } ``` - [ ] **Step 6: Run tests to verify they pass** ```bash cd "C:\Users\Jonny\Projekte\Claude\JARVIS\web" npm test ``` Expected: 2 passed. - [ ] **Step 7: Verify the build works end to end** ```bash cd "C:\Users\Jonny\Projekte\Claude\JARVIS\web" npm run build ``` Expected: exits 0, produces a `dist/` directory. - [ ] **Step 8: Confirm files saved** (no git repo for this project — see Global Constraints) --- ### Task 3: Login screen + app gate **Files:** - Create: `web/src/components/Login.tsx` - Modify: `web/src/App.tsx` (replace placeholder) **Interfaces:** - Consumes: `apiFetch`, `storeKey`, `getStoredKey`, `onUnauthorized`, `logout`, `UnauthorizedError` from `./api` (Task 2). - Produces: `App` default export used by `main.tsx` (Task 2, unchanged); `Login` component with props `{ onSuccess: () => void }`, used by `App.tsx` and later referenced by Tasks 4-5's parent (`App.tsx` renders `Chat`/`Dashboard` once authenticated — those components take no props). - [ ] **Step 1: Create the Login component** Create `web/src/components/Login.tsx`: ```tsx import { useState, FormEvent } from "react"; import { apiFetch, storeKey, UnauthorizedError } from "../api"; export default function Login({ onSuccess }: { onSuccess: () => void }) { const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [checking, setChecking] = useState(false); async function handleSubmit(e: FormEvent) { e.preventDefault(); setError(null); setChecking(true); try { const response = await apiFetch("/api/v1/admin/stats", {}, password); if (response.ok) { storeKey(password); onSuccess(); } else { setError("Login fehlgeschlagen."); } } catch (err) { if (err instanceof UnauthorizedError) { setError("Falsches Passwort."); } else { setError("Server nicht erreichbar."); } } finally { setChecking(false); } } return (

JARVIS

setPassword(e.target.value)} placeholder="Passwort" autoFocus /> {error &&

{error}

}
); } ``` - [ ] **Step 2: Replace the `App.tsx` placeholder** Replace the contents of `web/src/App.tsx`: ```tsx import { useState, useEffect } from "react"; import { getStoredKey, onUnauthorized, logout } from "./api"; import Login from "./components/Login"; import Chat from "./components/Chat"; import Dashboard from "./components/Dashboard"; type View = "chat" | "dashboard"; export default function App() { const [authenticated, setAuthenticated] = useState(() => getStoredKey() !== null); const [view, setView] = useState("chat"); useEffect(() => onUnauthorized(() => setAuthenticated(false)), []); if (!authenticated) { return setAuthenticated(true)} />; } return (
{view === "chat" ? : }
); } ``` Note: this references `./components/Chat` and `./components/Dashboard`, which don't exist until Tasks 4 and 5. The build will fail until both exist — that's expected; Step 3 below uses a temporary stub to verify Login in isolation first. - [ ] **Step 3: Verify with temporary stubs, then leave real work to Tasks 4-5** Create temporary placeholder files so the build can succeed and Login can be verified in isolation (Tasks 4 and 5 will overwrite these with the real implementation): Create `web/src/components/Chat.tsx`: ```tsx export default function Chat() { return

Chat placeholder

; } ``` Create `web/src/components/Dashboard.tsx`: ```tsx export default function Dashboard() { return

Dashboard placeholder

; } ``` - [ ] **Step 4: Verify the build works** ```bash cd "C:\Users\Jonny\Projekte\Claude\JARVIS\web" npm run build ``` Expected: exits 0. - [ ] **Step 5: Confirm files saved** (no git repo for this project — see Global Constraints) --- ### Task 4: Chat component **Files:** - Modify: `web/src/components/Chat.tsx` (replace Task 3's placeholder) **Interfaces:** - Consumes: `apiFetch` from `../api` (Task 2). Calls `POST /api/v1/chat` with body `{conversation_id: number | null, message: string}`, expects response `{conversation_id: number, response: string, tokens_used: number, timestamp: string}` (matches `ChatResponse` in `Claude outputs/main.py`). - Produces: `Chat` default export, no props (rendered by `App.tsx` from Task 3). - [ ] **Step 1: Replace the Chat placeholder** Replace the contents of `web/src/components/Chat.tsx`: ```tsx import { useState, KeyboardEvent } from "react"; import { apiFetch } from "../api"; type Message = { role: "user" | "assistant" | "system"; content: string }; export default function Chat() { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [conversationId, setConversationId] = useState(null); const [sending, setSending] = useState(false); async function sendMessage() { const text = input.trim(); if (!text || sending) return; setMessages((prev) => [...prev, { role: "user", content: text }]); setInput(""); setSending(true); try { const response = await apiFetch("/api/v1/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ conversation_id: conversationId, message: text }), }); const data = await response.json(); if (!response.ok) { setMessages((prev) => [ ...prev, { role: "system", content: `Fehler: ${data.detail ?? response.statusText}` }, ]); return; } setConversationId(data.conversation_id); setMessages((prev) => [...prev, { role: "assistant", content: data.response }]); } catch { setMessages((prev) => [...prev, { role: "system", content: "Server nicht erreichbar." }]); } finally { setSending(false); } } function handleKeyDown(e: KeyboardEvent) { if (e.key === "Enter") sendMessage(); } return (
{messages.map((m, i) => (
{m.content}
))}
setInput(e.target.value)} onKeyDown={handleKeyDown} placeholder="Nachricht an JARVIS..." disabled={sending} />
); } ``` - [ ] **Step 2: Verify the build works** ```bash cd "C:\Users\Jonny\Projekte\Claude\JARVIS\web" npm run build ``` Expected: exits 0. - [ ] **Step 3: Confirm files saved** (no git repo for this project — see Global Constraints) --- ### Task 5: Dashboard component **Files:** - Modify: `web/src/components/Dashboard.tsx` (replace Task 3's placeholder) **Interfaces:** - Consumes: `apiFetch` from `../api` (Task 2). Calls `GET /api/v1/admin/stats` (expects `{conversations, tasks, documents}`, matching `Claude outputs/main.py`'s `get_stats`) and `GET /api/v1/admin/health/detailed` (expects `{status, components: {[name]: {status, response_time_ms, error?}}}`, matching `detailed_health`). - Produces: `Dashboard` default export, no props (rendered by `App.tsx` from Task 3). - [ ] **Step 1: Replace the Dashboard placeholder** Replace the contents of `web/src/components/Dashboard.tsx`: ```tsx import { useEffect, useState } from "react"; import { apiFetch } from "../api"; type Stats = { conversations: number; tasks: number; documents: number }; type HealthComponent = { status: string; response_time_ms: number; error?: string }; type Health = { status: string; components: Record }; export default function Dashboard() { const [stats, setStats] = useState(null); const [health, setHealth] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); async function load() { setLoading(true); setError(null); try { const [statsRes, healthRes] = await Promise.all([ apiFetch("/api/v1/admin/stats"), apiFetch("/api/v1/admin/health/detailed"), ]); setStats(await statsRes.json()); setHealth(await healthRes.json()); } catch { setError("Server nicht erreichbar."); } finally { setLoading(false); } } useEffect(() => { load(); }, []); return (
{error &&

{error}

} {stats && (
{stats.conversations}Conversations
{stats.tasks}Tasks
{stats.documents}Documents
)} {health && ( {Object.entries(health.components).map(([name, c]) => ( ))}
Service Status ms
{name} {c.status} {c.response_time_ms}
)}
); } ``` - [ ] **Step 2: Verify the build works** ```bash cd "C:\Users\Jonny\Projekte\Claude\JARVIS\web" npm run build ``` Expected: exits 0. - [ ] **Step 3: Confirm files saved** (no git repo for this project — see Global Constraints) --- ### Task 6: Docker packaging, Traefik routing, deploy, end-to-end verification **Files:** - Create: `web/Dockerfile`, `web/nginx.conf` - Modify: `Claude outputs/docker-compose.yml` (jarvis-api router rule, new `jarvis-web` service) **Interfaces:** - Consumes: `web/` (Tasks 2-5), `Claude outputs/docker-compose.yml`'s existing `proxy-network` external network (already defined from the knowledge-base/Traefik-integration work). - [ ] **Step 1: Create the Dockerfile** Create `web/Dockerfile`: ```dockerfile FROM node:20-slim AS build WORKDIR /app COPY package.json package-lock.json* ./ RUN npm install COPY . . RUN npm run build FROM nginx:alpine COPY --from=build /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 ``` - [ ] **Step 2: Create the nginx SPA config** Create `web/nginx.conf`: ```nginx server { listen 80; server_name _; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } } ``` - [ ] **Step 3: Move the API's Traefik router to `api.jarvis.mbo-tech-it.de`** In `Claude outputs/docker-compose.yml`, under the `jarvis-api` service's `labels:`, change: ```yaml - "traefik.http.routers.jarvis-api.rule=Host(`${DOMAIN:-localhost}`)" ``` to: ```yaml - "traefik.http.routers.jarvis-api.rule=Host(`api.${DOMAIN:-localhost}`)" ``` - [ ] **Step 4: Add the `jarvis-web` service** In `Claude outputs/docker-compose.yml`, add a new service after `jarvis-api` (before the `networks:` top-level key): ```yaml jarvis-web: build: context: ./web dockerfile: Dockerfile container_name: jarvis-web networks: - proxy-network labels: - "traefik.enable=true" - "traefik.docker.network=proxy-network" - "traefik.http.routers.jarvis-web.rule=Host(`${DOMAIN:-localhost}`)" - "traefik.http.routers.jarvis-web.entrypoints=websecure" - "traefik.http.routers.jarvis-web.tls=true" - "traefik.http.routers.jarvis-web.tls.certresolver=netcup" - "traefik.http.services.jarvis-web.loadbalancer.server.port=80" restart: unless-stopped ``` - [ ] **Step 5: Deploy** ```bash SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key" OUT="C:\Users\Jonny\Projekte\Claude\JARVIS" scp $SSHOPTS "$OUT\Claude outputs\docker-compose.yml" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/docker-compose.yml ssh $SSHOPTS jarvis-core@72.61.186.98 "mkdir -p /home/jarvis-core/jarvis/web" scp $SSHOPTS -r "$OUT\web\." jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/web/ ssh $SSHOPTS jarvis-core@72.61.186.98 "cd /home/jarvis-core/jarvis && docker compose up -d jarvis-api jarvis-web && sleep 10 && docker compose ps jarvis-api jarvis-web" ``` Expected: both containers `Up`. - [ ] **Step 6: Verify the API moved correctly** ```bash ssh $SSHOPTS jarvis-core@72.61.186.98 "curl -s -o /dev/null -w 'api.jarvis health: %{http_code}\n' https://api.jarvis.mbo-tech-it.de/health --max-time 15" ssh $SSHOPTS jarvis-core@72.61.186.98 "curl -s -o /dev/null -w 'old jarvis root (should now 404 from Traefik or serve web): %{http_code}\n' https://jarvis.mbo-tech-it.de/health --max-time 15" ``` Expected: `api.jarvis health: 200`. The second call hits the new web frontend's nginx (not the API), so `/health` there returns nginx's SPA fallback (`index.html`, HTTP 200) rather than the JSON health payload — that's expected, since `/health` is an API route, not a frontend route. - [ ] **Step 7: Verify the web frontend loads** ```bash ssh $SSHOPTS jarvis-core@72.61.186.98 "curl -s https://jarvis.mbo-tech-it.de --max-time 15 | grep -o '[^<]*'" ``` Expected: `JARVIS`. - [ ] **Step 8: End-to-end browser verification** Using the claude-in-chrome browser tools (load them via `ToolSearch` with query `select:mcp__claude-in-chrome__tabs_context_mcp,mcp__claude-in-chrome__navigate,mcp__claude-in-chrome__computer,mcp__claude-in-chrome__read_page,mcp__claude-in-chrome__tabs_create_mcp` if not already loaded): 1. Create a new tab, navigate to `https://jarvis.mbo-tech-it.de`. 2. Enter a wrong password and submit — confirm "Falsches Passwort." appears. 3. Enter the real `API_KEY_ADMIN` value (from `Claude outputs/.env` fetched in Task 1 Step 9, or re-fetch with `ssh $SSHOPTS jarvis-core@72.61.186.98 "grep API_KEY_ADMIN /home/jarvis-core/jarvis/.env"`) and submit — confirm the Chat view appears (nav bar with Chat/Dashboard/Logout). 4. Type a message (e.g. "Sag in einem Satz, dass du JARVIS bist.") and send — confirm a user bubble and an assistant reply both appear. 5. Click "Dashboard" — confirm the three stat cards render with numbers and the health table shows rows for `postgres`, `redis`, `ollama`, `n8n`, `milvus` (milvus expected `error`, matching current infra state), `api`. 6. Click "Logout" — confirm the app returns to the login screen. - [ ] **Step 9: Confirm files saved** (no git repo for this project — see Global Constraints)