fix(ops): Layer3 health_monitor state dir and Telegram heredocs

Move fail counters under deploy/var/layer3-health, use docker_cmd with
sudo fallback, and refactor alert messages to avoid quote parse errors.

Also fix watch Sparkline rgba gradients, hide empty probe pagination,
and ship pending slot pause/menu UI polish.
This commit is contained in:
Nexus Agent
2026-06-14 04:20:55 +08:00
parent c91cb1cd9e
commit 05b0fde197
11 changed files with 309 additions and 90 deletions
+125 -16
View File
@@ -9,12 +9,44 @@ set -euo pipefail
DEPLOY_DIR="${NEXUS_DEPLOY_DIR:-/opt/nexus}" DEPLOY_DIR="${NEXUS_DEPLOY_DIR:-/opt/nexus}"
NEXUS_SERVICE="${NEXUS_SERVICE:-nexus}" NEXUS_SERVICE="${NEXUS_SERVICE:-nexus}"
FAIL_FILE="/tmp/nexus_health_fail_count" STATE_DIR="${DEPLOY_DIR}/var/layer3-health"
LAST_RESTART_FILE="/tmp/nexus_health_last_restart" FAIL_FILE="${STATE_DIR}/fail_count"
LAST_RESTART_FILE="${STATE_DIR}/last_restart"
# Legacy paths (cron user may have created root-owned files under /tmp)
LEGACY_FAIL_FILE="/tmp/nexus_health_fail_count"
LEGACY_LAST_RESTART_FILE="/tmp/nexus_health_last_restart"
MAX_FAIL=3 MAX_FAIL=3
RESTART_COOLDOWN_SEC="${NEXUS_HEALTH_RESTART_COOLDOWN:-300}" RESTART_COOLDOWN_SEC="${NEXUS_HEALTH_RESTART_COOLDOWN:-300}"
ENV_PROD="${DEPLOY_DIR}/docker/.env.prod" ENV_PROD="${DEPLOY_DIR}/docker/.env.prod"
docker_cmd() {
if docker "$@" 2>/dev/null; then
return 0
fi
if command -v sudo >/dev/null 2>&1; then
sudo docker "$@" 2>/dev/null
else
return 1
fi
}
init_layer3_state() {
mkdir -p "$STATE_DIR"
chmod 755 "$STATE_DIR" 2>/dev/null || true
if [[ ! -f "$FAIL_FILE" && -f "$LEGACY_FAIL_FILE" ]]; then
cp "$LEGACY_FAIL_FILE" "$FAIL_FILE" 2>/dev/null || true
fi
if [[ ! -f "$LAST_RESTART_FILE" && -f "$LEGACY_LAST_RESTART_FILE" ]]; then
cp "$LEGACY_LAST_RESTART_FILE" "$LAST_RESTART_FILE" 2>/dev/null || true
fi
}
write_state_file() {
local path="$1" value="$2"
init_layer3_state
printf '%s\n' "$value" >"$path" 2>/dev/null || return 1
}
resolve_publish_port() { resolve_publish_port() {
local port local port
port="$(grep -E '^NEXUS_PUBLISH_PORT=' "$ENV_PROD" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '\r" ' || true)" port="$(grep -E '^NEXUS_PUBLISH_PORT=' "$ENV_PROD" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '\r" ' || true)"
@@ -25,7 +57,7 @@ PUBLISH_PORT="$(resolve_publish_port)"
HEALTH_URL="http://127.0.0.1:${PUBLISH_PORT}/health" HEALTH_URL="http://127.0.0.1:${PUBLISH_PORT}/health"
resolve_nexus_container() { resolve_nexus_container() {
docker ps --format '{{.Names}}' 2>/dev/null | grep -E 'nexus-prod-nexus|nexus-nexus-1' | head -1 || true docker_cmd ps --format '{{.Names}}' 2>/dev/null | grep -E 'nexus-prod-nexus|nexus-nexus-1' | head -1 || true
} }
read_env_var() { read_env_var() {
@@ -50,7 +82,7 @@ load_telegram_from_db() {
[[ -n "$cname" ]] || return 1 [[ -n "$cname" ]] || return 1
[[ -n "${BOT_TOKEN:-}" && -n "${CHAT_ID:-}" ]] && return 0 [[ -n "${BOT_TOKEN:-}" && -n "${CHAT_ID:-}" ]] && return 0
local out token chat local out token chat
out="$(docker exec "$cname" python3 -c " out="$(docker_cmd exec "$cname" python3 -c "
import asyncio import asyncio
from server.config import settings as s from server.config import settings as s
from server.infrastructure.database.session import AsyncSessionLocal from server.infrastructure.database.session import AsyncSessionLocal
@@ -94,7 +126,7 @@ load_telegram_config() {
NEXUS_TELEGRAM_BOT_TOKEN=*) BOT_TOKEN="${line#NEXUS_TELEGRAM_BOT_TOKEN=}" ;; NEXUS_TELEGRAM_BOT_TOKEN=*) BOT_TOKEN="${line#NEXUS_TELEGRAM_BOT_TOKEN=}" ;;
NEXUS_TELEGRAM_CHAT_ID=*) CHAT_ID="${line#NEXUS_TELEGRAM_CHAT_ID=}" ;; NEXUS_TELEGRAM_CHAT_ID=*) CHAT_ID="${line#NEXUS_TELEGRAM_CHAT_ID=}" ;;
esac esac
done < <(docker exec "$cname" grep -E '^NEXUS_TELEGRAM_' /app/.env 2>/dev/null || true) done < <(docker_cmd exec "$cname" grep -E '^NEXUS_TELEGRAM_' /app/.env 2>/dev/null || true)
[[ -n "$BOT_TOKEN" && -n "$CHAT_ID" ]] && return 0 [[ -n "$BOT_TOKEN" && -n "$CHAT_ID" ]] && return 0
load_telegram_from_db "$cname" || true load_telegram_from_db "$cname" || true
fi fi
@@ -103,7 +135,7 @@ load_telegram_config() {
} }
send_telegram() { send_telegram() {
local msg="${1//\\n/$'\n'}" resp http log="${NEXUS_HEALTH_LOG:-/var/log/nexus_health.log}" local msg="${1}" resp http log="${NEXUS_HEALTH_LOG:-/var/log/nexus_health.log}"
BOT_TOKEN="$(normalize_env_value "${BOT_TOKEN:-}")" BOT_TOKEN="$(normalize_env_value "${BOT_TOKEN:-}")"
CHAT_ID="$(normalize_env_value "${CHAT_ID:-}")" CHAT_ID="$(normalize_env_value "${CHAT_ID:-}")"
[[ -n "$BOT_TOKEN" && -n "$CHAT_ID" ]] || return 1 [[ -n "$BOT_TOKEN" && -n "$CHAT_ID" ]] || return 1
@@ -121,24 +153,25 @@ send_telegram() {
bump_fail_count() { bump_fail_count() {
local fail=0 local fail=0
init_layer3_state
if command -v flock >/dev/null 2>&1; then if command -v flock >/dev/null 2>&1; then
( (
flock -x 9 flock -x 9
fail="$(cat "$FAIL_FILE" 2>/dev/null || echo 0)" fail="$(cat "$FAIL_FILE" 2>/dev/null || echo 0)"
fail=$((fail + 1)) fail=$((fail + 1))
echo "$fail" > "$FAIL_FILE" write_state_file "$FAIL_FILE" "$fail" || true
) 9>"${FAIL_FILE}.lock" ) 9>"${FAIL_FILE}.lock"
fail="$(cat "$FAIL_FILE" 2>/dev/null || echo 0)" fail="$(cat "$FAIL_FILE" 2>/dev/null || echo 0)"
else else
fail="$(cat "$FAIL_FILE" 2>/dev/null || echo 0)" fail="$(cat "$FAIL_FILE" 2>/dev/null || echo 0)"
fail=$((fail + 1)) fail=$((fail + 1))
echo "$fail" > "$FAIL_FILE" write_state_file "$FAIL_FILE" "$fail" || true
fi fi
echo "$fail" echo "$fail"
} }
reset_fail_count() { reset_fail_count() {
echo "0" > "$FAIL_FILE" write_state_file "$FAIL_FILE" "0" || true
} }
restart_cooldown_active() { restart_cooldown_active() {
@@ -151,13 +184,13 @@ restart_cooldown_active() {
} }
mark_restart_attempt() { mark_restart_attempt() {
date +%s > "$LAST_RESTART_FILE" write_state_file "$LAST_RESTART_FILE" "$(date +%s)" || true
} }
restart_nexus() { restart_nexus() {
local cname="$1" via="" local cname="$1" via=""
if [[ -n "$cname" ]]; then if [[ -n "$cname" ]]; then
docker restart "$cname" >/dev/null 2>&1 && via="Docker 容器 ${cname}" docker_cmd restart "$cname" >/dev/null 2>&1 && via="Docker 容器 ${cname}"
elif command -v supervisorctl >/dev/null 2>&1 && supervisorctl status "$NEXUS_SERVICE" >/dev/null 2>&1; then elif command -v supervisorctl >/dev/null 2>&1 && supervisorctl status "$NEXUS_SERVICE" >/dev/null 2>&1; then
supervisorctl restart "$NEXUS_SERVICE" >/dev/null 2>&1 && via="Supervisor ${NEXUS_SERVICE}" supervisorctl restart "$NEXUS_SERVICE" >/dev/null 2>&1 && via="Supervisor ${NEXUS_SERVICE}"
else else
@@ -166,8 +199,84 @@ restart_nexus() {
echo "$via" echo "$via"
} }
telegram_health_fail_cooldown() {
local body
body="$(cat <<EOF
🔴 <b>【Layer 3 宿主机巡检】健康检查连续失败(冷却期内)</b>
宿主机:${HOST_LABEL}
检测地址:${HEALTH_URL}
连续失败:已达 ${MAX_FAIL} 次
Docker 容器:${NEXUS_CONTAINER:-未识别}
具体情况:/health 仍不可达,但距上次自动重启不足 ${RESTART_COOLDOWN_SEC} 秒,本次跳过重启以免频繁抖动
建议操作:查看 docker logs ${NEXUS_CONTAINER:-nexus} 与 ${NEXUS_HEALTH_LOG:-/var/log/nexus_health.log}
时间:${TS_LABEL}
EOF
)"
send_telegram "$body" || true
}
telegram_health_fail_restart() {
local body
body="$(cat <<EOF
🔴 <b>【Layer 3 宿主机巡检】Nexus 连续 ${MAX_FAIL} 次无响应</b>
宿主机:${HOST_LABEL}
检测地址:${HEALTH_URL}
连续失败:${FAIL} 次(阈值 ${MAX_FAIL} 次)
Docker 容器:${NEXUS_CONTAINER:-未识别}
具体情况:宿主机 cron 无法访问 API 健康端点,平台可能已宕机或端口异常
正在执行:自动重启 Nexus 服务…
时间:${TS_LABEL}
EOF
)"
send_telegram "$body" || true
}
telegram_health_recovered() {
local via="$1" body
body="$(cat <<EOF
🟢 <b>【Layer 3 宿主机巡检】Nexus 已自动恢复</b>
宿主机:${HOST_LABEL}
检测地址:${HEALTH_URL}
恢复方式:${via}
具体情况:自动重启后 /health 检测通过,服务已恢复在线
时间:${TS_LABEL}
EOF
)"
send_telegram "$body" || true
}
telegram_health_still_down() {
local via="$1" body
body="$(cat <<EOF
🔴 <b>【Layer 3 宿主机巡检】自动重启后仍未恢复</b>
宿主机:${HOST_LABEL}
检测地址:${HEALTH_URL}
已尝试:${via}
具体情况:重启完成后 /health 仍失败,可能存在数据库/Redis 故障或配置错误
建议操作:立即 SSH 登录排查 docker logs、MySQL、Redis,需人工介入
时间:${TS_LABEL}
EOF
)"
send_telegram "$body" || true
}
telegram_health_restart_failed() {
local body
body="$(cat <<EOF
🔴 <b>【Layer 3 宿主机巡检】无法执行自动重启</b>
宿主机:${HOST_LABEL}
检测地址:${HEALTH_URL}
具体情况:未找到可重启的 Docker 容器或 Supervisor 服务(${NEXUS_SERVICE}
建议操作:确认 nexus 容器名称与 docker ps 输出,手动执行 docker restart 或 nx update
时间:${TS_LABEL}
EOF
)"
send_telegram "$body" || true
}
NEXUS_CONTAINER="$(resolve_nexus_container)" NEXUS_CONTAINER="$(resolve_nexus_container)"
load_telegram_config "$NEXUS_CONTAINER" load_telegram_config "$NEXUS_CONTAINER"
init_layer3_state
HOST_LABEL="$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo unknown)" HOST_LABEL="$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo unknown)"
TS_LABEL="$(date '+%Y-%m-%d %H:%M:%S %Z' 2>/dev/null || date)" TS_LABEL="$(date '+%Y-%m-%d %H:%M:%S %Z' 2>/dev/null || date)"
@@ -182,21 +291,21 @@ if [[ "$FAIL" -lt "$MAX_FAIL" ]]; then
fi fi
if restart_cooldown_active; then if restart_cooldown_active; then
send_telegram "🔴 <b>【Layer 3 宿主机巡检】健康检查连续失败(冷却期内)</b>\n宿主机:${HOST_LABEL}\n检测地址:${HEALTH_URL}\n连续失败:已达 ${MAX_FAIL} 次\nDocker 容器:${NEXUS_CONTAINER:-未识别}\n具体情况:/health 仍不可达,但距上次自动重启不足 ${RESTART_COOLDOWN_SEC} 秒,本次跳过重启以免频繁抖动\n建议操作:查看 docker logs ${NEXUS_CONTAINER:-nexus} 与 /var/log/nexus_health.log\n时间:${TS_LABEL}" || true telegram_health_fail_cooldown
exit 0 exit 0
fi fi
send_telegram "🔴 <b>【Layer 3 宿主机巡检】Nexus 连续 ${MAX_FAIL} 次无响应</b>\n宿主机:${HOST_LABEL}\n检测地址:${HEALTH_URL}\n连续失败:${FAIL} 次(阈值 ${MAX_FAIL} 次)\nDocker 容器:${NEXUS_CONTAINER:-未识别}\n具体情况:宿主机 cron 无法访问 API 健康端点,平台可能已宕机或端口异常\n正在执行:自动重启 Nexus 服务…\n时间:${TS_LABEL}" || true telegram_health_fail_restart
mark_restart_attempt mark_restart_attempt
if VIA="$(restart_nexus "$NEXUS_CONTAINER")"; then if VIA="$(restart_nexus "$NEXUS_CONTAINER")"; then
sleep 5 sleep 5
if curl -sf --max-time 5 "$HEALTH_URL" > /dev/null 2>&1; then if curl -sf --max-time 5 "$HEALTH_URL" > /dev/null 2>&1; then
send_telegram "🟢 <b>【Layer 3 宿主机巡检】Nexus 已自动恢复</b>\n宿主机:${HOST_LABEL}\n检测地址:${HEALTH_URL}\n恢复方式:${VIA}\n具体情况:自动重启后 /health 检测通过,服务已恢复在线\n时间:${TS_LABEL}" || true telegram_health_recovered "$VIA"
reset_fail_count reset_fail_count
else else
send_telegram "🔴 <b>【Layer 3 宿主机巡检】自动重启后仍未恢复</b>\n宿主机:${HOST_LABEL}\n检测地址:${HEALTH_URL}\n已尝试:${VIA}\n具体情况:重启完成后 /health 仍失败,可能存在数据库/Redis 故障或配置错误\n建议操作:立即 SSH 登录排查 docker logs、MySQL、Redis,需人工介入\n时间:${TS_LABEL}" || true telegram_health_still_down "$VIA"
fi fi
else else
send_telegram "🔴 <b>【Layer 3 宿主机巡检】无法执行自动重启</b>\n宿主机:${HOST_LABEL}\n检测地址:${HEALTH_URL}\n具体情况:未找到可重启的 Docker 容器或 Supervisor 服务(${NEXUS_SERVICE})\n建议操作:确认 nexus 容器名称与 docker ps 输出,手动执行 docker restart 或 nx update\n时间:${TS_LABEL}" || true telegram_health_restart_failed
fi fi
@@ -0,0 +1,37 @@
# 审计 — Layer3 health_monitor P2 + 监测 UI P3
**Changelog**: `docs/changelog/2026-06-14-layer3-health-monitor-p2-p3-ui.md`
## 审计范围
| 文件 | 变更 | 状态 |
|------|------|------|
| `deploy/health_monitor.sh` | 状态目录、docker_cmd、heredoc Telegram | ☑ |
| `tests/test_ops_patrol_sync.py` | bash -n + 路径断言 | ☑ |
| `frontend/src/utils/echartsColor.ts` | 新建 | ☑ |
| `WatchSparkline.vue` | rgba 渐变 | ☑ |
| `WatchProbeRecordsTable.vue` | 空表隐藏分页 | ☑ |
| `WatchSlotCard.vue` | ⋮ 菜单、暂停态精简(同批部署) | ☑ |
| `WatchMetricRing.vue` | `dimmed` 占位(同批) | ☑ |
| `server/background/watch_probe_runner.py` | 删除未使用 import | ☑ |
## Step 3
| H | 规则 | 结论 |
|---|------|------|
| H1 | sudo docker | SAFE — 与 `nexus-1panel.sh` 同模式,失败则跳过重启 |
| H2 | 状态目录权限 | SAFE — 在 DEPLOY_DIR 下,cron 用户可写 |
| H3 | Telegram HTML | SAFE — heredoc 真实换行,`--data-urlencode` 不变 |
## Closure
| H | 判定 |
|---|------|
| H1H3 | SAFE |
## DoD
- [x] changelog
- [x] `bash -n` + pytest ops_patrol
- [x] vite build
- [ ] 生产 L3 日志 5min 无新错误
@@ -6,7 +6,7 @@
| 文件 | 变更 | 状态 | | 文件 | 变更 | 状态 |
|------|------|------| |------|------|------|
| `WatchSlotCard.vue` | 暂停横幅/圆环占位/恢复按钮;顶栏操作区间距 | ☑ | | `WatchSlotCard.vue` | 顶栏 ⋮ 菜单(暂停/恢复/移除);底栏暂停;暂停态 UI | ☑ |
| `WatchMetricRing.vue` | `dimmed` 占位态 | ☑ | | `WatchMetricRing.vue` | `dimmed` 占位态 | ☑ |
## Step 3 规则扫描 ## Step 3 规则扫描
@@ -14,7 +14,7 @@
| H | 规则 | 结论 | | H | 规则 | 结论 |
|---|------|------| |---|------|------|
| H1 | 无新 API | SAFE — 仍 `emit('monitoring'/'remove')` | | H1 | 无新 API | SAFE — 仍 `emit('monitoring'/'remove')` |
| H2 | 误触移除 | SAFE — 开关与 ✕ 间距加大 | | H2 | 误触移除/暂停 | SAFE — 顶栏无开关;暂停/移除在菜单内;底栏「暂停」为文字链 |
| H3 | 无障碍 | SAFE — `aria-label` on 开关与移除 | | H3 | 无障碍 | SAFE — `aria-label` on 开关与移除 |
## Closure ## Closure
@@ -0,0 +1,35 @@
# 2026-06-14 — Layer3 巡检脚本与监测 UI 小修
## 摘要
修复 `health_monitor.sh` 状态文件权限与 Telegram 长串引号问题;Sparkline 渐变兼容 rgb 主题;探针表无数据时隐藏分页。
## 动机
巡检 P2cron 日志大量 `unexpected EOF``/tmp` 状态文件 Permission deniedLayer3 自愈可能失效。P3Sparkline `${color}33` 在非 hex 主题失效;探针表 total=0 仍显示分页。
## 变更
- `deploy/health_monitor.sh`:状态迁至 `${DEPLOY_DIR}/var/layer3-health``docker_cmd` 回退 sudoTelegram 文案改 heredoc
- `frontend/src/utils/echartsColor.ts` + `WatchSparkline.vue``colorWithAlpha`
- `WatchProbeRecordsTable.vue``total>0` 才显示分页
- `WatchSlotCard.vue` / `WatchMetricRing.vue`:监测槽 UI(菜单、暂停态,同批)
- `tests/test_ops_patrol_sync.py``bash -n` 与状态路径断言
## 涉及文件
见上。
## 迁移 / 重启
部署后 cron 自动使用新脚本;首次运行创建 `var/layer3-health/`。可选清理 `/tmp/nexus_health_*`(脚本会尝试迁移 fail_count)。
## 验证
```bash
bash -n deploy/health_monitor.sh
pytest tests/test_ops_patrol_sync.py -q
cd frontend && npx vite build
```
生产:观察 5 分钟 `/var/log/nexus_health.log` 无新 EOF/Permission denied。
@@ -2,7 +2,7 @@
## 摘要 ## 摘要
关闭实时监测后,去掉大号居中开关与虚线框;改为顶栏统一开关、浅色提示条、半透明圆环占位与「恢复实时监测」按钮,与开启态卡片结构一致 暂停态仅保留顶栏(服务器名 + 槽位 + ⋮ 菜单)与居中「恢复实时监测」;隐藏时长选择、状态横幅、圆环占位与底栏
## 动机 ## 动机
@@ -10,7 +10,7 @@
## 变更 ## 变更
- `WatchSlotCard.vue`:暂停横幅 + 四圆环占位 + 恢复按钮;底栏文案精简 - `WatchSlotCard.vue`顶栏 `⋮` 菜单替代开关与 ✕;底栏「暂停」;暂停横幅 + 四圆环占位 + 恢复按钮
- `WatchMetricRing.vue`:新增 `dimmed` 占位态(灰色细环、不可交互) - `WatchMetricRing.vue`:新增 `dimmed` 占位态(灰色细环、不可交互)
## 涉及文件 ## 涉及文件
@@ -24,4 +24,4 @@
## 验证 ## 验证
`cd frontend && npx vite build`开启监测后顶栏开关与 ✕ 间距适中、不易误触 `cd frontend && npx vite build`顶栏仅槽位芯片与 ⋮ 菜单;暂停/移除需点开菜单;底栏可点「暂停」
@@ -51,6 +51,7 @@ const headers = [
] ]
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / 50))) const pageCount = computed(() => Math.max(1, Math.ceil(total.value / 50)))
const showPagination = computed(() => total.value > 0)
async function load() { async function load() {
loading.value = true loading.value = true
@@ -155,7 +156,7 @@ watch(
</v-data-table> </v-data-table>
</v-sheet> </v-sheet>
<div class="d-flex justify-center align-center mt-4"> <div v-if="showPagination" class="d-flex justify-center align-center mt-4">
<v-pagination <v-pagination
v-model="page" v-model="page"
:length="pageCount" :length="pageCount"
+61 -65
View File
@@ -85,28 +85,44 @@ const showSwap = computed(() => (props.slot.metrics?.swap_total_gb ?? 0) > 0)
<v-chip size="x-small" variant="tonal" :color="monitoringOn ? 'info' : 'warning'" label> <v-chip size="x-small" variant="tonal" :color="monitoringOn ? 'info' : 'warning'" label>
{{ slot.slot_index + 1 }} {{ slot.slot_index + 1 }}
</v-chip> </v-chip>
<v-switch <v-menu location="bottom end" :close-on-content-click="true">
:model-value="monitoringOn" <template #activator="{ props: menuProps }">
density="compact" <v-btn
hide-details v-bind="menuProps"
color="primary" icon="mdi-dots-vertical"
class="watch-slot-switch" size="x-small"
:aria-label="monitoringOn ? '关闭实时监测' : '开启实时监测'" variant="text"
@click.stop class="watch-slot-menu-btn"
@update:model-value="emit('monitoring', slot.slot_index, $event ?? false)" aria-label="槽位操作"
/> @click.stop
<v-btn />
icon="mdi-close" </template>
size="x-small" <v-list density="compact" class="watch-slot-menu-list" min-width="168">
variant="text" <v-list-item
class="watch-slot-remove-btn" v-if="monitoringOn"
aria-label="移除此槽" prepend-icon="mdi-pause-circle-outline"
@click="emit('remove', slot.slot_index)" title="暂停实时监测"
/> @click="emit('monitoring', slot.slot_index, false)"
/>
<v-list-item
v-else
prepend-icon="mdi-play-circle-outline"
title="恢复实时监测"
@click="emit('monitoring', slot.slot_index, true)"
/>
<v-divider class="my-1" />
<v-list-item
prepend-icon="mdi-close-circle-outline"
title="移除此槽"
base-color="error"
@click="emit('remove', slot.slot_index)"
/>
</v-list>
</v-menu>
</div> </div>
</div> </div>
<div class="watch-ttl-row mb-2" @click.stop> <div v-if="monitoringOn" class="watch-ttl-row mb-2" @click.stop>
<span class="text-caption text-medium-emphasis watch-ttl-label">时长</span> <span class="text-caption text-medium-emphasis watch-ttl-label">时长</span>
<div class="watch-ttl-chips d-flex flex-wrap ga-1"> <div class="watch-ttl-chips d-flex flex-wrap ga-1">
<v-chip <v-chip
@@ -129,21 +145,8 @@ const showSwap = computed(() => (props.slot.metrics?.swap_total_gb ?? 0) > 0)
class="watch-slot-paused-body flex-grow-1" class="watch-slot-paused-body flex-grow-1"
@click.stop @click.stop
> >
<div class="watch-slot-paused-banner d-flex align-center ga-2 mb-3">
<v-icon icon="mdi-radar-off" size="18" class="text-medium-emphasis" />
<span class="text-body-2 font-weight-medium">实时监测已暂停</span>
<v-spacer />
<v-chip size="x-small" variant="tonal" color="warning" label>暂停</v-chip>
</div>
<div class="watch-metric-rings watch-metric-rings--paused d-flex justify-space-between mb-3">
<WatchMetricRing label="CPU" accent="cpu" :size="56" dimmed />
<WatchMetricRing label="内存" accent="mem" :size="56" dimmed />
<WatchMetricRing label="硬盘" accent="disk" :size="56" dimmed />
<WatchMetricRing label="负载" accent="load" :size="56" dimmed />
</div>
<div class="watch-slot-paused-cta text-center"> <div class="watch-slot-paused-cta text-center">
<v-icon icon="mdi-radar-off" size="40" class="text-medium-emphasis mb-3 watch-slot-paused-icon" />
<v-btn <v-btn
size="small" size="small"
variant="tonal" variant="tonal"
@@ -153,7 +156,7 @@ const showSwap = computed(() => (props.slot.metrics?.swap_total_gb ?? 0) > 0)
> >
恢复实时监测 恢复实时监测
</v-btn> </v-btn>
<div class="text-caption text-medium-emphasis mt-2 px-2"> <div class="text-caption text-medium-emphasis mt-3 px-4">
槽位与子机绑定保留 · 关闭期间由 Agent 60 秒上报 槽位与子机绑定保留 · 关闭期间由 Agent 60 秒上报
</div> </div>
</div> </div>
@@ -268,6 +271,15 @@ const showSwap = computed(() => (props.slot.metrics?.swap_total_gb ?? 0) > 0)
剩余 {{ formatRemaining(slot.remaining_sec) }} · {{ watchTtlShortLabel(slotTtlMinutes) }} 剩余 {{ formatRemaining(slot.remaining_sec) }} · {{ watchTtlShortLabel(slotTtlMinutes) }}
</span> </span>
<div class="d-flex ga-1"> <div class="d-flex ga-1">
<v-btn
v-if="monitoringOn"
size="x-small"
variant="text"
class="watch-slot-pause-btn"
@click.stop="emit('monitoring', slot.slot_index, false)"
>
暂停
</v-btn>
<v-btn size="x-small" variant="text" @click.stop="emit('processes', slot)">进程</v-btn> <v-btn size="x-small" variant="text" @click.stop="emit('processes', slot)">进程</v-btn>
<v-btn size="x-small" variant="text" @click.stop="emit('history', slot)">历史</v-btn> <v-btn size="x-small" variant="text" @click.stop="emit('history', slot)">历史</v-btn>
</div> </div>
@@ -282,17 +294,6 @@ const showSwap = computed(() => (props.slot.metrics?.swap_total_gb ?? 0) > 0)
</div> </div>
</div> </div>
</template> </template>
<div
v-if="!monitoringOn"
class="d-flex align-center justify-space-between mt-auto pt-2"
@click.stop
>
<span class="text-caption text-medium-emphasis">
{{ watchTtlShortLabel(slotTtlMinutes) }} · 槽位保留
</span>
<v-btn size="x-small" variant="text" @click.stop="emit('history', slot)">历史</v-btn>
</div>
</v-card> </v-card>
<v-card <v-card
@@ -347,6 +348,7 @@ const showSwap = computed(() => (props.slot.metrics?.swap_total_gb ?? 0) > 0)
} }
.watch-slot-card--paused { .watch-slot-card--paused {
cursor: default; cursor: default;
min-height: 200px;
} }
.watch-slot-card--paused:hover { .watch-slot-card--paused:hover {
border-color: rgba(var(--v-border-color), var(--v-border-opacity)) !important; border-color: rgba(var(--v-border-color), var(--v-border-opacity)) !important;
@@ -354,37 +356,31 @@ const showSwap = computed(() => (props.slot.metrics?.swap_total_gb ?? 0) > 0)
.watch-slot-paused-body { .watch-slot-paused-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center;
justify-content: center;
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 140px;
padding-top: 4px;
}
.watch-slot-paused-banner {
padding: 6px 10px;
border-radius: 10px;
background: rgba(var(--v-theme-warning), 0.08);
border: 1px solid rgba(var(--v-theme-warning), 0.18);
} }
.watch-slot-paused-cta { .watch-slot-paused-cta {
margin-top: auto; width: 100%;
padding-bottom: 4px; padding: 8px 0 12px;
} }
.watch-metric-rings--paused { .watch-slot-paused-icon {
opacity: 0.92; opacity: 0.45;
} }
.watch-slot-header-actions { .watch-slot-header-actions {
gap: 6px; gap: 4px;
margin-left: 8px; margin-left: 8px;
} }
.watch-slot-switch { .watch-slot-menu-btn {
flex: 0 0 auto; flex: 0 0 auto;
transform: scale(0.85); opacity: 0.72;
margin-right: 4px;
} }
.watch-slot-switch :deep(.v-selection-control) { .watch-slot-menu-btn:hover {
min-height: 32px; opacity: 1;
} }
.watch-slot-remove-btn { .watch-slot-pause-btn {
margin-left: 2px; color: rgba(var(--v-theme-warning), 0.95);
} }
.watch-ttl-row { .watch-ttl-row {
display: flex; display: flex;
@@ -8,6 +8,7 @@ import { GridComponent, TooltipComponent } from 'echarts/components'
import VChart from 'vue-echarts' import VChart from 'vue-echarts'
import type { WatchSparkPoint } from '@/composables/useWatchPins' import type { WatchSparkPoint } from '@/composables/useWatchPins'
import { formatDateTimeBeijing } from '@/utils/datetime' import { formatDateTimeBeijing } from '@/utils/datetime'
import { colorWithAlpha } from '@/utils/echartsColor'
use([CanvasRenderer, LineChart, GridComponent, TooltipComponent]) use([CanvasRenderer, LineChart, GridComponent, TooltipComponent])
@@ -59,8 +60,8 @@ const chartOption = computed(() => {
x2: 0, x2: 0,
y2: 1, y2: 1,
colorStops: [ colorStops: [
{ offset: 0, color: `${color}33` }, { offset: 0, color: colorWithAlpha(color, 0.2) },
{ offset: 1, color: `${color}05` }, { offset: 1, color: colorWithAlpha(color, 0.02) },
], ],
}, },
}, },
+27
View File
@@ -0,0 +1,27 @@
/** ECharts 渐变用:将 Vuetify 主题色转为带透明度的 rgba */
export function colorWithAlpha(color: string, alpha: number): string {
const a = Math.min(1, Math.max(0, alpha))
const raw = color.trim()
if (raw.startsWith('#')) {
let h = raw.slice(1)
if (h.length === 3) {
h = h.split('').map((c) => c + c).join('')
}
if (h.length >= 6) {
const r = Number.parseInt(h.slice(0, 2), 16)
const g = Number.parseInt(h.slice(2, 4), 16)
const b = Number.parseInt(h.slice(4, 6), 16)
if (!Number.isNaN(r) && !Number.isNaN(g) && !Number.isNaN(b)) {
return `rgba(${r},${g},${b},${a})`
}
}
}
const rgb = raw.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i)
if (rgb) {
return `rgba(${rgb[1]},${rgb[2]},${rgb[3]},${a})`
}
return raw
}
-1
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
import time
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
+14
View File
@@ -1,5 +1,6 @@
"""Tests for Layer-3 ops patrol Telegram env sync.""" """Tests for Layer-3 ops patrol Telegram env sync."""
import subprocess
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -7,6 +8,19 @@ import pytest
from server.infrastructure.env_file import escape_env_value, read_env_var, upsert_env_vars from server.infrastructure.env_file import escape_env_value, read_env_var, upsert_env_vars
from server.application.services import ops_patrol_sync from server.application.services import ops_patrol_sync
REPO_ROOT = Path(__file__).resolve().parents[1]
HEALTH_MONITOR = REPO_ROOT / "deploy" / "health_monitor.sh"
def test_health_monitor_shell_syntax():
subprocess.run(["bash", "-n", str(HEALTH_MONITOR)], check=True)
def test_health_monitor_uses_deploy_var_state_dir():
text = HEALTH_MONITOR.read_text(encoding="utf-8")
assert 'STATE_DIR="${DEPLOY_DIR}/var/layer3-health"' in text
assert 'FAIL_FILE="${STATE_DIR}/fail_count"' in text
def test_escape_env_value_quotes_special_chars(): def test_escape_env_value_quotes_special_chars():
assert escape_env_value('abc"def') == '"abc\\"def"' assert escape_env_value('abc"def') == '"abc\\"def"'