wip(opax): save operations console and MCP hardening progress
This commit is contained in:
parent
7bf4acd61d
commit
732dcbf59d
|
|
@ -12,7 +12,18 @@ echo "=== 1. Configure Docker auth ==="
|
||||||
gcloud auth configure-docker "${REGION}-docker.pkg.dev"
|
gcloud auth configure-docker "${REGION}-docker.pkg.dev"
|
||||||
|
|
||||||
echo "=== 2. Build locally ==="
|
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 ==="
|
echo "=== 3. Push image ==="
|
||||||
docker push "${FULL_IMAGE}"
|
docker push "${FULL_IMAGE}"
|
||||||
|
|
|
||||||
|
|
@ -33,12 +33,6 @@ WEB_AGENT_ALLOWED_TOOLS = frozenset({
|
||||||
"get_build_status",
|
"get_build_status",
|
||||||
"get_state",
|
"get_state",
|
||||||
"get_telemetry",
|
"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 ---
|
# --- 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:
|
except Exception as e:
|
||||||
raise RuntimeError(f"En uventet feil oppstod under kjøring av Git: {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
|
# Google Workspace Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -717,17 +567,6 @@ async def push_file(p):
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
TOOLS = {
|
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
|
# Billing
|
||||||
"get_billing_summary": (get_billing_summary, "Hent billing-sammendrag for OPAX", {}),
|
"get_billing_summary": (get_billing_summary, "Hent billing-sammendrag for OPAX", {}),
|
||||||
|
|
|
||||||
|
|
@ -11,20 +11,7 @@ from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
# Importer alt som skal testes
|
# Importer alt som skal testes
|
||||||
from server import app, TOOLS, WEB_AGENT_ALLOWED_TOOLS
|
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 ---
|
# --- Fixtures ---
|
||||||
|
|
||||||
|
|
@ -158,107 +145,6 @@ def test_run_git_invalid_args():
|
||||||
|
|
||||||
# --- Unit-tester for Read-only Verktøy ---
|
# --- 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 ---
|
# --- Tests for MCP Handler Dispatch Policy ---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,6 @@ const READ_ONLY_TOOLS = new Set([
|
||||||
'get_build_status',
|
'get_build_status',
|
||||||
'get_state',
|
'get_state',
|
||||||
'get_telemetry',
|
'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 {
|
export class AgentNotConfiguredError extends Error {
|
||||||
|
|
|
||||||
70
opax-web/backend/routes/consoleApi.js
Normal file
70
opax-web/backend/routes/consoleApi.js
Normal 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;
|
||||||
|
|
@ -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.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 })));
|
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) => {
|
io.on('connection', (s) => {
|
||||||
const u = s.request.session?.user;
|
const u = s.request.session?.user;
|
||||||
if (!u) return s.disconnect();
|
if (!u) return s.disconnect();
|
||||||
|
|
|
||||||
|
|
@ -1,67 +1,6 @@
|
||||||
// FORSLAG TIL opax-web/backend/socketHandlers.js (ENDELIG KORRIGERT)
|
// FORSLAG TIL opax-web/backend/socketHandlers.js (ENDELIG KORRIGERT)
|
||||||
|
|
||||||
/**
|
const SAFE_ACTIONS = {};
|
||||||
* 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.
|
* Registrerer sikre, nettleser-eksponerte Socket.IO-handlere.
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,13 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { io, Socket } from 'socket.io-client';
|
import OperationsConsole from './pages/OperationsConsole';
|
||||||
|
|
||||||
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 {
|
interface User {
|
||||||
email: string;
|
email: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
function App() {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
fetch('/auth/me', { credentials: 'include' })
|
fetch('/auth/me', { credentials: 'include' })
|
||||||
|
|
@ -62,89 +15,6 @@ export default function App() {
|
||||||
.then(setUser);
|
.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) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<main className="login">
|
<main className="login">
|
||||||
|
|
@ -157,44 +27,13 @@ export default function App() {
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<header>
|
<header>
|
||||||
<b>OPAX / Web TUI</b>
|
<b>OPAX / Operations Console</b>
|
||||||
<span>{user.email}</span>
|
<span>{user.email}</span>
|
||||||
</header>
|
</header>
|
||||||
<div className="content-wrapper">
|
<OperationsConsole />
|
||||||
<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>
|
|
||||||
</main>
|
</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;
|
||||||
119
opax-web/frontend/src/pages/OperationsConsole.tsx
Normal file
119
opax-web/frontend/src/pages/OperationsConsole.tsx
Normal 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;
|
||||||
Loading…
Reference in New Issue
Block a user