697 lines
30 KiB
Python
697 lines
30 KiB
Python
import time
|
|
import shlex
|
|
from typing import Any, Dict, Optional
|
|
import logging
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from sqlmodel import Session, select
|
|
|
|
from clients.edge.errors import log_edge_failure, summarize_edge_exception
|
|
from clients.edge.http import HttpEdgeClient
|
|
from core.cache import cache
|
|
from core.database import get_session
|
|
from models.bot import BotInstance
|
|
from providers.target import ProviderTarget
|
|
from providers.selector import get_runtime_provider
|
|
from schemas.platform import (
|
|
ManagedNodeConnectivityResult,
|
|
ManagedNodeNativePreflightResult,
|
|
ManagedNodePayload,
|
|
PlatformSettingsPayload,
|
|
SystemSettingPayload,
|
|
)
|
|
from services.node_registry_service import ManagedNode
|
|
from services.platform_service import (
|
|
build_node_resource_overview,
|
|
build_platform_overview,
|
|
create_or_update_system_setting,
|
|
delete_system_setting,
|
|
get_platform_settings,
|
|
list_system_settings,
|
|
list_activity_events,
|
|
list_usage,
|
|
save_platform_settings,
|
|
)
|
|
|
|
router = APIRouter()
|
|
logger = logging.getLogger(__name__)
|
|
PLATFORM_OVERVIEW_CACHE_KEY = "platform:overview"
|
|
PLATFORM_OVERVIEW_CACHE_TTL_SECONDS = 15
|
|
PLATFORM_NODES_CACHE_KEY = "platform:nodes:list"
|
|
PLATFORM_NODES_CACHE_TTL_SECONDS = 20
|
|
|
|
|
|
def _cached_platform_overview_payload() -> Optional[Dict[str, Any]]:
|
|
cached = cache.get_json(PLATFORM_OVERVIEW_CACHE_KEY)
|
|
return cached if isinstance(cached, dict) else None
|
|
|
|
|
|
def _store_platform_overview_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
cache.set_json(PLATFORM_OVERVIEW_CACHE_KEY, payload, ttl=PLATFORM_OVERVIEW_CACHE_TTL_SECONDS)
|
|
return payload
|
|
|
|
|
|
def _invalidate_platform_overview_cache() -> None:
|
|
cache.delete(PLATFORM_OVERVIEW_CACHE_KEY)
|
|
|
|
|
|
def _cached_platform_nodes_payload() -> Optional[Dict[str, Any]]:
|
|
cached = cache.get_json(PLATFORM_NODES_CACHE_KEY)
|
|
if not isinstance(cached, dict):
|
|
return None
|
|
items = cached.get("items")
|
|
if not isinstance(items, list):
|
|
return None
|
|
return {"items": items}
|
|
|
|
|
|
def _store_platform_nodes_payload(items: list[Dict[str, Any]]) -> Dict[str, Any]:
|
|
payload = {"items": items}
|
|
cache.set_json(PLATFORM_NODES_CACHE_KEY, payload, ttl=PLATFORM_NODES_CACHE_TTL_SECONDS)
|
|
return payload
|
|
|
|
|
|
def _invalidate_platform_nodes_cache() -> None:
|
|
cache.delete(PLATFORM_NODES_CACHE_KEY)
|
|
|
|
|
|
def _normalize_node_payload(payload: ManagedNodePayload) -> ManagedNodePayload:
|
|
normalized_node_id = str(payload.node_id or "").strip().lower()
|
|
if not normalized_node_id:
|
|
raise HTTPException(status_code=400, detail="node_id is required")
|
|
transport_kind = str(payload.transport_kind or "edge").strip().lower() or "edge"
|
|
if transport_kind != "edge":
|
|
raise HTTPException(status_code=400, detail="Only edge transport is supported")
|
|
runtime_kind = str(payload.runtime_kind or "docker").strip().lower() or "docker"
|
|
core_adapter = str(payload.core_adapter or "nanobot").strip().lower() or "nanobot"
|
|
native_sandbox_mode = _normalize_native_sandbox_mode(payload.native_sandbox_mode)
|
|
base_url = str(payload.base_url or "").strip()
|
|
if transport_kind == "edge" and not base_url:
|
|
raise HTTPException(status_code=400, detail="base_url is required for edge nodes")
|
|
return payload.model_copy(
|
|
update={
|
|
"node_id": normalized_node_id,
|
|
"display_name": str(payload.display_name or normalized_node_id).strip() or normalized_node_id,
|
|
"base_url": base_url,
|
|
"auth_token": str(payload.auth_token or "").strip(),
|
|
"transport_kind": transport_kind,
|
|
"runtime_kind": runtime_kind,
|
|
"core_adapter": core_adapter,
|
|
"workspace_root": str(payload.workspace_root or "").strip(),
|
|
"native_command": str(payload.native_command or "").strip(),
|
|
"native_workdir": str(payload.native_workdir or "").strip(),
|
|
"native_sandbox_mode": native_sandbox_mode,
|
|
}
|
|
)
|
|
|
|
|
|
def _normalize_native_sandbox_mode(raw_value: Any) -> str:
|
|
text = str(raw_value or "").strip().lower()
|
|
if text in {"workspace", "sandbox", "strict"}:
|
|
return "workspace"
|
|
if text in {"full_access", "full-access", "danger-full-access", "escape"}:
|
|
return "full_access"
|
|
return "inherit"
|
|
|
|
|
|
def _managed_node_from_payload(payload: ManagedNodePayload) -> ManagedNode:
|
|
normalized = _normalize_node_payload(payload)
|
|
return ManagedNode(
|
|
node_id=normalized.node_id,
|
|
display_name=normalized.display_name,
|
|
base_url=normalized.base_url,
|
|
enabled=bool(normalized.enabled),
|
|
auth_token=normalized.auth_token,
|
|
metadata={
|
|
"transport_kind": normalized.transport_kind,
|
|
"runtime_kind": normalized.runtime_kind,
|
|
"core_adapter": normalized.core_adapter,
|
|
"workspace_root": normalized.workspace_root,
|
|
"native_command": normalized.native_command,
|
|
"native_workdir": normalized.native_workdir,
|
|
"native_sandbox_mode": normalized.native_sandbox_mode,
|
|
},
|
|
)
|
|
|
|
|
|
def _node_status(node: ManagedNode, *, refresh_failed: bool = False) -> str:
|
|
if not bool(node.enabled):
|
|
return "disabled"
|
|
transport_kind = str((node.metadata or {}).get("transport_kind") or "edge").strip().lower()
|
|
if transport_kind != "edge":
|
|
return "unknown"
|
|
if refresh_failed:
|
|
return "offline"
|
|
return "online" if node.last_seen_at else "unknown"
|
|
|
|
|
|
def _serialize_node(node: ManagedNode, *, refresh_failed: bool = False) -> Dict[str, Any]:
|
|
metadata = dict(node.metadata or {})
|
|
return {
|
|
"node_id": node.node_id,
|
|
"display_name": node.display_name,
|
|
"base_url": node.base_url,
|
|
"enabled": bool(node.enabled),
|
|
"transport_kind": str(metadata.get("transport_kind") or ""),
|
|
"runtime_kind": str(metadata.get("runtime_kind") or ""),
|
|
"core_adapter": str(metadata.get("core_adapter") or ""),
|
|
"workspace_root": str(metadata.get("workspace_root") or ""),
|
|
"native_command": str(metadata.get("native_command") or ""),
|
|
"native_workdir": str(metadata.get("native_workdir") or ""),
|
|
"native_sandbox_mode": str(metadata.get("native_sandbox_mode") or "inherit"),
|
|
"metadata": metadata,
|
|
"capabilities": dict(node.capabilities or {}),
|
|
"resources": dict(getattr(node, "resources", {}) or {}),
|
|
"last_seen_at": node.last_seen_at,
|
|
"status": _node_status(node, refresh_failed=refresh_failed),
|
|
}
|
|
|
|
|
|
def _test_edge_connectivity(resolve_edge_client, node: ManagedNode) -> ManagedNodeConnectivityResult:
|
|
started = time.perf_counter()
|
|
try:
|
|
client = resolve_edge_client(
|
|
ProviderTarget(
|
|
node_id=node.node_id,
|
|
transport_kind="edge",
|
|
runtime_kind=str((node.metadata or {}).get("runtime_kind") or "docker"),
|
|
core_adapter=str((node.metadata or {}).get("core_adapter") or "nanobot"),
|
|
)
|
|
)
|
|
node_self = _edge_node_self_with_native_preflight(client=client, node=node)
|
|
latency_ms = max(1, int((time.perf_counter() - started) * 1000))
|
|
return ManagedNodeConnectivityResult(
|
|
ok=True,
|
|
status="online",
|
|
latency_ms=latency_ms,
|
|
detail="dashboard-edge reachable",
|
|
node_self=node_self,
|
|
)
|
|
except Exception as exc:
|
|
latency_ms = max(1, int((time.perf_counter() - started) * 1000))
|
|
return ManagedNodeConnectivityResult(
|
|
ok=False,
|
|
status="offline",
|
|
latency_ms=latency_ms,
|
|
detail=summarize_edge_exception(exc),
|
|
node_self=None,
|
|
)
|
|
|
|
|
|
def _split_native_command(raw_command: Optional[str]) -> list[str]:
|
|
text = str(raw_command or "").strip()
|
|
if not text:
|
|
return []
|
|
try:
|
|
return [str(item or "").strip() for item in shlex.split(text) if str(item or "").strip()]
|
|
except Exception:
|
|
return [text]
|
|
|
|
|
|
def _runtime_native_supported(node_self: Dict[str, Any]) -> bool:
|
|
capabilities = dict(node_self.get("capabilities") or {})
|
|
runtime_caps = dict(capabilities.get("runtime") or {})
|
|
return bool(runtime_caps.get("native") is True)
|
|
|
|
|
|
def _test_edge_native_preflight(
|
|
resolve_edge_client,
|
|
node: ManagedNode,
|
|
*,
|
|
native_command: Optional[str] = None,
|
|
native_workdir: Optional[str] = None,
|
|
) -> ManagedNodeNativePreflightResult:
|
|
started = time.perf_counter()
|
|
command_hint = _split_native_command(native_command)
|
|
workdir_hint = str(native_workdir or "").strip()
|
|
try:
|
|
client = resolve_edge_client(
|
|
ProviderTarget(
|
|
node_id=node.node_id,
|
|
transport_kind="edge",
|
|
runtime_kind=str((node.metadata or {}).get("runtime_kind") or "docker"),
|
|
core_adapter=str((node.metadata or {}).get("core_adapter") or "nanobot"),
|
|
)
|
|
)
|
|
node_self = dict(client.heartbeat_node() or {})
|
|
preflight = dict(
|
|
client.preflight_native(
|
|
native_command=native_command,
|
|
native_workdir=native_workdir,
|
|
)
|
|
or {}
|
|
)
|
|
latency_ms = max(1, int((time.perf_counter() - started) * 1000))
|
|
command = [str(item or "").strip() for item in list(preflight.get("command") or []) if str(item or "").strip()]
|
|
workdir = str(preflight.get("workdir") or "")
|
|
detail = str(preflight.get("detail") or "")
|
|
if not detail:
|
|
detail = "native launcher ready" if bool(preflight.get("ok")) else "native launcher not ready"
|
|
return ManagedNodeNativePreflightResult(
|
|
ok=bool(preflight.get("ok")),
|
|
status="online",
|
|
latency_ms=latency_ms,
|
|
detail=detail,
|
|
command=command,
|
|
workdir=workdir,
|
|
command_available=bool(preflight.get("command_available")),
|
|
workdir_exists=bool(preflight.get("workdir_exists")),
|
|
runtime_native_supported=_runtime_native_supported(node_self),
|
|
node_self=node_self,
|
|
)
|
|
except Exception as exc:
|
|
latency_ms = max(1, int((time.perf_counter() - started) * 1000))
|
|
return ManagedNodeNativePreflightResult(
|
|
ok=False,
|
|
status="offline",
|
|
latency_ms=latency_ms,
|
|
detail=summarize_edge_exception(exc),
|
|
command=command_hint,
|
|
workdir=workdir_hint,
|
|
command_available=False,
|
|
workdir_exists=False if workdir_hint else True,
|
|
runtime_native_supported=False,
|
|
node_self=None,
|
|
)
|
|
|
|
|
|
def _edge_node_self_with_native_preflight(*, client: HttpEdgeClient, node: ManagedNode) -> Dict[str, Any]:
|
|
node_self = dict(client.heartbeat_node() or {})
|
|
metadata = dict(node.metadata or {})
|
|
native_command = str(metadata.get("native_command") or "").strip() or None
|
|
native_workdir = str(metadata.get("native_workdir") or "").strip() or None
|
|
runtime_kind = str(metadata.get("runtime_kind") or "docker").strip().lower()
|
|
should_probe = bool(native_command or native_workdir or runtime_kind == "native")
|
|
if not should_probe:
|
|
return node_self
|
|
try:
|
|
preflight = dict(client.preflight_native(native_command=native_command, native_workdir=native_workdir) or {})
|
|
except Exception as exc:
|
|
log_edge_failure(
|
|
logger,
|
|
key=f"platform-node-native-preflight:{node.node_id}",
|
|
exc=exc,
|
|
message=f"Failed to run native preflight for node_id={node.node_id}",
|
|
)
|
|
return node_self
|
|
caps = dict(node_self.get("capabilities") or {})
|
|
process_caps = dict(caps.get("process") or {})
|
|
if preflight.get("command"):
|
|
process_caps["command"] = list(preflight.get("command") or [])
|
|
process_caps["available"] = bool(preflight.get("ok"))
|
|
process_caps["command_available"] = bool(preflight.get("command_available"))
|
|
process_caps["workdir_exists"] = bool(preflight.get("workdir_exists"))
|
|
process_caps["workdir"] = str(preflight.get("workdir") or "")
|
|
process_caps["detail"] = str(preflight.get("detail") or "")
|
|
caps["process"] = process_caps
|
|
node_self["capabilities"] = caps
|
|
node_self["native_preflight"] = preflight
|
|
return node_self
|
|
|
|
|
|
def _apply_platform_runtime_changes(request: Request) -> None:
|
|
_invalidate_platform_overview_cache()
|
|
_invalidate_platform_nodes_cache()
|
|
speech_service = getattr(request.app.state, "speech_service", None)
|
|
if speech_service is not None and hasattr(speech_service, "reset_runtime"):
|
|
speech_service.reset_runtime()
|
|
|
|
|
|
@router.get("/api/platform/overview")
|
|
def get_platform_overview(request: Request, session: Session = Depends(get_session)):
|
|
cached_payload = _cached_platform_overview_payload()
|
|
if cached_payload is not None:
|
|
return cached_payload
|
|
|
|
def _read_runtime(bot):
|
|
provider = get_runtime_provider(request.app.state, bot)
|
|
status = str(provider.get_runtime_status(bot_id=str(bot.id or "")) or "STOPPED").upper()
|
|
runtime = dict(provider.get_resource_snapshot(bot_id=str(bot.id or "")) or {})
|
|
runtime.setdefault("docker_status", status)
|
|
return status, runtime
|
|
|
|
payload = build_platform_overview(session, read_runtime=_read_runtime)
|
|
return _store_platform_overview_payload(payload)
|
|
|
|
|
|
@router.get("/api/platform/nodes")
|
|
def list_platform_nodes(request: Request, session: Session = Depends(get_session)):
|
|
cached_payload = _cached_platform_nodes_payload()
|
|
if cached_payload is not None:
|
|
return cached_payload
|
|
|
|
node_registry = getattr(request.app.state, "node_registry_service", None)
|
|
if node_registry is None or not hasattr(node_registry, "list_nodes"):
|
|
return {"items": []}
|
|
resolve_edge_client = getattr(request.app.state, "resolve_edge_client", None)
|
|
refreshed_items = []
|
|
for node in node_registry.list_nodes():
|
|
metadata = dict(node.metadata or {})
|
|
refresh_failed = False
|
|
if (
|
|
callable(resolve_edge_client)
|
|
and str(metadata.get("transport_kind") or "").strip().lower() == "edge"
|
|
and bool(node.enabled)
|
|
):
|
|
try:
|
|
client = resolve_edge_client(
|
|
ProviderTarget(
|
|
node_id=node.node_id,
|
|
transport_kind="edge",
|
|
runtime_kind=str(metadata.get("runtime_kind") or "docker"),
|
|
core_adapter=str(metadata.get("core_adapter") or "nanobot"),
|
|
)
|
|
)
|
|
node_self = _edge_node_self_with_native_preflight(client=client, node=node)
|
|
node = node_registry.mark_node_seen(
|
|
session,
|
|
node_id=node.node_id,
|
|
display_name=str(node.display_name or node_self.get("display_name") or node.node_id),
|
|
capabilities=dict(node_self.get("capabilities") or {}),
|
|
resources=dict(node_self.get("resources") or {}),
|
|
)
|
|
except Exception as exc:
|
|
refresh_failed = True
|
|
log_edge_failure(
|
|
logger,
|
|
key=f"platform-node-refresh:{node.node_id}",
|
|
exc=exc,
|
|
message=f"Failed to refresh edge node metadata for node_id={node.node_id}",
|
|
)
|
|
refreshed_items.append((node, refresh_failed))
|
|
items = []
|
|
for node, refresh_failed in refreshed_items:
|
|
items.append(_serialize_node(node, refresh_failed=refresh_failed))
|
|
return _store_platform_nodes_payload(items)
|
|
|
|
|
|
@router.get("/api/platform/nodes/{node_id}")
|
|
def get_platform_node(node_id: str, request: Request, session: Session = Depends(get_session)):
|
|
normalized_node_id = str(node_id or "").strip().lower()
|
|
node_registry = getattr(request.app.state, "node_registry_service", None)
|
|
if node_registry is None or not hasattr(node_registry, "get_node"):
|
|
raise HTTPException(status_code=500, detail="node registry is unavailable")
|
|
node = node_registry.get_node(normalized_node_id)
|
|
if node is None:
|
|
raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}")
|
|
return _serialize_node(node)
|
|
|
|
|
|
@router.post("/api/platform/nodes")
|
|
def create_platform_node(payload: ManagedNodePayload, request: Request, session: Session = Depends(get_session)):
|
|
node_registry = getattr(request.app.state, "node_registry_service", None)
|
|
if node_registry is None or not hasattr(node_registry, "get_node"):
|
|
raise HTTPException(status_code=500, detail="node registry is unavailable")
|
|
normalized = _normalize_node_payload(payload)
|
|
if node_registry.get_node(normalized.node_id) is not None:
|
|
raise HTTPException(status_code=409, detail=f"Node already exists: {normalized.node_id}")
|
|
node = node_registry.upsert_node(session, _managed_node_from_payload(normalized))
|
|
_invalidate_platform_overview_cache()
|
|
_invalidate_platform_nodes_cache()
|
|
return _serialize_node(node)
|
|
|
|
|
|
@router.put("/api/platform/nodes/{node_id}")
|
|
def update_platform_node(node_id: str, payload: ManagedNodePayload, request: Request, session: Session = Depends(get_session)):
|
|
normalized_node_id = str(node_id or "").strip().lower()
|
|
node_registry = getattr(request.app.state, "node_registry_service", None)
|
|
if node_registry is None or not hasattr(node_registry, "get_node"):
|
|
raise HTTPException(status_code=500, detail="node registry is unavailable")
|
|
existing = node_registry.get_node(normalized_node_id)
|
|
if existing is None:
|
|
raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}")
|
|
normalized = _normalize_node_payload(payload)
|
|
if normalized.node_id != normalized_node_id:
|
|
raise HTTPException(status_code=400, detail="node_id cannot be changed")
|
|
node = node_registry.upsert_node(
|
|
session,
|
|
ManagedNode(
|
|
node_id=normalized_node_id,
|
|
display_name=normalized.display_name,
|
|
base_url=normalized.base_url,
|
|
enabled=bool(normalized.enabled),
|
|
auth_token=normalized.auth_token or existing.auth_token,
|
|
metadata={
|
|
"transport_kind": normalized.transport_kind,
|
|
"runtime_kind": normalized.runtime_kind,
|
|
"core_adapter": normalized.core_adapter,
|
|
"workspace_root": normalized.workspace_root,
|
|
"native_command": normalized.native_command,
|
|
"native_workdir": normalized.native_workdir,
|
|
"native_sandbox_mode": normalized.native_sandbox_mode,
|
|
},
|
|
capabilities=dict(existing.capabilities or {}),
|
|
resources=dict(existing.resources or {}),
|
|
last_seen_at=existing.last_seen_at,
|
|
),
|
|
)
|
|
_invalidate_platform_overview_cache()
|
|
_invalidate_platform_nodes_cache()
|
|
return _serialize_node(node)
|
|
|
|
|
|
@router.delete("/api/platform/nodes/{node_id}")
|
|
def delete_platform_node(node_id: str, request: Request, session: Session = Depends(get_session)):
|
|
normalized_node_id = str(node_id or "").strip().lower()
|
|
if normalized_node_id == "local":
|
|
raise HTTPException(status_code=400, detail="Local node cannot be deleted")
|
|
node_registry = getattr(request.app.state, "node_registry_service", None)
|
|
if node_registry is None or not hasattr(node_registry, "get_node"):
|
|
raise HTTPException(status_code=500, detail="node registry is unavailable")
|
|
if node_registry.get_node(normalized_node_id) is None:
|
|
raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}")
|
|
attached_bot_ids = session.exec(select(BotInstance.id).where(BotInstance.node_id == normalized_node_id)).all()
|
|
if attached_bot_ids:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Node {normalized_node_id} still has bots assigned: {', '.join(str(item) for item in attached_bot_ids[:5])}",
|
|
)
|
|
node_registry.delete_node(session, normalized_node_id)
|
|
_invalidate_platform_overview_cache()
|
|
_invalidate_platform_nodes_cache()
|
|
return {"status": "deleted", "node_id": normalized_node_id}
|
|
|
|
|
|
@router.post("/api/platform/nodes/test")
|
|
def test_platform_node(payload: ManagedNodePayload, request: Request):
|
|
normalized = _normalize_node_payload(payload)
|
|
temp_node = _managed_node_from_payload(normalized)
|
|
result = _test_edge_connectivity(
|
|
lambda _target: HttpEdgeClient(
|
|
node=temp_node,
|
|
http_client_factory=lambda: httpx.Client(timeout=10.0, trust_env=False),
|
|
async_http_client_factory=lambda: httpx.AsyncClient(timeout=10.0, trust_env=False),
|
|
),
|
|
temp_node,
|
|
)
|
|
return result.model_dump()
|
|
|
|
|
|
@router.post("/api/platform/nodes/native/preflight")
|
|
def test_platform_node_native_preflight(payload: ManagedNodePayload, request: Request):
|
|
normalized = _normalize_node_payload(payload)
|
|
temp_node = _managed_node_from_payload(normalized)
|
|
result = _test_edge_native_preflight(
|
|
lambda _target: HttpEdgeClient(
|
|
node=temp_node,
|
|
http_client_factory=lambda: httpx.Client(timeout=10.0, trust_env=False),
|
|
async_http_client_factory=lambda: httpx.AsyncClient(timeout=10.0, trust_env=False),
|
|
),
|
|
temp_node,
|
|
native_command=str(normalized.native_command or "").strip() or None,
|
|
native_workdir=str(normalized.native_workdir or "").strip() or None,
|
|
)
|
|
return result.model_dump()
|
|
|
|
|
|
@router.post("/api/platform/nodes/{node_id}/test")
|
|
def test_saved_platform_node(node_id: str, request: Request, session: Session = Depends(get_session)):
|
|
normalized_node_id = str(node_id or "").strip().lower()
|
|
node_registry = getattr(request.app.state, "node_registry_service", None)
|
|
if node_registry is None or not hasattr(node_registry, "get_node"):
|
|
raise HTTPException(status_code=500, detail="node registry is unavailable")
|
|
node = node_registry.get_node(normalized_node_id)
|
|
if node is None:
|
|
raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}")
|
|
transport_kind = str((node.metadata or {}).get("transport_kind") or "edge").strip().lower()
|
|
if transport_kind != "edge":
|
|
_invalidate_platform_nodes_cache()
|
|
raise HTTPException(status_code=400, detail="Only edge transport is supported")
|
|
result = _test_edge_connectivity(
|
|
lambda target: HttpEdgeClient(
|
|
node=node,
|
|
http_client_factory=lambda: httpx.Client(timeout=10.0, trust_env=False),
|
|
async_http_client_factory=lambda: httpx.AsyncClient(timeout=10.0, trust_env=False),
|
|
),
|
|
node,
|
|
)
|
|
if result.ok:
|
|
node_registry.mark_node_seen(
|
|
session,
|
|
node_id=node.node_id,
|
|
display_name=str(node.display_name or result.node_self.get("display_name") or node.node_id) if result.node_self else node.display_name,
|
|
capabilities=dict(result.node_self.get("capabilities") or {}) if result.node_self else dict(node.capabilities or {}),
|
|
resources=dict(result.node_self.get("resources") or {}) if result.node_self else dict(getattr(node, "resources", {}) or {}),
|
|
)
|
|
_invalidate_platform_nodes_cache()
|
|
return result.model_dump()
|
|
|
|
|
|
@router.post("/api/platform/nodes/{node_id}/native/preflight")
|
|
def test_saved_platform_node_native_preflight(node_id: str, request: Request, session: Session = Depends(get_session)):
|
|
normalized_node_id = str(node_id or "").strip().lower()
|
|
node_registry = getattr(request.app.state, "node_registry_service", None)
|
|
if node_registry is None or not hasattr(node_registry, "get_node"):
|
|
raise HTTPException(status_code=500, detail="node registry is unavailable")
|
|
node = node_registry.get_node(normalized_node_id)
|
|
if node is None:
|
|
raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}")
|
|
transport_kind = str((node.metadata or {}).get("transport_kind") or "edge").strip().lower()
|
|
if transport_kind != "edge":
|
|
_invalidate_platform_nodes_cache()
|
|
raise HTTPException(status_code=400, detail="Only edge transport is supported")
|
|
metadata = dict(node.metadata or {})
|
|
result = _test_edge_native_preflight(
|
|
lambda _target: HttpEdgeClient(
|
|
node=node,
|
|
http_client_factory=lambda: httpx.Client(timeout=10.0, trust_env=False),
|
|
async_http_client_factory=lambda: httpx.AsyncClient(timeout=10.0, trust_env=False),
|
|
),
|
|
node,
|
|
native_command=str(metadata.get("native_command") or "").strip() or None,
|
|
native_workdir=str(metadata.get("native_workdir") or "").strip() or None,
|
|
)
|
|
if result.status == "online" and result.node_self:
|
|
node_registry.mark_node_seen(
|
|
session,
|
|
node_id=node.node_id,
|
|
display_name=str(node.display_name or result.node_self.get("display_name") or node.node_id),
|
|
capabilities=dict(result.node_self.get("capabilities") or {}),
|
|
resources=dict(result.node_self.get("resources") or {}),
|
|
)
|
|
_invalidate_platform_nodes_cache()
|
|
return result.model_dump()
|
|
|
|
|
|
@router.get("/api/platform/nodes/{node_id}/resources")
|
|
def get_platform_node_resources(node_id: str, request: Request, session: Session = Depends(get_session)):
|
|
normalized_node_id = str(node_id or "").strip().lower()
|
|
node_registry = getattr(request.app.state, "node_registry_service", None)
|
|
if node_registry is not None and hasattr(node_registry, "get_node"):
|
|
node = node_registry.get_node(normalized_node_id)
|
|
if node is not None:
|
|
metadata = dict(getattr(node, "metadata", {}) or {})
|
|
if str(metadata.get("transport_kind") or "").strip().lower() == "edge":
|
|
resolve_edge_client = getattr(request.app.state, "resolve_edge_client", None)
|
|
if callable(resolve_edge_client):
|
|
from providers.target import ProviderTarget
|
|
|
|
base = build_node_resource_overview(session, node_id=normalized_node_id, read_runtime=None)
|
|
client = resolve_edge_client(
|
|
ProviderTarget(
|
|
node_id=normalized_node_id,
|
|
transport_kind="edge",
|
|
runtime_kind=str(metadata.get("runtime_kind") or "docker"),
|
|
core_adapter=str(metadata.get("core_adapter") or "nanobot"),
|
|
)
|
|
)
|
|
try:
|
|
resource_report = dict(client.get_node_resources() or {})
|
|
except Exception as exc:
|
|
log_edge_failure(
|
|
logger,
|
|
key=f"platform-node-resources:{normalized_node_id}",
|
|
exc=exc,
|
|
message=f"Failed to load edge node resources for node_id={normalized_node_id}",
|
|
)
|
|
return base
|
|
base["resources"] = dict(resource_report.get("resources") or resource_report)
|
|
if resource_report:
|
|
base["node_report"] = resource_report
|
|
return base
|
|
|
|
def _read_runtime(bot):
|
|
provider = get_runtime_provider(request.app.state, bot)
|
|
status = str(provider.get_runtime_status(bot_id=str(bot.id or "")) or "STOPPED").upper()
|
|
runtime = dict(provider.get_resource_snapshot(bot_id=str(bot.id or "")) or {})
|
|
runtime.setdefault("docker_status", status)
|
|
return status, runtime
|
|
|
|
return build_node_resource_overview(session, node_id=normalized_node_id, read_runtime=_read_runtime)
|
|
|
|
|
|
@router.get("/api/platform/settings")
|
|
def get_platform_settings_api(session: Session = Depends(get_session)):
|
|
return get_platform_settings(session).model_dump()
|
|
|
|
|
|
@router.put("/api/platform/settings")
|
|
def update_platform_settings_api(payload: PlatformSettingsPayload, request: Request, session: Session = Depends(get_session)):
|
|
result = save_platform_settings(session, payload).model_dump()
|
|
_apply_platform_runtime_changes(request)
|
|
return result
|
|
|
|
|
|
@router.post("/api/platform/cache/clear")
|
|
def clear_platform_cache():
|
|
_invalidate_platform_overview_cache()
|
|
_invalidate_platform_nodes_cache()
|
|
return {"status": "cleared"}
|
|
|
|
|
|
@router.post("/api/platform/reload")
|
|
def reload_platform_runtime(request: Request):
|
|
_apply_platform_runtime_changes(request)
|
|
return {"status": "reloaded"}
|
|
|
|
|
|
@router.get("/api/platform/usage")
|
|
def get_platform_usage(
|
|
bot_id: Optional[str] = None,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
session: Session = Depends(get_session),
|
|
):
|
|
return list_usage(session, bot_id=bot_id, limit=limit, offset=offset)
|
|
|
|
|
|
@router.get("/api/platform/events")
|
|
def get_platform_events(bot_id: Optional[str] = None, limit: int = 100, session: Session = Depends(get_session)):
|
|
return {"items": list_activity_events(session, bot_id=bot_id, limit=limit)}
|
|
|
|
|
|
@router.get("/api/platform/system-settings")
|
|
def get_system_settings(search: str = "", session: Session = Depends(get_session)):
|
|
return {"items": list_system_settings(session, search=search)}
|
|
|
|
|
|
@router.post("/api/platform/system-settings")
|
|
def create_system_setting(payload: SystemSettingPayload, request: Request, session: Session = Depends(get_session)):
|
|
try:
|
|
result = create_or_update_system_setting(session, payload)
|
|
_apply_platform_runtime_changes(request)
|
|
return result
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
@router.put("/api/platform/system-settings/{key}")
|
|
def update_system_setting(key: str, payload: SystemSettingPayload, request: Request, session: Session = Depends(get_session)):
|
|
try:
|
|
result = create_or_update_system_setting(session, payload.model_copy(update={"key": key}))
|
|
_apply_platform_runtime_changes(request)
|
|
return result
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
@router.delete("/api/platform/system-settings/{key}")
|
|
def remove_system_setting(key: str, request: Request, session: Session = Depends(get_session)):
|
|
try:
|
|
delete_system_setting(session, key)
|
|
_apply_platform_runtime_changes(request)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
return {"status": "deleted", "key": key}
|