feat(opax-web): add read-only Git BFF and hardened UI
Replace the generic mcp-call socket path with two authenticated, read-only Git actions. Add response normalization and error masking, a purpose-built frontend, container build configuration, and socket handler unit tests.
This commit is contained in:
parent
bb2f61beb4
commit
dc9f7b6626
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*
|
||||
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"]
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { callAgent, AgentNotConfiguredError } from './agentClient.js';
|
||||
import { callAgent } from './agentClient.js';
|
||||
import { registerBrowserSocketHandlers } from './socketHandlers.js';
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
|
|
@ -7,6 +7,8 @@ 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';
|
||||
|
||||
const app = express();
|
||||
const http = createServer(app);
|
||||
|
|
@ -14,6 +16,10 @@ 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);
|
||||
}
|
||||
|
|
@ -34,10 +40,10 @@ const sm = session({
|
|||
},
|
||||
});
|
||||
const io = new Server(http, { cors: { origin: front, credentials: true } });
|
||||
const allowed = new Set(['get_health', 'get_build_status', 'get_state', 'get_telemetry', 'get_git_status', 'get_git_branch', 'get_git_log', 'get_git_diff', 'list_repo_files', 'read_repo_file']);
|
||||
|
||||
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' }));
|
||||
|
|
@ -60,50 +66,33 @@ app.get('/auth/me', (q, r) => (q.session.user ? r.json(q.session.user) : r.statu
|
|||
app.post('/auth/logout', (q, r) => q.session.destroy(() => r.json({ ok: true })));
|
||||
|
||||
io.on('connection', (s) => {
|
||||
let u = s.request.session?.user;
|
||||
const u = s.request.session?.user;
|
||||
if (!u) return s.disconnect();
|
||||
|
||||
s.on('mcp-call', async ({ id, tool, args }) => {
|
||||
if (typeof id !== 'string' || id.length > 128) {
|
||||
return s.emit('mcp-result', { id, error: 'Invalid request id' });
|
||||
}
|
||||
if (typeof tool !== 'string' || !allowed.has(tool)) {
|
||||
return s.emit('mcp-result', { id, error: 'Tool denied by server allowlist' });
|
||||
}
|
||||
try {
|
||||
if (typeof args !== 'object' || args === null || Array.isArray(args)) {
|
||||
return s.emit('mcp-result', { id, error: 'Invalid arguments' });
|
||||
}
|
||||
const argsString = JSON.stringify(args);
|
||||
if (argsString.length > 16384) {
|
||||
return s.emit('mcp-result', { id, error: 'Invalid arguments' });
|
||||
}
|
||||
} catch {
|
||||
return s.emit('mcp-result', { id, error: 'Invalid arguments' });
|
||||
}
|
||||
registerBrowserSocketHandlers(s, { callAgentFn: callAgent });
|
||||
});
|
||||
|
||||
const request_id = randomUUID();
|
||||
try {
|
||||
const result = await callAgent(tool, args);
|
||||
s.emit('mcp-result', { id, result });
|
||||
} catch (e) {
|
||||
if (e instanceof AgentNotConfiguredError) {
|
||||
console.log(JSON.stringify({
|
||||
request_id,
|
||||
event: 'agent_not_configured',
|
||||
category: 'agent_not_configured',
|
||||
}));
|
||||
s.emit('mcp-result', { id, error: 'Agent integration is not configured.' });
|
||||
} else {
|
||||
console.log(JSON.stringify({
|
||||
request_id,
|
||||
event: 'agent_request_failed',
|
||||
category: 'agent_request_failed',
|
||||
}));
|
||||
s.emit('mcp-result', { id, error: 'An unexpected error occurred.' });
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
http.listen(Number(process.env.PORT || 8080), '0.0.0.0');
|
||||
|
|
|
|||
100
opax-web/backend/socketHandlers.js
Normal file
100
opax-web/backend/socketHandlers.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// FORSLAG TIL opax-web/backend/socketHandlers.js (ENDELIG KORRIGERT)
|
||||
|
||||
/**
|
||||
* Normaliserer resultatet fra MCP-toolen 'get_git_status'.
|
||||
* Kaster feil hvis responsen ikke har forventet format.
|
||||
* @param {any} data - Rådata fra agentClient.
|
||||
* @returns {{status: string, details: string}} Normalisert status-objekt.
|
||||
*/
|
||||
function normalizeGitStatus(data) {
|
||||
if (
|
||||
!data ||
|
||||
typeof data !== 'object' ||
|
||||
Array.isArray(data) ||
|
||||
typeof data.status !== 'string' ||
|
||||
typeof data.details !== 'string'
|
||||
) {
|
||||
throw new Error('Unexpected Git status response shape.');
|
||||
}
|
||||
|
||||
return {
|
||||
status: data.status,
|
||||
details: data.details,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normaliserer resultatet fra MCP-toolen 'get_git_branch'.
|
||||
* Kaster feil hvis responsen ikke har forventet format.
|
||||
* @param {any} data - Rådata fra agentClient.
|
||||
* @returns {{current_branch: string}} Normalisert branch-objekt.
|
||||
*/
|
||||
function normalizeGitBranch(data) {
|
||||
if (
|
||||
!data ||
|
||||
typeof data !== 'object' ||
|
||||
Array.isArray(data) ||
|
||||
typeof data.current_branch !== 'string'
|
||||
) {
|
||||
// I motsetning til forrige versjon, tillater vi ikke lenger null her.
|
||||
// Hvis branchen er borte, bør MCP-toolen returnere en tom string.
|
||||
// Ugyldig format skal alltid feile.
|
||||
throw new Error('Unexpected Git branch response shape.');
|
||||
}
|
||||
|
||||
return {
|
||||
current_branch: data.current_branch,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const SAFE_ACTIONS = {
|
||||
'git:getStatus': {
|
||||
tool: 'get_git_status',
|
||||
responseEvent: 'git:status',
|
||||
errorMessage: 'Kunne ikke hente Git-status.',
|
||||
normalize: normalizeGitStatus,
|
||||
},
|
||||
'git:getBranch': {
|
||||
tool: 'get_git_branch',
|
||||
responseEvent: 'git:branch',
|
||||
errorMessage: 'Kunne ikke hente aktiv branch.',
|
||||
normalize: normalizeGitBranch,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
// 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,18 +1,44 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
|
||||
const tools: string[] = [
|
||||
'get_health',
|
||||
'get_build_status',
|
||||
'get_state',
|
||||
'get_telemetry',
|
||||
'get_git_status',
|
||||
'get_git_branch',
|
||||
'get_git_log',
|
||||
'get_git_diff',
|
||||
'list_repo_files',
|
||||
'read_repo_file',
|
||||
];
|
||||
interface GitStatusData {
|
||||
status: string;
|
||||
details: string;
|
||||
}
|
||||
|
||||
interface GitBranchData {
|
||||
current_branch: string;
|
||||
}
|
||||
|
||||
function isDataEnvelope(payload: unknown): payload is { data: unknown } {
|
||||
return (
|
||||
typeof payload === 'object' &&
|
||||
payload !== null &&
|
||||
!Array.isArray(payload) &&
|
||||
'data' in payload
|
||||
);
|
||||
}
|
||||
|
||||
function isGitStatusData(data: unknown): data is GitStatusData {
|
||||
const d = data as GitStatusData;
|
||||
return (
|
||||
!!d &&
|
||||
typeof d === 'object' &&
|
||||
!Array.isArray(d) &&
|
||||
typeof d.status === 'string' &&
|
||||
typeof d.details === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function isGitBranchData(data: unknown): data is GitBranchData {
|
||||
const d = data as GitBranchData;
|
||||
return (
|
||||
!!d &&
|
||||
typeof d === 'object' &&
|
||||
!Array.isArray(d) &&
|
||||
typeof d.current_branch === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
interface User {
|
||||
email: string;
|
||||
|
|
@ -21,8 +47,13 @@ interface User {
|
|||
|
||||
export default function App() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [output, setOutput] = useState<string[]>(['OPAX Web TUI', 'Type tools or: mcp get_health', '']);
|
||||
const [input, setInput] = useState('');
|
||||
const [statusData, setStatusData] = useState<GitStatusData | null>(null);
|
||||
const [branchData, setBranchData] = useState<GitBranchData | null>(null);
|
||||
const [statusLoading, setStatusLoading] = useState<boolean>(false);
|
||||
const [branchLoading, setBranchLoading] = useState<boolean>(false);
|
||||
const [statusError, setStatusError] = useState<string | null>(null);
|
||||
const [branchError, setBranchError] = useState<string | null>(null);
|
||||
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -37,15 +68,79 @@ export default function App() {
|
|||
const socket = io('/');
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on('mcp-result', (x) => {
|
||||
setOutput((a) => [...a, x.error ? 'ERROR ' + x.error : JSON.stringify(x.result, null, 2), '']);
|
||||
});
|
||||
const onStatusResult = (payload: unknown) => {
|
||||
setStatusLoading(false);
|
||||
if (isDataEnvelope(payload) && isGitStatusData(payload.data)) {
|
||||
setStatusData({ status: payload.data.status, details: payload.data.details });
|
||||
} else {
|
||||
setStatusData(null);
|
||||
setStatusError('Ugyldig svar for Git-status.');
|
||||
}
|
||||
};
|
||||
|
||||
const onStatusError = () => {
|
||||
setStatusData(null);
|
||||
setStatusLoading(false);
|
||||
setStatusError('Kunne ikke hente Git-status.');
|
||||
};
|
||||
|
||||
const onBranchResult = (payload: unknown) => {
|
||||
setBranchLoading(false);
|
||||
if (isDataEnvelope(payload) && isGitBranchData(payload.data)) {
|
||||
setBranchData({ current_branch: payload.data.current_branch });
|
||||
} else {
|
||||
setBranchData(null);
|
||||
setBranchError('Ugyldig svar for aktiv branch.');
|
||||
}
|
||||
};
|
||||
|
||||
const onBranchError = () => {
|
||||
setBranchData(null);
|
||||
setBranchLoading(false);
|
||||
setBranchError('Kunne ikke hente aktiv branch.');
|
||||
};
|
||||
|
||||
socket.on('git:status:result', onStatusResult);
|
||||
socket.on('git:status:error', onStatusError);
|
||||
socket.on('git:branch:result', onBranchResult);
|
||||
socket.on('git:branch:error', onBranchError);
|
||||
|
||||
return () => {
|
||||
socket.off('git:status:result', onStatusResult);
|
||||
socket.off('git:status:error', onStatusError);
|
||||
socket.off('git:branch:result', onBranchResult);
|
||||
socket.off('git:branch:error', onBranchError);
|
||||
socket.close();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
const handleGetStatus = () => {
|
||||
if (!socketRef.current) {
|
||||
setStatusData(null);
|
||||
setStatusLoading(false);
|
||||
setStatusError('Kunne ikke hente Git-status.');
|
||||
return;
|
||||
}
|
||||
setStatusError(null);
|
||||
setStatusData(null);
|
||||
setStatusLoading(true);
|
||||
socketRef.current.emit('git:getStatus');
|
||||
};
|
||||
|
||||
const handleGetBranch = () => {
|
||||
if (!socketRef.current) {
|
||||
setBranchData(null);
|
||||
setBranchLoading(false);
|
||||
setBranchError('Kunne ikke hente aktiv branch.');
|
||||
return;
|
||||
}
|
||||
setBranchError(null);
|
||||
setBranchData(null);
|
||||
setBranchLoading(true);
|
||||
socketRef.current.emit('git:getBranch');
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<main className="login">
|
||||
|
|
@ -55,29 +150,6 @@ export default function App() {
|
|||
);
|
||||
}
|
||||
|
||||
function go(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
let v = input.trim();
|
||||
setInput('');
|
||||
setOutput((a) => [...a, '$ ' + v]);
|
||||
if (v === 'tools') {
|
||||
return setOutput((a) => [...a, ...tools, '']);
|
||||
}
|
||||
let m = v.match(/^mcp\s+(\S+)(?:\s+(.+))?$/);
|
||||
if (!m) {
|
||||
return setOutput((a) => [...a, 'Use mcp <tool> [JSON]', '']);
|
||||
}
|
||||
try {
|
||||
socketRef.current?.emit('mcp-call', {
|
||||
id: crypto.randomUUID(),
|
||||
tool: m[1],
|
||||
args: m[2] ? JSON.parse(m[2]) : {},
|
||||
});
|
||||
} catch {
|
||||
setOutput((a) => [...a, 'JSON arguments invalid', '']);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<header>
|
||||
|
|
@ -85,18 +157,30 @@ export default function App() {
|
|||
<span>{user.email}</span>
|
||||
</header>
|
||||
<section>
|
||||
<aside>
|
||||
{tools.map((tool) => (
|
||||
<button key={tool} onClick={() => setInput('mcp ' + tool)}>
|
||||
{tool}
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
<div className="term">
|
||||
<pre>{output.join('\n')}</pre>
|
||||
<form onSubmit={go}>
|
||||
$ <input autoFocus value={input} onChange={(e) => setInput(e.target.value)} />
|
||||
</form>
|
||||
<div>
|
||||
<h2>Git Status</h2>
|
||||
<button onClick={handleGetStatus} disabled={statusLoading}>
|
||||
{statusLoading ? 'Laster...' : 'Hent Git-status'}
|
||||
</button>
|
||||
{statusError && <p className="error">{statusError}</p>}
|
||||
{statusData && (
|
||||
<pre>
|
||||
<strong>Status:</strong> {statusData.status}\n\n
|
||||
<strong>Detaljer:</strong>\n{statusData.details || '(ingen endringer)'}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h2>Aktiv Branch</h2>
|
||||
<button onClick={handleGetBranch} disabled={branchLoading}>
|
||||
{branchLoading ? 'Laster...' : 'Hent aktiv branch'}
|
||||
</button>
|
||||
{branchError && <p className="error">{branchError}</p>}
|
||||
{branchData && (
|
||||
<p>
|
||||
<strong>Nåværende branch:</strong> {branchData.current_branch}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user