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}"
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 | 判定 |
|---|------|
| 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` 占位态 | ☑ |
## 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 主题;探针表无数据时隐藏分页。
## 动机
巡检 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` 占位态(灰色细环、不可交互)
## 涉及文件
@@ -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"
+56 -60
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>
{{ 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-menu location="bottom end" :close-on-content-click="true">
<template #activator="{ props: menuProps }">
<v-btn
icon="mdi-close"
v-bind="menuProps"
icon="mdi-dots-vertical"
size="x-small"
variant="text"
class="watch-slot-remove-btn"
aria-label="移除此"
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) },
],
},
},
+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 json
import logging
import time
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import Any
+14
View File
@@ -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"'