Show Windows Agent status on AI structure page
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled

This commit is contained in:
2026-05-22 01:05:09 +03:00
parent d93b7cb07e
commit e12332d3c6
4 changed files with 110 additions and 0 deletions
@@ -13,6 +13,7 @@ def render_html5_ai_structure_page(
projects: Iterable[object], projects: Iterable[object],
result: dict | None = None, result: dict | None = None,
saved_credentials: dict[str, str] | None = None, saved_credentials: dict[str, str] | None = None,
agent_info: dict[str, str] | None = None,
) -> str: ) -> str:
project_nav = "\n".join(_project_link(project, project_id) for project in projects) project_nav = "\n".join(_project_link(project, project_id) for project in projects)
return _page( return _page(
@@ -30,6 +31,7 @@ def render_html5_ai_structure_page(
</aside> </aside>
<section class="panel setup-main"> <section class="panel setup-main">
<div class="panel-title">Подготовка структуры</div> <div class="panel-title">Подготовка структуры</div>
{render_html5_ai_structure_agent_panel(project_id, agent_info=agent_info)}
{render_html5_ai_structure_form(project_id, saved_credentials=saved_credentials)} {render_html5_ai_structure_form(project_id, saved_credentials=saved_credentials)}
<p class="object-summary">Пути должны быть доступны серверу SFERA/API. Для docker-test используйте папку, смонтированную или доступную внутри контейнера; локальные диски Windows и закрытые SMB-папки без учетных данных сервер не увидит.</p> <p class="object-summary">Пути должны быть доступны серверу SFERA/API. Для docker-test используйте папку, смонтированную или доступную внутри контейнера; локальные диски Windows и закрытые SMB-папки без учетных данных сервер не увидит.</p>
<div data-html5-ai-structure-result>{render_html5_ai_structure_result(result)}</div> <div data-html5-ai-structure-result>{render_html5_ai_structure_result(result)}</div>
@@ -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'<article class="access-card"><strong>{escape(label)}</strong><small>{escape(value)}</small></article>'
for label, value in details
) or '<article class="access-card"><strong>Windows Agent</strong><small>Пока не выбран</small></article>'
return f"""
<section class="ai-agent-panel">
<div class="access-plan-head">
<span class="status-pill">{escape(title)}</span>
<strong>Агент для CF/CFE</strong>
</div>
<p class="object-summary">{escape(advice)}</p>
<div class="access-operations">{detail_html}</div>
<p class="muted padded"><a class="button" href="/html5/projects/{quote(project_id)}/setup">Открыть setup проекта</a></p>
</section>
"""
def render_html5_ai_structure_form(project_id: str, *, saved_credentials: dict[str, str] | None = None) -> str: def render_html5_ai_structure_form(project_id: str, *, saved_credentials: dict[str, str] | None = None) -> str:
saved_credentials = saved_credentials or {} saved_credentials = saved_credentials or {}
saved_username = str(saved_credentials.get("username") or "") saved_username = str(saved_credentials.get("username") or "")
@@ -203,3 +237,20 @@ def _artifact_text(value: object) -> str:
"normalized_project.json": "Нормализованный проект", "normalized_project.json": "Нормализованный проект",
} }
return mapping.get(str(value or ""), str(value or "")) 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. Укажите его в настройках проекта."
@@ -25,11 +25,13 @@ def html5_ai_structure_page(
project_id: str, project_id: str,
project_summaries: Callable[[], Iterable[object]], project_summaries: Callable[[], Iterable[object]],
load_credentials: Callable[[str], SmbCredentials | None] | None = None, load_credentials: Callable[[str], SmbCredentials | None] | None = None,
load_agent_info: Callable[[str], dict[str, str]] | None = None,
) -> str: ) -> str:
return render_html5_ai_structure_page( return render_html5_ai_structure_page(
project_id=project_id, project_id=project_id,
projects=project_summaries(), projects=project_summaries(),
saved_credentials=load_credentials(project_id) if load_credentials else None, saved_credentials=load_credentials(project_id) if load_credentials else None,
agent_info=load_agent_info(project_id) if load_agent_info else None,
) )
@@ -703,6 +703,24 @@ def _format_binary_file_list(paths: list[Path]) -> str:
return result 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: def _cancel_stale_extension_install_jobs(project_id: str, selected_agent_id: str) -> None:
now = _current_timestamp() now = _current_timestamp()
for job in list(_agent_import_jobs.values()): 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_id=project_id,
project_summaries=_project_summaries, project_summaries=_project_summaries,
load_credentials=_load_ai_structure_smb_credentials, load_credentials=_load_ai_structure_smb_credentials,
load_agent_info=_ai_structure_agent_info,
) )
) )
+38
View File
@@ -2071,6 +2071,44 @@ def test_html5_ai_structure_reports_offline_agent_with_last_seen(tmp_path: Path)
assert "Последний heartbeat" in queued.text 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): def test_import_full_replace_replaces_current_normalized_project(tmp_path: Path):
first = tmp_path / "first" first = tmp_path / "first"
second = tmp_path / "second" second = tmp_path / "second"