1030 lines
33 KiB
Markdown
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)
|