jarvis-assist/docs/superpowers/plans/2026-09-12-web-frontend.md

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 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 <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_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:

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_ADMIN into 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 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:

{
  "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, 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:

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.tsx placeholder

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: 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:

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: 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:

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, 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:

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-web service

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):

  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)