From 429cc95c9a16eae040c3687e886321401cb97a9c Mon Sep 17 00:00:00 2001 From: Chris Christiansen Date: Sun, 13 Sep 2026 11:21:00 +0000 Subject: [PATCH 01/43] feat(opax-web): import P1A Operations Console --- opax-web/.dockerignore | 15 + opax-web/.gitignore | 5 + opax-web/Dockerfile | 50 +++ opax-web/README-opax-web.md | 55 +++ opax-web/README.md | 41 ++ opax-web/SECURITY.md | 14 + opax-web/backend/.env.example | 7 + opax-web/backend/agentClient.js | 139 ++++++ opax-web/backend/agentClient.test.js | 403 ++++++++++++++++++ opax-web/backend/routes/consoleApi.js | 70 +++ opax-web/backend/server.js | 122 ++++++ opax-web/backend/socketHandlers.js | 40 ++ opax-web/backend/socketHandlers.test.js | 204 +++++++++ opax-web/frontend/index.html | 1 + opax-web/frontend/src/App.tsx | 39 ++ .../src/components/AgentConsoleView.tsx | 15 + .../frontend/src/components/ApprovalsView.tsx | 12 + .../src/components/PlatformStatus.tsx | 78 ++++ .../src/components/ProjectRegistryView.tsx | 12 + .../frontend/src/components/WorkQueueView.tsx | 12 + opax-web/frontend/src/main.tsx | 1 + .../frontend/src/pages/OperationsConsole.tsx | 138 ++++++ opax-web/frontend/src/style.css | 165 +++++++ opax-web/frontend/vite.config.ts | 1 + 24 files changed, 1639 insertions(+) create mode 100644 opax-web/.dockerignore create mode 100644 opax-web/.gitignore create mode 100644 opax-web/Dockerfile create mode 100644 opax-web/README-opax-web.md create mode 100644 opax-web/README.md create mode 100644 opax-web/SECURITY.md create mode 100644 opax-web/backend/.env.example create mode 100644 opax-web/backend/agentClient.js create mode 100644 opax-web/backend/agentClient.test.js create mode 100644 opax-web/backend/routes/consoleApi.js create mode 100644 opax-web/backend/server.js create mode 100644 opax-web/backend/socketHandlers.js create mode 100644 opax-web/backend/socketHandlers.test.js create mode 100644 opax-web/frontend/index.html create mode 100644 opax-web/frontend/src/App.tsx create mode 100644 opax-web/frontend/src/components/AgentConsoleView.tsx create mode 100644 opax-web/frontend/src/components/ApprovalsView.tsx create mode 100644 opax-web/frontend/src/components/PlatformStatus.tsx create mode 100644 opax-web/frontend/src/components/ProjectRegistryView.tsx create mode 100644 opax-web/frontend/src/components/WorkQueueView.tsx create mode 100644 opax-web/frontend/src/main.tsx create mode 100644 opax-web/frontend/src/pages/OperationsConsole.tsx create mode 100644 opax-web/frontend/src/style.css create mode 100644 opax-web/frontend/vite.config.ts diff --git a/opax-web/.dockerignore b/opax-web/.dockerignore new file mode 100644 index 0000000..1e4b14a --- /dev/null +++ b/opax-web/.dockerignore @@ -0,0 +1,15 @@ +# Ekskluder lokale avhengighetsmapper fra build context +node_modules +**/node_modules + +# Ekskluder lokale build-artefakter +dist +**/dist + +# Ekskluder miljøfiler og Git-historikk +.env +.env.* +.git + +# Ekskluder npm-debug-logger +npm-debug.log* diff --git a/opax-web/.gitignore b/opax-web/.gitignore new file mode 100644 index 0000000..ddfa183 --- /dev/null +++ b/opax-web/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +dist/ + +backend/.env diff --git a/opax-web/Dockerfile b/opax-web/Dockerfile new file mode 100644 index 0000000..c6afbb6 --- /dev/null +++ b/opax-web/Dockerfile @@ -0,0 +1,50 @@ +# === STADIUM 1: BUILD === +# Bygger frontend-appen og installerer alle workspace-dependencies for build +FROM node:20-slim AS build + +WORKDIR /app + +# 1. Kopier kun manifests for å utnytte Docker layer caching +COPY package.json package-lock.json ./ +COPY frontend/package.json ./frontend/ +COPY backend/package.json ./backend/ + +# 2. Installer ALLE avhengigheter (inkl. dev) basert på lockfilen +RUN npm ci + +# 3. Kopier resten av frontend-kilden og bygg den +COPY frontend/ ./frontend/ +RUN npm run build --workspace=frontend + + +# === STADIUM 2: RUNTIME === +# Bygger et slankt produksjons-image med kun det nødvendigste +FROM node:20-slim + +WORKDIR /app + +ENV NODE_ENV=production + +# 1. Kopier kun manifests for en ren produksjonsinstallasjon +COPY package.json package-lock.json ./ +COPY frontend/package.json ./frontend/ +COPY backend/package.json ./backend/ + +# 2. Installer KUN produksjonsavhengigheter for hele workspacet +# --omit=dev skal hindre at frontend dev-dependencies (vite, etc.) installeres +RUN npm ci --omit=dev + +# 3. Kopier backend-kildekoden, eid av non-root 'node'-bruker +COPY --chown=node:node backend/ ./backend/ + +# 4. Kopier det ferdigbygde frontend-resultatet fra BUILD-stadiet +COPY --chown=node:node --from=build /app/frontend/dist ./frontend/dist + +# 5. Bruk non-root bruker for å kjøre applikasjonen +USER node + +# Port-dokumentasjon (Cloud Run vil bruke PORT env var) +EXPOSE 8080 + +# Start backend-serveren +CMD ["node", "backend/server.js"] diff --git a/opax-web/README-opax-web.md b/opax-web/README-opax-web.md new file mode 100644 index 0000000..65dde98 --- /dev/null +++ b/opax-web/README-opax-web.md @@ -0,0 +1,55 @@ +# OPAX / Web TUI Intern Dokumentasjon + +Denne filen beskriver hvordan man kjører og overvåker backend-tjenesten for OPAX / Web TUI. + +## Kjøre i produksjonsmodus + +Applikasjonen er designet for å kjøre i en Node.js-container. For å starte serveren i produksjonsmodus, sørg for at følgende er satt: + +1. **Miljøvariabel:** `NODE_ENV` må være satt til `production`. +2. **Secrets:** Følgende miljøvariabler må være tilgjengelige i kjøremiljøet, f.eks. via Secret Manager: + * `GOOGLE_CLIENT_ID` + * `GOOGLE_CLIENT_SECRET` + * `SESSION_SECRET` +3. **Konfigurasjon:** + * `FRONTEND_URL`: Den fulle URL-en til frontend-applikasjonen (f.eks. `https://opax.vauco.no`). + * `ALLOWED_EMAILS`: En komma-separert liste med e-postadresser som har lov til å logge inn. + * `PORT`: Porten serveren skal lytte på (default er `8080`). + +Serveren startes ved å kjøre hovedfilen: + +```bash +node opax-web/backend/server.js +``` + +## Health Check + +Tjenesten har et health check-endepunkt for ekstern overvåking. + +- **URL:** `/health` +- **Metode:** `GET` +- **Suksessrespons (HTTP 200):** + ```json + { + "status": "ok" + } + ``` + +## API-endepunkter (Socket.IO) + +Klienten kommuniserer med serveren via Socket.IO for å hente Git-informasjon. All kommunikasjon krever en aktiv, autentisert sesjon. + +1. **Hent Git Status** + - **Klient-event:** `git:getStatus` + - **Server-respons (suksess):** `git:status:result` med payload `{ data: { status: string, details: string } }` + - **Server-respons (feil):** `git:status:error` med payload `{ message: string }` + +2. **Hent Aktiv Branch** + - **Klient-event:** `git:getBranch` + - **Server-respons (suksess):** `git:branch:result` med payload `{ data: { current_branch: string } }` + - **Server-respons (feil):** `git:branch:error` med payload `{ message: string }` + +## Driftstips + +- **Overvåking:** Health-check-endepunktet `/health` kan brukes av en ekstern monitortjeneste (f.eks. Google Cloud Monitoring Uptime Check). Konfigurer monitoren til å sende en GET-forespørsel hvert minutt og varsle ved ikke-200-status over en periode på for eksempel 3-5 minutter. +- **Logging:** All applikasjonslogging (inkludert feil) skrives til `stdout`/`stderr`. I et container-miljø som Cloud Run, blir disse loggene automatisk samlet inn og kan sees i Google Cloud Logging. Bruk filter i Logging for å isolere logger fra denne spesifikke Cloud Run-tjenesten. diff --git a/opax-web/README.md b/opax-web/README.md new file mode 100644 index 0000000..b5a1e8d --- /dev/null +++ b/opax-web/README.md @@ -0,0 +1,41 @@ +# OPAX Web TUI + +This project provides a starter scaffold for a Google-login-gated web control plane. It consists of a React frontend and a Node.js backend. + +The frontend provides a terminal-like interface for interacting with the system. The backend handles user authentication via Google OAuth2 and provides a secure boundary for potential agent integrations. + +## Local Development + +To run the application locally, first install the dependencies: +```bash +npm install +``` + +Then, start the development server: +```bash +npm run dev +``` + +### Configuration + +The backend requires several environment variables for configuration. For local development, you can create a `.env` file in the `opax-web/backend/` directory. + +- Do not commit `.env` files to Git. +- **NEVER** use or copy production secrets into a local `.env` file. +- For configuration that requires secrets, use local, rotatable test values. +- Production secrets must be stored and delivered via the dedicated secrets solution described in `SECURITY.md`. + +Required configuration keys: +- `FRONTEND_URL` +- `ALLOWED_EMAILS` +- `GOOGLE_CLIENT_ID` +- `GOOGLE_CLIENT_SECRET` +- `SESSION_SECRET` + +**Note:** The outbound agent integration is currently disabled by default (fail-closed). The backend will not attempt to connect to any external agent services. + +## Security + +**IMPORTANT:** Never commit passwords, tokens, OAuth client secrets, or service account keys to the Git repository. If any credential is accidentally exposed, it **MUST** be revoked and rotated immediately. + +For more details, see `SECURITY.md`. diff --git a/opax-web/SECURITY.md b/opax-web/SECURITY.md new file mode 100644 index 0000000..d9d4d01 --- /dev/null +++ b/opax-web/SECURITY.md @@ -0,0 +1,14 @@ +# Security Policy + +## Responsible Disclosure + +To report a security vulnerability, please send a private email to `chris.christiansen@vauco.no`. + +Do not include sensitive information, customer data, or secrets in public GitHub issues or chat discussions. + +## Credential Security + +- **NEVER** commit credentials, secrets, or tokens to the Git repository. +- **NEVER** include credentials in logs, frontend code, or any other uncontrolled files. +- If a credential is accidentally exposed, it **MUST** be rotated immediately. +- For production environments, all secrets must be stored in a dedicated secret management service (e.g., Google Secret Manager) and accessed using least-privilege IAM principles. diff --git a/opax-web/backend/.env.example b/opax-web/backend/.env.example new file mode 100644 index 0000000..1bd4990 --- /dev/null +++ b/opax-web/backend/.env.example @@ -0,0 +1,7 @@ +PORT=8080 +FRONTEND_URL=http://localhost:5173 +GOOGLE_CLIENT_ID=replace-me +GOOGLE_CLIENT_SECRET=store-only-in-secret-manager +SESSION_SECRET=generate-a-long-random-secret +ALLOWED_EMAILS=your-email@example.com +MCP_GATEWAY_URL=https://your-controlled-gateway.example diff --git a/opax-web/backend/agentClient.js b/opax-web/backend/agentClient.js new file mode 100644 index 0000000..1902a82 --- /dev/null +++ b/opax-web/backend/agentClient.js @@ -0,0 +1,139 @@ +import { randomUUID } from 'node:crypto'; + +const TIMEOUT_MS = 10000; +const MAX_REQUEST_SIZE = 16384; +const MAX_RESPONSE_SIZE = 1024 * 1024; +const GENERIC_ERROR_MESSAGE = 'Agent request failed.'; + +const READ_ONLY_TOOLS = new Set([ + 'get_health', + 'get_build_status', + 'get_state', + 'get_telemetry', +]); + +export class AgentNotConfiguredError extends Error { + constructor() { + super('Agent integration is not configured.'); + this.name = 'AgentNotConfiguredError'; + } +} + +function validateMcpUrl(urlString, allowedOrigin) { + const url = new URL(urlString); + + if (url.username || url.password || url.hash) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + if (url.protocol === 'http:') { + const isLoopback = + url.hostname === 'localhost' || + url.hostname === '127.0.0.1' || + url.hostname === '[::1]'; + + if (!isLoopback) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + return url; + } + + if (url.protocol !== 'https:' || !allowedOrigin) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + const configuredOrigin = new URL(allowedOrigin); + + if (url.origin !== configuredOrigin.origin) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + return url; +} + +export async function callAgent(tool, args) { + const mcpUrl = process.env.OPAX_MCP_URL?.trim(); + const mcpSecret = process.env.MCP_SECRET?.trim(); + + if (!mcpUrl || !mcpSecret) { + throw new AgentNotConfiguredError(); + } + + try { + const allowedOrigin = process.env.OPAX_MCP_ALLOWED_ORIGIN?.trim(); + + if (!READ_ONLY_TOOLS.has(tool)) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + const validatedUrl = validateMcpUrl(mcpUrl, allowedOrigin); + + const requestBody = JSON.stringify({ + jsonrpc: '2.0', + id: randomUUID(), + method: 'tools/call', + params: { + name: tool, + arguments: args, + }, + }); + + if (requestBody.length > MAX_REQUEST_SIZE) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS); + + try { + const response = await fetch(validatedUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${mcpSecret}`, + }, + body: requestBody, + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + const responseText = await response.text(); + + if (responseText.length > MAX_RESPONSE_SIZE) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + const rpcResponse = JSON.parse(responseText); + + if (rpcResponse.error) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + const content = rpcResponse.result?.content; + + if (!Array.isArray(content) || content.length === 0) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + const firstContent = content[0]; + + if ( + firstContent?.type !== 'text' || + typeof firstContent.text !== 'string' || + firstContent.text.length > MAX_RESPONSE_SIZE + ) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + return JSON.parse(firstContent.text); + } finally { + clearTimeout(timeoutId); + } + } catch { + throw new Error(GENERIC_ERROR_MESSAGE); + } +} diff --git a/opax-web/backend/agentClient.test.js b/opax-web/backend/agentClient.test.js new file mode 100644 index 0000000..dbf1789 --- /dev/null +++ b/opax-web/backend/agentClient.test.js @@ -0,0 +1,403 @@ +import { after, afterEach, before, beforeEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { + AgentNotConfiguredError, + callAgent, +} from './agentClient.js'; + +const DUMMY_SECRET = 'test-secret-not-production'; +const DUMMY_TOOL = 'get_health'; +const GENERIC_ERROR_MESSAGE = 'Agent request failed.'; +const MAX_RESPONSE_SIZE = 1024 * 1024; +const ENV_KEYS = [ + 'OPAX_MCP_URL', + 'MCP_SECRET', + 'OPAX_MCP_ALLOWED_ORIGIN', +]; + +const originalEnv = Object.fromEntries( + ENV_KEYS.map((key) => [key, process.env[key]]), +); +const originalFetch = globalThis.fetch; + +function restoreEnvironment() { + for (const key of ENV_KEYS) { + if (originalEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + } +} + +function clearTestEnvironment() { + for (const key of ENV_KEYS) { + delete process.env[key]; + } +} + +function configureLocalMock(url) { + process.env.OPAX_MCP_URL = url; + process.env.MCP_SECRET = DUMMY_SECRET; + delete process.env.OPAX_MCP_ALLOWED_ORIGIN; +} + +async function expectGenericFailure(action) { + await assert.rejects(action, { + message: GENERIC_ERROR_MESSAGE, + }); +} + +async function expectNoFetch(action) { + let fetchCalled = false; + + globalThis.fetch = async () => { + fetchCalled = true; + throw new Error('fetch must not be called'); + }; + + try { + await action(); + assert.equal(fetchCalled, false); + } finally { + globalThis.fetch = originalFetch; + } +} + +function sendJson(response, statusCode, body) { + response.writeHead(statusCode, { + 'Content-Type': 'application/json', + }); + response.end(JSON.stringify(body)); +} + +describe('callAgent', { concurrency: false }, () => { + beforeEach(() => { + clearTestEnvironment(); + globalThis.fetch = originalFetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + restoreEnvironment(); + }); + + after(() => { + globalThis.fetch = originalFetch; + restoreEnvironment(); + }); + + describe('configuration and local validation', () => { + it('rejects a missing MCP URL with AgentNotConfiguredError', async () => { + process.env.MCP_SECRET = DUMMY_SECRET; + + await assert.rejects( + () => callAgent(DUMMY_TOOL, {}), + AgentNotConfiguredError, + ); + }); + + it('rejects a missing MCP secret with AgentNotConfiguredError', async () => { + process.env.OPAX_MCP_URL = 'http://127.0.0.1:12345'; + + await assert.rejects( + () => callAgent(DUMMY_TOOL, {}), + AgentNotConfiguredError, + ); + }); + + it('rejects whitespace-only MCP configuration', async () => { + process.env.OPAX_MCP_URL = ' '; + process.env.MCP_SECRET = DUMMY_SECRET; + + await assert.rejects( + () => callAgent(DUMMY_TOOL, {}), + AgentNotConfiguredError, + ); + + process.env.OPAX_MCP_URL = 'http://127.0.0.1:12345'; + process.env.MCP_SECRET = ' '; + + await assert.rejects( + () => callAgent(DUMMY_TOOL, {}), + AgentNotConfiguredError, + ); + }); + + it('rejects invalid and unsafe URLs before fetch', async () => { + const unsafeUrls = [ + 'not a URL', + 'ftp://127.0.0.1:12345', + 'http://example.com', + 'http://user:password@127.0.0.1:12345', + 'http://127.0.0.1:12345#fragment', + ]; + + for (const url of unsafeUrls) { + process.env.OPAX_MCP_URL = url; + process.env.MCP_SECRET = DUMMY_SECRET; + + await expectNoFetch(() => + expectGenericFailure(() => callAgent(DUMMY_TOOL, {})), + ); + } + }); + + it('rejects HTTPS without an allowed origin before fetch', async () => { + process.env.OPAX_MCP_URL = 'https://example.invalid'; + process.env.MCP_SECRET = DUMMY_SECRET; + + await expectNoFetch(() => + expectGenericFailure(() => callAgent(DUMMY_TOOL, {})), + ); + }); + + it('rejects HTTPS with a mismatched origin before fetch', async () => { + process.env.OPAX_MCP_URL = 'https://example.invalid'; + process.env.MCP_SECRET = DUMMY_SECRET; + process.env.OPAX_MCP_ALLOWED_ORIGIN = 'https://allowed.invalid'; + + await expectNoFetch(() => + expectGenericFailure(() => callAgent(DUMMY_TOOL, {})), + ); + }); + + it('rejects non-allowlisted and mutating tools before fetch', async () => { + process.env.OPAX_MCP_URL = 'http://127.0.0.1:12345'; + process.env.MCP_SECRET = DUMMY_SECRET; + + for (const tool of ['unknown_tool', 'commit_and_push_files']) { + await expectNoFetch(() => + expectGenericFailure(() => callAgent(tool, {})), + ); + } + }); + + it('rejects circular arguments before fetch', async () => { + process.env.OPAX_MCP_URL = 'http://127.0.0.1:12345'; + process.env.MCP_SECRET = DUMMY_SECRET; + + const circular = {}; + circular.self = circular; + + await expectNoFetch(() => + expectGenericFailure(() => callAgent(DUMMY_TOOL, circular)), + ); + }); + + it('rejects an oversized request before fetch', async () => { + process.env.OPAX_MCP_URL = 'http://127.0.0.1:12345'; + process.env.MCP_SECRET = DUMMY_SECRET; + + const largeArguments = { + data: 'a'.repeat(17000), + }; + + await expectNoFetch(() => + expectGenericFailure(() => callAgent(DUMMY_TOOL, largeArguments)), + ); + }); + }); + + describe('local MCP mock', () => { + let server; + let serverUrl; + let handler; + + before(async () => { + server = createServer((request, response) => { + Promise.resolve(handler?.(request, response)) + .catch(() => { + if (!response.headersSent) { + response.writeHead(500); + } + response.end(); + }); + }); + + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + + const address = server.address(); + serverUrl = `http://127.0.0.1:${address.port}`; + }); + + beforeEach(() => { + configureLocalMock(serverUrl); + + handler = (_request, response) => { + response.writeHead(500); + response.end(); + }; + }); + + after(async () => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }); + + it('returns parsed inner JSON from a valid JSON-RPC response', async () => { + const expected = { + ok: true, + status: 'healthy', + }; + + handler = (_request, response) => { + sendJson(response, 200, { + jsonrpc: '2.0', + id: 'mock-response-id', + result: { + content: [{ + type: 'text', + text: JSON.stringify(expected), + }], + }, + }); + }; + + const result = await callAgent(DUMMY_TOOL, { + check: true, + }); + + assert.deepEqual(result, expected); + }); + + it('sends the required JSON-RPC request contract', async () => { + const argumentsValue = { + scope: 'local-test', + }; + let received; + + handler = async (request, response) => { + let body = ''; + + for await (const chunk of request) { + body += chunk; + } + + received = { + method: request.method, + contentType: request.headers['content-type'], + authorization: request.headers.authorization, + body: JSON.parse(body), + }; + + sendJson(response, 200, { + jsonrpc: '2.0', + id: received.body.id, + result: { + content: [{ + type: 'text', + text: '{}', + }], + }, + }); + }; + + await callAgent(DUMMY_TOOL, argumentsValue); + + assert.equal(received.method, 'POST'); + assert.equal(received.contentType, 'application/json'); + assert.equal( + received.authorization, + `Bearer ${DUMMY_SECRET}`, + ); + assert.equal(received.body.jsonrpc, '2.0'); + assert.equal(received.body.method, 'tools/call'); + assert.equal(typeof received.body.id, 'string'); + assert.ok(received.body.id.length > 0); + assert.equal(received.body.params.name, DUMMY_TOOL); + assert.deepEqual( + received.body.params.arguments, + argumentsValue, + ); + }); + + it('normalizes an HTTP error', async () => { + handler = (_request, response) => { + response.writeHead(500); + response.end(); + }; + + await expectGenericFailure(() => callAgent(DUMMY_TOOL, {})); + }); + + it('normalizes malformed outer JSON', async () => { + handler = (_request, response) => { + response.writeHead(200, { + 'Content-Type': 'application/json', + }); + response.end('not-json'); + }; + + await expectGenericFailure(() => callAgent(DUMMY_TOOL, {})); + }); + + it('normalizes a JSON-RPC error', async () => { + handler = (_request, response) => { + sendJson(response, 200, { + jsonrpc: '2.0', + id: 'mock-response-id', + error: { + code: -32000, + message: 'mock failure', + }, + }); + }; + + await expectGenericFailure(() => callAgent(DUMMY_TOOL, {})); + }); + + it('normalizes invalid content responses', async () => { + const invalidResponses = [ + { + result: { + content: [], + }, + }, + { + result: { + content: [{ + type: 'image', + text: '{}', + }], + }, + }, + { + result: { + content: [{ + type: 'text', + text: 'not-json', + }], + }, + }, + ]; + + for (const invalidResponse of invalidResponses) { + handler = (_request, response) => { + sendJson(response, 200, { + jsonrpc: '2.0', + id: 'mock-response-id', + ...invalidResponse, + }); + }; + + await expectGenericFailure(() => callAgent(DUMMY_TOOL, {})); + } + }); + + it('normalizes a response body larger than 1 MiB', async () => { + handler = (_request, response) => { + response.writeHead(200, { + 'Content-Type': 'text/plain', + }); + response.end('a'.repeat(MAX_RESPONSE_SIZE + 1)); + }; + + await expectGenericFailure(() => callAgent(DUMMY_TOOL, {})); + }); + }); +}); diff --git a/opax-web/backend/routes/consoleApi.js b/opax-web/backend/routes/consoleApi.js new file mode 100644 index 0000000..9416c18 --- /dev/null +++ b/opax-web/backend/routes/consoleApi.js @@ -0,0 +1,70 @@ +import { Router } from 'express'; +import { callAgent } from '../agentClient.js'; + +const router = Router(); + +const unavailable = { + status: 'unavailable', + summary: null, + message: 'Unavailable', +}; + +const adaptHealthSummary = (raw) => { + if (raw?.status !== 'ok' || !raw.service || !raw.version) { + return null; + } + return { + status: raw.status, + service: raw.service, + version: raw.version, + }; +}; + +router.get('/status', async (req, res) => { + let health; + try { + const rawHealth = await callAgent('get_health', {}); + const summary = adaptHealthSummary(rawHealth); + if (summary) { + health = { status: 'success', summary, message: 'Available' }; + } else { + health = unavailable; + console.warn('[Console API] Platform health data has an unexpected shape.'); + } + } catch (error) { + console.warn('[Console API] Platform health data is unavailable.'); + health = unavailable; + } + + res.json({ + health, + buildStatus: unavailable, + platformState: unavailable, + }); +}); + +router.get('/projects', (req, res) => { + res.json({ + status: 'unavailable', + message: 'No project registry configured yet.', + projects: [], + }); +}); + +router.get('/work-queue', (req, res) => { + res.json({ + status: 'unavailable', + message: 'No ticket read model configured yet.', + tickets: [], + }); +}); + +router.get('/approvals', (req, res) => { + res.json({ + status: 'unavailable', + message: 'No approval read model configured yet.', + approvals: [], + }); +}); + +export default router; diff --git a/opax-web/backend/server.js b/opax-web/backend/server.js new file mode 100644 index 0000000..cf6556e --- /dev/null +++ b/opax-web/backend/server.js @@ -0,0 +1,122 @@ +import { callAgent } from './agentClient.js'; +import { registerBrowserSocketHandlers } from './socketHandlers.js'; +import 'dotenv/config'; +import express from 'express'; +import cors from 'cors'; +import session from 'express-session'; +import { OAuth2Client } from 'google-auth-library'; +import { createServer } from 'node:http'; +import { Server } from 'socket.io'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import consoleApiRouter from './routes/consoleApi.js'; + +const app = express(); +const http = createServer(app); +const front = process.env.FRONTEND_URL || 'http://localhost:5173'; +const emails = new Set((process.env.ALLOWED_EMAILS || '').split(',').filter(Boolean)); +const oauth = new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, `${front}/auth/callback`); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const frontendDistPath = path.join(__dirname, '../frontend/dist'); +const frontendIndexPath = path.join(frontendDistPath, 'index.html'); + +if (process.env.NODE_ENV === 'production') { + app.set('trust proxy', 1); +} + +const sessionSecret = process.env.SESSION_SECRET; +if (!sessionSecret || (sessionSecret === 'CHANGE_ME' && process.env.NODE_ENV !== 'production')) { + throw new Error('A valid SESSION_SECRET must be set.'); +} + +const sm = session({ + secret: sessionSecret, + resave: false, + saveUninitialized: false, + cookie: { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' + }, +}); +const io = new Server(http, { cors: { origin: front, credentials: true } }); + +app.use(cors({ origin: front, credentials: true })); +app.use(sm); +app.use(express.static(frontendDistPath)); +io.use((s, n) => sm(s.request, {}, n)); + +app.get('/health', (_, r) => r.json({ status: 'ok' })); + +app.get('/auth/google', (q, r) => r.redirect(oauth.generateAuthUrl({ scope: ['openid', 'email', 'profile'], prompt: 'consent' }))); + +app.get('/auth/callback', async (req, res, next) => { + try { + let { tokens } = await oauth.getToken(req.query.code); + let p = (await oauth.verifyIdToken({ idToken: tokens.id_token, audience: process.env.GOOGLE_CLIENT_ID })).getPayload(); + if (!p?.email || !emails.has(p.email)) return res.status(403).send('Not authorised'); + req.session.user = { email: p.email, name: p.name || p.email }; + res.redirect('/'); + } catch (e) { + next(e); + } +}); + +app.get('/auth/me', (q, r) => (q.session.user ? r.json(q.session.user) : r.status(401).end())); +app.post('/auth/logout', (q, r) => q.session.destroy(() => r.json({ ok: true }))); + +const ensureAuthenticated = (req, res, next) => { + if (req.session.user) { + return next(); + } + res.status(401).end(); +}; + +app.use('/api/console', ensureAuthenticated, consoleApiRouter); + + +io.on('connection', (s) => { + const u = s.request.session?.user; + if (!u) return s.disconnect(); + + registerBrowserSocketHandlers(s, { callAgentFn: callAgent }); +}); + +app.get('*' , (req, res) => { + if (req.method !== 'GET') { + return res.status(404).end(); + } + + const isApiRoute = /^\/(auth|socket\.io|health|api)(\/.*|\/?)$/.test(req.path); + if (isApiRoute) { + return res.status(404).end(); + } + + if (req.accepts('html')) { + res.sendFile(frontendIndexPath, (err) => { + if (err && !res.headersSent) { + res.status(404).end(); + } + }); + } else { + if (!res.headersSent) { + res.status(404).end(); + } + } +}); + +// Central Error Handler +app.use((err, req, res, next) => { + // TODO: Integrate with an external error tracking service like Sentry + console.error(`[Express Error] Path: ${req.path}, Error:`, err); + + // Avoid leaking stack trace to client + if (res.headersSent) { + return next(err); + } + + res.status(500).json({ error: 'Internal Server Error' }); +}); + +http.listen(Number(process.env.PORT || 8080), '0.0.0.0'); diff --git a/opax-web/backend/socketHandlers.js b/opax-web/backend/socketHandlers.js new file mode 100644 index 0000000..c768ee2 --- /dev/null +++ b/opax-web/backend/socketHandlers.js @@ -0,0 +1,40 @@ +// FORSLAG TIL opax-web/backend/socketHandlers.js (ENDELIG KORRIGERT) + +const SAFE_ACTIONS = {}; + +/** + * Registrerer sikre, nettleser-eksponerte Socket.IO-handlere. + * @param {import('socket.io').Socket} socket - Den tilkoblede socketen. + * @param {{callAgentFn: function}} dependencies - Injisert avhengighet for å kalle MCP. + */ +export function registerBrowserSocketHandlers(socket, { callAgentFn }) { + // Avvis den gamle, usikre mcp-call-handleren + socket.on('mcp-call', (payload) => { + const safeId = (payload && typeof payload.id === 'string' && payload.id.length <= 128) ? payload.id : null; + socket.emit('mcp-result', { + id: safeId, + error: 'Denne funksjonen er ikke tilgjengelig.', + }); + }); + + // Registrer de nye, sikre handlerne + for (const eventName in SAFE_ACTIONS) { + const config = SAFE_ACTIONS[eventName]; + + socket.on(eventName, async () => { + if (!socket.request.session?.user) { + return socket.emit(`${config.responseEvent}:error`, { message: 'Du må være logget inn.' }); + } + + try { + const result = await callAgentFn(config.tool, {}); + const normalizedData = config.normalize(result); + socket.emit(`${config.responseEvent}:result`, { data: normalizedData }); + } catch (e) { + console.error(`[Socket Handler Error] Event: ${eventName}, User: ${socket.request.session.user.email}, Error:`, e); + // e.message logges ikke eller sendes til klienten. + socket.emit(`${config.responseEvent}:error`, { message: config.errorMessage }); + } + }); + } +} diff --git a/opax-web/backend/socketHandlers.test.js b/opax-web/backend/socketHandlers.test.js new file mode 100644 index 0000000..27f617a --- /dev/null +++ b/opax-web/backend/socketHandlers.test.js @@ -0,0 +1,204 @@ +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { registerBrowserSocketHandlers } from './socketHandlers.js'; + +// --- Test-hjelpere --- + +class FakeSocket { + constructor(user = null) { + this.request = { session: { user } }; + this.handlers = new Map(); + this.emitted = []; + } + + on(eventName, handler) { + this.handlers.set(eventName, handler); + } + + emit(eventName, payload) { + this.emitted.push({ eventName, payload }); + } + + async trigger(eventName, payload) { + const handler = this.handlers.get(eventName); + if (handler) { + await handler(payload); + } + } + + findEmitted(eventName) { + return this.emitted.find(e => e.eventName === eventName); + } +} + +function createFakeCallAgent() { + const fakeCallAgent = async (tool, args) => { + fakeCallAgent.calls.push({ tool, args }); + if (fakeCallAgent.nextError) { + throw fakeCallAgent.nextError; + } + return fakeCallAgent.nextResult; + }; + + fakeCallAgent.calls = []; + fakeCallAgent.nextResult = null; + fakeCallAgent.nextError = null; + + fakeCallAgent.setNextResult = (result) => { + fakeCallAgent.nextResult = result; + fakeCallAgent.nextError = null; + }; + + fakeCallAgent.setNextError = (error) => { + fakeCallAgent.nextError = error; + fakeCallAgent.nextResult = null; + }; + + return fakeCallAgent; +} + +// --- Tester --- + +describe('registerBrowserSocketHandlers', () => { + let fakeSocket; + let fakeCallAgent; + + beforeEach(() => { + fakeCallAgent = createFakeCallAgent(); + }); + + describe('for unauthenticated user', () => { + beforeEach(() => { + fakeSocket = new FakeSocket(null); // Ingen bruker i session + registerBrowserSocketHandlers(fakeSocket, { callAgentFn: fakeCallAgent }); + }); + + it('git:getStatus is rejected and does not call agent', async () => { + await fakeSocket.trigger('git:getStatus'); + const errorEvent = fakeSocket.findEmitted('git:status:error'); + assert.deepStrictEqual(errorEvent?.payload, { message: 'Du må være logget inn.' }); + assert.strictEqual(fakeCallAgent.calls.length, 0); + assert.strictEqual(fakeSocket.emitted.length, 1); + }); + + it('git:getBranch is rejected and does not call agent', async () => { + await fakeSocket.trigger('git:getBranch'); + const errorEvent = fakeSocket.findEmitted('git:branch:error'); + assert.deepStrictEqual(errorEvent?.payload, { message: 'Du må være logget inn.' }); + assert.strictEqual(fakeCallAgent.calls.length, 0); + assert.strictEqual(fakeSocket.emitted.length, 1); + }); + }); + + describe('for authenticated user', () => { + beforeEach(() => { + fakeSocket = new FakeSocket({ email: 'test@example.com' }); + registerBrowserSocketHandlers(fakeSocket, { callAgentFn: fakeCallAgent }); + }); + + it('git:getStatus calls agent correctly and returns normalized data', async () => { + const mcpResult = { status: 'dirty', details: 'M file.txt', secret: 'should-be-stripped' }; + fakeCallAgent.setNextResult(mcpResult); + + await fakeSocket.trigger('git:getStatus'); + + assert.strictEqual(fakeCallAgent.calls.length, 1); + assert.deepStrictEqual(fakeCallAgent.calls[0], { tool: 'get_git_status', args: {} }); + + const resultEvent = fakeSocket.findEmitted('git:status:result'); + assert.ok(resultEvent, 'Expected a result event'); + assert.deepStrictEqual(resultEvent.payload.data, { status: 'dirty', details: 'M file.txt' }); + }); + + it('git:getBranch calls agent correctly and returns normalized data', async () => { + const mcpResult = { current_branch: 'main', url: 'https://internal.example.invalid' }; + fakeCallAgent.setNextResult(mcpResult); + + await fakeSocket.trigger('git:getBranch'); + + assert.strictEqual(fakeCallAgent.calls.length, 1); + assert.deepStrictEqual(fakeCallAgent.calls[0], { tool: 'get_git_branch', args: {} }); + + const resultEvent = fakeSocket.findEmitted('git:branch:result'); + assert.ok(resultEvent, 'Expected a result event'); + assert.deepStrictEqual(resultEvent.payload.data, { current_branch: 'main' }); + }); + + it('emits static error if git:getStatus agent response is malformed', async () => { + const malformedResults = [null, {}, [], 'a string', { status: 123, details: 'a' }]; + for (const mcpResult of malformedResults) { + fakeCallAgent.setNextResult(mcpResult); + await fakeSocket.trigger('git:getStatus'); + } + + assert.strictEqual(fakeSocket.emitted.length, malformedResults.length); + assert.ok(fakeSocket.emitted.every(e => e.eventName === 'git:status:error')); + assert.ok(fakeSocket.emitted.every(e => e.payload.message === 'Kunne ikke hente Git-status.')); + }); + + it('emits static error if git:getBranch agent response is malformed', async () => { + const malformedResults = [null, {}, [], 'a string', { current_branch: 123 }]; + for (const mcpResult of malformedResults) { + fakeCallAgent.setNextResult(mcpResult); + await fakeSocket.trigger('git:getBranch'); + } + + assert.strictEqual(fakeSocket.emitted.length, malformedResults.length); + assert.ok(fakeSocket.emitted.every(e => e.eventName === 'git:branch:error')); + assert.ok(fakeSocket.emitted.every(e => e.payload.message === 'Kunne ikke hente aktiv branch.')); + }); + + it('masks agent error for git:getStatus and returns static message', async () => { + const internalError = new Error('Contains test-secret-must-not-leak'); + fakeCallAgent.setNextError(internalError); + + await fakeSocket.trigger('git:getStatus'); + + const errorEvent = fakeSocket.findEmitted('git:status:error'); + assert.ok(errorEvent, 'Expected an error event'); + assert.deepStrictEqual(errorEvent.payload, { message: 'Kunne ikke hente Git-status.' }); + assert.strictEqual(fakeSocket.findEmitted('git:status:result'), undefined); + }); + + it('masks agent error for git:getBranch and returns static message', async () => { + const internalError = new Error('Another secret for https://internal.example.invalid'); + fakeCallAgent.setNextError(internalError); + + await fakeSocket.trigger('git:getBranch'); + + const errorEvent = fakeSocket.findEmitted('git:branch:error'); + assert.ok(errorEvent, 'Expected an error event'); + assert.deepStrictEqual(errorEvent.payload, { message: 'Kunne ikke hente aktiv branch.' }); + assert.strictEqual(fakeSocket.findEmitted('git:branch:result'), undefined); + }); + }); + + describe('legacy mcp-call handler', () => { + beforeEach(() => { + fakeSocket = new FakeSocket({ email: 'test@example.com' }); + registerBrowserSocketHandlers(fakeSocket, { callAgentFn: fakeCallAgent }); + }); + + it('rejects calls and does not call agent', async () => { + const payload = { id: 'test-id', tool: 'commit_and_push_files', args: { message: 'evil' } }; + await fakeSocket.trigger('mcp-call', payload); + + assert.strictEqual(fakeCallAgent.calls.length, 0); + const resultEvent = fakeSocket.findEmitted('mcp-result'); + assert.ok(resultEvent, 'Expected a result event'); + assert.deepStrictEqual(resultEvent.payload, { id: 'test-id', error: 'Denne funksjonen er ikke tilgjengelig.' }); + }); + + it('handles malformed payloads gracefully and uses null id', async () => { + const malformed = [null, 'a string', { id: 'a'.repeat(129) }]; + for (const payload of malformed) { + await fakeSocket.trigger('mcp-call', payload); + } + + assert.strictEqual(fakeCallAgent.calls.length, 0); + assert.strictEqual(fakeSocket.emitted.length, malformed.length); + assert.ok(fakeSocket.emitted.every(e => e.eventName === 'mcp-result')); + assert.ok(fakeSocket.emitted.every(e => e.payload.id === null)); + }); + }); +}); diff --git a/opax-web/frontend/index.html b/opax-web/frontend/index.html new file mode 100644 index 0000000..c41b5d7 --- /dev/null +++ b/opax-web/frontend/index.html @@ -0,0 +1 @@ +
diff --git a/opax-web/frontend/src/App.tsx b/opax-web/frontend/src/App.tsx new file mode 100644 index 0000000..28b883a --- /dev/null +++ b/opax-web/frontend/src/App.tsx @@ -0,0 +1,39 @@ +import React, { useState, useEffect } from 'react'; +import OperationsConsole from './pages/OperationsConsole'; + +interface User { + email: string; + name?: string; +} + +function App() { + const [user, setUser] = useState(null); + + useEffect(() => { + fetch('/auth/me', { credentials: 'include' }) + .then((r) => (r.ok ? r.json() : null)) + .then(setUser); + }, []); + + if (!user) { + return ( +
+

OPAX

+ Continue with Google +
+ ); + } + + return ( +
+
+ OPAX / Operations Console + {user.email} +
+ +
+ ); +} + +export default App; + diff --git a/opax-web/frontend/src/components/AgentConsoleView.tsx b/opax-web/frontend/src/components/AgentConsoleView.tsx new file mode 100644 index 0000000..b66c9c5 --- /dev/null +++ b/opax-web/frontend/src/components/AgentConsoleView.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +const AgentConsoleView = () => { + return ( +
+

Agent Console

+
+ DraftPlanScope ApprovalImplementationDiff ApprovalValidationDeployment ApprovalDeploy +
+

Ticket-bound agent execution is not configured yet.

+
+ ); +}; + +export default AgentConsoleView; diff --git a/opax-web/frontend/src/components/ApprovalsView.tsx b/opax-web/frontend/src/components/ApprovalsView.tsx new file mode 100644 index 0000000..5075d7e --- /dev/null +++ b/opax-web/frontend/src/components/ApprovalsView.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +const ApprovalsView = () => { + return ( +
+

Approvals

+

No approval read model configured yet.

+
+ ); +}; + +export default ApprovalsView; diff --git a/opax-web/frontend/src/components/PlatformStatus.tsx b/opax-web/frontend/src/components/PlatformStatus.tsx new file mode 100644 index 0000000..a9cdc7d --- /dev/null +++ b/opax-web/frontend/src/components/PlatformStatus.tsx @@ -0,0 +1,78 @@ +import React from 'react'; + +type ConsoleStatus = 'success' | 'unavailable' | 'loading'; + +type SafeHealthSummary = { + status: 'ok'; + service: string; + version: string; +}; + +type ConsoleStatusItem = { + status: Exclude; + summary: SafeHealthSummary | null; + message: 'Available' | 'Unavailable'; +}; + +interface ConsoleStatusResponse { + health?: ConsoleStatusItem; + buildStatus?: ConsoleStatusItem; + platformState?: ConsoleStatusItem; +} + +interface StatusBlockProps { + title: string; + status: ConsoleStatus; + summary: SafeHealthSummary | null; +} + +const StatusBlock: React.FC = ({ title, status, summary }) => { + return ( +
+

{title}

+ {status === 'loading' &&

Loading...

} + {status === 'unavailable' &&

Unavailable

} + {status === 'success' && summary && ( +
    +
  • Status: {summary.status}
  • +
  • Service: {summary.service}
  • +
  • Version: {summary.version}
  • +
+ )} +
+ ); +}; + +interface PlatformStatusProps { + statusData: ConsoleStatusResponse | null; + loading: boolean; +} + +const unavailableStatus: ConsoleStatus = 'unavailable'; + +const PlatformStatus: React.FC = ({ statusData, loading }) => { + return ( +
+

Platform Overview

+
+ + + +
+
+ ); +}; + +export default PlatformStatus; diff --git a/opax-web/frontend/src/components/ProjectRegistryView.tsx b/opax-web/frontend/src/components/ProjectRegistryView.tsx new file mode 100644 index 0000000..945b196 --- /dev/null +++ b/opax-web/frontend/src/components/ProjectRegistryView.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +const ProjectRegistryView = () => { + return ( +
+

Projects

+

No project registry configured yet.

+
+ ); +}; + +export default ProjectRegistryView; diff --git a/opax-web/frontend/src/components/WorkQueueView.tsx b/opax-web/frontend/src/components/WorkQueueView.tsx new file mode 100644 index 0000000..c46374d --- /dev/null +++ b/opax-web/frontend/src/components/WorkQueueView.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +const WorkQueueView = () => { + return ( +
+

Work Queue

+

No ticket read model configured yet.

+
+ ); +}; + +export default WorkQueueView; diff --git a/opax-web/frontend/src/main.tsx b/opax-web/frontend/src/main.tsx new file mode 100644 index 0000000..2e5d9d3 --- /dev/null +++ b/opax-web/frontend/src/main.tsx @@ -0,0 +1 @@ +import React from'react';import{createRoot}from'react-dom/client';import App from'./App';import'./style.css';createRoot(document.getElementById('root')!).render(); diff --git a/opax-web/frontend/src/pages/OperationsConsole.tsx b/opax-web/frontend/src/pages/OperationsConsole.tsx new file mode 100644 index 0000000..7de4721 --- /dev/null +++ b/opax-web/frontend/src/pages/OperationsConsole.tsx @@ -0,0 +1,138 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import PlatformStatus from '../components/PlatformStatus'; +import ProjectRegistryView from '../components/ProjectRegistryView'; +import WorkQueueView from '../components/WorkQueueView'; +import ApprovalsView from '../components/ApprovalsView'; +import AgentConsoleView from '../components/AgentConsoleView'; + +// --- Types for Safe Data Handling --- +type ConsoleStatus = 'success' | 'unavailable'; + +type SafeHealthSummary = { + status: 'ok'; + service: string; + version: string; +}; + +type ConsoleStatusItem = { + status: ConsoleStatus; + summary: SafeHealthSummary | null; + message: 'Available' | 'Unavailable'; +}; + +export interface ConsoleStatusResponse { + health: ConsoleStatusItem; + buildStatus: ConsoleStatusItem; + platformState: ConsoleStatusItem; +} + +const unavailableItem: ConsoleStatusItem = { + status: 'unavailable', + summary: null, + message: 'Unavailable', +}; + +// --- Normalizer Function --- +type UnknownRecord = Record; + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isHealthSummary(value: unknown): value is SafeHealthSummary { + return ( + isRecord(value) && + value.status === 'ok' && + typeof value.service === 'string' && + typeof value.version === 'string' + ); +} + +function normalizeStatusResponse(raw: unknown): ConsoleStatusResponse { + let health: ConsoleStatusItem = unavailableItem; + + if (isRecord(raw) && isRecord(raw.health)) { + const healthItem = raw.health; + + if ( + healthItem.status === 'success' && + isHealthSummary(healthItem.summary) + ) { + health = { + status: 'success', + summary: { + status: 'ok', + service: healthItem.summary.service, + version: healthItem.summary.version, + }, + message: 'Available', + }; + } + } + + return { + health, + buildStatus: unavailableItem, + platformState: unavailableItem, + }; +} + +// --- Component --- +const OperationsConsole = () => { + const [statusData, setStatusData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastRefreshed, setLastRefreshed] = useState(''); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch('/api/console/status', { credentials: 'include' }); + if (!res.ok) { + // Handle auth errors or server errors safely + if (res.status === 401) { + throw new Error('Access denied. Please log in again.'); + } else { + throw new Error('Console data is unavailable.'); + } + } + const rawData = await res.json(); + setStatusData(normalizeStatusResponse(rawData)); + setLastRefreshed(new Date().toLocaleTimeString()); + } catch { + setError('Console data is unavailable. Please try again.'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + return ( +
+
+

