feat(opax-web): import P1A Operations Console
This commit is contained in:
parent
fceec08823
commit
429cc95c9a
15
opax-web/.dockerignore
Normal file
15
opax-web/.dockerignore
Normal file
|
|
@ -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*
|
||||||
5
opax-web/.gitignore
vendored
Normal file
5
opax-web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
dist/
|
||||||
|
|
||||||
|
backend/.env
|
||||||
50
opax-web/Dockerfile
Normal file
50
opax-web/Dockerfile
Normal file
|
|
@ -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"]
|
||||||
55
opax-web/README-opax-web.md
Normal file
55
opax-web/README-opax-web.md
Normal file
|
|
@ -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.
|
||||||
41
opax-web/README.md
Normal file
41
opax-web/README.md
Normal file
|
|
@ -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`.
|
||||||
14
opax-web/SECURITY.md
Normal file
14
opax-web/SECURITY.md
Normal file
|
|
@ -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.
|
||||||
7
opax-web/backend/.env.example
Normal file
7
opax-web/backend/.env.example
Normal file
|
|
@ -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
|
||||||
139
opax-web/backend/agentClient.js
Normal file
139
opax-web/backend/agentClient.js
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
403
opax-web/backend/agentClient.test.js
Normal file
403
opax-web/backend/agentClient.test.js
Normal file
|
|
@ -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, {}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
70
opax-web/backend/routes/consoleApi.js
Normal file
70
opax-web/backend/routes/consoleApi.js
Normal file
|
|
@ -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;
|
||||||
122
opax-web/backend/server.js
Normal file
122
opax-web/backend/server.js
Normal file
|
|
@ -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');
|
||||||
40
opax-web/backend/socketHandlers.js
Normal file
40
opax-web/backend/socketHandlers.js
Normal file
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
204
opax-web/backend/socketHandlers.test.js
Normal file
204
opax-web/backend/socketHandlers.test.js
Normal file
|
|
@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
1
opax-web/frontend/index.html
Normal file
1
opax-web/frontend/index.html
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<div id="root"></div><script type="module" src="/src/main.tsx"></script>
|
||||||
39
opax-web/frontend/src/App.tsx
Normal file
39
opax-web/frontend/src/App.tsx
Normal file
|
|
@ -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<User | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/auth/me', { credentials: 'include' })
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then(setUser);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<main className="login">
|
||||||
|
<h1>OPAX</h1>
|
||||||
|
<a href="/auth/google">Continue with Google</a>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<b>OPAX / Operations Console</b>
|
||||||
|
<span>{user.email}</span>
|
||||||
|
</header>
|
||||||
|
<OperationsConsole />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|
||||||
15
opax-web/frontend/src/components/AgentConsoleView.tsx
Normal file
15
opax-web/frontend/src/components/AgentConsoleView.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const AgentConsoleView = () => {
|
||||||
|
return (
|
||||||
|
<div className="console-card">
|
||||||
|
<h2>Agent Console</h2>
|
||||||
|
<div className="workflow-diagram">
|
||||||
|
<span>Draft</span> → <span>Plan</span> → <span>Scope Approval</span> → <span>Implementation</span> → <span>Diff Approval</span> → <span>Validation</span> → <span>Deployment Approval</span> → <span>Deploy</span>
|
||||||
|
</div>
|
||||||
|
<p className="unavailable-message">Ticket-bound agent execution is not configured yet.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AgentConsoleView;
|
||||||
12
opax-web/frontend/src/components/ApprovalsView.tsx
Normal file
12
opax-web/frontend/src/components/ApprovalsView.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const ApprovalsView = () => {
|
||||||
|
return (
|
||||||
|
<div className="console-card">
|
||||||
|
<h2>Approvals</h2>
|
||||||
|
<p className="unavailable-message">No approval read model configured yet.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApprovalsView;
|
||||||
78
opax-web/frontend/src/components/PlatformStatus.tsx
Normal file
78
opax-web/frontend/src/components/PlatformStatus.tsx
Normal file
|
|
@ -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<ConsoleStatus, 'loading'>;
|
||||||
|
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<StatusBlockProps> = ({ title, status, summary }) => {
|
||||||
|
return (
|
||||||
|
<div className={`status-block status-${status}`}>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
{status === 'loading' && <p>Loading...</p>}
|
||||||
|
{status === 'unavailable' && <p>Unavailable</p>}
|
||||||
|
{status === 'success' && summary && (
|
||||||
|
<ul>
|
||||||
|
<li><strong>Status:</strong> {summary.status}</li>
|
||||||
|
<li><strong>Service:</strong> {summary.service}</li>
|
||||||
|
<li><strong>Version:</strong> {summary.version}</li>
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PlatformStatusProps {
|
||||||
|
statusData: ConsoleStatusResponse | null;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unavailableStatus: ConsoleStatus = 'unavailable';
|
||||||
|
|
||||||
|
const PlatformStatus: React.FC<PlatformStatusProps> = ({ statusData, loading }) => {
|
||||||
|
return (
|
||||||
|
<div className="console-card">
|
||||||
|
<h2>Platform Overview</h2>
|
||||||
|
<div className="status-container">
|
||||||
|
<StatusBlock
|
||||||
|
title="Health"
|
||||||
|
status={loading ? 'loading' : statusData?.health?.status ?? unavailableStatus}
|
||||||
|
summary={statusData?.health?.summary ?? null}
|
||||||
|
/>
|
||||||
|
<StatusBlock
|
||||||
|
title="Latest Build"
|
||||||
|
status={loading ? 'loading' : statusData?.buildStatus?.status ?? unavailableStatus}
|
||||||
|
summary={null} // Schema not confirmed, so always null for now
|
||||||
|
/>
|
||||||
|
<StatusBlock
|
||||||
|
title="Platform State"
|
||||||
|
status={loading ? 'loading' : statusData?.platformState?.status ?? unavailableStatus}
|
||||||
|
summary={null} // Schema not confirmed, so always null for now
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlatformStatus;
|
||||||
12
opax-web/frontend/src/components/ProjectRegistryView.tsx
Normal file
12
opax-web/frontend/src/components/ProjectRegistryView.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const ProjectRegistryView = () => {
|
||||||
|
return (
|
||||||
|
<div className="console-card">
|
||||||
|
<h2>Projects</h2>
|
||||||
|
<p className="unavailable-message">No project registry configured yet.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProjectRegistryView;
|
||||||
12
opax-web/frontend/src/components/WorkQueueView.tsx
Normal file
12
opax-web/frontend/src/components/WorkQueueView.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const WorkQueueView = () => {
|
||||||
|
return (
|
||||||
|
<div className="console-card">
|
||||||
|
<h2>Work Queue</h2>
|
||||||
|
<p className="unavailable-message">No ticket read model configured yet.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WorkQueueView;
|
||||||
1
opax-web/frontend/src/main.tsx
Normal file
1
opax-web/frontend/src/main.tsx
Normal file
|
|
@ -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(<App/>);
|
||||||
138
opax-web/frontend/src/pages/OperationsConsole.tsx
Normal file
138
opax-web/frontend/src/pages/OperationsConsole.tsx
Normal file
|
|
@ -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<string, unknown>;
|
||||||
|
|
||||||
|
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<ConsoleStatusResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [lastRefreshed, setLastRefreshed] = useState<string>('');
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="console-page">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>OPAX Operations Console</h1>
|
||||||
|
<div className="header-actions">
|
||||||
|
<span>Last refreshed: {lastRefreshed || 'never'}</span>
|
||||||
|
<button onClick={fetchData} disabled={loading} className="primary">
|
||||||
|
{loading ? 'Refreshing...' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
|
<PlatformStatus statusData={statusData} loading={loading} />
|
||||||
|
<ProjectRegistryView />
|
||||||
|
<WorkQueueView />
|
||||||
|
<ApprovalsView />
|
||||||
|
<AgentConsoleView />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OperationsConsole;
|
||||||
165
opax-web/frontend/src/style.css
Normal file
165
opax-web/frontend/src/style.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
1
opax-web/frontend/vite.config.ts
Normal file
1
opax-web/frontend/vite.config.ts
Normal file
|
|
@ -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}}}});
|
||||||
Loading…
Reference in New Issue
Block a user