From e12332d3c618937d0c2d1cc9f83cd1adf0150531 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Fri, 22 May 2026 01:05:09 +0300 Subject: [PATCH] Show Windows Agent status on AI structure page --- .../src/api_server/html5_ai_structure.py | 51 +++++++++++++++++++ .../html5_ai_structure_controller.py | 2 + services/api-server/src/api_server/main.py | 19 +++++++ services/api-server/tests/test_api.py | 38 ++++++++++++++ 4 files changed, 110 insertions(+) diff --git a/services/api-server/src/api_server/html5_ai_structure.py b/services/api-server/src/api_server/html5_ai_structure.py index 57fbdf3..f11a3e0 100644 --- a/services/api-server/src/api_server/html5_ai_structure.py +++ b/services/api-server/src/api_server/html5_ai_structure.py @@ -13,6 +13,7 @@ def render_html5_ai_structure_page( projects: Iterable[object], result: dict | None = None, saved_credentials: dict[str, str] | None = None, + agent_info: dict[str, str] | None = None, ) -> str: project_nav = "\n".join(_project_link(project, project_id) for project in projects) return _page( @@ -30,6 +31,7 @@ def render_html5_ai_structure_page(
Подготовка структуры
+ {render_html5_ai_structure_agent_panel(project_id, agent_info=agent_info)} {render_html5_ai_structure_form(project_id, saved_credentials=saved_credentials)}

Пути должны быть доступны серверу SFERA/API. Для docker-test используйте папку, смонтированную или доступную внутри контейнера; локальные диски Windows и закрытые SMB-папки без учетных данных сервер не увидит.

{render_html5_ai_structure_result(result)}
@@ -40,6 +42,38 @@ def render_html5_ai_structure_page( ) +def render_html5_ai_structure_agent_panel(project_id: str, *, agent_info: dict[str, str] | None = None) -> str: + info = agent_info or {} + agent_id = str(info.get("agent_id") or "") + status = str(info.get("status") or "not_configured") + title = _agent_status_title(status) + advice = _agent_status_advice(status, agent_id) + details = [] + if agent_id: + details.append(("Windows Agent", agent_id)) + if info.get("last_seen_at"): + details.append(("Последний heartbeat", str(info["last_seen_at"]))) + if info.get("host"): + details.append(("Хост", str(info["host"]))) + if info.get("version"): + details.append(("Версия", str(info["version"]))) + detail_html = "".join( + f'
{escape(label)}{escape(value)}
' + for label, value in details + ) or '
Windows AgentПока не выбран
' + return f""" +
+
+ {escape(title)} + Агент для CF/CFE +
+

{escape(advice)}

+
{detail_html}
+

Открыть setup проекта

+
+ """ + + def render_html5_ai_structure_form(project_id: str, *, saved_credentials: dict[str, str] | None = None) -> str: saved_credentials = saved_credentials or {} saved_username = str(saved_credentials.get("username") or "") @@ -203,3 +237,20 @@ def _artifact_text(value: object) -> str: "normalized_project.json": "Нормализованный проект", } return mapping.get(str(value or ""), str(value or "")) + + +def _agent_status_title(status: str) -> str: + mapping = { + "online": "агент онлайн", + "offline": "агент офлайн", + "not_configured": "агент не настроен", + } + return mapping.get(status, status) + + +def _agent_status_advice(status: str, agent_id: str) -> str: + if status == "online": + return f"Для бинарных .cf/.cfe будет использован Windows Agent {agent_id}. Можно запускать подготовку." + if status == "offline": + return f"Windows Agent {agent_id} выбран, но сейчас не отвечает. Обновите или запустите агент, затем повторите." + return "Для прямого разбора .cf/.cfe нужен выбранный Windows Agent. Укажите его в настройках проекта." diff --git a/services/api-server/src/api_server/html5_ai_structure_controller.py b/services/api-server/src/api_server/html5_ai_structure_controller.py index 5472f71..606a0a3 100644 --- a/services/api-server/src/api_server/html5_ai_structure_controller.py +++ b/services/api-server/src/api_server/html5_ai_structure_controller.py @@ -25,11 +25,13 @@ def html5_ai_structure_page( project_id: str, project_summaries: Callable[[], Iterable[object]], load_credentials: Callable[[str], SmbCredentials | None] | None = None, + load_agent_info: Callable[[str], dict[str, str]] | None = None, ) -> str: return render_html5_ai_structure_page( project_id=project_id, projects=project_summaries(), saved_credentials=load_credentials(project_id) if load_credentials else None, + agent_info=load_agent_info(project_id) if load_agent_info else None, ) diff --git a/services/api-server/src/api_server/main.py b/services/api-server/src/api_server/main.py index 59bbf32..41b7a62 100644 --- a/services/api-server/src/api_server/main.py +++ b/services/api-server/src/api_server/main.py @@ -703,6 +703,24 @@ def _format_binary_file_list(paths: list[Path]) -> str: return result +def _ai_structure_agent_info(project_id: str) -> dict[str, str]: + try: + settings = _project_settings_or_404(project_id) + except HTTPException: + return {"status": "not_configured"} + agent_id = _agent_id_for_source(settings, ImportSourceKind.CF_FILE) + if not agent_id: + return {"status": "not_configured"} + status = _agent_status_with_liveness(_agent_statuses.get(agent_id, AgentStatus(agent_id=agent_id))) + return { + "agent_id": agent_id, + "status": str(status.status or "offline"), + "last_seen_at": str(status.last_seen_at or ""), + "host": str(status.host or ""), + "version": str(status.version or ""), + } + + def _cancel_stale_extension_install_jobs(project_id: str, selected_agent_id: str) -> None: now = _current_timestamp() for job in list(_agent_import_jobs.values()): @@ -1791,6 +1809,7 @@ async def html5_project_ai_structure(project_id: str) -> Response: project_id=project_id, project_summaries=_project_summaries, load_credentials=_load_ai_structure_smb_credentials, + load_agent_info=_ai_structure_agent_info, ) ) diff --git a/services/api-server/tests/test_api.py b/services/api-server/tests/test_api.py index 6e02f0d..79a72cf 100644 --- a/services/api-server/tests/test_api.py +++ b/services/api-server/tests/test_api.py @@ -2071,6 +2071,44 @@ def test_html5_ai_structure_reports_offline_agent_with_last_seen(tmp_path: Path) assert "Последний heartbeat" in queued.text +def test_html5_ai_structure_page_shows_agent_status_panel(): + client = TestClient(app) + project_id = f"ai-page-{uuid4()}" + agent_id = f"win-agent-{uuid4()}" + + settings = client.post( + f"/projects/{project_id}/settings", + json={"name": "AI Page", "structure_source": "CF_FILE", "agent": {"cf_agent_id": agent_id}}, + ) + assert settings.status_code == 200 + heartbeat = client.post("/agent/heartbeat", json={"agent_id": agent_id, "host": "test-host", "version": "0.2.31"}) + assert heartbeat.status_code == 200 + + page = client.get(f"/html5/projects/{project_id}/ai-structure") + assert page.status_code == 200 + assert "Агент для CF/CFE" in page.text + assert "агент онлайн" in page.text + assert agent_id in page.text + assert "test-host" in page.text + assert "0.2.31" in page.text + + +def test_html5_ai_structure_page_shows_missing_agent_hint(): + client = TestClient(app) + project_id = f"ai-page-missing-{uuid4()}" + + settings = client.post( + f"/projects/{project_id}/settings", + json={"name": "AI Page Missing", "structure_source": "CF_FILE"}, + ) + assert settings.status_code == 200 + + page = client.get(f"/html5/projects/{project_id}/ai-structure") + assert page.status_code == 200 + assert "агент не настроен" in page.text + assert "Укажите его в настройках проекта" in page.text + + def test_import_full_replace_replaces_current_normalized_project(tmp_path: Path): first = tmp_path / "first" second = tmp_path / "second"