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:
+125
-16
@@ -9,12 +9,44 @@ set -euo pipefail
|
||||
|
||||
DEPLOY_DIR="${NEXUS_DEPLOY_DIR:-/opt/nexus}"
|
||||
NEXUS_SERVICE="${NEXUS_SERVICE:-nexus}"
|
||||
FAIL_FILE="/tmp/nexus_health_fail_count"
|
||||
LAST_RESTART_FILE="/tmp/nexus_health_last_restart"
|
||||
STATE_DIR="${DEPLOY_DIR}/var/layer3-health"
|
||||
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
|
||||
RESTART_COOLDOWN_SEC="${NEXUS_HEALTH_RESTART_COOLDOWN:-300}"
|
||||
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() {
|
||||
local port
|
||||
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"
|
||||
|
||||
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() {
|
||||
@@ -50,7 +82,7 @@ load_telegram_from_db() {
|
||||
[[ -n "$cname" ]] || return 1
|
||||
[[ -n "${BOT_TOKEN:-}" && -n "${CHAT_ID:-}" ]] && return 0
|
||||
local out token chat
|
||||
out="$(docker exec "$cname" python3 -c "
|
||||
out="$(docker_cmd exec "$cname" python3 -c "
|
||||
import asyncio
|
||||
from server.config import settings as s
|
||||
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_CHAT_ID=*) CHAT_ID="${line#NEXUS_TELEGRAM_CHAT_ID=}" ;;
|
||||
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
|
||||
load_telegram_from_db "$cname" || true
|
||||
fi
|
||||
@@ -103,7 +135,7 @@ load_telegram_config() {
|
||||
}
|
||||
|
||||
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:-}")"
|
||||
CHAT_ID="$(normalize_env_value "${CHAT_ID:-}")"
|
||||
[[ -n "$BOT_TOKEN" && -n "$CHAT_ID" ]] || return 1
|
||||
@@ -121,24 +153,25 @@ send_telegram() {
|
||||
|
||||
bump_fail_count() {
|
||||
local fail=0
|
||||
init_layer3_state
|
||||
if command -v flock >/dev/null 2>&1; then
|
||||
(
|
||||
flock -x 9
|
||||
fail="$(cat "$FAIL_FILE" 2>/dev/null || echo 0)"
|
||||
fail=$((fail + 1))
|
||||
echo "$fail" > "$FAIL_FILE"
|
||||
write_state_file "$FAIL_FILE" "$fail" || true
|
||||
) 9>"${FAIL_FILE}.lock"
|
||||
fail="$(cat "$FAIL_FILE" 2>/dev/null || echo 0)"
|
||||
else
|
||||
fail="$(cat "$FAIL_FILE" 2>/dev/null || echo 0)"
|
||||
fail=$((fail + 1))
|
||||
echo "$fail" > "$FAIL_FILE"
|
||||
write_state_file "$FAIL_FILE" "$fail" || true
|
||||
fi
|
||||
echo "$fail"
|
||||
}
|
||||
|
||||
reset_fail_count() {
|
||||
echo "0" > "$FAIL_FILE"
|
||||
write_state_file "$FAIL_FILE" "0" || true
|
||||
}
|
||||
|
||||
restart_cooldown_active() {
|
||||
@@ -151,13 +184,13 @@ restart_cooldown_active() {
|
||||
}
|
||||
|
||||
mark_restart_attempt() {
|
||||
date +%s > "$LAST_RESTART_FILE"
|
||||
write_state_file "$LAST_RESTART_FILE" "$(date +%s)" || true
|
||||
}
|
||||
|
||||
restart_nexus() {
|
||||
local cname="$1" via=""
|
||||
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
|
||||
supervisorctl restart "$NEXUS_SERVICE" >/dev/null 2>&1 && via="Supervisor ${NEXUS_SERVICE}"
|
||||
else
|
||||
@@ -166,8 +199,84 @@ restart_nexus() {
|
||||
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)"
|
||||
load_telegram_config "$NEXUS_CONTAINER"
|
||||
init_layer3_state
|
||||
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)"
|
||||
|
||||
@@ -182,21 +291,21 @@ if [[ "$FAIL" -lt "$MAX_FAIL" ]]; then
|
||||
fi
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
if VIA="$(restart_nexus "$NEXUS_CONTAINER")"; then
|
||||
sleep 5
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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 | 判定 |
|
||||
|---|------|
|
||||
| H1–H3 | 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` 占位态 | ☑ |
|
||||
|
||||
## Step 3 规则扫描
|
||||
@@ -14,7 +14,7 @@
|
||||
| H | 规则 | 结论 |
|
||||
|---|------|------|
|
||||
| H1 | 无新 API | SAFE — 仍 `emit('monitoring'/'remove')` |
|
||||
| H2 | 误触移除 | SAFE — 开关与 ✕ 间距加大 |
|
||||
| H2 | 误触移除/暂停 | SAFE — 顶栏无开关;暂停/移除在菜单内;底栏「暂停」为文字链 |
|
||||
| H3 | 无障碍 | SAFE — `aria-label` on 开关与移除 |
|
||||
|
||||
## Closure
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# 2026-06-14 — Layer3 巡检脚本与监测 UI 小修
|
||||
|
||||
## 摘要
|
||||
|
||||
修复 `health_monitor.sh` 状态文件权限与 Telegram 长串引号问题;Sparkline 渐变兼容 rgb 主题;探针表无数据时隐藏分页。
|
||||
|
||||
## 动机
|
||||
|
||||
巡检 P2:cron 日志大量 `unexpected EOF` 与 `/tmp` 状态文件 Permission denied,Layer3 自愈可能失效。P3:Sparkline `${color}33` 在非 hex 主题失效;探针表 total=0 仍显示分页。
|
||||
|
||||
## 变更
|
||||
|
||||
- `deploy/health_monitor.sh`:状态迁至 `${DEPLOY_DIR}/var/layer3-health`;`docker_cmd` 回退 sudo;Telegram 文案改 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` 占位态(灰色细环、不可交互)
|
||||
|
||||
## 涉及文件
|
||||
@@ -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 showPagination = computed(() => total.value > 0)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
@@ -155,7 +156,7 @@ watch(
|
||||
</v-data-table>
|
||||
</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-model="page"
|
||||
:length="pageCount"
|
||||
|
||||
@@ -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>
|
||||
槽 {{ slot.slot_index + 1 }}
|
||||
</v-chip>
|
||||
<v-switch
|
||||
:model-value="monitoringOn"
|
||||
density="compact"
|
||||
hide-details
|
||||
color="primary"
|
||||
class="watch-slot-switch"
|
||||
:aria-label="monitoringOn ? '关闭实时监测' : '开启实时监测'"
|
||||
@click.stop
|
||||
@update:model-value="emit('monitoring', slot.slot_index, $event ?? false)"
|
||||
/>
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="watch-slot-remove-btn"
|
||||
aria-label="移除此槽"
|
||||
@click="emit('remove', slot.slot_index)"
|
||||
/>
|
||||
<v-menu location="bottom end" :close-on-content-click="true">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
v-bind="menuProps"
|
||||
icon="mdi-dots-vertical"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="watch-slot-menu-btn"
|
||||
aria-label="槽位操作"
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
<v-list density="compact" class="watch-slot-menu-list" min-width="168">
|
||||
<v-list-item
|
||||
v-if="monitoringOn"
|
||||
prepend-icon="mdi-pause-circle-outline"
|
||||
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 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>
|
||||
<div class="watch-ttl-chips d-flex flex-wrap ga-1">
|
||||
<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"
|
||||
@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">
|
||||
<v-icon icon="mdi-radar-off" size="40" class="text-medium-emphasis mb-3 watch-slot-paused-icon" />
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
@@ -153,7 +156,7 @@ const showSwap = computed(() => (props.slot.metrics?.swap_total_gb ?? 0) > 0)
|
||||
>
|
||||
恢复实时监测
|
||||
</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 秒上报
|
||||
</div>
|
||||
</div>
|
||||
@@ -268,6 +271,15 @@ const showSwap = computed(() => (props.slot.metrics?.swap_total_gb ?? 0) > 0)
|
||||
剩余 {{ formatRemaining(slot.remaining_sec) }} · {{ watchTtlShortLabel(slotTtlMinutes) }}
|
||||
</span>
|
||||
<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('history', slot)">历史</v-btn>
|
||||
</div>
|
||||
@@ -282,17 +294,6 @@ const showSwap = computed(() => (props.slot.metrics?.swap_total_gb ?? 0) > 0)
|
||||
</div>
|
||||
</div>
|
||||
</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
|
||||
@@ -347,6 +348,7 @@ const showSwap = computed(() => (props.slot.metrics?.swap_total_gb ?? 0) > 0)
|
||||
}
|
||||
.watch-slot-card--paused {
|
||||
cursor: default;
|
||||
min-height: 200px;
|
||||
}
|
||||
.watch-slot-card--paused:hover {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
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);
|
||||
min-height: 140px;
|
||||
}
|
||||
.watch-slot-paused-cta {
|
||||
margin-top: auto;
|
||||
padding-bottom: 4px;
|
||||
width: 100%;
|
||||
padding: 8px 0 12px;
|
||||
}
|
||||
.watch-metric-rings--paused {
|
||||
opacity: 0.92;
|
||||
.watch-slot-paused-icon {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.watch-slot-header-actions {
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.watch-slot-switch {
|
||||
.watch-slot-menu-btn {
|
||||
flex: 0 0 auto;
|
||||
transform: scale(0.85);
|
||||
margin-right: 4px;
|
||||
opacity: 0.72;
|
||||
}
|
||||
.watch-slot-switch :deep(.v-selection-control) {
|
||||
min-height: 32px;
|
||||
.watch-slot-menu-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.watch-slot-remove-btn {
|
||||
margin-left: 2px;
|
||||
.watch-slot-pause-btn {
|
||||
color: rgba(var(--v-theme-warning), 0.95);
|
||||
}
|
||||
.watch-ttl-row {
|
||||
display: flex;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { GridComponent, TooltipComponent } from 'echarts/components'
|
||||
import VChart from 'vue-echarts'
|
||||
import type { WatchSparkPoint } from '@/composables/useWatchPins'
|
||||
import { formatDateTimeBeijing } from '@/utils/datetime'
|
||||
import { colorWithAlpha } from '@/utils/echartsColor'
|
||||
|
||||
use([CanvasRenderer, LineChart, GridComponent, TooltipComponent])
|
||||
|
||||
@@ -59,8 +60,8 @@ const chartOption = computed(() => {
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: `${color}33` },
|
||||
{ offset: 1, color: `${color}05` },
|
||||
{ offset: 0, color: colorWithAlpha(color, 0.2) },
|
||||
{ offset: 1, color: colorWithAlpha(color, 0.02) },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for Layer-3 ops patrol Telegram env sync."""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
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.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():
|
||||
assert escape_env_value('abc"def') == '"abc\\"def"'
|
||||
|
||||
Reference in New Issue
Block a user