33 KiB
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 inClaude outputs/(note the space — quote the path), local frontend files go in a newweb/directory at the project root (C:\Users\Jonny\Projekte\Claude\JARVIS\web). - Reuse the existing
API_KEY_ADMINsecret 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'sHeader()parameter namex_admin_keymaps 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.dewildcard DNS record — no new DNS entries needed. GET /healthstays 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 importmain.pymust run inside apython:3.11-slimcontainer, same pattern as the knowledge-base plan:docker run --rm -v <dir>:/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_ADMINconfig,require_admin_keydependency, route decorators) - Modify:
Claude outputs/docker-compose.yml(jarvis-api environment) - Create:
Claude outputs/requirements-dev.txt(addhttpxif not already present) - Test:
Claude outputs/tests/test_auth.py
Interfaces:
-
Produces:
require_admin_keyFastAPI dependency inmain.py, applied viadependencies=[Depends(require_admin_key)]on every protected route. Later tasks (frontend) rely on the header nameX-Admin-Keyand onGET /healthremaining open. -
Step 1: Add
httpxto the dev requirements (needed for FastAPI'sTestClient)
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:
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):
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):
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:
from fastapi import FastAPI, HTTPException
to:
from fastapi import FastAPI, HTTPException, Depends, Header
Add the config constant next to the other # ============ CONFIG ============ constants:
API_KEY_ADMIN = os.getenv("API_KEY_ADMIN")
Add the dependency function right after the CORS app.add_middleware(...) block:
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:
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
to:
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):
@app.post("/api/v1/chat", response_model=ChatResponse)
→
@app.post("/api/v1/chat", response_model=ChatResponse, dependencies=[Depends(require_admin_key)])
@app.get("/api/v1/conversations/{conversation_id}")
→
@app.get("/api/v1/conversations/{conversation_id}", dependencies=[Depends(require_admin_key)])
@app.post("/api/v1/tasks", response_model=TaskResponse)
→
@app.post("/api/v1/tasks", response_model=TaskResponse, dependencies=[Depends(require_admin_key)])
@app.get("/api/v1/tasks")
→
@app.get("/api/v1/tasks", dependencies=[Depends(require_admin_key)])
@app.post("/api/v1/documents")
→
@app.post("/api/v1/documents", dependencies=[Depends(require_admin_key)])
@app.get("/api/v1/documents")
→
@app.get("/api/v1/documents", dependencies=[Depends(require_admin_key)])
@app.post("/api/v1/workflows/trigger")
→
@app.post("/api/v1/workflows/trigger", dependencies=[Depends(require_admin_key)])
@app.get("/api/v1/admin/stats")
→
@app.get("/api/v1/admin/stats", dependencies=[Depends(require_admin_key)])
@app.get("/api/v1/admin/health/detailed")
→
@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_ADMINinto the deployed container environment
In Claude outputs/docker-compose.yml, under jarvis-api: environment:, add a line next to CLAUDE_API_KEY:
- API_KEY_ADMIN=${API_KEY_ADMIN:-}
- Step 9: Deploy and verify against the live API
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 <ADMIN_KEY> with the real value from the deployed .env's API_KEY_ADMIN):
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:
ssh $SSHOPTS jarvis-core@72.61.186.98 "curl -s -o /dev/null -w 'with key: %{http_code}\n' -H 'X-Admin-Key: <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 fromweb/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:
{
"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:
{
"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:
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
},
});
Create web/index.html:
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>JARVIS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Create web/.env.production:
VITE_API_URL=https://api.jarvis.mbo-tech-it.de
Create web/src/index.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):
export default function App() {
return <p>JARVIS placeholder</p>;
}
Create web/src/main.tsx:
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
- Step 2: Install dependencies
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:
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
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:
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<Response> {
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
cd "C:\Users\Jonny\Projekte\Claude\JARVIS\web"
npm test
Expected: 2 passed.
- Step 7: Verify the build works end to end
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,UnauthorizedErrorfrom./api(Task 2). -
Produces:
Appdefault export used bymain.tsx(Task 2, unchanged);Logincomponent with props{ onSuccess: () => void }, used byApp.tsxand later referenced by Tasks 4-5's parent (App.tsxrendersChat/Dashboardonce authenticated — those components take no props). -
Step 1: Create the Login component
Create web/src/components/Login.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<string | null>(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 (
<form onSubmit={handleSubmit} className="login">
<h1>JARVIS</h1>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Passwort"
autoFocus
/>
<button type="submit" disabled={checking || password.length === 0}>
{checking ? "Pruefe..." : "Anmelden"}
</button>
{error && <p className="error">{error}</p>}
</form>
);
}
- Step 2: Replace the
App.tsxplaceholder
Replace the contents of web/src/App.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<boolean>(() => getStoredKey() !== null);
const [view, setView] = useState<View>("chat");
useEffect(() => onUnauthorized(() => setAuthenticated(false)), []);
if (!authenticated) {
return <Login onSuccess={() => setAuthenticated(true)} />;
}
return (
<div className="app">
<nav className="nav">
<button onClick={() => setView("chat")} disabled={view === "chat"}>Chat</button>
<button onClick={() => setView("dashboard")} disabled={view === "dashboard"}>Dashboard</button>
<button onClick={() => logout()}>Logout</button>
</nav>
{view === "chat" ? <Chat /> : <Dashboard />}
</div>
);
}
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:
export default function Chat() {
return <p>Chat placeholder</p>;
}
Create web/src/components/Dashboard.tsx:
export default function Dashboard() {
return <p>Dashboard placeholder</p>;
}
- Step 4: Verify the build works
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:
apiFetchfrom../api(Task 2). CallsPOST /api/v1/chatwith body{conversation_id: number | null, message: string}, expects response{conversation_id: number, response: string, tokens_used: number, timestamp: string}(matchesChatResponseinClaude outputs/main.py). -
Produces:
Chatdefault export, no props (rendered byApp.tsxfrom Task 3). -
Step 1: Replace the Chat placeholder
Replace the contents of web/src/components/Chat.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<Message[]>([]);
const [input, setInput] = useState("");
const [conversationId, setConversationId] = useState<number | null>(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<HTMLInputElement>) {
if (e.key === "Enter") sendMessage();
}
return (
<div className="chat">
<div className="messages">
{messages.map((m, i) => (
<div key={i} className={`message ${m.role}`}>
{m.content}
</div>
))}
</div>
<div className="input-row">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Nachricht an JARVIS..."
disabled={sending}
/>
<button onClick={sendMessage} disabled={sending || input.trim().length === 0}>
Senden
</button>
</div>
</div>
);
}
- Step 2: Verify the build works
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:
apiFetchfrom../api(Task 2). CallsGET /api/v1/admin/stats(expects{conversations, tasks, documents}, matchingClaude outputs/main.py'sget_stats) andGET /api/v1/admin/health/detailed(expects{status, components: {[name]: {status, response_time_ms, error?}}}, matchingdetailed_health). -
Produces:
Dashboarddefault export, no props (rendered byApp.tsxfrom Task 3). -
Step 1: Replace the Dashboard placeholder
Replace the contents of web/src/components/Dashboard.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<string, HealthComponent> };
export default function Dashboard() {
const [stats, setStats] = useState<Stats | null>(null);
const [health, setHealth] = useState<Health | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="dashboard">
<button onClick={load} disabled={loading}>
Aktualisieren
</button>
{error && <p className="error">{error}</p>}
{stats && (
<div className="stat-cards">
<div className="stat-card">
<span>{stats.conversations}</span>Conversations
</div>
<div className="stat-card">
<span>{stats.tasks}</span>Tasks
</div>
<div className="stat-card">
<span>{stats.documents}</span>Documents
</div>
</div>
)}
{health && (
<table className="health-table">
<thead>
<tr>
<th>Service</th>
<th>Status</th>
<th>ms</th>
</tr>
</thead>
<tbody>
{Object.entries(health.components).map(([name, c]) => (
<tr key={name}>
<td>{name}</td>
<td className={c.status === "ok" ? "badge-ok" : "badge-error"}>{c.status}</td>
<td>{c.response_time_ms}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
- Step 2: Verify the build works
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, newjarvis-webservice)
Interfaces:
-
Consumes:
web/(Tasks 2-5),Claude outputs/docker-compose.yml's existingproxy-networkexternal network (already defined from the knowledge-base/Traefik-integration work). -
Step 1: Create the Dockerfile
Create web/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:
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:
- "traefik.http.routers.jarvis-api.rule=Host(`${DOMAIN:-localhost}`)"
to:
- "traefik.http.routers.jarvis-api.rule=Host(`api.${DOMAIN:-localhost}`)"
- Step 4: Add the
jarvis-webservice
In Claude outputs/docker-compose.yml, add a new service after jarvis-api (before the networks: top-level key):
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
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
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
ssh $SSHOPTS jarvis-core@72.61.186.98 "curl -s https://jarvis.mbo-tech-it.de --max-time 15 | grep -o '<title>[^<]*</title>'"
Expected: <title>JARVIS</title>.
- 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):
- Create a new tab, navigate to
https://jarvis.mbo-tech-it.de. - Enter a wrong password and submit — confirm "Falsches Passwort." appears.
- Enter the real
API_KEY_ADMINvalue (fromClaude outputs/.envfetched in Task 1 Step 9, or re-fetch withssh $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). - 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.
- Click "Dashboard" — confirm the three stat cards render with numbers and the health table shows rows for
postgres,redis,ollama,n8n,milvus(milvus expectederror, matching current infra state),api. - Click "Logout" — confirm the app returns to the login screen.
- Step 9: Confirm files saved (no git repo for this project — see Global Constraints)