uopdate
This commit is contained in:
parent
377c6ee930
commit
1fff753260
115
app/server.py
115
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'<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:
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user