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 { required } from '@/utils/validation'
|
||||||
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS, headersWithoutSort } from '@/constants/dataTable'
|
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS, headersWithoutSort } from '@/constants/dataTable'
|
||||||
import { fetchPagePerPage } from '@/utils/paginatedFetch'
|
import { fetchPagePerPage } from '@/utils/paginatedFetch'
|
||||||
|
import { formatDateTimeBeijing } from '@/utils/datetime'
|
||||||
|
|
||||||
defineOptions({ name: 'CredentialsManager' })
|
defineOptions({ name: 'CredentialsManager' })
|
||||||
|
|
||||||
@@ -253,9 +254,7 @@ function onItemsPerPageChange(n: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatPresetTime(iso: string | undefined): string {
|
function formatPresetTime(iso: string | undefined): string {
|
||||||
if (!iso) return '—'
|
return formatDateTimeBeijing(iso)
|
||||||
const d = new Date(iso)
|
|
||||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('zh-CN', { hour12: false })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPasswords(silent = false) {
|
async function loadPasswords(silent = false) {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import { http } from '@/api'
|
|||||||
import { formatPushPermission } from '@/composables/push/labels'
|
import { formatPushPermission } from '@/composables/push/labels'
|
||||||
import { formatApiError } from '@/utils/apiError'
|
import { formatApiError } from '@/utils/apiError'
|
||||||
import type { PushItem, ServerApiItem } from '@/types/api'
|
import type { PushItem, ServerApiItem } from '@/types/api'
|
||||||
|
import { formatDateTimeBeijing } from '@/utils/datetime'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
server: ServerApiItem
|
server: ServerApiItem
|
||||||
@@ -96,7 +97,7 @@ function syncLogStatusLabel(status: string): string {
|
|||||||
|
|
||||||
function formatSyncLogTime(log: PushItem): string {
|
function formatSyncLogTime(log: PushItem): string {
|
||||||
const parts: 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.sync_mode) parts.push(log.sync_mode)
|
||||||
if (log.trigger_type) parts.push(log.trigger_type)
|
if (log.trigger_type) parts.push(log.trigger_type)
|
||||||
if (log.push_permission) parts.push(`权限 ${formatPushPermission(log.push_permission)}`)
|
if (log.push_permission) parts.push(`权限 ${formatPushPermission(log.push_permission)}`)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/** Display helpers for push UI */
|
/** Display helpers for push UI */
|
||||||
|
|
||||||
export { formatPushErrorMessage } from '@/utils/pushErrorMessage'
|
export { formatPushErrorMessage } from '@/utils/pushErrorMessage'
|
||||||
|
export { formatDateTimeBeijing as formatLogTime } from '@/utils/datetime'
|
||||||
|
|
||||||
export function formatSize(bytes: number): string {
|
export function formatSize(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`
|
if (bytes < 1024) return `${bytes} B`
|
||||||
@@ -8,13 +9,6 @@ export function formatSize(bytes: number): string {
|
|||||||
return `${(bytes / 1048576).toFixed(1)} MB`
|
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 {
|
export function logStatusLabel(status: string): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'success': return '成功'
|
case 'success': return '成功'
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
*/
|
*/
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { formatTimeBeijing } from '@/utils/datetime'
|
||||||
import {
|
import {
|
||||||
handleScriptWsMessage,
|
handleScriptWsMessage,
|
||||||
setScriptQueueWsConnected,
|
setScriptQueueWsConnected,
|
||||||
@@ -98,7 +99,7 @@ export function useWebSocket() {
|
|||||||
serverName: msg.server_name,
|
serverName: msg.server_name,
|
||||||
alertType: msg.alert_type || msg.metric,
|
alertType: msg.alert_type || msg.metric,
|
||||||
value: msg.alert_value || msg.value,
|
value: msg.alert_value || msg.value,
|
||||||
time: new Date().toLocaleTimeString(),
|
time: formatTimeBeijing(new Date()),
|
||||||
}
|
}
|
||||||
alerts.value.unshift(alert)
|
alerts.value.unshift(alert)
|
||||||
if (alerts.value.length > MAX_ALERTS) alerts.value.pop()
|
if (alerts.value.length > MAX_ALERTS) alerts.value.pop()
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #item.created_at="{ item }">
|
<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>
|
||||||
<template #no-data>
|
<template #no-data>
|
||||||
<div class="text-center text-medium-emphasis py-6">暂无数据</div>
|
<div class="text-center text-medium-emphasis py-6">暂无数据</div>
|
||||||
@@ -100,6 +100,7 @@ import { http } from '@/api'
|
|||||||
import { useSnackbar } from '@/composables/useSnackbar'
|
import { useSnackbar } from '@/composables/useSnackbar'
|
||||||
import type { PaginatedResponse, AlertLogItem, AlertStatsResponse } from '@/types/api'
|
import type { PaginatedResponse, AlertLogItem, AlertStatsResponse } from '@/types/api'
|
||||||
import { headersWithoutSort } from '@/constants/dataTable'
|
import { headersWithoutSort } from '@/constants/dataTable'
|
||||||
|
import { formatDateTimeBeijing } from '@/utils/datetime'
|
||||||
|
|
||||||
defineOptions({ name: 'AlertsPage' })
|
defineOptions({ name: 'AlertsPage' })
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #item.created_at="{ item }">
|
<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>
|
||||||
<template #no-data>
|
<template #no-data>
|
||||||
<div class="text-center text-medium-emphasis py-6">暂无数据</div>
|
<div class="text-center text-medium-emphasis py-6">暂无数据</div>
|
||||||
@@ -122,6 +122,7 @@ import {
|
|||||||
import { openServerBatchJobResult } from '@/composables/useServerBatchJobViewer'
|
import { openServerBatchJobResult } from '@/composables/useServerBatchJobViewer'
|
||||||
import { normalizeAuditList } from '@/utils/auditNormalize'
|
import { normalizeAuditList } from '@/utils/auditNormalize'
|
||||||
import { headersWithoutSort } from '@/constants/dataTable'
|
import { headersWithoutSort } from '@/constants/dataTable'
|
||||||
|
import { formatDateTimeBeijing } from '@/utils/datetime'
|
||||||
|
|
||||||
defineOptions({ name: 'AuditPage' })
|
defineOptions({ name: 'AuditPage' })
|
||||||
|
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
<span class="text-medium-emphasis">{{ item.admin_username || '—' }}</span>
|
<span class="text-medium-emphasis">{{ item.admin_username || '—' }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template #item.created_at="{ item }">
|
<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>
|
||||||
<template #no-data>
|
<template #no-data>
|
||||||
<div class="text-center text-medium-emphasis py-6">暂无数据</div>
|
<div class="text-center text-medium-emphasis py-6">暂无数据</div>
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
</v-chip>
|
</v-chip>
|
||||||
</template>
|
</template>
|
||||||
<template #item.started_at="{ item }">
|
<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>
|
||||||
<template #no-data>
|
<template #no-data>
|
||||||
<div class="text-center text-medium-emphasis py-6">暂无数据</div>
|
<div class="text-center text-medium-emphasis py-6">暂无数据</div>
|
||||||
@@ -153,7 +153,7 @@
|
|||||||
@update:items-per-page="onItemsPerPageChange"
|
@update:items-per-page="onItemsPerPageChange"
|
||||||
>
|
>
|
||||||
<template #item.started_at="{ item }">
|
<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>
|
||||||
<template #item.server_name="{ item }">
|
<template #item.server_name="{ item }">
|
||||||
<span class="text-medium-emphasis">{{ item.server_name || '—' }}</span>
|
<span class="text-medium-emphasis">{{ item.server_name || '—' }}</span>
|
||||||
@@ -211,6 +211,7 @@ import { useServerList } from '@/composables/useServerList'
|
|||||||
import { useSnackbar } from '@/composables/useSnackbar'
|
import { useSnackbar } from '@/composables/useSnackbar'
|
||||||
import { formatApiError } from '@/utils/apiError'
|
import { formatApiError } from '@/utils/apiError'
|
||||||
import { formatPushPermission } from '@/composables/push/labels'
|
import { formatPushPermission } from '@/composables/push/labels'
|
||||||
|
import { formatDateTimeBeijing } from '@/utils/datetime'
|
||||||
import type { CommandLogItem, PushItem, SshSessionItem } from '@/types/api'
|
import type { CommandLogItem, PushItem, SshSessionItem } from '@/types/api'
|
||||||
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS, headersWithoutSort } from '@/constants/dataTable'
|
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS, headersWithoutSort } from '@/constants/dataTable'
|
||||||
import { fetchPagePerPage } from '@/utils/paginatedFetch'
|
import { fetchPagePerPage } from '@/utils/paginatedFetch'
|
||||||
|
|||||||
@@ -51,11 +51,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #item.next_retry_at="{ item }">
|
<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>
|
||||||
|
|
||||||
<template #item.created_at="{ item }">
|
<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>
|
||||||
|
|
||||||
<template #item.actions="{ item }">
|
<template #item.actions="{ item }">
|
||||||
@@ -95,6 +95,7 @@ import { http } from '@/api'
|
|||||||
import { useSnackbar } from '@/composables/useSnackbar'
|
import { useSnackbar } from '@/composables/useSnackbar'
|
||||||
import type { PaginatedResponse, RetryItem } from '@/types/api'
|
import type { PaginatedResponse, RetryItem } from '@/types/api'
|
||||||
import { headersWithoutSort } from '@/constants/dataTable'
|
import { headersWithoutSort } from '@/constants/dataTable'
|
||||||
|
import { formatDateTimeBeijing } from '@/utils/datetime'
|
||||||
|
|
||||||
defineOptions({ name: 'RetriesPage' })
|
defineOptions({ name: 'RetriesPage' })
|
||||||
|
|
||||||
|
|||||||
@@ -52,11 +52,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #item.last_run_at="{ item }">
|
<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>
|
||||||
|
|
||||||
<template #item.next_run="{ item }">
|
<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>
|
||||||
|
|
||||||
<template #item.actions="{ item }">
|
<template #item.actions="{ item }">
|
||||||
@@ -245,6 +245,7 @@ import {
|
|||||||
validateCycleConfig,
|
validateCycleConfig,
|
||||||
type ScheduleCycleConfig,
|
type ScheduleCycleConfig,
|
||||||
} from '@/utils/scheduleCycle'
|
} from '@/utils/scheduleCycle'
|
||||||
|
import { formatDateTimeBeijing } from '@/utils/datetime'
|
||||||
|
|
||||||
defineOptions({ name: 'SchedulesPage' })
|
defineOptions({ name: 'SchedulesPage' })
|
||||||
|
|
||||||
|
|||||||
@@ -255,6 +255,9 @@
|
|||||||
<template #item.server_count="{ item }">
|
<template #item.server_count="{ item }">
|
||||||
{{ item.server_count }}
|
{{ item.server_count }}
|
||||||
</template>
|
</template>
|
||||||
|
<template #item.started_at="{ item }">
|
||||||
|
<span class="text-caption text-medium-emphasis">{{ formatDateTimeBeijing(item.started_at) }}</span>
|
||||||
|
</template>
|
||||||
<template #item.actions="{ item }">
|
<template #item.actions="{ item }">
|
||||||
<v-btn size="x-small" variant="text" color="primary" @click="openDetail(item)">
|
<v-btn size="x-small" variant="text" color="primary" @click="openDetail(item)">
|
||||||
详情
|
详情
|
||||||
@@ -302,6 +305,7 @@ import {
|
|||||||
resolveExecServerName,
|
resolveExecServerName,
|
||||||
} from '@/utils/scriptExecutionLabels'
|
} from '@/utils/scriptExecutionLabels'
|
||||||
import { groupFailedExecRows } from '@/utils/execDetailGrouping'
|
import { groupFailedExecRows } from '@/utils/execDetailGrouping'
|
||||||
|
import { formatDateTimeBeijing } from '@/utils/datetime'
|
||||||
|
|
||||||
defineOptions({ name: 'ScriptRunsPage' })
|
defineOptions({ name: 'ScriptRunsPage' })
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@
|
|||||||
</v-chip>
|
</v-chip>
|
||||||
<div class="text-body-2 text-medium-emphasis">{{ script.description || '无描述' }}</div>
|
<div class="text-body-2 text-medium-emphasis">{{ script.description || '无描述' }}</div>
|
||||||
<div class="text-caption text-medium-emphasis mt-2">
|
<div class="text-caption text-medium-emphasis mt-2">
|
||||||
更新于 {{ script.updated_at || script.created_at || '—' }}
|
更新于 {{ formatDateTimeBeijing(script.updated_at || script.created_at) }}
|
||||||
</div>
|
</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
<template #subtitle>
|
<template #subtitle>
|
||||||
<div class="text-caption">
|
<div class="text-caption">
|
||||||
{{ execStatusLabel(item.status) }} · {{ parseServerCount(item.server_ids) }} 台
|
{{ execStatusLabel(item.status) }} · {{ parseServerCount(item.server_ids) }} 台
|
||||||
· {{ item.operator || '—' }} · {{ formatExecTime(item.started_at) }}
|
· {{ item.operator || '—' }} · {{ formatDateTimeBeijing(item.started_at) }}
|
||||||
</div>
|
</div>
|
||||||
<div v-for="(ev, idx) in execEventLines(item)" :key="idx" class="text-caption text-medium-emphasis font-monospace mt-1">
|
<div v-for="(ev, idx) in execEventLines(item)" :key="idx" class="text-caption text-medium-emphasis font-monospace mt-1">
|
||||||
{{ ev }}
|
{{ ev }}
|
||||||
@@ -254,7 +254,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #item.started_at="{ item }">
|
<template #item.started_at="{ item }">
|
||||||
{{ formatExecTime(item.started_at) }}
|
{{ formatDateTimeBeijing(item.started_at) }}
|
||||||
</template>
|
</template>
|
||||||
<template #item.actions="{ item }">
|
<template #item.actions="{ item }">
|
||||||
<v-btn size="x-small" variant="text" @click="openExecDetail(item.id)">详情</v-btn>
|
<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' })
|
defineOptions({ name: 'ScriptsPage' })
|
||||||
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
|
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
|
||||||
import { useScriptExecutionListRefresh } from '@/composables/useScriptExecutionListRefresh'
|
import { useScriptExecutionListRefresh } from '@/composables/useScriptExecutionListRefresh'
|
||||||
|
import { formatDateTimeBeijing } from '@/utils/datetime'
|
||||||
|
|
||||||
const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
|
const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
|
||||||
const snackbar = useSnackbar()
|
const snackbar = useSnackbar()
|
||||||
@@ -884,20 +885,12 @@ function execLabel(item: ScriptExecItem): string {
|
|||||||
return '临时命令'
|
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[] {
|
function execEventLines(item: ScriptExecItem): string[] {
|
||||||
const events = item.events || []
|
const events = item.events || []
|
||||||
if (!events.length) return []
|
if (!events.length) return []
|
||||||
return events.slice(-3).map(ev => {
|
return events.slice(-3).map(ev => {
|
||||||
const label = execEventActionLabels[ev.action || ''] || ev.action || '事件'
|
const label = execEventActionLabels[ev.action || ''] || ev.action || '事件'
|
||||||
const time = formatExecTime(ev.at)
|
const time = formatDateTimeBeijing(ev.at)
|
||||||
const detail = ev.detail || ''
|
const detail = ev.detail || ''
|
||||||
return `${time} · ${label}${detail ? ` — ${detail}` : ''}`
|
return `${time} · ${label}${detail ? ` — ${detail}` : ''}`
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -599,6 +599,7 @@ import { useSnackbar } from '@/composables/useSnackbar'
|
|||||||
import { useServerPagination } from '@/composables/useServerPagination'
|
import { useServerPagination } from '@/composables/useServerPagination'
|
||||||
import { categoryDisplayLabel, useServerCategories } from '@/composables/useServerCategories'
|
import { categoryDisplayLabel, useServerCategories } from '@/composables/useServerCategories'
|
||||||
import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status'
|
import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status'
|
||||||
|
import { beijingTodayYmd } from '@/utils/datetime'
|
||||||
import { fetchPagePerPage } from '@/utils/paginatedFetch'
|
import { fetchPagePerPage } from '@/utils/paginatedFetch'
|
||||||
import { isUnsetTargetPath } from '@/utils/serverTargetPath'
|
import { isUnsetTargetPath } from '@/utils/serverTargetPath'
|
||||||
import type { AddByIpResponse, AddByIpsBatchResult, BatchJobStarted, PendingServerItem, PollErrorItem, ServerApiItem } from '@/types/api'
|
import type { AddByIpResponse, AddByIpsBatchResult, BatchJobStarted, PendingServerItem, PollErrorItem, ServerApiItem } from '@/types/api'
|
||||||
@@ -1372,7 +1373,7 @@ async function exportCSV() {
|
|||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
a.href = url
|
||||||
a.download = `servers_${new Date().toISOString().slice(0, 10)}.csv`
|
a.download = `servers_${beijingTodayYmd()}.csv`
|
||||||
a.click()
|
a.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
snackbar('导出完成')
|
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.
|
* Extracted from DashboardPage and ServersPage.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { parseApiDateTime } from '@/utils/datetime'
|
||||||
|
|
||||||
/** Vuetify chip color for server status */
|
/** Vuetify chip color for server status */
|
||||||
export function statusChipColor(status: string): string {
|
export function statusChipColor(status: string): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -25,8 +27,8 @@ export function statusLabel(status: string): string {
|
|||||||
/** Relative time formatting (Chinese) */
|
/** Relative time formatting (Chinese) */
|
||||||
export function formatRelativeTime(t: string | null): string {
|
export function formatRelativeTime(t: string | null): string {
|
||||||
if (!t) return '—'
|
if (!t) return '—'
|
||||||
const date = new Date(t)
|
const date = parseApiDateTime(t)
|
||||||
if (isNaN(date.getTime())) return '—'
|
if (!date) return '—'
|
||||||
const diff = Date.now() - date.getTime()
|
const diff = Date.now() - date.getTime()
|
||||||
if (diff < 60000) return '刚刚'
|
if (diff < 60000) return '刚刚'
|
||||||
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
|
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
|
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:
|
def _detect_recovery(system_info: dict, prev_alerts: set, thresholds: dict) -> list:
|
||||||
"""Detect previously alerted metrics that have returned to normal
|
"""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:
|
for prev in prev_alerts:
|
||||||
parts = prev.split(":")
|
metric = _alert_metric_key(prev)
|
||||||
if len(parts) != 2:
|
if not metric or metric in seen_metrics:
|
||||||
continue
|
continue
|
||||||
metric = parts[0]
|
|
||||||
current = _safe_float(
|
current = _safe_float(
|
||||||
system_info.get(f"{metric}_usage") or system_info.get(metric), metric
|
system_info.get(f"{metric}_usage") or system_info.get(metric), metric
|
||||||
)
|
)
|
||||||
if current is not None and current < _threshold_for(thresholds, metric):
|
if current is not None and current < _threshold_for(thresholds, metric):
|
||||||
|
seen_metrics.add(metric)
|
||||||
recoveries.append((metric, current))
|
recoveries.append((metric, current))
|
||||||
|
|
||||||
return recoveries
|
return recoveries
|
||||||
@@ -277,7 +287,8 @@ async def receive_heartbeat(
|
|||||||
# Track active alert in Redis
|
# Track active alert in Redis
|
||||||
try:
|
try:
|
||||||
redis = get_redis()
|
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
|
await redis.expire(f"{REDIS_ALERT_KEY_PREFIX}{server_id}", 3600) # 1h TTL
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug(f"Failed to track alert in Redis for server {server_id}")
|
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}%")
|
logger.info(f"Recovery: server {server_id} {metric}={value}%")
|
||||||
await broadcast_recovery(server_id, metric, value, server_name)
|
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:
|
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)
|
await redis.srem(f"{REDIS_ALERT_KEY_PREFIX}{server_id}", prev)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Recovery detection failed for server {server_id}: {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:
|
if not _settings.TELEGRAM_CHAT_ID:
|
||||||
raise HTTPException(status_code=400, detail="TELEGRAM_CHAT_ID 未配置,请先检测并填写")
|
raise HTTPException(status_code=400, detail="TELEGRAM_CHAT_ID 未配置,请先检测并填写")
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from server.utils.display_time import format_beijing_now
|
||||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
||||||
|
now = format_beijing_now()
|
||||||
sys_name = html.escape((_settings.SYSTEM_NAME or "Nexus").strip())
|
sys_name = html.escape((_settings.SYSTEM_NAME or "Nexus").strip())
|
||||||
message = (
|
message = (
|
||||||
f"✅ <b>【Telegram 配置测试】消息发送成功</b>\n"
|
f"✅ <b>【Telegram 配置测试】消息发送成功</b>\n"
|
||||||
|
|||||||
@@ -80,8 +80,9 @@ async def _do_refresh():
|
|||||||
settings.LOGIN_SUBSCRIPTION_IPS = sub_val
|
settings.LOGIN_SUBSCRIPTION_IPS = sub_val
|
||||||
settings.LOGIN_ALLOWED_IPS = combined_val
|
settings.LOGIN_ALLOWED_IPS = combined_val
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from server.utils.display_time import format_beijing
|
||||||
_last_refresh_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
||||||
|
_last_refresh_at = format_beijing(with_label=False)
|
||||||
_last_subscription_count = len(new_sub_ips)
|
_last_subscription_count = len(new_sub_ips)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Subscription IPs refreshed: %d → combined %d (+ %d manual)",
|
"Subscription IPs refreshed: %d → combined %d (+ %d manual)",
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ import html
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import httpx
|
import httpx
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from server.config import settings
|
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
|
from server.utils.notify_toggles import notify_attr_enabled, resource_alert_notify_enabled, resource_recovery_notify_enabled
|
||||||
|
|
||||||
logger = logging.getLogger("nexus.telegram")
|
logger = logging.getLogger("nexus.telegram")
|
||||||
@@ -34,7 +33,7 @@ _METRIC_CN: dict[str, str] = {
|
|||||||
|
|
||||||
|
|
||||||
def _now_cn() -> 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:
|
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