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

1030 lines
33 KiB
Markdown

# 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`:
```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 `<ADMIN_KEY>` 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: <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
/// <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`:
```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`:
```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 <p>JARVIS placeholder</p>;
}
```
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(
<React.StrictMode>
<App />
</React.StrictMode>
);
```
- [ ] **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<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**
```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<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`:
```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`:
```tsx
export default function Chat() {
return <p>Chat placeholder</p>;
}
```
Create `web/src/components/Dashboard.tsx`:
```tsx
export default function Dashboard() {
return <p>Dashboard placeholder</p>;
}
```
- [ ] **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<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**
```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<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**
```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 '<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)