diff --git a/docs/changelog/2026-06-12-server-agent-action-hint.md b/docs/changelog/2026-06-12-server-agent-action-hint.md new file mode 100644 index 00000000..0653d6cd --- /dev/null +++ b/docs/changelog/2026-06-12-server-agent-action-hint.md @@ -0,0 +1,33 @@ +# 2026-06-12 新服务器 Agent 安装/升级提示 + +## 摘要 + +新添加的服务器不再因预生成 `agent_api_key` 误显示「离线」;列表与展开详情明确提示需安装或升级 Agent,并提供一键操作。 + +## 动机 + +用户添加服务器后 CPU/内存/心跳为空,界面无指引。根因:仅有 DB 中的 API Key 被当作「已装 Agent」,实际从未安装。 + +## 涉及文件 + +- `server/utils/agent_version.py` — `agent_is_installed`、`compute_agent_guidance` +- `server/api/servers.py` — 列表/详情返回 `agent_action*`;状态判定修正 +- `frontend/src/components/servers/ServerAgentHint.vue` — 提示条(新增) +- `frontend/src/components/servers/ServerInlineDetail.vue` — 展开详情提示 +- `frontend/src/pages/ServersPage.vue` — 列表「需安装/需升级」芯片、单台安装/升级 +- `frontend/src/composables/servers/useServerFormDialog.ts` — 创建后 toast +- `frontend/src/types/api.ts` — 类型 +- `tests/test_agent_version.py` — 单测 + +## 迁移 / 重启 + +需重启 API;前端构建部署。 + +## 验证 + +```bash +.venv/bin/pytest tests/test_agent_version.py -q +cd frontend && npm run type-check +``` + +添加服务器 → 状态应为「未知」→ 展开行见「尚未安装 Agent」→ 点「安装 Agent」。 diff --git a/frontend/src/components/servers/ServerAgentHint.vue b/frontend/src/components/servers/ServerAgentHint.vue new file mode 100644 index 00000000..da616654 --- /dev/null +++ b/frontend/src/components/servers/ServerAgentHint.vue @@ -0,0 +1,68 @@ + + + + + + {{ props.message }} + + + + 安装 Agent + + + 升级 Agent + + + 诊断 + + + + + diff --git a/frontend/src/components/servers/ServerInlineDetail.vue b/frontend/src/components/servers/ServerInlineDetail.vue index ddc82f98..c800d0a3 100644 --- a/frontend/src/components/servers/ServerInlineDetail.vue +++ b/frontend/src/components/servers/ServerInlineDetail.vue @@ -1,5 +1,15 @@ + + () defineEmits<{ 'edit-path': [] + 'install-agent': [] + 'upgrade-agent': [] + 'diagnose-agent': [] }>() const detail = ref(null) diff --git a/frontend/src/composables/servers/useServerFormDialog.ts b/frontend/src/composables/servers/useServerFormDialog.ts index e4b76685..10499140 100644 --- a/frontend/src/composables/servers/useServerFormDialog.ts +++ b/frontend/src/composables/servers/useServerFormDialog.ts @@ -246,6 +246,11 @@ export function useServerFormDialog(onSaved: () => void) { ) showScriptSubmitToast('新服务器初始化已在后台执行(sudo + 路径探测)') } + if (created.agent_action === 'install') { + showScriptSubmitToast( + created.agent_action_message || '请展开服务器行并点击「安装 Agent」以采集 CPU/内存等指标', + ) + } } close() onSaved() diff --git a/frontend/src/pages/ServersPage.vue b/frontend/src/pages/ServersPage.vue index 96759edf..53829d85 100644 --- a/frontend/src/pages/ServersPage.vue +++ b/frontend/src/pages/ServersPage.vue @@ -236,7 +236,29 @@ - {{ item.agent_version || '—' }} + + {{ item.agent_version || '—' }} + + 需安装 + + + 需升级 + + @@ -278,7 +300,11 @@ @@ -1528,6 +1554,7 @@ function openFiles(item: ServerApiItem) { const showAgentDiagnose = ref(false) const agentDiagnoseLoading = ref(false) const agentDiagnoseLoadingId = ref(null) +const agentActionLoadingId = ref(null) const agentDiagnoseResult = ref(null) const agentDiagnoseTarget = ref(null) @@ -1548,6 +1575,31 @@ async function openAgentDiagnose(item: ServerApiItem) { } } +async function installAgentOne(item: ServerApiItem) { + agentActionLoadingId.value = item.id + try { + await http.post(`/servers/${item.id}/install-agent`, {}) + snackbar(`Agent 安装完成:${item.name}`, 'success') + await _load() + } catch (e: unknown) { + snackbar(formatApiError(e, '安装 Agent 失败'), 'error') + } finally { + agentActionLoadingId.value = null + } +} + +async function upgradeAgentOne(item: ServerApiItem) { + agentActionLoadingId.value = item.id + try { + await submitServerBatchJob('upgrade-agent', [item.id]) + snackbar(`已提交 Agent 升级:${item.name}`, 'success') + } catch (e: unknown) { + snackbar(formatApiError(e, '提交升级失败'), 'error') + } finally { + agentActionLoadingId.value = null + } +} + function editServer(item: ServerApiItem) { void openEditServerDialog(item) } diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 845ad5a8..b97e6e39 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -149,6 +149,8 @@ export interface FilesCapabilityResult { } /** Server item from /api/servers/ */ +export type ServerAgentAction = 'install' | 'upgrade' | 'offline' | 'ok' + export interface ServerApiItem { id: number name: string @@ -175,6 +177,9 @@ export interface ServerApiItem { last_heartbeat: string | null agent_version: string | null status: string + agent_action?: ServerAgentAction + agent_action_message?: string | null + central_agent_version?: string | null created_at: string | null updated_at: string | null system_info?: { diff --git a/server/api/servers.py b/server/api/servers.py index 278a5ef1..c7d9e7f5 100644 --- a/server/api/servers.py +++ b/server/api/servers.py @@ -63,8 +63,21 @@ async def _schedule_server_onboarding(server_ids: list[int], operator: str) -> i def _server_has_agent_monitoring(server: Server) -> bool: - """True when this server is expected to run Nexus Agent (key or version on file).""" - return bool((server.agent_api_key or "").strip() or (server.agent_version or "").strip()) + """True when Agent has actually reported in (version/heartbeat), not key-only.""" + from server.utils.agent_version import agent_is_installed + + return agent_is_installed(agent_version_db=server.agent_version, heartbeat=None) + + +def _apply_agent_guidance(server_data: dict, server: Server, heartbeat: dict | None) -> None: + from server.utils.agent_version import compute_agent_guidance + + server_data.update( + compute_agent_guidance( + agent_version_db=server.agent_version, + heartbeat=heartbeat, + ) + ) # ── CRUD ── @@ -2123,7 +2136,9 @@ def _derive_server_status(is_online: bool | None, agent_version: str | None, *, def _apply_heartbeat_overlay(server_data: dict, server: Server, heartbeat: dict | None) -> None: """Overlay Redis heartbeat onto server_data and refresh derived status.""" - has_agent = _server_has_agent_monitoring(server) + from server.utils.agent_version import agent_is_installed + + installed = agent_is_installed(agent_version_db=server.agent_version, heartbeat=heartbeat) if heartbeat: server_data["is_online"] = heartbeat.get("is_online") == "True" if heartbeat.get("system_info"): @@ -2143,21 +2158,22 @@ def _apply_heartbeat_overlay(server_data: dict, server: Server, heartbeat: dict server_data["time_drift_seconds"] = 0.0 server_data["drift_level"] = heartbeat.get("drift_level", "ok") server_data["_source"] = "redis" - elif has_agent: + elif installed: server_data["is_online"] = False server_data["_source"] = "redis_miss" server_data["status"] = _derive_server_status( server_data.get("is_online"), server_data.get("agent_version") or server.agent_version, - has_agent=has_agent, + has_agent=installed, ) + _apply_agent_guidance(server_data, server, heartbeat) def _server_to_dict(server: Server) -> dict: """Convert Server model to API response dict (hide sensitive fields)""" from server.utils.files_elevation import get_files_elevation - return { + data = { "id": server.id, "name": server.name, "domain": server.domain, @@ -2196,6 +2212,8 @@ def _server_to_dict(server: Server) -> dict: "created_at": str(server.created_at) if server.created_at else None, "updated_at": str(server.updated_at) if server.updated_at else None, } + _apply_agent_guidance(data, server, None) + return data def _sync_log_to_dict(log, server_name: str = None) -> dict: diff --git a/server/utils/agent_version.py b/server/utils/agent_version.py index bba32c66..dddee1a2 100644 --- a/server/utils/agent_version.py +++ b/server/utils/agent_version.py @@ -54,3 +54,71 @@ def is_version_at_least(remote: str, central: str) -> bool: if not central_t: return bool(remote_t) return remote_t >= central_t + + +def agent_is_installed( + *, + agent_version_db: str | None, + heartbeat: dict[str, str] | None = None, +) -> bool: + """True when Agent has reported a version or heartbeat (not merely a DB API key).""" + if heartbeat: + if (heartbeat.get("agent_version") or "").strip(): + return True + if heartbeat.get("last_heartbeat"): + return True + return bool((agent_version_db or "").strip()) + + +def compute_agent_guidance( + *, + agent_version_db: str | None, + heartbeat: dict[str, str] | None = None, + central: str | None = None, +) -> dict[str, str | None]: + """UI hints: install / upgrade / offline / ok.""" + central_v = central or get_central_agent_version() + live_ver = "" + if heartbeat: + live_ver = (heartbeat.get("agent_version") or "").strip() + if not live_ver: + live_ver = (agent_version_db or "").strip() + + has_heartbeat = bool(heartbeat and heartbeat.get("last_heartbeat")) + installed = agent_is_installed(agent_version_db=agent_version_db, heartbeat=heartbeat) + + base: dict[str, str | None] = { + "central_agent_version": central_v, + "agent_action": "ok", + "agent_action_message": None, + } + + if not installed: + return { + **base, + "agent_action": "install", + "agent_action_message": ( + f"尚未安装 Agent(主站版本 {central_v})。" + "安装后可自动上报 CPU/内存/磁盘与在线状态;未安装时仅能通过 SSH 手动探活。" + ), + } + + if live_ver and is_version_lower(live_ver, central_v): + return { + **base, + "agent_action": "upgrade", + "agent_action_message": ( + f"当前 Agent {live_ver},低于主站 {central_v},建议升级以获取最新心跳与探针能力。" + ), + } + + if not has_heartbeat: + return { + **base, + "agent_action": "offline", + "agent_action_message": ( + "Agent 已登记但暂无心跳,请检查子机 nexus-agent 服务或点击「诊断」。" + ), + } + + return base diff --git a/tests/test_agent_version.py b/tests/test_agent_version.py index 236aed13..2b4af2a9 100644 --- a/tests/test_agent_version.py +++ b/tests/test_agent_version.py @@ -7,6 +7,8 @@ import pytest from server.application.server_batch_common import agent_install_skip_result from server.domain.models import Server from server.utils.agent_version import ( + agent_is_installed, + compute_agent_guidance, get_central_agent_version, is_version_at_least, is_version_lower, @@ -36,6 +38,40 @@ def test_central_agent_version_matches_agent_py(): assert get_central_agent_version() == "2.0.0" +def test_agent_is_installed(): + assert agent_is_installed(agent_version_db=None, heartbeat=None) is False + assert agent_is_installed(agent_version_db="2.0.0", heartbeat=None) is True + assert agent_is_installed( + agent_version_db=None, + heartbeat={"agent_version": "2.0.0", "last_heartbeat": "2026-01-01"}, + ) is True + assert agent_is_installed(agent_version_db=None, heartbeat={"last_heartbeat": "x"}) is True + + +def test_compute_agent_guidance_install(): + g = compute_agent_guidance(agent_version_db=None, heartbeat=None, central="2.0.0") + assert g["agent_action"] == "install" + assert "尚未安装" in (g["agent_action_message"] or "") + + +def test_compute_agent_guidance_upgrade(): + g = compute_agent_guidance( + agent_version_db="1.0.0", + heartbeat={"agent_version": "1.0.0", "last_heartbeat": "t"}, + central="2.0.0", + ) + assert g["agent_action"] == "upgrade" + + +def test_compute_agent_guidance_ok(): + g = compute_agent_guidance( + agent_version_db="2.0.0", + heartbeat={"agent_version": "2.0.0", "last_heartbeat": "t", "is_online": "True"}, + central="2.0.0", + ) + assert g["agent_action"] == "ok" + + @pytest.mark.asyncio async def test_agent_install_skip_when_version_current(): server = Server(id=1, name="A", agent_api_key="nxs-k", agent_version="2.0.0")