Files
ai-ops/internal/handler/dashboard_handler.go
2026-05-12 17:48:22 +08:00

119 lines
5.8 KiB
Go

package handler
import (
"html/template"
"net/http"
)
// DashboardHandler 是前端页面路由处理器
type DashboardHandler struct {
templates *template.Template
}
func NewDashboardHandler() *DashboardHandler {
tmpl := template.Must(template.New("dashboard").Parse(dashboardHTML))
return &DashboardHandler{templates: tmpl}
}
// RegisterRoutes 注册页面路由
func (h *DashboardHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /ops/dashboard", h.Dashboard)
mux.HandleFunc("GET /ops/dashboard/logs", h.Dashboard)
mux.HandleFunc("GET /ops/dashboard/rules", h.Dashboard)
mux.HandleFunc("GET /ops/dashboard/alerts", h.Dashboard)
mux.HandleFunc("GET /ops/dashboard/channels", h.Dashboard)
}
// Dashboard 首页
func (h *DashboardHandler) Dashboard(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = h.templates.ExecuteTemplate(w, "dashboard", nil)
}
const dashboardHTML = `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>AI-Ops 运维看板</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; background: #0f172a; color: #e2e8f0; }
header { padding: 18px 28px; background: #111827; border-bottom: 1px solid #334155; display:flex; justify-content:space-between; align-items:center; }
main { padding: 24px; display: grid; gap: 18px; }
.grid { display: grid; grid-template-columns: repeat(4, minmax(140px, 1fr)); gap: 14px; }
.card { background:#111827; border:1px solid #334155; border-radius:12px; padding:16px; box-shadow:0 10px 24px rgba(0,0,0,.18); }
.metric { font-size: 28px; font-weight: 700; color:#38bdf8; margin-top:8px; }
button, input { border-radius:8px; border:1px solid #475569; background:#0b1220; color:#e2e8f0; padding:8px 10px; }
button { cursor:pointer; background:#2563eb; border-color:#2563eb; }
table { width:100%; border-collapse: collapse; font-size: 14px; }
th, td { border-bottom: 1px solid #334155; padding: 8px; text-align:left; vertical-align: top; }
th { color:#93c5fd; }
.muted { color:#94a3b8; }
.row { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
.error { color:#fca5a5; white-space:pre-wrap; }
code { color:#bae6fd; }
</style>
</head>
<body>
<header>
<div><strong>AI-Ops 运维看板</strong><span class="muted"> · 规则 / 事件 / 渠道 / 日志</span></div>
<div class="row"><input id="username" placeholder="admin" value="admin"><input id="password" type="password" placeholder="admin" value="admin"><button onclick="login()">登录</button><button onclick="loadAll()">刷新</button></div>
</header>
<main>
<section class="grid">
<div class="card">QPS<div id="qps" class="metric">-</div></div>
<div class="card">平均延迟<div id="avg" class="metric">-</div></div>
<div class="card">P99<div id="p99" class="metric">-</div></div>
<div class="card">错误率<div id="err" class="metric">-</div></div>
</section>
<section class="card"><h3>告警事件</h3><div id="alerts"></div></section>
<section class="card"><h3>告警规则</h3><div id="rules"></div></section>
<section class="card"><h3>通知渠道</h3><div id="channels"></div></section>
<section class="card"><h3>日志</h3><div id="logs"></div></section>
<section class="card error" id="error"></section>
</main>
<script>
const api = '/api/v1/ai-ops';
function token(){ return localStorage.getItem('ai_ops_token') || ''; }
function setError(e){ document.getElementById('error').textContent = e ? String(e) : ''; }
async function login(){
setError('');
const res = await fetch(api + '/login', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({username: username.value, password: password.value})});
const data = await res.json();
const t = data?.data?.token || data?.token;
if(!res.ok || !t){ setError(JSON.stringify(data)); return; }
localStorage.setItem('ai_ops_token', t);
await loadAll();
}
async function get(path){
const res = await fetch(api + path, {headers:{Authorization:'Bearer ' + token()}});
const data = await res.json();
if(!res.ok) throw new Error(path + ' ' + JSON.stringify(data));
return data.data ?? data;
}
function table(rows, cols){
if(!Array.isArray(rows) || rows.length === 0) return '<p class="muted">暂无数据</p>';
return '<table><thead><tr>'+cols.map(c=>'<th>'+c[0]+'</th>').join('')+'</tr></thead><tbody>'+rows.map(r=>'<tr>'+cols.map(c=>'<td>'+escapeHtml(String(r[c[1]] ?? ''))+'</td>').join('')+'</tr>').join('')+'</tbody></table>';
}
function escapeHtml(s){ return s.replace(/[&<>"]/g, m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m])); }
async function loadAll(){
try{
setError('');
const m = await get('/metrics/realtime');
qps.textContent = m.qps ?? '-'; avg.textContent = (m.avg_latency_ms ?? '-') + 'ms'; p99.textContent = (m.p99_latency_ms ?? '-') + 'ms'; err.textContent = m.error_rate ?? '-';
const ev = await get('/alerts?page=1&page_size=20');
alerts.innerHTML = table(ev.items || [], [['级别','level'],['资源','resource_id'],['状态','status'],['聚合','is_aggregated'],['数量','aggregated_count'],['开始时间','started_at']]);
const rs = await get('/rules');
rules.innerHTML = table(rs || [], [['名称','name'],['指标','metric_name'],['条件','threshold_type'],['阈值','threshold_value'],['级别','level'],['启用','enabled']]);
const cs = await get('/channels');
channels.innerHTML = table(cs || [], [['名称','name'],['类型','channel_type'],['优先级','priority'],['启用','enabled']]);
const lg = await get('/logs?page=1&page_size=20');
logs.innerHTML = table(lg.items || [], [['服务','service'],['级别','level'],['消息','message'],['时间','timestamp']]);
}catch(e){ setError(e); }
}
if(token()) loadAll();
</script>
</body>
</html>
`