wip(opax): save operations console and MCP hardening progress

This commit is contained in:
Chris Christiansen 2026-09-12 19:20:39 +00:00
parent 7bf4acd61d
commit 732dcbf59d
14 changed files with 351 additions and 514 deletions

View File

@ -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}"

View File

@ -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": (

View File

@ -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 ---

View File

@ -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 {

View File

@ -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;

View File

@ -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();

View File

@ -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.

View File

@ -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<User | null>(null);
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(() => {
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 (
<main className="login">
@ -157,44 +27,13 @@ export default function App() {
return (
<main>
<header>
<b>OPAX / Web TUI</b>
<b>OPAX / Operations Console</b>
<span>{user.email}</span>
</header>
<div className="content-wrapper">
<div className="page-header">
<h1>Git Web TUI</h1>
<p>Et enkelt grensesnitt for å se Git-status og aktiv branch for repoet.</p>
</div>
<section className="actions-container">
<div className="action-card">
<h2>Git Status</h2>
<button onClick={handleGetStatus} disabled={statusLoading || branchLoading} className="primary">
{statusLoading ? 'Laster...' : 'Hent Git-status'}
</button>
{statusError && <p className="error">{statusError}</p>}
{statusData && (
<div className="result-display">
<strong>Status:</strong> {statusData.status}
<strong>Detaljer:</strong>
{statusData.details || '(ingen endringer)'}
</div>
)}
</div>
<div className="action-card">
<h2>Aktiv Branch</h2>
<button onClick={handleGetBranch} disabled={branchLoading || statusLoading}>
{branchLoading ? 'Laster...' : 'Hent aktiv branch'}
</button>
{branchError && <p className="error">{branchError}</p>}
{branchData && (
<div className="result-display">
<strong>Nåværende branch:</strong> {branchData.current_branch}
</div>
)}
</div>
</section>
</div>
<OperationsConsole />
</main>
);
}
export default App;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@ -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<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 (e: any) {
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;