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:
@@ -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 FINDING(1 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,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 '成功'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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}` : ''}`
|
||||
})
|
||||
|
||||
@@ -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('导出完成')
|
||||
|
||||
@@ -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,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
@@ -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}")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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) == []
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user