OPAX Operations Console

+
+ Last refreshed: {lastRefreshed || 'never'} + +
+
+ + {error &&

{error}

} + + + + + + + +
+ ); +}; + +export default OperationsConsole; diff --git a/opax-web/frontend/src/style.css b/opax-web/frontend/src/style.css new file mode 100644 index 0000000..c8e88b2 --- /dev/null +++ b/opax-web/frontend/src/style.css @@ -0,0 +1,165 @@ +:root { + --font-family: monospace; + --background-color: #0d1117; + --text-color: #e6edf3; + --text-color-secondary: #8b949e; + --border-color: #30363d; + --container-background: #161b22; + --button-background: #21262d; + --button-background-hover: #30363d; + --button-primary-background: #238636; + --button-primary-background-hover: #2ea043; + --error-color: #f85149; + --spacing-unit: 16px; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + background-color: var(--background-color); + color: var(--text-color); + font-family: var(--font-family); + font-size: 14px; +} + +main { + height: 100vh; + display: flex; + flex-direction: column; +} + +header { + padding: var(--spacing-unit); + background-color: var(--container-background); + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; + font-size: 16px; +} + +header span { + color: var(--text-color-secondary); +} + +.content-wrapper { + padding: calc(var(--spacing-unit) * 2); + max-width: 800px; + margin: 0 auto; + width: 100%; +} + +.page-header { + margin-bottom: calc(var(--spacing-unit) * 2); + text-align: center; +} + +.page-header h1 { + font-size: 24px; + margin-bottom: calc(var(--spacing-unit) / 2); +} + +.page-header p { + font-size: 16px; + color: var(--text-color-secondary); + max-width: 600px; + margin: 0 auto; +} + +.actions-container { + display: grid; + grid-template-columns: 1fr; + gap: calc(var(--spacing-unit) * 2); +} + +@media (min-width: 768px) { + .actions-container { + grid-template-columns: 1fr 1fr; + } +} + +.action-card { + background-color: var(--container-background); + border: 1px solid var(--border-color); + padding: var(--spacing-unit); + border-radius: 6px; +} + +.action-card h2 { + margin-top: 0; + font-size: 18px; + border-bottom: 1px solid var(--border-color); + padding-bottom: var(--spacing-unit); + margin-bottom: var(--spacing-unit); +} + +button { + width: 100%; + background: var(--button-background); + color: var(--text-color); + border: 1px solid var(--border-color); + padding: 10px var(--spacing-unit); + border-radius: 6px; + font-family: inherit; + font-size: 14px; + cursor: pointer; + transition: background-color 0.2s; +} + +button:hover:not(:disabled) { + background-color: var(--button-background-hover); +} + +button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +button.primary { + background-color: var(--button-primary-background); + border-color: var(--button-primary-background-hover); +} + +button.primary:hover:not(:disabled) { + background-color: var(--button-primary-background-hover); +} + +.result-display { + margin-top: var(--spacing-unit); + padding: var(--spacing-unit); + background-color: var(--background-color); + border: 1px solid var(--border-color); + border-radius: 6px; + white-space: pre-wrap; + word-wrap: break-word; + font-size: 13px; + min-height: 100px; +} + +.result-display strong { + color: var(--text-color-secondary); +} + +.error { + color: var(--error-color); + margin-top: calc(var(--spacing-unit) / 2); + font-size: 13px; +} + +.login { + height: 100vh; + display: grid; + place-content: center; + gap: 20px; + text-align: center; +} + +.login a { + color: #58a6ff; + font-size: 18px; +} \ No newline at end of file diff --git a/opax-web/frontend/vite.config.ts b/opax-web/frontend/vite.config.ts new file mode 100644 index 0000000..073f58e --- /dev/null +++ b/opax-web/frontend/vite.config.ts @@ -0,0 +1 @@ +import{defineConfig}from'vite';import react from'@vitejs/plugin-react';export default defineConfig({plugins:[react()],server:{proxy:{'/auth':'http://localhost:8081','/socket.io':{target:'http://localhost:8081',ws:true}}}}); From 0b40d556cd5f1e952f88c53ad844786ab25fa033 Mon Sep 17 00:00:00 2001 From: Chris Christiansen Date: Sun, 13 Sep 2026 11:22:18 +0000 Subject: [PATCH 02/43] style(opax-web): fix whitespace hygiene --- opax-web/frontend/src/App.tsx | 1 - opax-web/frontend/src/components/PlatformStatus.tsx | 6 +++--- opax-web/frontend/src/pages/OperationsConsole.tsx | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/opax-web/frontend/src/App.tsx b/opax-web/frontend/src/App.tsx index 28b883a..76d6eb0 100644 --- a/opax-web/frontend/src/App.tsx +++ b/opax-web/frontend/src/App.tsx @@ -36,4 +36,3 @@ function App() { } export default App; - diff --git a/opax-web/frontend/src/components/PlatformStatus.tsx b/opax-web/frontend/src/components/PlatformStatus.tsx index a9cdc7d..a33cdc4 100644 --- a/opax-web/frontend/src/components/PlatformStatus.tsx +++ b/opax-web/frontend/src/components/PlatformStatus.tsx @@ -55,17 +55,17 @@ const PlatformStatus: React.FC = ({ statusData, loading })

Platform Overview

- - - {
{error &&

{error}

} - + From 8f1c5016513b8ad47014d3be7d5efe8c23461ed2 Mon Sep 17 00:00:00 2001 From: Chris Christiansen Date: Sun, 13 Sep 2026 15:53:04 +0000 Subject: [PATCH 03/43] feat(emma): deploy browser-based Emma Local operator POC --- opax-web/backend/agentClient.js | 1 + opax-web/backend/capabilityRegistry.js | 27 +++++++ opax-web/backend/routes/consoleApi.js | 34 ++++++++ opax-web/backend/server.js | 1 + .../src/components/AgentConsoleView.tsx | 80 ++++++++++++++++--- .../src/components/CapabilityPanel.tsx | 49 ++++++++++++ .../frontend/src/pages/OperationsConsole.tsx | 2 + 7 files changed, 184 insertions(+), 10 deletions(-) create mode 100644 opax-web/backend/capabilityRegistry.js create mode 100644 opax-web/frontend/src/components/CapabilityPanel.tsx diff --git a/opax-web/backend/agentClient.js b/opax-web/backend/agentClient.js index 1902a82..67d3d5c 100644 --- a/opax-web/backend/agentClient.js +++ b/opax-web/backend/agentClient.js @@ -10,6 +10,7 @@ const READ_ONLY_TOOLS = new Set([ 'get_build_status', 'get_state', 'get_telemetry', + 'run_emma', ]); export class AgentNotConfiguredError extends Error { diff --git a/opax-web/backend/capabilityRegistry.js b/opax-web/backend/capabilityRegistry.js new file mode 100644 index 0000000..db3470f --- /dev/null +++ b/opax-web/backend/capabilityRegistry.js @@ -0,0 +1,27 @@ +export const capabilities = { + get_health: { type: 'read' }, + get_build_status: { type: 'read' }, + get_state: { type: 'read' }, + get_telemetry: { type: 'read' }, + list_commits: { type: 'read' }, + get_file: { type: 'read' }, + list_customers: { type: 'read' }, + get_billing_summary: { type: 'read' }, + get_billing_forecast: { type: 'read' }, + get_billing_anomalies: { type: 'read' }, + get_billing_budget: { type: 'read' }, + list_workspace_users: { type: 'read' }, + list_user_aliases: { type: 'read' }, + list_calendar_events: { type: 'read' }, + run_emma: { type: 'read' }, + trigger_build: { type: 'proposal' }, + commit_and_push_files: { type: 'proposal' }, + create_a2h2a_ticket: { type: 'proposal' }, + send_email: { type: 'proposal' }, + send_sms: { type: 'proposal' }, + send_webhook: { type: 'proposal' }, + create_invite: { type: 'proposal' }, + create_email_alias: { type: 'proposal' }, + delete_email_alias: { type: 'proposal' }, + set_billing_budget: { type: 'proposal' }, +}; diff --git a/opax-web/backend/routes/consoleApi.js b/opax-web/backend/routes/consoleApi.js index 9416c18..ca9129a 100644 --- a/opax-web/backend/routes/consoleApi.js +++ b/opax-web/backend/routes/consoleApi.js @@ -67,4 +67,38 @@ router.get('/approvals', (req, res) => { }); }); +router.post('/emma/chat', async (req, res) => { + const { message, session_id, ticket_number } = req.body; + + if (!message || typeof message !== 'string' || message.length > 4096) { + return res.status(400).json({ status: 'error', message: 'Invalid message.'}); + } + + if (ticket_number && (typeof ticket_number !== 'number' || !Number.isInteger(ticket_number) || ticket_number <= 0)) { + return res.status(400).json({ status: 'error', message: 'Invalid ticket number.'}); + } + + try { + const emmaData = await callAgent('run_emma', { prompt: message, history: [] }); + + res.json({ + status: 'success', + reply: emmaData.response, + session_id, + ticket_number, + model_label: 'Emma Local', + message: 'Message processed by Emma.', + }); + + } catch (e) { + res.status(500).json({ status: 'error', message: 'Could not connect to Emma.' }); + } +}); + +import { capabilities } from '../capabilityRegistry.js'; + +router.get('/capabilities', (req, res) => { + res.json(capabilities); +}); + export default router; diff --git a/opax-web/backend/server.js b/opax-web/backend/server.js index cf6556e..951be97 100644 --- a/opax-web/backend/server.js +++ b/opax-web/backend/server.js @@ -43,6 +43,7 @@ const sm = session({ const io = new Server(http, { cors: { origin: front, credentials: true } }); app.use(cors({ origin: front, credentials: true })); +app.use(express.json()); app.use(sm); app.use(express.static(frontendDistPath)); io.use((s, n) => sm(s.request, {}, n)); diff --git a/opax-web/frontend/src/components/AgentConsoleView.tsx b/opax-web/frontend/src/components/AgentConsoleView.tsx index b66c9c5..a38a591 100644 --- a/opax-web/frontend/src/components/AgentConsoleView.tsx +++ b/opax-web/frontend/src/components/AgentConsoleView.tsx @@ -1,15 +1,75 @@ -import React from 'react'; +import React, { useState } from 'react'; const AgentConsoleView = () => { - return ( -
-

Agent Console

-
- DraftPlanScope ApprovalImplementationDiff ApprovalValidationDeployment ApprovalDeploy -
-

Ticket-bound agent execution is not configured yet.

-
- ); + const [message, setMessage] = useState(''); + const [transcript, setTranscript] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [ticketNumber, setTicketNumber] = useState(null); + + const handleSend = async () => { + if (!message.trim()) return; + + const newMessage = { role: 'user', content: message }; + setTranscript(prev => [...prev, newMessage]); + setMessage(''); + setLoading(true); + setError(null); + + try { + const res = await fetch('/api/console/emma/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ message, ticket_number: ticketNumber }), + }); + + if (!res.ok) { + throw new Error('Emma is unavailable.'); + } + + const data = await res.json(); + const emmaMessage = { role: 'assistant', content: data.reply }; + setTranscript(prev => [...prev, emmaMessage]); + + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + return ( +
+

Emma Local

+
+ Local model · server-side memory enabled + {ticketNumber && Working context: Gitea #{ticketNumber}} +
+
+ {transcript.map((msg, index) => ( +
+ {msg.role}: {msg.content} +
+ ))} + {loading &&
...
} + {error &&
{error}
} +
+
+ setMessage(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleSend()} + placeholder="Chat with Emma..." + disabled={loading} + /> + +
+
+ ); }; export default AgentConsoleView; diff --git a/opax-web/frontend/src/components/CapabilityPanel.tsx b/opax-web/frontend/src/components/CapabilityPanel.tsx new file mode 100644 index 0000000..3d6d30a --- /dev/null +++ b/opax-web/frontend/src/components/CapabilityPanel.tsx @@ -0,0 +1,49 @@ +import React, { useState, useEffect } from 'react'; + +const CapabilityPanel = () => { + const [capabilities, setCapabilities] = useState({}); + + useEffect(() => { + const fetchCapabilities = async () => { + const res = await fetch('/api/console/capabilities'); + const data = await res.json(); + setCapabilities(data); + }; + fetchCapabilities(); + }, []); + + const capabilityGroups = { + 'Platform Status': ['get_health', 'get_build_status', 'get_state', 'get_telemetry'], + 'Git and Gitea': ['list_commits', 'get_file'], + 'Tickets and Work Queue': [], + 'Code Agents': ['run_emma'], + 'Tests and Validation': [], + 'Builds and Deployments': ['trigger_build', 'commit_and_push_files'], + 'Cloud Run and Infrastructure': [], + 'Logs and Telemetry': [], + 'Billing and CostGuard': ['get_billing_summary', 'get_billing_forecast', 'get_billing_anomalies', 'get_billing_budget', 'set_billing_budget'], + 'Customers and Workspace': ['list_customers', 'list_workspace_users', 'create_invite'], + 'Notifications': ['send_email', 'send_sms', 'send_webhook'], + 'Memory and Knowledge': [], + }; + + return ( +
+

Capabilities

+ {Object.entries(capabilityGroups).map(([group, capabilityKeys]) => ( +
+

{group}

+
    + {capabilityKeys.map(key => { + const capability = capabilities[key]; + if (!capability) return null; + return
  • {key}
  • + })} +
+
+ ))} +
+ ); +}; + +export default CapabilityPanel; diff --git a/opax-web/frontend/src/pages/OperationsConsole.tsx b/opax-web/frontend/src/pages/OperationsConsole.tsx index 9983aef..6ccae6b 100644 --- a/opax-web/frontend/src/pages/OperationsConsole.tsx +++ b/opax-web/frontend/src/pages/OperationsConsole.tsx @@ -4,6 +4,7 @@ import ProjectRegistryView from '../components/ProjectRegistryView'; import WorkQueueView from '../components/WorkQueueView'; import ApprovalsView from '../components/ApprovalsView'; import AgentConsoleView from '../components/AgentConsoleView'; +import CapabilityPanel from '../components/CapabilityPanel'; // --- Types for Safe Data Handling --- type ConsoleStatus = 'success' | 'unavailable'; @@ -130,6 +131,7 @@ const OperationsConsole = () => { +
); From c646649a7633b3ede7a423f4bce3a6ef8f451e37 Mon Sep 17 00:00:00 2001 From: Chris Christiansen Date: Sun, 13 Sep 2026 16:26:04 +0000 Subject: [PATCH 04/43] feat(opax-ui): make Emma a modern operator workspace --- opax-web/backend/routes/consoleApi.js | 9 ++ .../src/components/AgentConsoleView.tsx | 75 ---------- .../frontend/src/components/ApprovalsView.tsx | 12 -- .../src/components/CapabilityPanel.tsx | 58 +++++--- .../frontend/src/components/ChatWorkspace.tsx | 105 +++++++++++++ .../frontend/src/components/ContextPanel.tsx | 28 ++++ .../src/components/PlatformStatus.tsx | 78 ---------- .../src/components/ProjectRegistryView.tsx | 12 -- opax-web/frontend/src/components/Sidebar.tsx | 26 ++++ .../frontend/src/components/WorkQueueView.tsx | 12 -- .../frontend/src/pages/OperationsConsole.tsx | 140 +----------------- 11 files changed, 210 insertions(+), 345 deletions(-) delete mode 100644 opax-web/frontend/src/components/AgentConsoleView.tsx delete mode 100644 opax-web/frontend/src/components/ApprovalsView.tsx create mode 100644 opax-web/frontend/src/components/ChatWorkspace.tsx create mode 100644 opax-web/frontend/src/components/ContextPanel.tsx delete mode 100644 opax-web/frontend/src/components/PlatformStatus.tsx delete mode 100644 opax-web/frontend/src/components/ProjectRegistryView.tsx create mode 100644 opax-web/frontend/src/components/Sidebar.tsx delete mode 100644 opax-web/frontend/src/components/WorkQueueView.tsx diff --git a/opax-web/backend/routes/consoleApi.js b/opax-web/backend/routes/consoleApi.js index ca9129a..c18ae50 100644 --- a/opax-web/backend/routes/consoleApi.js +++ b/opax-web/backend/routes/consoleApi.js @@ -101,4 +101,13 @@ router.get('/capabilities', (req, res) => { res.json(capabilities); }); +router.get('/emma/status', async (req, res) => { + try { + await callAgent('get_health', {}); + res.json({ status: 'ready', label: 'Emma Local', message: 'Emma is ready.' }); + } catch (e) { + res.status(500).json({ status: 'unavailable', label: 'Emma Local', message: 'Emma is unavailable.' }); + } +}); + export default router; diff --git a/opax-web/frontend/src/components/AgentConsoleView.tsx b/opax-web/frontend/src/components/AgentConsoleView.tsx deleted file mode 100644 index a38a591..0000000 --- a/opax-web/frontend/src/components/AgentConsoleView.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React, { useState } from 'react'; - -const AgentConsoleView = () => { - const [message, setMessage] = useState(''); - const [transcript, setTranscript] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [ticketNumber, setTicketNumber] = useState(null); - - const handleSend = async () => { - if (!message.trim()) return; - - const newMessage = { role: 'user', content: message }; - setTranscript(prev => [...prev, newMessage]); - setMessage(''); - setLoading(true); - setError(null); - - try { - const res = await fetch('/api/console/emma/chat', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ message, ticket_number: ticketNumber }), - }); - - if (!res.ok) { - throw new Error('Emma is unavailable.'); - } - - const data = await res.json(); - const emmaMessage = { role: 'assistant', content: data.reply }; - setTranscript(prev => [...prev, emmaMessage]); - - } catch (err) { - setError(err.message); - } finally { - setLoading(false); - } - }; - - return ( -
-

Emma Local

-
- Local model · server-side memory enabled - {ticketNumber && Working context: Gitea #{ticketNumber}} -
-
- {transcript.map((msg, index) => ( -
- {msg.role}: {msg.content} -
- ))} - {loading &&
...
} - {error &&
{error}
} -
-
- setMessage(e.target.value)} - onKeyPress={(e) => e.key === 'Enter' && handleSend()} - placeholder="Chat with Emma..." - disabled={loading} - /> - -
-
- ); -}; - -export default AgentConsoleView; diff --git a/opax-web/frontend/src/components/ApprovalsView.tsx b/opax-web/frontend/src/components/ApprovalsView.tsx deleted file mode 100644 index 5075d7e..0000000 --- a/opax-web/frontend/src/components/ApprovalsView.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; - -const ApprovalsView = () => { - return ( -
-

Approvals

-

No approval read model configured yet.

-
- ); -}; - -export default ApprovalsView; diff --git a/opax-web/frontend/src/components/CapabilityPanel.tsx b/opax-web/frontend/src/components/CapabilityPanel.tsx index 3d6d30a..1430178 100644 --- a/opax-web/frontend/src/components/CapabilityPanel.tsx +++ b/opax-web/frontend/src/components/CapabilityPanel.tsx @@ -1,7 +1,16 @@ import React, { useState, useEffect } from 'react'; +interface Capability { + type: string; +} + +interface Capabilities { + [key: string]: Capability; +} + const CapabilityPanel = () => { - const [capabilities, setCapabilities] = useState({}); + const [capabilities, setCapabilities] = useState({}); + const [isOpen, setIsOpen] = useState(false); useEffect(() => { const fetchCapabilities = async () => { @@ -13,35 +22,36 @@ const CapabilityPanel = () => { }, []); const capabilityGroups = { - 'Platform Status': ['get_health', 'get_build_status', 'get_state', 'get_telemetry'], - 'Git and Gitea': ['list_commits', 'get_file'], - 'Tickets and Work Queue': [], - 'Code Agents': ['run_emma'], - 'Tests and Validation': [], - 'Builds and Deployments': ['trigger_build', 'commit_and_push_files'], - 'Cloud Run and Infrastructure': [], - 'Logs and Telemetry': [], - 'Billing and CostGuard': ['get_billing_summary', 'get_billing_forecast', 'get_billing_anomalies', 'get_billing_budget', 'set_billing_budget'], - 'Customers and Workspace': ['list_customers', 'list_workspace_users', 'create_invite'], + 'Platform': ['get_health', 'get_build_status', 'get_state', 'get_telemetry'], + 'Repository': ['list_commits', 'get_file'], + 'Work and tickets': [], + 'Code agents': ['run_emma'], + 'Validation': [], + 'Deployment': ['trigger_build', 'commit_and_push_files'], + 'Costs': ['get_billing_summary', 'get_billing_forecast', 'get_billing_anomalies', 'get_billing_budget', 'set_billing_budget'], 'Notifications': ['send_email', 'send_sms', 'send_webhook'], + 'Customers and Workspace': ['list_customers', 'list_workspace_users', 'create_invite'], 'Memory and Knowledge': [], }; return ( -
-

Capabilities

- {Object.entries(capabilityGroups).map(([group, capabilityKeys]) => ( -
-

{group}

-
    - {capabilityKeys.map(key => { - const capability = capabilities[key]; - if (!capability) return null; - return
  • {key}
  • - })} -
+
+ +
+

Capabilities

+ {Object.entries(capabilityGroups).map(([group, capabilityKeys]) => ( +
+

{group}

+
    + {capabilityKeys.map(key => { + const capability = capabilities[key]; + if (!capability) return null; + return
  • {key.replace(/_/g, ' ')}
  • + })} +
+
+ ))}
- ))}
); }; diff --git a/opax-web/frontend/src/components/ChatWorkspace.tsx b/opax-web/frontend/src/components/ChatWorkspace.tsx new file mode 100644 index 0000000..670cf03 --- /dev/null +++ b/opax-web/frontend/src/components/ChatWorkspace.tsx @@ -0,0 +1,105 @@ +import React, { useState, useEffect } from 'react'; + +interface Message { + role: string; + content: string; +} + +const ChatWorkspace = () => { + const [message, setMessage] = useState(''); + const [transcript, setTranscript] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [emmaStatus, setEmmaStatus] = useState('unavailable'); + const [ticketNumber, setTicketNumber] = useState(null); + + useEffect(() => { + const fetchEmmaStatus = async () => { + try { + const res = await fetch('/api/console/emma/status', { credentials: 'include' }); + const data = await res.json(); + setEmmaStatus(data.status); + } catch (e) { + setEmmaStatus('unavailable'); + } + }; + fetchEmmaStatus(); + }, []); + + const handleSend = async () => { + if (!message.trim()) return; + + const newMessage: Message = { role: 'user', content: message }; + setTranscript(prev => [...prev, newMessage]); + setMessage(''); + setLoading(true); + setError(null); + + try { + const res = await fetch('/api/console/emma/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ message, ticket_number: ticketNumber }), + }); + + if (!res.ok) { + throw new Error('Emma is unavailable.'); + } + + const data = await res.json(); + const emmaMessage: Message = { role: 'assistant', content: data.reply }; + setTranscript(prev => [...prev, emmaMessage]); + + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Emma Local

+ {emmaStatus === 'ready' ? 'Connected' : 'Unavailable'} + {ticketNumber && Gitea #{ticketNumber}} +
+
+ {transcript.length === 0 && !loading && ( +
+

Hei Chris. Hva vil du at jeg skal få gjort?

+
+ + + + + +
+
+ )} + {transcript.map((msg, index) => ( +
+ {msg.role}: {msg.content} +
+ ))} + {loading &&
...
} + {error &&
{error}
} +
+
+