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
|
from app.dispatcher import Dispatcher, ValidationError, build_feishu_message, normalize_alert
|
||||||
|
|
||||||
|
|
||||||
|
LOG_PAGE_SIZE = 25
|
||||||
|
|
||||||
|
|
||||||
class AppContext:
|
class AppContext:
|
||||||
def __init__(self, settings: Settings):
|
def __init__(self, settings: Settings):
|
||||||
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):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
context: AppContext
|
context: AppContext
|
||||||
|
|
||||||
@ -146,7 +190,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
elif parsed.path == "/rules/delete":
|
elif parsed.path == "/rules/delete":
|
||||||
self.render_rule_delete(parsed)
|
self.render_rule_delete(parsed)
|
||||||
elif parsed.path == "/logs":
|
elif parsed.path == "/logs":
|
||||||
self.render_logs()
|
self.render_logs(parsed)
|
||||||
elif parsed.path == "/test":
|
elif parsed.path == "/test":
|
||||||
self.render_test()
|
self.render_test()
|
||||||
elif parsed.path == "/account":
|
elif parsed.path == "/account":
|
||||||
@ -156,7 +200,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
elif parsed.path == "/api/rules":
|
elif parsed.path == "/api/rules":
|
||||||
json_response(self, 200, self.list_rules())
|
json_response(self, 200, self.list_rules())
|
||||||
elif parsed.path == "/api/logs":
|
elif parsed.path == "/api/logs":
|
||||||
json_response(self, 200, self.list_logs())
|
json_response(self, 200, self.list_recent_logs())
|
||||||
else:
|
else:
|
||||||
self.send_error(404)
|
self.send_error(404)
|
||||||
|
|
||||||
@ -339,7 +383,36 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
rows = conn.execute("SELECT * FROM message_templates ORDER BY id DESC").fetchall()
|
rows = conn.execute("SELECT * FROM message_templates ORDER BY id DESC").fetchall()
|
||||||
return [dict(row) for row in rows]
|
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:
|
with self.context.db.connect() as conn:
|
||||||
alerts = conn.execute("SELECT * FROM alerts ORDER BY id DESC LIMIT 100").fetchall()
|
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()
|
deliveries = conn.execute("SELECT * FROM deliveries ORDER BY id DESC LIMIT 200").fetchall()
|
||||||
@ -601,8 +674,18 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
</section>"""
|
</section>"""
|
||||||
self.send_html("删除路由规则", body)
|
self.send_html("删除路由规则", body)
|
||||||
|
|
||||||
def render_logs(self) -> None:
|
def render_logs(self, parsed: Any) -> None:
|
||||||
logs = self.list_logs()
|
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 = ""
|
alert_rows = ""
|
||||||
for row in logs["alerts"]:
|
for row in logs["alerts"]:
|
||||||
try:
|
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>"
|
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"]
|
for row in logs["deliveries"]
|
||||||
)
|
)
|
||||||
body = f"""<header><h1>日志</h1><p>最近 100 条 alert 和 200 条分发任务。</p></header>
|
alert_empty = '<tr><td colspan="8" class="empty-cell">暂无 Alert 日志</td></tr>'
|
||||||
<form method="post" action="/deliveries/retry"><button type="submit">处理到期重试</button></form>
|
delivery_empty = '<tr><td colspan="8" class="empty-cell">暂无分发日志</td></tr>'
|
||||||
<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>
|
alert_active = " active" if active_tab == "alerts" else ""
|
||||||
<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>"""
|
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)
|
self.send_html("日志", body)
|
||||||
|
|
||||||
def render_test(self) -> None:
|
def render_test(self) -> None:
|
||||||
|
|||||||
@ -66,6 +66,49 @@ nav a:hover {
|
|||||||
background: rgba(255, 255, 255, 0.09);
|
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 {
|
.shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-left: 236px;
|
margin-left: 236px;
|
||||||
@ -511,6 +554,56 @@ pre {
|
|||||||
margin: 10px 0 0;
|
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 {
|
.inline {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8030:8000"
|
- "8030:8000"
|
||||||
environment:
|
environment:
|
||||||
|
TZ: Asia/Shanghai
|
||||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me-now}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me-now}
|
||||||
SESSION_SECRET: ${SESSION_SECRET:-replace-with-a-long-random-secret}
|
SESSION_SECRET: ${SESSION_SECRET:-replace-with-a-long-random-secret}
|
||||||
@ -18,6 +19,7 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
command: ["python", "-m", "app.worker"]
|
command: ["python", "-m", "app.worker"]
|
||||||
environment:
|
environment:
|
||||||
|
TZ: Asia/Shanghai
|
||||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me-now}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me-now}
|
||||||
SESSION_SECRET: ${SESSION_SECRET:-replace-with-a-long-random-secret}
|
SESSION_SECRET: ${SESSION_SECRET:-replace-with-a-long-random-secret}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user