Fix recovery Telegram dedup and unify Beijing time display.

Store alert metrics without per-value Redis members to stop duplicate recovery pushes; format all operator-facing timestamps in Asia/Shanghai across Web UI and Telegram.
This commit is contained in:
Nexus Agent
2026-06-08 23:18:56 +08:00
parent 972456a246
commit abeab9d3e4
25 changed files with 433 additions and 51 deletions
@@ -0,0 +1,111 @@
# 审计 — 北京时间展示统一 + 资源恢复 Telegram 去重(2026-06-08
**Changelog**:
- `docs/changelog/2026-06-08-beijing-time-display-unified.md`
- `docs/changelog/2026-06-08-telegram-recovery-dedup-timezone.md`
**触发原因**: Bug 修复(恢复通知秒级重复、Telegram/UI 时间非北京时间)+ 展示层时区统一
## 审计范围
| 文件 | 行数 | 状态 |
|------|------|------|
| `server/utils/display_time.py` | 27 | ☑ 已审 |
| `server/infrastructure/telegram/__init__.py` | ~320 | ☑ 已审(`_now_cn` 段) |
| `server/background/ip_allowlist_refresh.py` | ~100 | ☑ 已审(刷新时间) |
| `server/api/settings.py` | ~1570 | ☑ 已审(Telegram 测试消息) |
| `server/api/agent.py` | ~385 | ☑ 已审(恢复去重) |
| `frontend/src/utils/datetime.ts` | 85 | ☑ 已审 |
| `frontend/src/utils/status.ts` | 36 | ☑ 已审 |
| `frontend/src/composables/push/labels.ts` | 68 | ☑ 已审 |
| `frontend/src/composables/useWebSocket.ts` | ~190 | ☑ 已审 |
| `frontend/src/pages/AuditPage.vue` | ~196 | ☑ 已审 |
| `frontend/src/pages/AlertsPage.vue` | ~190 | ☑ 已审 |
| `frontend/src/pages/CommandsPage.vue` | ~393 | ☑ 已审 |
| `frontend/src/pages/RetriesPage.vue` | ~180 | ☑ 已审 |
| `frontend/src/pages/SchedulesPage.vue` | ~533 | ☑ 已审 |
| `frontend/src/pages/ScriptsPage.vue` | ~1315 | ☑ 已审(时间列) |
| `frontend/src/pages/ScriptRunsPage.vue` | ~681 | ☑ 已审 |
| `frontend/src/pages/ServersPage.vue` | ~1400 | ☑ 已审(导出文件名) |
| `frontend/src/components/credentials/CredentialsManager.vue` | ~436 | ☑ 已审 |
| `frontend/src/components/servers/ServerInlineDetail.vue` | ~158 | ☑ 已审 |
| `tests/test_display_time.py` | 18 | ☑ 已审 |
| `tests/test_telegram_time_format.py` | 20 | ☑ 已审 |
| `tests/test_agent_recovery_dedup.py` | 42 | ☑ 已审 |
## 审计8步结果
### Step 1: 登记 ✅
两项用户反馈:① Telegram 恢复时间显示 UTC;② 同一指标 1 秒内连发多条恢复通知。
### Step 2: 全文 Read ✅
上述文件全文走读;`broadcast_recovery` / `websocket.py` 交叉确认恢复无 Telegram cooldown 设计意图(恢复应即时推送,根因在 Redis 成员重复)。
### Step 3: 规则扫描 H
| ID | 规则 | 命中点 | 初判 |
|----|------|--------|------|
| H1 | 静默吞错 | `agent.py` Redis 告警跟踪 `except: debug` | SAFE — 已有模式,告警主路径仍广播 |
| H2 | 时区一致性 | `parseApiDateTime` 假定 naive = UTC | SAFE — 与 `sync_log_repo`、ORM `_utcnow` 一致 |
| H3 | 去重完整性 | Redis legacy `mem:92` 多条 | SAFE — `_detect_recovery` 按 metric 去重 + srem 全清 |
| H4 | XSS / 注入 | 前端 `formatDateTimeBeijing` 仅格式化 | SAFE — Vue 文本插值,无 `v-html` |
| H5 | 密钥泄露 | `display_time` / `datetime.ts` | SAFE — 无凭据 |
| H6 | CUD 审计 | 无新 CUD 路径 | N/A |
| H7 | 多 worker | 恢复 Telegram 仍无 Redis 锁 | LOW — 单心跳内重复已根治;跨 worker 同毫秒双 POST 概率极低,接受 |
### Step 4: Closure表
| H | 判定 | 依据 |
|---|------|------|
| H1 | SAFE | 跟踪失败不影响 `broadcast_alert`;日志可见 |
| H2 | SAFE | DB/API 约定 UTC;文档与 changelog 已说明 |
| H3 | SAFE | 单测 `test_detect_recovery_dedupes_legacy_metric_value_entries` |
| H4 | SAFE | 展示层数值格式化 |
| H5 | SAFE | — |
| H6 | N/A | — |
| H7 | ACCEPT | 根因修复;若后续再现可加 `recovery_sent:{id}:{metric}` SETNX |
### Step 5: 入口表
| 入口 | 变更 |
|------|------|
| `POST /api/agent/heartbeat` | Redis `alerts:{id}` 成员格式、恢复检测 |
| Telegram 出站 | `_now_cn``format_beijing_now` |
| `POST .../telegram/test` | 测试消息时间 |
| 前端各列表页 | 只读展示,无新 API |
### Step 6: 输入→Sink
- Agent `system_info` → 阈值比较 → Redis set / `broadcast_recovery` → Telegram:无新 sink;去重降低 Telegram 频率。
- API `created_at` 字符串 → `parseApiDateTime``Intl` 北京时间:只读展示。
### Step 7: 归类
```
display_time.py 27行 2H 0 FINDING
agent.py (recovery) ~50行 3H 0 FINDING
datetime.ts 85行 2H 0 FINDING
前端 10 文件 ~40行 1H 0 FINDING
测试 3 文件 80行 0H 0 FINDING
──────────────────────────────────────────
总计 8H 0 FINDING1 ACCEPT
```
### Step 8: DoD ✅
- [x] 恢复通知同一指标单次心跳最多 1 条 Telegram
- [x] Telegram / 审计页 / 告警页等绝对时间为北京时间
- [x] DB 仍存 UTC,未改调度/漂移计算
- [x] `pytest` 7 项通过(`test_display_time` / `test_telegram_time_format` / `test_agent_recovery_dedup`
- [x] `npm run type-check` 通过
- [ ] 生产部署与浏览器终验(待用户批准 deploy)
## FINDING 列表
无。H7 为风险接受项,非 FINDING。
## 结论
**审计通过,0 FINDING,可进入部署门控**(需 commit + `pre_deploy_check.sh` + 前端 build
@@ -0,0 +1,40 @@
# 2026-06-08 — 全站用户可见时间统一为北京时间
## 变更摘要
存储与计算仍用 UTC;所有面向运维的**展示层**(Web 表格、Telegram、设置页刷新时间等)统一为 `Asia/Shanghai` 北京时间。
## 动机
用户要求不论浏览器时区,界面与通知时间与国内运维习惯一致。
## 方案
- **后端**`server/utils/display_time.py``format_beijing` / `format_beijing_now`);Telegram、IP 白名单刷新时间、Telegram 测试消息接入
- **前端**`frontend/src/utils/datetime.ts``parseApiDateTime` 将 MySQL naive UTC 正确解析 + `formatDateTimeBeijing` 等);15 页相关表格/内联详情/WS 告警条改用该工具
## 涉及文件
- `server/utils/display_time.py`(新)
- `server/infrastructure/telegram/__init__.py`
- `server/background/ip_allowlist_refresh.py`
- `server/api/settings.py`
- `frontend/src/utils/datetime.ts`(新)
- `frontend/src/utils/status.ts`
- `frontend/src/composables/push/labels.ts`
- `frontend/src/composables/useWebSocket.ts`
- 多页 Vue`Alerts` `Audit` `Commands` `Retries` `Schedules` `Scripts` `ScriptRuns` `Servers` 及凭据/子机内联组件
## 迁移 / 重启
- 无 DB 迁移
- 需重新构建前端并重启 API
## 验证方式
```bash
.venv/bin/python -m pytest tests/test_display_time.py tests/test_telegram_time_format.py tests/test_agent_recovery_dedup.py -q
cd frontend && npm run type-check
```
浏览器:告警历史、审计、推送历史等列时间应为北京时间(与 UTC 差 8 小时)。
@@ -0,0 +1,34 @@
# 2026-06-08 — 资源恢复 Telegram 去重与时区修正
## 变更摘要
修复子机资源恢复通知「1 秒内连发多条」及消息内「时间」显示为 UTC 而非北京时间的问题。
## 动机
用户反馈子机 #212 内存恢复 Telegram 在约 1 秒内收到多条相同内容;消息底部时间为 UTC,与运维习惯不符。
## 根因
1. **重复推送**Redis `alerts:{server_id}` 集合以 `mem:92.1` 形式每次超阈值心跳追加新成员;恢复检测对集合内每条成员各触发一次 `broadcast_recovery`,同一指标一次心跳可连发 N 条 Telegram。
2. **时间不对**`_now_cn()` 使用 `datetime.now(timezone.utc)` 并标注 `UTC`,对中国用户相当于慢 8 小时。
## 涉及文件
- `server/api/agent.py` — 告警集合仅存指标名;恢复检测按指标去重;清理时兼容 legacy `metric:value`
- `server/infrastructure/telegram/__init__.py``_now_cn()` 改为 `Asia/Shanghai` +「北京时间」
- `tests/test_agent_recovery_dedup.py`
- `tests/test_telegram_time_format.py`
## 迁移 / 重启
- 无需 DB 迁移
- 部署后重启 API 生效;Redis 中既有 `metric:value` 成员在下次恢复时会被按指标批量清除
## 验证方式
```bash
pytest tests/test_agent_recovery_dedup.py tests/test_telegram_time_format.py -q
```
生产:子机超阈值后再回落,Telegram 每条指标仅应收到 **一条** 恢复通知,时间为北京时间。
@@ -187,6 +187,7 @@ import { formatApiError } from '@/utils/apiError'
import { required } from '@/utils/validation'
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS, headersWithoutSort } from '@/constants/dataTable'
import { fetchPagePerPage } from '@/utils/paginatedFetch'
import { formatDateTimeBeijing } from '@/utils/datetime'
defineOptions({ name: 'CredentialsManager' })
@@ -253,9 +254,7 @@ function onItemsPerPageChange(n: number) {
}
function formatPresetTime(iso: string | undefined): string {
if (!iso) return '—'
const d = new Date(iso)
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('zh-CN', { hour12: false })
return formatDateTimeBeijing(iso)
}
async function loadPasswords(silent = false) {
@@ -60,6 +60,7 @@ import { http } from '@/api'
import { formatPushPermission } from '@/composables/push/labels'
import { formatApiError } from '@/utils/apiError'
import type { PushItem, ServerApiItem } from '@/types/api'
import { formatDateTimeBeijing } from '@/utils/datetime'
const props = defineProps<{
server: ServerApiItem
@@ -96,7 +97,7 @@ function syncLogStatusLabel(status: string): string {
function formatSyncLogTime(log: PushItem): string {
const parts: string[] = []
if (log.started_at) parts.push(log.started_at)
if (log.started_at) parts.push(formatDateTimeBeijing(log.started_at))
if (log.sync_mode) parts.push(log.sync_mode)
if (log.trigger_type) parts.push(log.trigger_type)
if (log.push_permission) parts.push(`权限 ${formatPushPermission(log.push_permission)}`)
+1 -7
View File
@@ -1,6 +1,7 @@
/** Display helpers for push UI */
export { formatPushErrorMessage } from '@/utils/pushErrorMessage'
export { formatDateTimeBeijing as formatLogTime } from '@/utils/datetime'
export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
@@ -8,13 +9,6 @@ export function formatSize(bytes: number): string {
return `${(bytes / 1048576).toFixed(1)} MB`
}
export function formatLogTime(iso: string | null | undefined): string {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleString('zh-CN', { hour12: false })
}
export function logStatusLabel(status: string): string {
switch (status) {
case 'success': return '成功'
+2 -1
View File
@@ -14,6 +14,7 @@
*/
import { ref, computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { formatTimeBeijing } from '@/utils/datetime'
import {
handleScriptWsMessage,
setScriptQueueWsConnected,
@@ -98,7 +99,7 @@ export function useWebSocket() {
serverName: msg.server_name,
alertType: msg.alert_type || msg.metric,
value: msg.alert_value || msg.value,
time: new Date().toLocaleTimeString(),
time: formatTimeBeijing(new Date()),
}
alerts.value.unshift(alert)
if (alerts.value.length > MAX_ALERTS) alerts.value.pop()
+2 -1
View File
@@ -83,7 +83,7 @@
</template>
<template #item.created_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ item.created_at }}</span>
<span class="text-caption text-medium-emphasis">{{ formatDateTimeBeijing(item.created_at) }}</span>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无数据</div>
@@ -100,6 +100,7 @@ import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import type { PaginatedResponse, AlertLogItem, AlertStatsResponse } from '@/types/api'
import { headersWithoutSort } from '@/constants/dataTable'
import { formatDateTimeBeijing } from '@/utils/datetime'
defineOptions({ name: 'AlertsPage' })
+2 -1
View File
@@ -96,7 +96,7 @@
</template>
<template #item.created_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ item.created_at }}</span>
<span class="text-caption text-medium-emphasis">{{ formatDateTimeBeijing(item.created_at) }}</span>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无数据</div>
@@ -122,6 +122,7 @@ import {
import { openServerBatchJobResult } from '@/composables/useServerBatchJobViewer'
import { normalizeAuditList } from '@/utils/auditNormalize'
import { headersWithoutSort } from '@/constants/dataTable'
import { formatDateTimeBeijing } from '@/utils/datetime'
defineOptions({ name: 'AuditPage' })
+4 -3
View File
@@ -99,7 +99,7 @@
<span class="text-medium-emphasis">{{ item.admin_username || '—' }}</span>
</template>
<template #item.created_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ item.created_at }}</span>
<span class="text-caption text-medium-emphasis">{{ formatDateTimeBeijing(item.created_at) }}</span>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无数据</div>
@@ -130,7 +130,7 @@
</v-chip>
</template>
<template #item.started_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ item.started_at }}</span>
<span class="text-caption text-medium-emphasis">{{ formatDateTimeBeijing(item.started_at) }}</span>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无数据</div>
@@ -153,7 +153,7 @@
@update:items-per-page="onItemsPerPageChange"
>
<template #item.started_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ item.started_at || '—' }}</span>
<span class="text-caption text-medium-emphasis">{{ formatDateTimeBeijing(item.started_at) }}</span>
</template>
<template #item.server_name="{ item }">
<span class="text-medium-emphasis">{{ item.server_name || '—' }}</span>
@@ -211,6 +211,7 @@ import { useServerList } from '@/composables/useServerList'
import { useSnackbar } from '@/composables/useSnackbar'
import { formatApiError } from '@/utils/apiError'
import { formatPushPermission } from '@/composables/push/labels'
import { formatDateTimeBeijing } from '@/utils/datetime'
import type { CommandLogItem, PushItem, SshSessionItem } from '@/types/api'
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS, headersWithoutSort } from '@/constants/dataTable'
import { fetchPagePerPage } from '@/utils/paginatedFetch'
+3 -2
View File
@@ -51,11 +51,11 @@
</template>
<template #item.next_retry_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ item.next_retry_at || '—' }}</span>
<span class="text-caption text-medium-emphasis">{{ formatDateTimeBeijing(item.next_retry_at) }}</span>
</template>
<template #item.created_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ item.created_at }}</span>
<span class="text-caption text-medium-emphasis">{{ formatDateTimeBeijing(item.created_at) }}</span>
</template>
<template #item.actions="{ item }">
@@ -95,6 +95,7 @@ import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import type { PaginatedResponse, RetryItem } from '@/types/api'
import { headersWithoutSort } from '@/constants/dataTable'
import { formatDateTimeBeijing } from '@/utils/datetime'
defineOptions({ name: 'RetriesPage' })
+3 -2
View File
@@ -52,11 +52,11 @@
</template>
<template #item.last_run_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ item.last_run_at || '从未运行' }}</span>
<span class="text-caption text-medium-emphasis">{{ item.last_run_at ? formatDateTimeBeijing(item.last_run_at) : '从未运行' }}</span>
</template>
<template #item.next_run="{ item }">
<span class="text-caption text-medium-emphasis">{{ item.next_run || '—' }}</span>
<span class="text-caption text-medium-emphasis">{{ item.next_run ? formatDateTimeBeijing(item.next_run) : '—' }}</span>
</template>
<template #item.actions="{ item }">
@@ -245,6 +245,7 @@ import {
validateCycleConfig,
type ScheduleCycleConfig,
} from '@/utils/scheduleCycle'
import { formatDateTimeBeijing } from '@/utils/datetime'
defineOptions({ name: 'SchedulesPage' })
+4
View File
@@ -255,6 +255,9 @@
<template #item.server_count="{ item }">
{{ item.server_count }}
</template>
<template #item.started_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ formatDateTimeBeijing(item.started_at) }}</span>
</template>
<template #item.actions="{ item }">
<v-btn size="x-small" variant="text" color="primary" @click="openDetail(item)">
详情
@@ -302,6 +305,7 @@ import {
resolveExecServerName,
} from '@/utils/scriptExecutionLabels'
import { groupFailedExecRows } from '@/utils/execDetailGrouping'
import { formatDateTimeBeijing } from '@/utils/datetime'
defineOptions({ name: 'ScriptRunsPage' })
+5 -12
View File
@@ -129,7 +129,7 @@
</v-chip>
<div class="text-body-2 text-medium-emphasis">{{ script.description || '无描述' }}</div>
<div class="text-caption text-medium-emphasis mt-2">
更新于 {{ script.updated_at || script.created_at || '—' }}
更新于 {{ formatDateTimeBeijing(script.updated_at || script.created_at) }}
</div>
</v-card-text>
<v-card-actions>
@@ -178,7 +178,7 @@
<template #subtitle>
<div class="text-caption">
{{ execStatusLabel(item.status) }} · {{ parseServerCount(item.server_ids) }}
· {{ item.operator || '—' }} · {{ formatExecTime(item.started_at) }}
· {{ item.operator || '—' }} · {{ formatDateTimeBeijing(item.started_at) }}
</div>
<div v-for="(ev, idx) in execEventLines(item)" :key="idx" class="text-caption text-medium-emphasis font-monospace mt-1">
{{ ev }}
@@ -254,7 +254,7 @@
</div>
</template>
<template #item.started_at="{ item }">
{{ formatExecTime(item.started_at) }}
{{ formatDateTimeBeijing(item.started_at) }}
</template>
<template #item.actions="{ item }">
<v-btn size="x-small" variant="text" @click="openExecDetail(item.id)">详情</v-btn>
@@ -652,6 +652,7 @@ import { registerScriptExecution } from '@/composables/useScriptExecutionQueue'
defineOptions({ name: 'ScriptsPage' })
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
import { useScriptExecutionListRefresh } from '@/composables/useScriptExecutionListRefresh'
import { formatDateTimeBeijing } from '@/utils/datetime'
const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
const snackbar = useSnackbar()
@@ -884,20 +885,12 @@ function execLabel(item: ScriptExecItem): string {
return '临时命令'
}
function formatExecTime(ts: string | null | undefined): string {
if (!ts) return '—'
const d = new Date(ts)
if (Number.isNaN(d.getTime())) return ts.replace('T', ' ').slice(0, 19)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
function execEventLines(item: ScriptExecItem): string[] {
const events = item.events || []
if (!events.length) return []
return events.slice(-3).map(ev => {
const label = execEventActionLabels[ev.action || ''] || ev.action || '事件'
const time = formatExecTime(ev.at)
const time = formatDateTimeBeijing(ev.at)
const detail = ev.detail || ''
return `${time} · ${label}${detail ? `${detail}` : ''}`
})
+2 -1
View File
@@ -599,6 +599,7 @@ import { useSnackbar } from '@/composables/useSnackbar'
import { useServerPagination } from '@/composables/useServerPagination'
import { categoryDisplayLabel, useServerCategories } from '@/composables/useServerCategories'
import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status'
import { beijingTodayYmd } from '@/utils/datetime'
import { fetchPagePerPage } from '@/utils/paginatedFetch'
import { isUnsetTargetPath } from '@/utils/serverTargetPath'
import type { AddByIpResponse, AddByIpsBatchResult, BatchJobStarted, PendingServerItem, PollErrorItem, ServerApiItem } from '@/types/api'
@@ -1372,7 +1373,7 @@ async function exportCSV() {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `servers_${new Date().toISOString().slice(0, 10)}.csv`
a.download = `servers_${beijingTodayYmd()}.csv`
a.click()
URL.revokeObjectURL(url)
snackbar('导出完成')
+84
View File
@@ -0,0 +1,84 @@
/**
* User-visible absolute timestamps — always Asia/Shanghai (北京时间).
* API / DB store UTC; parse with parseApiDateTime before formatting.
*/
export const BEIJING_TZ = 'Asia/Shanghai'
/** Parse API/MySQL datetime (naive UTC or ISO Z) into a Date instant. */
export function parseApiDateTime(raw: string | null | undefined): Date | null {
if (raw == null) return null
const s = String(raw).trim()
if (!s) return null
let normalized = s
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(s)) {
normalized = `${s.replace(' ', 'T')}Z`
} else if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(s)) {
normalized = `${s}Z`
} else if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+$/.test(s)) {
normalized = `${s}Z`
}
const d = new Date(normalized)
return Number.isNaN(d.getTime()) ? null : d
}
function beijingParts(d: Date): Record<string, string> {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: BEIJING_TZ,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).formatToParts(d)
return Object.fromEntries(
parts.filter((p) => p.type !== 'literal').map((p) => [p.type, p.value]),
)
}
function formatParts(d: Date, mode: 'datetime' | 'date' | 'time'): string {
const p = beijingParts(d)
if (mode === 'date') return `${p.year}-${p.month}-${p.day}`
if (mode === 'time') return `${p.hour}:${p.minute}:${p.second}`
return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}:${p.second}`
}
function resolveDate(raw: string | Date | null | undefined): Date | null {
if (raw instanceof Date) return Number.isNaN(raw.getTime()) ? null : raw
return parseApiDateTime(raw)
}
/** YYYY-MM-DD HH:mm:ss in Beijing. */
export function formatDateTimeBeijing(raw: string | Date | null | undefined): string {
const d = resolveDate(raw)
if (!d) {
if (typeof raw === 'string' && raw.trim()) {
return raw.replace('T', ' ').slice(0, 19)
}
return '—'
}
return formatParts(d, 'datetime')
}
/** YYYY-MM-DD in Beijing. */
export function formatDateBeijing(raw: string | Date | null | undefined): string {
const d = resolveDate(raw)
if (!d) return '—'
return formatParts(d, 'date')
}
/** HH:mm:ss in Beijing. */
export function formatTimeBeijing(raw: string | Date | null | undefined): string {
const d = resolveDate(raw)
if (!d) return '—'
return formatParts(d, 'time')
}
/** Today's date in Beijing (for filenames, etc.). */
export function beijingTodayYmd(): string {
return formatDateBeijing(new Date())
}
+4 -2
View File
@@ -4,6 +4,8 @@
* Extracted from DashboardPage and ServersPage.
*/
import { parseApiDateTime } from '@/utils/datetime'
/** Vuetify chip color for server status */
export function statusChipColor(status: string): string {
switch (status) {
@@ -25,8 +27,8 @@ export function statusLabel(status: string): string {
/** Relative time formatting (Chinese) */
export function formatRelativeTime(t: string | null): string {
if (!t) return '—'
const date = new Date(t)
if (isNaN(date.getTime())) return '—'
const date = parseApiDateTime(t)
if (!date) return '—'
const diff = Date.now() - date.getTime()
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
+19 -8
View File
@@ -128,21 +128,31 @@ def _detect_alerts(system_info: dict, thresholds: dict) -> list:
return alerts
def _alert_metric_key(entry: str) -> str | None:
"""Parse Redis alert set member — supports legacy ``metric:value`` and ``metric``."""
if not entry:
return None
metric = entry.split(":", 1)[0].strip()
return metric or None
def _detect_recovery(system_info: dict, prev_alerts: set, thresholds: dict) -> list:
"""Detect previously alerted metrics that have returned to normal
Returns list of (metric, current_value) tuples.
Returns list of (metric, current_value) tuples — at most one entry per metric
even when Redis still holds legacy ``metric:value`` duplicates.
"""
recoveries = []
recoveries: list[tuple[str, float]] = []
seen_metrics: set[str] = set()
for prev in prev_alerts:
parts = prev.split(":")
if len(parts) != 2:
metric = _alert_metric_key(prev)
if not metric or metric in seen_metrics:
continue
metric = parts[0]
current = _safe_float(
system_info.get(f"{metric}_usage") or system_info.get(metric), metric
)
if current is not None and current < _threshold_for(thresholds, metric):
seen_metrics.add(metric)
recoveries.append((metric, current))
return recoveries
@@ -277,7 +287,8 @@ async def receive_heartbeat(
# Track active alert in Redis
try:
redis = get_redis()
await redis.sadd(f"{REDIS_ALERT_KEY_PREFIX}{server_id}", f"{alert_type}:{alert_value}")
# Store metric name only — value in member caused duplicate recovery pushes
await redis.sadd(f"{REDIS_ALERT_KEY_PREFIX}{server_id}", alert_type)
await redis.expire(f"{REDIS_ALERT_KEY_PREFIX}{server_id}", 3600) # 1h TTL
except Exception:
logger.debug(f"Failed to track alert in Redis for server {server_id}")
@@ -293,9 +304,9 @@ async def receive_heartbeat(
logger.info(f"Recovery: server {server_id} {metric}={value}%")
await broadcast_recovery(server_id, metric, value, server_name)
# Remove recovered alert from Redis tracking
# Remove recovered metric (legacy metric:value members included)
for prev in prev_alerts:
if prev.startswith(f"{metric}:"):
if _alert_metric_key(prev) == metric:
await redis.srem(f"{REDIS_ALERT_KEY_PREFIX}{server_id}", prev)
except Exception as e:
logger.error(f"Recovery detection failed for server {server_id}: {e}")
+3 -2
View File
@@ -1025,8 +1025,9 @@ async def telegram_test_send(
if not _settings.TELEGRAM_CHAT_ID:
raise HTTPException(status_code=400, detail="TELEGRAM_CHAT_ID 未配置,请先检测并填写")
from datetime import datetime, timezone
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
from server.utils.display_time import format_beijing_now
now = format_beijing_now()
sys_name = html.escape((_settings.SYSTEM_NAME or "Nexus").strip())
message = (
f"✅ <b>【Telegram 配置测试】消息发送成功</b>\n"
+3 -2
View File
@@ -80,8 +80,9 @@ async def _do_refresh():
settings.LOGIN_SUBSCRIPTION_IPS = sub_val
settings.LOGIN_ALLOWED_IPS = combined_val
from datetime import datetime, timezone
_last_refresh_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
from server.utils.display_time import format_beijing
_last_refresh_at = format_beijing(with_label=False)
_last_subscription_count = len(new_sub_ips)
logger.info(
"Subscription IPs refreshed: %d → combined %d (+ %d manual)",
+2 -3
View File
@@ -7,9 +7,8 @@ import html
import logging
import re
import httpx
from datetime import datetime, timezone
from server.config import settings
from server.utils.display_time import format_beijing_now
from server.utils.notify_toggles import notify_attr_enabled, resource_alert_notify_enabled, resource_recovery_notify_enabled
logger = logging.getLogger("nexus.telegram")
@@ -34,7 +33,7 @@ _METRIC_CN: dict[str, str] = {
def _now_cn() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
return format_beijing_now()
def _metric_cn(metric: str) -> str:
+26
View File
@@ -0,0 +1,26 @@
"""User-visible timestamps — always Asia/Shanghai (北京时间)."""
from __future__ import annotations
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
BEIJING_TZ = ZoneInfo("Asia/Shanghai")
def to_beijing(dt: datetime) -> datetime:
"""Convert aware or naive-UTC datetime to Beijing wall clock."""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(BEIJING_TZ)
def format_beijing(dt: datetime | None = None, *, with_label: bool = True) -> str:
"""Format datetime for operators (default: now in 北京时间)."""
when = to_beijing(dt or datetime.now(timezone.utc))
text = when.strftime("%Y-%m-%d %H:%M:%S")
return f"{text} 北京时间" if with_label else text
def format_beijing_now() -> str:
return format_beijing(with_label=True)
+38
View File
@@ -0,0 +1,38 @@
"""Recovery detection must emit one notification per metric, not per Redis entry."""
from server.api.agent import _alert_metric_key, _detect_recovery
def test_alert_metric_key_parses_legacy_and_new_formats():
assert _alert_metric_key("mem") == "mem"
assert _alert_metric_key("mem:92.1") == "mem"
assert _alert_metric_key("cpu:85.0") == "cpu"
assert _alert_metric_key("") is None
def test_detect_recovery_dedupes_legacy_metric_value_entries():
thresholds = {"cpu": 80, "mem": 80, "disk": 80}
prev_alerts = {"mem:88.0", "mem:91.2", "mem:93.5"}
system_info = {"mem_usage": 55.0}
recoveries = _detect_recovery(system_info, prev_alerts, thresholds)
assert recoveries == [("mem", 55.0)]
def test_detect_recovery_one_per_metric_when_multiple_metrics():
thresholds = {"cpu": 80, "mem": 80, "disk": 80}
prev_alerts = {"cpu:90.0", "mem:88.0", "mem:92.0"}
system_info = {"cpu_usage": 40.0, "mem_usage": 50.0}
recoveries = _detect_recovery(system_info, prev_alerts, thresholds)
assert sorted(recoveries) == [("cpu", 40.0), ("mem", 50.0)]
def test_detect_recovery_skips_still_alerting_metric():
thresholds = {"cpu": 80, "mem": 80, "disk": 80}
prev_alerts = {"mem:88.0", "mem:91.0"}
system_info = {"mem_usage": 85.0}
assert _detect_recovery(system_info, prev_alerts, thresholds) == []
+18
View File
@@ -0,0 +1,18 @@
"""Display time helpers use Asia/Shanghai for operators."""
from datetime import datetime, timezone
from server.utils.display_time import format_beijing, to_beijing
def test_format_beijing_from_utc():
dt = datetime(2026, 6, 8, 4, 30, 0, tzinfo=timezone.utc)
assert format_beijing(dt, with_label=False) == "2026-06-08 12:30:00"
assert format_beijing(dt).endswith("北京时间")
def test_to_beijing_naive_as_utc():
naive = datetime(2026, 6, 8, 4, 30, 0)
bj = to_beijing(naive)
assert bj.hour == 12
assert bj.minute == 30
+19
View File
@@ -0,0 +1,19 @@
"""Telegram timestamps should display Beijing local time."""
from datetime import datetime
from zoneinfo import ZoneInfo
from server.infrastructure.telegram import _now_cn
from server.utils.display_time import format_beijing_now
def test_now_cn_uses_beijing_timezone_label():
text = _now_cn()
assert text == format_beijing_now()
assert text.endswith("北京时间")
assert "UTC" not in text
wall = text.replace(" 北京时间", "")
parsed = datetime.strptime(wall, "%Y-%m-%d %H:%M:%S").replace(tzinfo=ZoneInfo("Asia/Shanghai"))
utc_now = datetime.now(ZoneInfo("UTC"))
delta_seconds = abs((parsed - utc_now).total_seconds())
assert delta_seconds < 120