This commit is contained in:
aaron 2026-05-18 20:07:05 +08:00
parent 1fff753260
commit 027970de29
5 changed files with 50 additions and 8 deletions

View File

@ -1,6 +1,7 @@
APP_HOST=0.0.0.0
APP_PORT=8030
DATABASE_PATH=data/dispatcher.db
TZ=Asia/Shanghai
ADMIN_USERNAME=admin
ADMIN_PASSWORD=12345678

View File

@ -18,6 +18,7 @@ class Settings:
max_delivery_attempts: int = 3
retry_backoff_seconds: int = 60
feishu_timeout_seconds: int = 10
timezone: str = ""
def get_settings() -> Settings:
@ -33,4 +34,5 @@ def get_settings() -> Settings:
max_delivery_attempts=int(os.getenv("MAX_DELIVERY_ATTEMPTS", "3")),
retry_backoff_seconds=int(os.getenv("RETRY_BACKOFF_SECONDS", "60")),
feishu_timeout_seconds=int(os.getenv("FEISHU_TIMEOUT_SECONDS", "10")),
timezone=os.getenv("APP_TIMEZONE") or os.getenv("TZ", ""),
)

View File

@ -6,11 +6,13 @@ import mimetypes
import os
import urllib.error
import urllib.request
from datetime import datetime, timezone
from http import HTTPStatus
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from urllib.parse import parse_qs, urlparse
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from app.auth import COOKIE_NAME, check_credentials, hash_password, is_valid_session, make_session_cookie
from app.config import Settings, get_settings
@ -148,6 +150,33 @@ def render_pagination(base_path: str, active_tab: str, page: int, total: int, pa
)
def display_timezone(settings: Settings) -> timezone:
if settings.timezone:
try:
return ZoneInfo(settings.timezone)
except ZoneInfoNotFoundError:
return datetime.now().astimezone().tzinfo or timezone.utc
return datetime.now().astimezone().tzinfo or timezone.utc
def display_timezone_label(settings: Settings) -> str:
if settings.timezone:
return settings.timezone
return str(display_timezone(settings))
def format_display_time(settings: Settings, value: str | None) -> str:
if not value:
return ""
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return value
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(display_timezone(settings)).strftime("%Y-%m-%d %H:%M:%S")
class Handler(BaseHTTPRequestHandler):
context: AppContext
@ -452,7 +481,7 @@ class Handler(BaseHTTPRequestHandler):
("Pending", counts["pending"]),
])
rows = "".join(
f"<tr><td>{row['id']}</td><td>{html.escape(row['symbol'])}</td><td>{html.escape(row['timeframe'])}</td><td>{html.escape(row['strategy'])}</td><td><span class='status'>{html.escape(row['status'])}</span></td><td>{row['created_at']}</td></tr>"
f"<tr><td>{row['id']}</td><td>{html.escape(row['symbol'])}</td><td>{html.escape(row['timeframe'])}</td><td>{html.escape(row['strategy'])}</td><td><span class='status'>{html.escape(row['status'])}</span></td><td>{format_display_time(self.context.settings, row['created_at'])}</td></tr>"
for row in recent
)
self.send_html("概览", f"<header><h1>概览</h1><p>结构化 alert 分发、飞书转发和重试状态。</p></header>{webhook_panel}<section class='metrics'>{cards}</section><section><h2>最近 Alert</h2><table><thead><tr><th>ID</th><th>品种</th><th>周期</th><th>策略</th><th>状态</th><th>时间</th></tr></thead><tbody>{rows}</tbody></table></section>")
@ -692,6 +721,7 @@ class Handler(BaseHTTPRequestHandler):
raw_payload = json.dumps(json.loads(row["payload"]), ensure_ascii=False, indent=2)
except Exception:
raw_payload = row["payload"] or ""
created_at = format_display_time(self.context.settings, row["created_at"])
alert_rows += f"""<tr>
<td>{row['id']}</td>
<td>{html.escape(row['symbol'])}</td>
@ -699,25 +729,26 @@ class Handler(BaseHTTPRequestHandler):
<td>{html.escape(row['strategy'])}</td>
<td><span class='status'>{html.escape(row['status'])}</span></td>
<td>{html.escape(row['error'] or '')}</td>
<td>{row['created_at']}</td>
<td>{created_at}</td>
<td><details class="payload-details"><summary>查看</summary><pre>{html.escape(raw_payload)}</pre></details></td>
</tr>"""
delivery_rows = "".join(
f"<tr><td>{row['id']}</td><td>{row['alert_id']}</td><td>{html.escape(row['target_name'])}</td><td><span class='status'>{html.escape(row['status'])}</span></td><td>{row['attempts']}</td><td>{html.escape(str(row['response_code'] or ''))}</td><td>{html.escape(row['error'] or '')}</td><td>{html.escape(row['next_attempt_at'] or '')}</td></tr>"
f"<tr><td>{row['id']}</td><td>{row['alert_id']}</td><td>{html.escape(row['target_name'])}</td><td><span class='status'>{html.escape(row['status'])}</span></td><td>{row['attempts']}</td><td>{html.escape(str(row['response_code'] or ''))}</td><td>{format_display_time(self.context.settings, row['last_attempt_at'])}</td><td>{html.escape(row['error'] or '')}</td><td>{format_display_time(self.context.settings, row['next_attempt_at'])}</td></tr>"
for row in logs["deliveries"]
)
alert_empty = '<tr><td colspan="8" class="empty-cell">暂无 Alert 日志</td></tr>'
delivery_empty = '<tr><td colspan="8" class="empty-cell">暂无分发日志</td></tr>'
delivery_empty = '<tr><td colspan="9" class="empty-cell">暂无分发日志</td></tr>'
alert_active = " active" if active_tab == "alerts" else ""
delivery_active = " active" if active_tab == "deliveries" else ""
active_table = (
f"""<table><thead><tr><th>ID</th><th>品种</th><th>周期</th><th>策略</th><th>状态</th><th>错误</th><th>时间</th><th>原始 Alert</th></tr></thead><tbody>{alert_rows or alert_empty}</tbody></table>
{render_pagination("/logs", "alerts", page, logs["alert_total"], LOG_PAGE_SIZE)}"""
if active_tab == "alerts"
else f"""<table><thead><tr><th>ID</th><th>Alert</th><th>目标</th><th>状态</th><th>次数</th><th>HTTP</th><th>错误</th><th>下次重试</th></tr></thead><tbody>{delivery_rows or delivery_empty}</tbody></table>
else f"""<table><thead><tr><th>ID</th><th>Alert</th><th>目标</th><th>状态</th><th>次数</th><th>HTTP</th><th>发送时间</th><th>错误</th><th>下次重试</th></tr></thead><tbody>{delivery_rows or delivery_empty}</tbody></table>
{render_pagination("/logs", "deliveries", page, logs["delivery_total"], LOG_PAGE_SIZE)}"""
)
body = f"""<header class="page-header"><div><h1>日志</h1><p>按类型查看 Alert 和分发记录,每页 {LOG_PAGE_SIZE} 条。</p></div>
timezone_label = html.escape(display_timezone_label(self.context.settings))
body = f"""<header class="page-header"><div><h1>日志</h1><p>按类型查看 Alert 和分发记录,每页 {LOG_PAGE_SIZE} 条。当前显示时区:{timezone_label}。</p></div>
<form method="post" action="/deliveries/retry"><button type="submit">处理到期重试</button></form></header>
<nav class="tabs" aria-label="日志类型">
<a class="tab{alert_active}" href="/logs?tab=alerts&page=1">Alert 日志 <span>{logs["alert_total"]}</span></a>

View File

@ -4,7 +4,7 @@ services:
ports:
- "8030:8000"
environment:
TZ: Asia/Shanghai
TZ: ${TZ:-Asia/Shanghai}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me-now}
SESSION_SECRET: ${SESSION_SECRET:-replace-with-a-long-random-secret}
@ -19,7 +19,7 @@ services:
build: .
command: ["python", "-m", "app.worker"]
environment:
TZ: Asia/Shanghai
TZ: ${TZ:-Asia/Shanghai}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me-now}
SESSION_SECRET: ${SESSION_SECRET:-replace-with-a-long-random-secret}

View File

@ -7,6 +7,7 @@ import unittest
from app.config import Settings
from app.db import Database, now_iso, to_json
from app.dispatcher import Dispatcher, ValidationError, build_feishu_message
from app.server import format_display_time
class DispatcherTest(unittest.TestCase):
@ -190,6 +191,13 @@ class DispatcherTest(unittest.TestCase):
self.assertEqual(message["card"]["header"]["title"]["content"], "TradingView BTCUSDT buy")
self.assertEqual(message["card"]["elements"][0]["text"]["content"], "价格 68000")
def test_display_time_uses_configured_timezone(self) -> None:
settings = Settings(timezone="Asia/Shanghai")
rendered = format_display_time(settings, "2026-05-18T08:20:00+00:00")
self.assertEqual(rendered, "2026-05-18 16:20:00")
if __name__ == "__main__":
unittest.main()