Compare commits
4 Commits
fix/982
...
claude/iss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
798ca3aa06 | ||
|
|
e8886f10c8 | ||
|
|
d2ce6b8749 | ||
|
|
a8a086548d |
@@ -50,78 +50,6 @@ def sanitize_context(text: str) -> str:
|
||||
return _FENCE_TAG_RE.sub('', text)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prefetch filtering helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Meta-instruction debris that memory providers sometimes echo back.
|
||||
# These are prompts/instructions, not user-generated content.
|
||||
_META_INSTRUCTION_PATTERNS = [
|
||||
re.compile(r"^\s*[\-\*]?\s*>?\s*Focus on:\s*", re.IGNORECASE),
|
||||
re.compile(r"^\s*[\-\*]?\s*>?\s*Note:\s*", re.IGNORECASE),
|
||||
re.compile(r"^\s*[\-\*]?\s*>?\s*System\s+(note|prompt|instruction):", re.IGNORECASE),
|
||||
re.compile(r"^\s*[\-\*]?\s*>?\s*You are\s+", re.IGNORECASE),
|
||||
re.compile(r"^\s*[\-\*]?\s*>?\s*Please\s+(provide|respond|answer|write)", re.IGNORECASE),
|
||||
re.compile(r"^\s*[\-\*]?\s*>?\s*Do not\s+", re.IGNORECASE),
|
||||
re.compile(r"^\s*[\-\*]?\s*>?\s*Always\s+", re.IGNORECASE),
|
||||
re.compile(r"^\s*[\-\*]?\s*>?\s*Consider\s+(the following|these|this)\b", re.IGNORECASE),
|
||||
re.compile(r"^\s*[\-\*]?\s*>?\s*Here\s+(is|are)\s+(some|the|a few)\b", re.IGNORECASE),
|
||||
]
|
||||
|
||||
|
||||
def _is_meta_instruction_line(line: str) -> bool:
|
||||
"""Return True if the line looks like a prompt/template instruction, not memory content."""
|
||||
for pat in _META_INSTRUCTION_PATTERNS:
|
||||
if pat.search(line):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_low_signal_line(line: str) -> bool:
|
||||
"""Return True for very short or content-free lines."""
|
||||
stripped = line.strip()
|
||||
# Empty or just punctuation/list marker
|
||||
if not stripped or stripped in {"-", "*", ">", "•", "—", "--"}:
|
||||
return True
|
||||
# Too short to be meaningful (< 15 chars after stripping markers)
|
||||
cleaned = re.sub(r"^[\-\*•>\s]+", "", stripped)
|
||||
if len(cleaned) < 15:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _filter_prefetch_lines(text: str) -> str:
|
||||
"""Filter and deduplicate prefetch result lines.
|
||||
|
||||
Removes:
|
||||
- exact duplicate lines
|
||||
- meta-instruction debris (prompts, templates)
|
||||
- very short / content-free lines
|
||||
|
||||
Returns cleaned text, preserving original line grouping.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return ""
|
||||
|
||||
seen: set = set()
|
||||
kept: list = []
|
||||
for line in text.splitlines(keepends=False):
|
||||
stripped = line.strip()
|
||||
# Deduplicate exact lines
|
||||
if stripped in seen:
|
||||
continue
|
||||
# Skip meta-instructions
|
||||
if _is_meta_instruction_line(line):
|
||||
continue
|
||||
# Skip low-signal lines
|
||||
if _is_low_signal_line(line):
|
||||
continue
|
||||
seen.add(stripped)
|
||||
kept.append(line)
|
||||
|
||||
return "\n".join(kept)
|
||||
|
||||
|
||||
def build_memory_context_block(raw_context: str) -> str:
|
||||
"""Wrap prefetched memory in a fenced block with system note.
|
||||
|
||||
@@ -252,14 +180,7 @@ class MemoryManager:
|
||||
"Memory provider '%s' prefetch failed (non-fatal): %s",
|
||||
provider.name, e,
|
||||
)
|
||||
raw = "\n\n".join(parts)
|
||||
if not raw:
|
||||
return ""
|
||||
# Apply line-level filtering: dedupe, strip meta-instructions,
|
||||
# remove very short fragments. This prevents noisy providers
|
||||
# (e.g. MemPalace transcript recall) from bloating context.
|
||||
filtered = _filter_prefetch_lines(raw)
|
||||
return filtered
|
||||
return "\n\n".join(parts)
|
||||
|
||||
def queue_prefetch_all(self, query: str, *, session_id: str = "") -> None:
|
||||
"""Queue background prefetch on all providers for the next turn."""
|
||||
|
||||
@@ -46,7 +46,6 @@ from hermes_cli.config import (
|
||||
)
|
||||
from gateway.status import get_running_pid, read_runtime_status
|
||||
from agent.agent_card import get_agent_card_json
|
||||
from agent.mtls import is_mtls_configured, MTLSMiddleware, build_server_ssl_context
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
@@ -88,10 +87,6 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# mTLS: enforce client certificate on A2A endpoints when configured.
|
||||
# Activated by setting HERMES_MTLS_CERT, HERMES_MTLS_KEY, HERMES_MTLS_CA.
|
||||
app.add_middleware(MTLSMiddleware)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints that do NOT require the session token. Everything else under
|
||||
# /api/ is gated by the auth middleware below. Keep this list minimal —
|
||||
@@ -1986,6 +1981,73 @@ async def update_config_raw(body: RawConfigUpdate):
|
||||
raise HTTPException(status_code=400, detail=f"Invalid YAML: {e}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Action endpoints — restart gateway / update Hermes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ActionResponse(BaseModel):
|
||||
ok: bool
|
||||
detail: str = ""
|
||||
|
||||
|
||||
@app.post("/api/actions/restart-gateway")
|
||||
async def restart_gateway():
|
||||
"""Send SIGUSR1 to the running gateway so it drains and restarts.
|
||||
|
||||
Falls back to a hard kill+restart if no PID is found or the signal
|
||||
fails (e.g. the gateway is managed by a remote process / container).
|
||||
Returns immediately with ``{"ok": true}`` if the signal was delivered;
|
||||
the caller should poll ``/api/status`` to confirm the new state.
|
||||
"""
|
||||
from gateway.status import get_running_pid
|
||||
|
||||
pid = get_running_pid()
|
||||
if pid is None:
|
||||
raise HTTPException(status_code=409, detail="Gateway is not running")
|
||||
|
||||
import signal as _signal
|
||||
|
||||
try:
|
||||
os.kill(pid, _signal.SIGUSR1)
|
||||
except (ProcessLookupError, PermissionError, OSError, AttributeError) as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to signal gateway: {exc}")
|
||||
|
||||
return {"ok": True, "detail": f"Restart signal sent to PID {pid}"}
|
||||
|
||||
|
||||
@app.post("/api/actions/update-hermes")
|
||||
async def update_hermes():
|
||||
"""Run ``hermes update`` in a subprocess and return the output.
|
||||
|
||||
The update is performed synchronously (in a thread pool executor) so
|
||||
the endpoint blocks until completion. Clients should treat a 200
|
||||
response with ``"ok": true`` as success; ``"ok": false`` means the
|
||||
subprocess exited non-zero.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _run_update():
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "hermes_cli.main", "update", "--yes"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
combined = (result.stdout + result.stderr).strip()
|
||||
return result.returncode == 0, combined
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Update timed out after 5 minutes"
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
ok, detail = await loop.run_in_executor(None, _run_update)
|
||||
return {"ok": ok, "detail": detail}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token / cost analytics endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2110,20 +2172,6 @@ def start_server(
|
||||
"authentication. Only use on trusted networks.", host,
|
||||
)
|
||||
|
||||
# mTLS: when configured, pass SSL context to uvicorn so all connections
|
||||
# are TLS with mandatory client certificate verification.
|
||||
ssl_context = None
|
||||
scheme = "http"
|
||||
if is_mtls_configured():
|
||||
try:
|
||||
ssl_context = build_server_ssl_context()
|
||||
scheme = "https"
|
||||
_log.info(
|
||||
"mTLS enabled — server requires client certificates (A2A auth)"
|
||||
)
|
||||
except Exception as exc:
|
||||
_log.error("Failed to build mTLS SSL context: %s — starting without TLS", exc)
|
||||
|
||||
if open_browser:
|
||||
import threading
|
||||
import webbrowser
|
||||
@@ -2131,11 +2179,9 @@ def start_server(
|
||||
def _open():
|
||||
import time as _t
|
||||
_t.sleep(1.0)
|
||||
webbrowser.open(f"{scheme}://{host}:{port}")
|
||||
webbrowser.open(f"http://{host}:{port}")
|
||||
|
||||
threading.Thread(target=_open, daemon=True).start()
|
||||
|
||||
print(f" Hermes Web UI → {scheme}://{host}:{port}")
|
||||
if ssl_context is not None:
|
||||
print(" mTLS enabled — client certificate required for A2A endpoints")
|
||||
uvicorn.run(app, host=host, port=port, log_level="warning", ssl=ssl_context)
|
||||
print(f" Hermes Web UI → http://{host}:{port}")
|
||||
uvicorn.run(app, host=host, port=port, log_level="warning")
|
||||
|
||||
@@ -198,14 +198,14 @@ class TestMemoryManager:
|
||||
def test_prefetch_skips_empty(self):
|
||||
mgr = MemoryManager()
|
||||
p1 = FakeMemoryProvider("builtin")
|
||||
p1._prefetch_result = "This provider has meaningful memories with enough length"
|
||||
p1._prefetch_result = "Has memories"
|
||||
p2 = FakeMemoryProvider("external")
|
||||
p2._prefetch_result = ""
|
||||
mgr.add_provider(p1)
|
||||
mgr.add_provider(p2)
|
||||
|
||||
result = mgr.prefetch_all("query")
|
||||
assert result == "This provider has meaningful memories with enough length"
|
||||
assert result == "Has memories"
|
||||
|
||||
def test_queue_prefetch_all(self):
|
||||
mgr = MemoryManager()
|
||||
@@ -695,92 +695,3 @@ class TestMemoryContextFencing:
|
||||
fence_end = combined.index("</memory-context>")
|
||||
assert "Alice" in combined[fence_start:fence_end]
|
||||
assert combined.index("weather") < fence_start
|
||||
|
||||
|
||||
class TestPrefetchFiltering:
|
||||
"""Tests for _filter_prefetch_lines and related helpers."""
|
||||
|
||||
def test_deduplicates_exact_lines(self):
|
||||
from agent.memory_manager import _filter_prefetch_lines
|
||||
raw = "- This is line one with enough characters\n- This is line two with enough characters\n- This is line one with enough characters\n- This is line three with enough characters"
|
||||
result = _filter_prefetch_lines(raw)
|
||||
lines = [l for l in result.splitlines() if l.strip()]
|
||||
assert len(lines) == 3
|
||||
assert "- This is line one with enough characters" in result
|
||||
assert "- This is line two with enough characters" in result
|
||||
assert "- This is line three with enough characters" in result
|
||||
|
||||
def test_removes_meta_instruction_debris(self):
|
||||
from agent.memory_manager import _filter_prefetch_lines
|
||||
raw = (
|
||||
"## Fleet Memories\n"
|
||||
"- > Focus on: was a non-trivial approach used\n"
|
||||
"- > Focus on: was a non-trivial approach used\n"
|
||||
"- Actual memory content about fleet ops\n"
|
||||
"- Note: this is just a note\n"
|
||||
)
|
||||
result = _filter_prefetch_lines(raw)
|
||||
assert "Focus on" not in result
|
||||
assert "Note:" not in result
|
||||
assert "Actual memory content about fleet ops" in result
|
||||
assert "Fleet Memories" in result
|
||||
|
||||
def test_removes_low_signal_short_lines(self):
|
||||
from agent.memory_manager import _filter_prefetch_lines
|
||||
raw = (
|
||||
"- \n"
|
||||
"- x\n"
|
||||
"- This is a meaningful memory entry with enough length\n"
|
||||
)
|
||||
result = _filter_prefetch_lines(raw)
|
||||
assert "- x" not in result
|
||||
assert "meaningful memory entry" in result
|
||||
|
||||
def test_preserves_structured_facts(self):
|
||||
from agent.memory_manager import _filter_prefetch_lines
|
||||
raw = (
|
||||
"## Local Facts (Hologram)\n"
|
||||
"- ALEXANDER: Prefers Gitea for reports and deliverables.\n"
|
||||
"- Telegram home channel is Timmy Time.\n"
|
||||
)
|
||||
result = _filter_prefetch_lines(raw)
|
||||
assert "ALEXANDER" in result
|
||||
assert "Gitea" in result
|
||||
assert "Telegram" in result
|
||||
|
||||
def test_is_meta_instruction_line(self):
|
||||
from agent.memory_manager import _is_meta_instruction_line
|
||||
assert _is_meta_instruction_line("- > Focus on: something") is True
|
||||
assert _is_meta_instruction_line("- Focus on: something") is True
|
||||
assert _is_meta_instruction_line("* Focus on: something") is True
|
||||
assert _is_meta_instruction_line("- Actual user memory content") is False
|
||||
assert _is_meta_instruction_line("ALEXANDER: Prefers Gitea") is False
|
||||
|
||||
def test_is_low_signal_line(self):
|
||||
from agent.memory_manager import _is_low_signal_line
|
||||
assert _is_low_signal_line("- ") is True
|
||||
assert _is_low_signal_line("*") is True
|
||||
assert _is_low_signal_line("- x") is True
|
||||
assert _is_low_signal_line("- Short line") is True
|
||||
assert _is_low_signal_line("- This is a long meaningful memory entry") is False
|
||||
|
||||
def test_prefetch_all_applies_filtering(self):
|
||||
from agent.memory_manager import MemoryManager
|
||||
mgr = MemoryManager()
|
||||
fake = FakeMemoryProvider(name="test")
|
||||
fake._prefetch_result = (
|
||||
"- > Focus on: was a non-trivial approach\n"
|
||||
"- > Focus on: was a non-trivial approach\n"
|
||||
"- Real memory fact\n"
|
||||
)
|
||||
mgr.add_provider(fake)
|
||||
result = mgr.prefetch_all("query")
|
||||
assert "Focus on" not in result
|
||||
assert "Real memory fact" in result
|
||||
assert result.count("Real memory fact") == 1
|
||||
|
||||
def test_empty_prefetch_returns_empty(self):
|
||||
from agent.memory_manager import _filter_prefetch_lines
|
||||
assert _filter_prefetch_lines("") == ""
|
||||
assert _filter_prefetch_lines(" ") == ""
|
||||
assert _filter_prefetch_lines("\n\n") == ""
|
||||
|
||||
@@ -1176,3 +1176,135 @@ class TestStatusRemoteGateway:
|
||||
assert data["gateway_running"] is True
|
||||
assert data["gateway_pid"] is None
|
||||
assert data["gateway_state"] == "running"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Action endpoint tests — restart-gateway / update-hermes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestActionEndpoints:
|
||||
"""Test the /api/actions/* endpoints."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_test_client(self):
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
|
||||
from hermes_cli.web_server import app, _SESSION_TOKEN
|
||||
self.client = TestClient(app)
|
||||
self.client.headers["Authorization"] = f"Bearer {_SESSION_TOKEN}"
|
||||
|
||||
# ── restart-gateway ────────────────────────────────────────────────────
|
||||
|
||||
def test_restart_gateway_sends_sigusr1(self, monkeypatch):
|
||||
"""POST /api/actions/restart-gateway signals the running PID."""
|
||||
killed = {}
|
||||
|
||||
def _fake_kill(pid, sig):
|
||||
killed["pid"] = pid
|
||||
killed["sig"] = sig
|
||||
|
||||
monkeypatch.setattr("gateway.status.get_running_pid", lambda: 12345)
|
||||
monkeypatch.setattr("hermes_cli.web_server.os.kill", _fake_kill)
|
||||
|
||||
resp = self.client.post("/api/actions/restart-gateway")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is True
|
||||
assert "12345" in data["detail"]
|
||||
assert killed["pid"] == 12345
|
||||
|
||||
def test_restart_gateway_409_when_not_running(self, monkeypatch):
|
||||
"""POST /api/actions/restart-gateway returns 409 when gateway is not running."""
|
||||
monkeypatch.setattr("gateway.status.get_running_pid", lambda: None)
|
||||
|
||||
resp = self.client.post("/api/actions/restart-gateway")
|
||||
|
||||
assert resp.status_code == 409
|
||||
|
||||
def test_restart_gateway_500_on_signal_error(self, monkeypatch):
|
||||
"""POST /api/actions/restart-gateway returns 500 when the signal fails."""
|
||||
monkeypatch.setattr("gateway.status.get_running_pid", lambda: 99999)
|
||||
monkeypatch.setattr("hermes_cli.web_server.os.kill", lambda pid, sig: (_ for _ in ()).throw(ProcessLookupError("no such process")))
|
||||
|
||||
resp = self.client.post("/api/actions/restart-gateway")
|
||||
|
||||
assert resp.status_code == 500
|
||||
assert "Failed to signal" in resp.json()["detail"]
|
||||
|
||||
# ── update-hermes ──────────────────────────────────────────────────────
|
||||
|
||||
def test_update_hermes_success(self, monkeypatch):
|
||||
"""POST /api/actions/update-hermes returns ok=true on zero exit."""
|
||||
import hermes_cli.web_server as ws
|
||||
|
||||
class _FakeResult:
|
||||
returncode = 0
|
||||
stdout = "Already up to date.\n"
|
||||
stderr = ""
|
||||
|
||||
def _fake_run(cmd, **kwargs):
|
||||
assert "--yes" in cmd
|
||||
return _FakeResult()
|
||||
|
||||
monkeypatch.setattr("subprocess.run", _fake_run)
|
||||
|
||||
resp = self.client.post("/api/actions/update-hermes")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is True
|
||||
assert "Already up to date" in data["detail"]
|
||||
|
||||
def test_update_hermes_failure_on_nonzero_exit(self, monkeypatch):
|
||||
"""POST /api/actions/update-hermes returns ok=false on non-zero exit."""
|
||||
import hermes_cli.web_server as ws
|
||||
|
||||
class _FakeResult:
|
||||
returncode = 1
|
||||
stdout = ""
|
||||
stderr = "error: update failed\n"
|
||||
|
||||
monkeypatch.setattr("subprocess.run", lambda cmd, **kw: _FakeResult())
|
||||
|
||||
resp = self.client.post("/api/actions/update-hermes")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is False
|
||||
assert "error: update failed" in data["detail"]
|
||||
|
||||
def test_update_hermes_timeout(self, monkeypatch):
|
||||
"""POST /api/actions/update-hermes returns ok=false on timeout."""
|
||||
import subprocess
|
||||
import hermes_cli.web_server as ws
|
||||
|
||||
def _fake_run(cmd, **kwargs):
|
||||
raise subprocess.TimeoutExpired(cmd, 300)
|
||||
|
||||
monkeypatch.setattr("subprocess.run", _fake_run)
|
||||
|
||||
resp = self.client.post("/api/actions/update-hermes")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is False
|
||||
assert "timed out" in data["detail"].lower()
|
||||
|
||||
def test_action_endpoints_require_auth(self):
|
||||
"""Action endpoints reject requests without a valid Bearer token."""
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
|
||||
from hermes_cli.web_server import app
|
||||
unauthed = TestClient(app)
|
||||
|
||||
for path in ["/api/actions/restart-gateway", "/api/actions/update-hermes"]:
|
||||
resp = unauthed.post(path)
|
||||
assert resp.status_code in (401, 403), f"{path} should require auth"
|
||||
|
||||
@@ -86,6 +86,15 @@ export const en: Translations = {
|
||||
lastUpdate: "Last update",
|
||||
platformError: "error",
|
||||
platformDisconnected: "disconnected",
|
||||
actions: "Actions",
|
||||
restartGateway: "Restart Gateway",
|
||||
restarting: "Restarting…",
|
||||
restartSuccess: "Gateway restart signal sent",
|
||||
restartFailed: "Restart failed",
|
||||
updateHermes: "Update Hermes",
|
||||
updating: "Updating…",
|
||||
updateSuccess: "Update complete",
|
||||
updateFailed: "Update failed",
|
||||
},
|
||||
|
||||
sessions: {
|
||||
|
||||
@@ -89,6 +89,15 @@ export interface Translations {
|
||||
lastUpdate: string;
|
||||
platformError: string;
|
||||
platformDisconnected: string;
|
||||
actions: string;
|
||||
restartGateway: string;
|
||||
restarting: string;
|
||||
restartSuccess: string;
|
||||
restartFailed: string;
|
||||
updateHermes: string;
|
||||
updating: string;
|
||||
updateSuccess: string;
|
||||
updateFailed: string;
|
||||
};
|
||||
|
||||
// ── Sessions page ──
|
||||
|
||||
@@ -86,6 +86,15 @@ export const zh: Translations = {
|
||||
lastUpdate: "最后更新",
|
||||
platformError: "错误",
|
||||
platformDisconnected: "已断开",
|
||||
actions: "操作",
|
||||
restartGateway: "重启网关",
|
||||
restarting: "重启中…",
|
||||
restartSuccess: "重启信号已发送",
|
||||
restartFailed: "重启失败",
|
||||
updateHermes: "更新 Hermes",
|
||||
updating: "更新中…",
|
||||
updateSuccess: "更新完成",
|
||||
updateFailed: "更新失败",
|
||||
},
|
||||
|
||||
sessions: {
|
||||
|
||||
@@ -182,6 +182,12 @@ export const api = {
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
// Dashboard actions
|
||||
restartGateway: () =>
|
||||
fetchJSON<ActionResponse>("/api/actions/restart-gateway", { method: "POST" }),
|
||||
updateHermes: () =>
|
||||
fetchJSON<ActionResponse>("/api/actions/update-hermes", { method: "POST" }),
|
||||
};
|
||||
|
||||
export interface PlatformStatus {
|
||||
@@ -409,9 +415,15 @@ export interface OAuthSubmitResponse {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ActionResponse {
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface OAuthPollResponse {
|
||||
session_id: string;
|
||||
status: "pending" | "approved" | "denied" | "expired" | "error";
|
||||
error_message?: string | null;
|
||||
expires_at?: number | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
@@ -6,19 +6,30 @@ import {
|
||||
Cpu,
|
||||
Database,
|
||||
Radio,
|
||||
RefreshCw,
|
||||
TriangleAlert,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { PlatformStatus, SessionInfo, StatusResponse } from "@/lib/api";
|
||||
import { timeAgo, isoTimeAgo } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
type ActionState = "idle" | "running" | "success" | "failure";
|
||||
|
||||
export default function StatusPage() {
|
||||
const [status, setStatus] = useState<StatusResponse | null>(null);
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const [restartState, setRestartState] = useState<ActionState>("idle");
|
||||
const [restartDetail, setRestartDetail] = useState("");
|
||||
const [updateState, setUpdateState] = useState<ActionState>("idle");
|
||||
const [updateDetail, setUpdateDetail] = useState("");
|
||||
const resetTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -31,6 +42,39 @@ export default function StatusPage() {
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
function scheduleReset(key: string, setter: (s: ActionState) => void) {
|
||||
clearTimeout(resetTimers.current[key]);
|
||||
resetTimers.current[key] = setTimeout(() => setter("idle"), 8000);
|
||||
}
|
||||
|
||||
async function handleRestartGateway() {
|
||||
setRestartState("running");
|
||||
setRestartDetail("");
|
||||
try {
|
||||
const resp = await api.restartGateway();
|
||||
setRestartState(resp.ok ? "success" : "failure");
|
||||
setRestartDetail(resp.detail);
|
||||
} catch (err: unknown) {
|
||||
setRestartState("failure");
|
||||
setRestartDetail(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
scheduleReset("restart", setRestartState);
|
||||
}
|
||||
|
||||
async function handleUpdateHermes() {
|
||||
setUpdateState("running");
|
||||
setUpdateDetail("");
|
||||
try {
|
||||
const resp = await api.updateHermes();
|
||||
setUpdateState(resp.ok ? "success" : "failure");
|
||||
setUpdateDetail(resp.detail);
|
||||
} catch (err: unknown) {
|
||||
setUpdateState("failure");
|
||||
setUpdateDetail(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
scheduleReset("update", setUpdateState);
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
@@ -159,6 +203,57 @@ export default function StatusPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action buttons — restart gateway / update Hermes */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">{t.status.actions}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-3">
|
||||
{/* Restart Gateway */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={restartState === "running"}
|
||||
onClick={handleRestartGateway}
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 mr-1 ${restartState === "running" ? "animate-spin" : ""}`} />
|
||||
{restartState === "running" ? t.status.restarting : t.status.restartGateway}
|
||||
</Button>
|
||||
{(restartDetail || restartState === "success") && (
|
||||
<p className={`text-xs max-w-xs truncate ${restartState === "failure" ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{restartState === "failure" && <TriangleAlert className="inline h-3 w-3 mr-1" />}
|
||||
{restartState === "success" ? t.status.restartSuccess : restartState === "failure" ? t.status.restartFailed : ""}
|
||||
{restartDetail && ` — ${restartDetail}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Update Hermes */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={updateState === "running"}
|
||||
onClick={handleUpdateHermes}
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 mr-1 ${updateState === "running" ? "animate-spin" : ""}`} />
|
||||
{updateState === "running" ? t.status.updating : t.status.updateHermes}
|
||||
</Button>
|
||||
{(updateDetail || updateState === "success" || updateState === "failure") && (
|
||||
<p className={`text-xs max-w-xs ${updateState === "failure" ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{updateState === "failure" && <TriangleAlert className="inline h-3 w-3 mr-1" />}
|
||||
{updateState === "success" ? t.status.updateSuccess : updateState === "failure" ? t.status.updateFailed : ""}
|
||||
{updateDetail && ` — ${updateDetail}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{platforms.length > 0 && (
|
||||
<PlatformsCard platforms={platforms} platformStateBadge={PLATFORM_STATE_BADGE} />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user