From 1fff7532603d9f5969f0a43d0879dce6f91b8c55 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Mon, 18 May 2026 16:16:40 +0800 Subject: [PATCH] uopdate --- app/server.py | 115 ++++++++++++++++++++++++++++++++++++++---- app/static/styles.css | 93 ++++++++++++++++++++++++++++++++++ docker-compose.yml | 2 + 3 files changed, 201 insertions(+), 9 deletions(-) diff --git a/app/server.py b/app/server.py index 0099a9d..4bee7a0 100644 --- a/app/server.py +++ b/app/server.py @@ -18,6 +18,9 @@ from app.db import Database, from_json, now_iso, to_json from app.dispatcher import Dispatcher, ValidationError, build_feishu_message, normalize_alert +LOG_PAGE_SIZE = 25 + + class AppContext: def __init__(self, settings: Settings): self.settings = settings @@ -104,6 +107,47 @@ def template_picker(templates: list[dict[str, Any]]) -> str: ) +def parse_positive_int(value: str | None, default: int = 1) -> int: + try: + parsed = int(value or "") + except ValueError: + return default + return parsed if parsed > 0 else default + + +def page_window(current: int, total_pages: int) -> list[int]: + start = max(1, current - 2) + end = min(total_pages, current + 2) + return list(range(start, end + 1)) + + +def render_pagination(base_path: str, active_tab: str, page: int, total: int, page_size: int) -> str: + total_pages = max(1, (total + page_size - 1) // page_size) + if total_pages <= 1: + return f'' + + def item(label: str, target_page: int, disabled: bool = False, current: bool = False) -> str: + if disabled: + return f'{html.escape(label)}' + active_class = " current" if current else "" + return ( + f'{html.escape(label)}' + ) + + links = [ + item("上一页", page - 1, disabled=page <= 1), + *(item(str(number), number, current=number == page) for number in page_window(page, total_pages)), + item("下一页", page + 1, disabled=page >= total_pages), + ] + return ( + '' + ) + + class Handler(BaseHTTPRequestHandler): context: AppContext @@ -146,7 +190,7 @@ class Handler(BaseHTTPRequestHandler): elif parsed.path == "/rules/delete": self.render_rule_delete(parsed) elif parsed.path == "/logs": - self.render_logs() + self.render_logs(parsed) elif parsed.path == "/test": self.render_test() elif parsed.path == "/account": @@ -156,7 +200,7 @@ class Handler(BaseHTTPRequestHandler): elif parsed.path == "/api/rules": json_response(self, 200, self.list_rules()) elif parsed.path == "/api/logs": - json_response(self, 200, self.list_logs()) + json_response(self, 200, self.list_recent_logs()) else: self.send_error(404) @@ -339,7 +383,36 @@ class Handler(BaseHTTPRequestHandler): rows = conn.execute("SELECT * FROM message_templates ORDER BY id DESC").fetchall() return [dict(row) for row in rows] - def list_logs(self) -> dict[str, list[dict[str, Any]]]: + def list_logs( + self, + active_tab: str = "alerts", + page: int = 1, + page_size: int = LOG_PAGE_SIZE, + ) -> dict[str, Any]: + offset = (page - 1) * page_size + with self.context.db.connect() as conn: + alert_total = conn.execute("SELECT COUNT(*) AS c FROM alerts").fetchone()["c"] + delivery_total = conn.execute("SELECT COUNT(*) AS c FROM deliveries").fetchone()["c"] + alerts = [] + deliveries = [] + if active_tab == "alerts": + alerts = conn.execute( + "SELECT * FROM alerts ORDER BY id DESC LIMIT ? OFFSET ?", + (page_size, offset), + ).fetchall() + else: + deliveries = conn.execute( + "SELECT * FROM deliveries ORDER BY id DESC LIMIT ? OFFSET ?", + (page_size, offset), + ).fetchall() + return { + "alerts": [dict(row) for row in alerts], + "deliveries": [dict(row) for row in deliveries], + "alert_total": alert_total, + "delivery_total": delivery_total, + } + + def list_recent_logs(self) -> dict[str, list[dict[str, Any]]]: with self.context.db.connect() as conn: alerts = conn.execute("SELECT * FROM alerts ORDER BY id DESC LIMIT 100").fetchall() deliveries = conn.execute("SELECT * FROM deliveries ORDER BY id DESC LIMIT 200").fetchall() @@ -601,8 +674,18 @@ class Handler(BaseHTTPRequestHandler): """ self.send_html("删除路由规则", body) - def render_logs(self) -> None: - logs = self.list_logs() + def render_logs(self, parsed: Any) -> None: + query = parse_qs(parsed.query) + active_tab = query.get("tab", ["alerts"])[-1] + if active_tab not in {"alerts", "deliveries"}: + active_tab = "alerts" + page = parse_positive_int(query.get("page", ["1"])[-1]) + total_key = "alert_total" if active_tab == "alerts" else "delivery_total" + logs = self.list_logs(active_tab=active_tab, page=page) + total_pages = max(1, (logs[total_key] + LOG_PAGE_SIZE - 1) // LOG_PAGE_SIZE) + if page > total_pages: + page = total_pages + logs = self.list_logs(active_tab=active_tab, page=page) alert_rows = "" for row in logs["alerts"]: try: @@ -623,10 +706,24 @@ class Handler(BaseHTTPRequestHandler): f"{row['id']}{row['alert_id']}{html.escape(row['target_name'])}{html.escape(row['status'])}{row['attempts']}{html.escape(str(row['response_code'] or ''))}{html.escape(row['error'] or '')}{html.escape(row['next_attempt_at'] or '')}" for row in logs["deliveries"] ) - body = f"""

日志

最近 100 条 alert 和 200 条分发任务。

-
-

Alert 日志

{alert_rows}
ID品种周期策略状态错误时间原始 Alert
-

Delivery 日志

{delivery_rows}
IDAlert目标状态次数HTTP错误下次重试
""" + alert_empty = '暂无 Alert 日志' + delivery_empty = '暂无分发日志' + alert_active = " active" if active_tab == "alerts" else "" + delivery_active = " active" if active_tab == "deliveries" else "" + active_table = ( + f"""{alert_rows or alert_empty}
ID品种周期策略状态错误时间原始 Alert
+{render_pagination("/logs", "alerts", page, logs["alert_total"], LOG_PAGE_SIZE)}""" + if active_tab == "alerts" + else f"""{delivery_rows or delivery_empty}
IDAlert目标状态次数HTTP错误下次重试
+{render_pagination("/logs", "deliveries", page, logs["delivery_total"], LOG_PAGE_SIZE)}""" + ) + body = f""" + +
{active_table}
""" self.send_html("日志", body) def render_test(self) -> None: diff --git a/app/static/styles.css b/app/static/styles.css index 4ab3da7..672a7d2 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -66,6 +66,49 @@ nav a:hover { background: rgba(255, 255, 255, 0.09); } +.tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: -4px 0 18px; + font-family: ui-sans-serif, system-ui, sans-serif; +} + +.tab { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fffdf8; + color: var(--ink); + text-decoration: none; + font-weight: 800; +} + +.tab:hover, +.tab.active { + border-color: var(--accent); + background: #eef8f5; + color: var(--accent-strong); +} + +.tab span { + min-width: 24px; + padding: 2px 7px; + border-radius: 999px; + background: #ece7da; + color: var(--muted); + text-align: center; + font-size: 12px; +} + +.tab.active span { + background: var(--accent); + color: #fff; +} + .shell { position: relative; margin-left: 236px; @@ -511,6 +554,56 @@ pre { margin: 10px 0 0; } +.log-panel table { + margin-bottom: 12px; +} + +.pagination { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin: 12px 0 28px; + font-family: ui-sans-serif, system-ui, sans-serif; +} + +.page-total { + margin-right: 6px; + color: var(--muted); + font-size: 13px; +} + +.page-link { + display: inline-flex; + align-items: center; + min-height: 34px; + padding: 7px 11px; + border: 1px solid var(--line); + border-radius: 6px; + background: #fffdf8; + color: var(--ink); + font-size: 13px; + font-weight: 800; + text-decoration: none; +} + +.page-link.current { + border-color: var(--accent); + background: var(--accent); + color: #fff; +} + +.page-link.disabled { + color: #a9a092; + background: #eee9dc; +} + +.empty-cell { + padding: 28px; + color: var(--muted); + text-align: center; +} + .inline { display: inline; } diff --git a/docker-compose.yml b/docker-compose.yml index 4e7dfc2..6e65b33 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,7 @@ services: ports: - "8030:8000" environment: + 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} @@ -18,6 +19,7 @@ services: build: . command: ["python", "-m", "app.worker"] environment: + 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}