feat(servers): 新服务器 Agent 安装/升级引导提示
修正仅有 API Key 误判为已装 Agent;列表与展开详情提示安装/升级并提供一键操作。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user