diff --git a/deploy_mcp.sh b/deploy_mcp.sh index 14c6e57..d9411ec 100755 --- a/deploy_mcp.sh +++ b/deploy_mcp.sh @@ -12,7 +12,18 @@ echo "=== 1. Configure Docker auth ===" gcloud auth configure-docker "${REGION}-docker.pkg.dev" echo "=== 2. Build locally ===" -docker build --no-cache --pull -t "${FULL_IMAGE}" -f opax-mcp/Dockerfile ./opax-mcp +# Resolve the repository root from this script's own location. +REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd -P)" + +# Fail safely if the script was moved or invoked from an unexpected layout. +if [[ ! -f "${REPO_ROOT}/opax-mcp/Dockerfile" ]]; then + echo "ERROR: Could not locate opax-mcp/Dockerfile under repository root: ${REPO_ROOT}" >&2 + exit 1 +fi + +cd "${REPO_ROOT}" + +docker build --no-cache --pull -t "${FULL_IMAGE}" -f opax-mcp/Dockerfile . echo "=== 3. Push image ===" docker push "${FULL_IMAGE}" diff --git a/opax-mcp/server.py b/opax-mcp/server.py index 30db979..70f1df3 100644 --- a/opax-mcp/server.py +++ b/opax-mcp/server.py @@ -33,12 +33,6 @@ WEB_AGENT_ALLOWED_TOOLS = frozenset({ "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", }) # --- Sikkerhets- og konfigurasjonskonstanter for Git-verktøy --- @@ -248,150 +242,6 @@ def _run_git(args: list[str], timeout: int, check: bool = True) -> subprocess.Co except Exception as e: raise RuntimeError(f"En uventet feil oppstod under kjøring av Git: {e}") -# --------------------------------------------------------------------------- -# Read-only Git Tools -# --------------------------------------------------------------------------- -async def get_git_status(p: dict) -> dict: - """Henter status for Git-repoet (porcelain-format).""" - result = _run_git(["status", "--porcelain"], timeout=GIT_TIMEOUT_READ) - return {"status": "clean" if not result.stdout else "dirty", "details": result.stdout} - -async def get_git_branch(p: dict) -> dict: - """Lister alle lokale og remote branches og viser den nåværende branchen.""" - current_branch_res = _run_git(["branch", "--show-current"], timeout=GIT_TIMEOUT_READ) - all_branches_res = _run_git(["branch", "-a"], timeout=GIT_TIMEOUT_READ) - return { - "current_branch": current_branch_res.stdout.strip(), - "all_branches": all_branches_res.stdout.strip().split("\n") - } - -async def get_git_log(p: dict) -> dict: - """Henter Git-loggen (oneline-format).""" - limit = p.get("limit", 10) - if not isinstance(limit, int) or not 1 <= limit <= 100: - limit = 10 - result = _run_git(["log", "--oneline", "-n", str(limit)], timeout=GIT_TIMEOUT_READ) - return {"log": result.stdout.strip().split("\n")} - -async def get_git_diff(p: dict) -> dict: - """Viser diff for spesifiserte filer, eller for alle endringer hvis ingen filer er angitt.""" - paths = p.get("paths") - args = ["diff"] - if paths: - validated_paths = [_validate_path_inside_repo(path).relative_to(REPO_ROOT) for path in paths] - args.extend([str(p) for p in validated_paths]) - result = _run_git(args, timeout=GIT_TIMEOUT_READ, check=False) - return {"diff": result.stdout} - -async def read_repo_file(p: dict) -> dict: - """Leser innholdet i en spesifikk fil i repoet, med masking av secrets.""" - path_str = p.get("path") - if not path_str: raise ValueError("Parameter 'path' er påkrevd.") - file_path = _validate_path_inside_repo(path_str) - if not file_path.is_file(): - raise FileNotFoundError(f"Filen ble ikke funnet: {path_str}") - content = file_path.read_text(encoding="utf-8") - content = _mask_secrets(content) - start = p.get("start_line") - end = p.get("end_line") - if start and end and isinstance(start, int) and isinstance(end, int) and start > 0 and end >= start: - lines = content.splitlines() - return {"path": path_str, "content": "\n".join(lines[start-1:end])} - return {"path": path_str, "content": content} - -async def list_repo_files(p: dict) -> dict: - """Lister filer og mapper i en gitt sti i repoet.""" - recursive = p.get("recursive", False) - - if "path" in p: - path_str = p["path"] - # An explicitly provided path must be strictly validated. - start_path = _validate_path_inside_repo(path_str) - else: - # If the 'path' key is missing entirely, default to the repo root. - start_path = REPO_ROOT - path_str = "." # Keep original behavior for the return value - - if recursive: - files = [str(f.relative_to(REPO_ROOT)) for f in start_path.rglob('*')] - else: - files = [str(f.relative_to(REPO_ROOT)) for f in start_path.iterdir()] - return {"path": path_str, "files": files} - -# --------------------------------------------------------------------------- -# Write Git Tools -# --------------------------------------------------------------------------- -async def preview_git_change(p: dict) -> dict: - """Forhåndsviser en Git-endring (status, diff, HEAD) før en eventuell commit.""" - paths = p.get("paths") - expected_head_sha = p.get("expected_head_sha") - if not paths or not isinstance(paths, list): - raise ValueError("'paths' må være en liste med filstier.") - if not expected_head_sha: - raise ValueError("'expected_head_sha' er påkrevd for å unngå race conditions.") - - validated_paths = [str(_validate_path_inside_repo(path).relative_to(REPO_ROOT)) for path in paths] - - head_res = _run_git(["rev-parse", "HEAD"], timeout=GIT_TIMEOUT_READ) - current_head_sha = head_res.stdout.strip() - if current_head_sha != expected_head_sha: - raise ValueError(f"HEAD mismatch. Forventet: {expected_head_sha}, Faktisk: {current_head_sha}") - - status_res = _run_git(["status", "--porcelain"], timeout=GIT_TIMEOUT_READ) - untracked_changes = [] - for line in status_res.stdout.strip().splitlines(): - changed_file = line[3:] - if changed_file not in validated_paths: - untracked_changes.append(line) - if untracked_changes: - raise RuntimeError(f"Repoet har endringer utenfor de angitte stiene: {untracked_changes}") - - diff_res = _run_git(["diff"] + validated_paths, timeout=GIT_TIMEOUT_READ, check=False) - - return { - "status": "ok", - "current_head_sha": current_head_sha, - "diff": diff_res.stdout, - "message": "Forhåndsvisning generert. Klar for commit_and_push_files." - } - -async def commit_and_push_files(p: dict) -> dict: - """Commiter og pusher filer etter validering. Krever eksplisitt godkjenning i klienten.""" - branch, paths, msg, head_sha = p.get("branch"), p.get("paths"), p.get("commit_message"), p.get("expected_head_sha") - if not all([branch, paths, msg, head_sha]): - raise ValueError("Alle parametere (branch, paths, commit_message, expected_head_sha) er påkrevde.") - - validated_paths = [str(_validate_path_inside_repo(path).relative_to(REPO_ROOT)) for path in paths] - - await preview_git_change({"paths": validated_paths, "expected_head_sha": head_sha}) - - if not re.match(r"^[a-zA-Z0-9/_-]+$", branch): - raise ValueError(f"Ugyldig branch-navn: {branch}") - - remote_url = _run_git(["remote", "get-url", "origin"], timeout=GIT_TIMEOUT_READ).stdout.strip() - if not _is_allowed_remote(remote_url): - raise RuntimeError(f"Push nektet: Remote URL '{remote_url}' er ikke tillatt.") - - _run_git(["add"] + validated_paths, timeout=GIT_TIMEOUT_WRITE) - - diff_cached_res = _run_git(["diff", "--cached", "--quiet"], timeout=GIT_TIMEOUT_WRITE, check=False) - if diff_cached_res.returncode == 0: - raise RuntimeError("Ingen endringer å committe. Avbryter.") - - commit_res = _run_git(["commit", "-m", msg], timeout=GIT_TIMEOUT_WRITE) - new_commit_sha = _run_git(["rev-parse", "HEAD"], timeout=GIT_TIMEOUT_READ).stdout.strip() - - push_refspec = f"HEAD:refs/heads/{branch}" - push_res = _run_git(["push", "origin", push_refspec], timeout=GIT_TIMEOUT_WRITE) - - return { - "status": "success", - "commit_sha": new_commit_sha, - "old_head_sha": head_sha, - "push_output": push_res.stdout, - "message": f"Filer committet og pushet til branch '{branch}'." - } - # --------------------------------------------------------------------------- # Google Workspace Helpers # --------------------------------------------------------------------------- @@ -717,18 +567,7 @@ async def push_file(p): # --------------------------------------------------------------------------- TOOLS = { - # --- Read-only Git Tools --- - "get_git_status": (get_git_status, "Henter status for Git-repoet (porcelain-format)", {}), - "get_git_branch": (get_git_branch, "Lister branches og viser nåværende branch", {}), - "get_git_log": (get_git_log, "Henter Git-loggen", {"type":"object", "properties": {"limit": {"type": "integer"}}}), - "get_git_diff": (get_git_diff, "Viser diff for endringer", {"type":"object", "properties": {"paths": {"type": "array", "items": {"type": "string"}}}}), - "read_repo_file": (read_repo_file, "Leser en fil fra repoet", {"type":"object", "properties": {"path": {"type": "string"}, "start_line": {"type": "integer"}, "end_line": {"type": "integer"}}, "required": ["path"]}), - "list_repo_files": (list_repo_files, "Lister filer og mapper i repoet", {"type":"object", "properties": {"path": {"type": "string"}, "recursive": {"type": "boolean"}}}), - # --- Write Git Tools (krever godkjenning) --- - "preview_git_change": (preview_git_change, "Forhåndsviser en Git-endring før commit", {"type":"object", "properties": {"paths": {"type": "array", "items": {"type": "string"}}, "expected_head_sha": {"type": "string"}}, "required": ["paths", "expected_head_sha"]}), - "commit_and_push_files": (commit_and_push_files, "Commiter og pusher filer til Git", {"type":"object", "properties": {"branch": {"type": "string"}, "paths": {"type": "array", "items": {"type": "string"}}, "commit_message": {"type": "string"}, "expected_head_sha": {"type": "string"}}, "required": ["branch", "paths", "commit_message", "expected_head_sha"]}), - # Billing "get_billing_summary": (get_billing_summary, "Hent billing-sammendrag for OPAX", {}), "get_billing_forecast": ( diff --git a/opax-mcp/test_mcp_tools.py b/opax-mcp/test_mcp_tools.py index 0390d92..229ca17 100644 --- a/opax-mcp/test_mcp_tools.py +++ b/opax-mcp/test_mcp_tools.py @@ -11,20 +11,7 @@ from httpx import ASGITransport, AsyncClient # Importer alt som skal testes from server import app, TOOLS, WEB_AGENT_ALLOWED_TOOLS -from server import ( - get_git_status, - get_git_branch, - get_git_log, - get_git_diff, - read_repo_file, - list_repo_files, - preview_git_change, - commit_and_push_files, - _validate_path_inside_repo, - _is_allowed_remote, - _mask_secrets, - _run_git -) + # --- Fixtures --- @@ -158,107 +145,6 @@ def test_run_git_invalid_args(): # --- Unit-tester for Read-only Verktøy --- -@pytest.mark.asyncio -@patch("server._run_git") -async def test_get_git_status_clean(mock_run_git): - mock_run_git.return_value = MagicMock(stdout="") - result = await get_git_status({}) - assert result == {"status": "clean", "details": ""} - -@pytest.mark.asyncio -@patch("server._run_git") -async def test_get_git_branch(mock_run_git): - mock_run_git.side_effect = [MagicMock(stdout="main\n"), MagicMock(stdout="* main\n")] - result = await get_git_branch({}) - assert result["current_branch"] == "main" - -@pytest.mark.asyncio -async def test_list_repo_files_no_path_uses_root(mock_repo_root): - """Tests that calling list_repo_files with no 'path' argument lists the root.""" - (mock_repo_root / "dir1").mkdir() - (mock_repo_root / "file.txt").touch() - - result = await list_repo_files({}) - - assert result["path"] == "." - assert set(result["files"]) == {"dir1", "file.txt"} - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "invalid_path_value", - [".", "", None], - ids=["explicit-dot", "empty-string", "none-value"], -) -async def test_list_repo_files_rejects_explicit_invalid_paths( - mock_repo_root, - invalid_path_value, -): - """Tests that list_repo_files rejects invalid explicit path values.""" - with pytest.raises(ValueError): - await list_repo_files({"path": invalid_path_value}) - -@pytest.mark.asyncio -@patch("server._mask_secrets") -async def test_read_repo_file(mock_mask_secrets, mock_repo_root): - (mock_repo_root / "my_file.txt").write_text("secret content") - mock_mask_secrets.return_value = "masked" - result = await read_repo_file({"path": "my_file.txt"}) - assert result["content"] == "masked" - mock_mask_secrets.assert_called_once_with("secret content") - -@pytest.mark.asyncio -async def test_read_repo_file_not_found(mock_repo_root): - with pytest.raises(FileNotFoundError): - await read_repo_file({"path": "non_existent.txt"}) - -# --- Integrasjonstester for Skrive-flyt --- - -@pytest.mark.asyncio -async def test_e2e_commit_flow_success(isolated_git_repo, monkeypatch): - local_repo_path, remote_repo_path = isolated_git_repo - - remote_url = subprocess.run( - ["git", "remote", "get-url", "origin"], - cwd=local_repo_path, check=True, capture_output=True, text=True - ).stdout.strip() - monkeypatch.setattr("server._is_allowed_remote", lambda url: url == remote_url) - - test_file = local_repo_path / "test.txt" - test_file.write_text("Hello, Git!") - head_sha_before = subprocess.run( - ["git", "rev-parse", "HEAD"], cwd=local_repo_path, check=True, capture_output=True, text=True - ).stdout.strip() - - preview_result = await preview_git_change({"paths": ["test.txt"], "expected_head_sha": head_sha_before}) - assert preview_result["status"] == "ok" - - branch_name = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=local_repo_path, check=True, capture_output=True, text=True).stdout.strip() - commit_result = await commit_and_push_files({ - "branch": branch_name, "paths": ["test.txt"], - "commit_message": "Test commit", "expected_head_sha": head_sha_before - }) - assert commit_result["status"] == "success" - - with tempfile.TemporaryDirectory() as clone_dir: - subprocess.run(["git", "clone", str(remote_repo_path), clone_dir], check=True) - assert (Path(clone_dir) / "test.txt").read_text() == "Hello, Git!" - -@pytest.mark.asyncio -async def test_commit_flow_unrelated_changes_fails(isolated_git_repo): - """Tester at preview feiler hvis det finnes urelaterte endringer.""" - local_repo_path, _ = isolated_git_repo - (local_repo_path / "file_a.txt").write_text("a") - (local_repo_path / "file_b.txt").write_text("b") - - # Stage one file to make the repo dirty - subprocess.run(["git", "add", "file_a.txt"], cwd=local_repo_path, check=True) - - head_sha = subprocess.run( - ["git", "rev-parse", "HEAD"], cwd=local_repo_path, check=True, capture_output=True, text=True - ).stdout.strip() - - with pytest.raises(RuntimeError, match="Repoet har endringer utenfor de angitte stiene"): - await preview_git_change({"paths": ["file_a.txt"], "expected_head_sha": head_sha}) # --- Tests for MCP Handler Dispatch Policy --- diff --git a/opax-web/backend/agentClient.js b/opax-web/backend/agentClient.js index bbbd619..1902a82 100644 --- a/opax-web/backend/agentClient.js +++ b/opax-web/backend/agentClient.js @@ -10,12 +10,6 @@ const READ_ONLY_TOOLS = new Set([ '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', ]); export class AgentNotConfiguredError extends Error { diff --git a/opax-web/backend/routes/consoleApi.js b/opax-web/backend/routes/consoleApi.js new file mode 100644 index 0000000..a209fee --- /dev/null +++ b/opax-web/backend/routes/consoleApi.js @@ -0,0 +1,70 @@ +const { Router } = require('express'); +const { callAgent } = require('../agentClient'); + +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: [], + }); +}); + +module.exports = router; diff --git a/opax-web/backend/server.js b/opax-web/backend/server.js index 8248dce..0664765 100644 --- a/opax-web/backend/server.js +++ b/opax-web/backend/server.js @@ -65,6 +65,17 @@ app.get('/auth/callback', async (req, res, next) => { 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(); +}; + +const consoleApiRouter = require('./routes/consoleApi.js'); +app.use('/api/console', ensureAuthenticated, consoleApiRouter); + + io.on('connection', (s) => { const u = s.request.session?.user; if (!u) return s.disconnect(); diff --git a/opax-web/backend/socketHandlers.js b/opax-web/backend/socketHandlers.js index b8a5d2a..c768ee2 100644 --- a/opax-web/backend/socketHandlers.js +++ b/opax-web/backend/socketHandlers.js @@ -1,67 +1,6 @@ // 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, - }, -}; +const SAFE_ACTIONS = {}; /** * Registrerer sikre, nettleser-eksponerte Socket.IO-handlere. diff --git a/opax-web/frontend/src/App.tsx b/opax-web/frontend/src/App.tsx index ac1260c..28b883a 100644 --- a/opax-web/frontend/src/App.tsx +++ b/opax-web/frontend/src/App.tsx @@ -1,60 +1,13 @@ -import { useEffect, useRef, useState } from 'react'; -import { io, Socket } from 'socket.io-client'; - -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' - ); -} +import React, { useState, useEffect } from 'react'; +import OperationsConsole from './pages/OperationsConsole'; interface User { email: string; name?: string; } -export default function App() { +function App() { const [user, setUser] = useState(null); - const [statusData, setStatusData] = useState(null); - const [branchData, setBranchData] = useState(null); - const [statusLoading, setStatusLoading] = useState(false); - const [branchLoading, setBranchLoading] = useState(false); - const [statusError, setStatusError] = useState(null); - const [branchError, setBranchError] = useState(null); - - const socketRef = useRef(null); useEffect(() => { fetch('/auth/me', { credentials: 'include' }) @@ -62,89 +15,6 @@ export default function App() { .then(setUser); }, []); - useEffect(() => { - if (!user) return; - - const socket = io('/'); - socketRef.current = socket; - - 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); - setBranchData(null); // Clear other result for clarity - setBranchError(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); - setStatusData(null); // Clear other result for clarity - setStatusError(null); - setBranchLoading(true); - socketRef.current.emit('git:getBranch'); - }; - if (!user) { return (
@@ -157,44 +27,13 @@ export default function App() { return (
- OPAX / Web TUI + OPAX / Operations Console {user.email}
-
-
-

Git Web TUI

-

Et enkelt grensesnitt for å se Git-status og aktiv branch for repoet.

-
-
-
-

Git Status

- - {statusError &&

{statusError}

} - {statusData && ( -
- Status: {statusData.status} - - Detaljer: - {statusData.details || '(ingen endringer)'} -
- )} -
-
-

Aktiv Branch

- - {branchError &&

{branchError}

} - {branchData && ( -
- Nåværende branch: {branchData.current_branch} -
- )} -
-
-
+
); } + +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/pages/OperationsConsole.tsx b/opax-web/frontend/src/pages/OperationsConsole.tsx new file mode 100644 index 0000000..9dec221 --- /dev/null +++ b/opax-web/frontend/src/pages/OperationsConsole.tsx @@ -0,0 +1,119 @@ +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 --- +function normalizeStatusResponse(raw: any): ConsoleStatusResponse { + let health = unavailableItem; + + if (raw?.health?.status === 'success' && + raw.health.summary?.status === 'ok' && + typeof raw.health.summary.service === 'string' && + typeof raw.health.summary.version === 'string') { + health = { + status: 'success', + summary: { + status: 'ok', + service: raw.health.summary.service, + version: raw.health.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 (e: any) { + 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;