This commit is contained in:
aaron 2026-05-18 16:16:40 +08:00
parent 377c6ee930
commit 1fff753260
3 changed files with 201 additions and 9 deletions

View File

@ -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'<div class="pagination muted-note">共 {total} 条</div>'
def item(label: str, target_page: int, disabled: bool = False, current: bool = False) -> str:
if disabled:
return f'<span class="page-link disabled">{html.escape(label)}</span>'
active_class = " current" if current else ""
return (
f'<a class="page-link{active_class}" '
f'href="{base_path}?tab={active_tab}&page={target_page}">{html.escape(label)}</a>'
)
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 (
'<div class="pagination">'
f'<span class="page-total">共 {total} 条,第 {page} / {total_pages} 页</span>'
f'{"".join(links)}'
'</div>'
)
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):
</section>"""
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"<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>"
for row in logs["deliveries"]
)
body = f"""<header><h1>日志</h1><p>最近 100 条 alert 和 200 条分发任务。</p></header>
<form method="post" action="/deliveries/retry"><button type="submit">处理到期重试</button></form>
<section><h2>Alert 日志</h2><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}</tbody></table></section>
<section><h2>Delivery 日志</h2><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}</tbody></table></section>"""
alert_empty = '<tr><td colspan="8" class="empty-cell">暂无 Alert 日志</td></tr>'
delivery_empty = '<tr><td colspan="8" 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>
{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>
<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>
<a class="tab{delivery_active}" href="/logs?tab=deliveries&page=1">分发日志 <span>{logs["delivery_total"]}</span></a>
</nav>
<section class="log-panel">{active_table}</section>"""
self.send_html("日志", body)
def render_test(self) -> None:

View File

@ -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;
}

View File

@ -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}