feat(servers): 新服务器 Agent 安装/升级引导提示

修正仅有 API Key 误判为已装 Agent;列表与展开详情提示安装/升级并提供一键操作。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-12 03:16:12 +08:00
parent f37047f746
commit 7bb85aac41
9 changed files with 307 additions and 7 deletions
@@ -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」。
@@ -0,0 +1,68 @@
<script setup lang="ts">
import type { ServerAgentAction } from '@/types/api'
const props = defineProps<{
action?: ServerAgentAction | null
message?: string | null
compact?: boolean
loading?: boolean
}>()
defineEmits<{
install: []
upgrade: []
diagnose: []
}>()
function alertType(action: ServerAgentAction | null | undefined): 'info' | 'warning' | 'error' {
if (action === 'install') return 'info'
if (action === 'upgrade') return 'warning'
if (action === 'offline') return 'error'
return 'info'
}
</script>
<template>
<v-alert
v-if="props.action && props.action !== 'ok' && props.message"
:type="alertType(props.action)"
variant="tonal"
:density="props.compact ? 'compact' : 'default'"
class="mb-0"
>
<div class="d-flex flex-wrap align-center ga-2">
<span class="text-body-2">{{ props.message }}</span>
<v-spacer v-if="!props.compact" />
<div class="d-flex ga-1 flex-shrink-0">
<v-btn
v-if="props.action === 'install'"
size="small"
variant="flat"
color="primary"
:loading="props.loading"
@click="$emit('install')"
>
安装 Agent
</v-btn>
<v-btn
v-if="props.action === 'upgrade'"
size="small"
variant="flat"
color="warning"
:loading="props.loading"
@click="$emit('upgrade')"
>
升级 Agent
</v-btn>
<v-btn
v-if="props.action === 'offline'"
size="small"
variant="text"
@click="$emit('diagnose')"
>
诊断
</v-btn>
</div>
</div>
</v-alert>
</template>
@@ -1,5 +1,15 @@
<template>
<div class="server-inline-detail pa-3 rounded-b-lg">
<ServerAgentHint
class="mb-3"
:action="displayServer.agent_action"
:message="displayServer.agent_action_message"
:loading="props.agentActionLoading"
@install="$emit('install-agent')"
@upgrade="$emit('upgrade-agent')"
@diagnose="$emit('diagnose-agent')"
/>
<div class="d-flex flex-wrap align-center ga-2 mb-3">
<v-chip
:color="statusChipColor(displayServer.status)"
@@ -111,14 +121,19 @@ import type {
} from '@/types/api'
import { formatDateTimeBeijing } from '@/utils/datetime'
import ServerMetricSparklines from '@/components/servers/ServerMetricSparklines.vue'
import ServerAgentHint from '@/components/servers/ServerAgentHint.vue'
const props = defineProps<{
server: ServerApiItem
active?: boolean
agentActionLoading?: boolean
}>()
defineEmits<{
'edit-path': []
'install-agent': []
'upgrade-agent': []
'diagnose-agent': []
}>()
const detail = ref<ServerApiItem | null>(null)
@@ -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()
+53 -1
View File
@@ -236,7 +236,29 @@
</template>
<template #item.agent_version="{ item }">
<span class="text-medium-emphasis">{{ item.agent_version || '—' }}</span>
<div>
<span class="text-medium-emphasis">{{ item.agent_version || '—' }}</span>
<v-chip
v-if="item.agent_action === 'install'"
size="x-small"
color="info"
variant="tonal"
label
class="mt-1"
>
需安装
</v-chip>
<v-chip
v-else-if="item.agent_action === 'upgrade'"
size="x-small"
color="warning"
variant="tonal"
label
class="mt-1"
>
需升级
</v-chip>
</div>
</template>
<template #item.last_heartbeat="{ item }">
@@ -278,7 +300,11 @@
<ServerInlineDetail
:server="item"
:active="expandedMainIds.includes(item.id)"
:agent-action-loading="agentActionLoadingId === item.id"
@edit-path="startEditPath(item)"
@install-agent="installAgentOne(item)"
@upgrade-agent="upgradeAgentOne(item)"
@diagnose-agent="openAgentDiagnose(item)"
/>
</td>
</tr>
@@ -1528,6 +1554,7 @@ function openFiles(item: ServerApiItem) {
const showAgentDiagnose = ref(false)
const agentDiagnoseLoading = ref(false)
const agentDiagnoseLoadingId = ref<number | null>(null)
const agentActionLoadingId = ref<number | null>(null)
const agentDiagnoseResult = ref<AgentDiagnoseResult | null>(null)
const agentDiagnoseTarget = ref<ServerApiItem | null>(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)
}
+5
View File
@@ -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?: {
+24 -6
View File
@@ -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:
+68
View File
@@ -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
+36
View File
@@ -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")