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"
|
||||
|
||||
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}"
|
||||
|
|
|
|||
|
|
@ -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,17 +567,6 @@ 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", {}),
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
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.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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
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