监控网页实现

This commit is contained in:
Z
2026-04-09 00:14:57 +08:00
parent 6b1c7242c7
commit 1cce5634b1
17 changed files with 1186 additions and 38 deletions

View File

@@ -0,0 +1,378 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Hightube Admin Console</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=IBM+Plex+Mono:wght@400;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0f1c2e;
--bg2: #102944;
--card: #f7f6f3;
--ink: #12263d;
--accent: #ff6b35;
--accent2: #0ea5a3;
--danger: #dc2626;
--ok: #15803d;
--line: #d9d5cc;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Space Grotesk", "Segoe UI", sans-serif;
color: var(--ink);
background:
radial-gradient(1200px 600px at -10% -10%, #2a4b70 0%, transparent 65%),
radial-gradient(1000px 500px at 110% 0%, #1f4f6d 0%, transparent 65%),
linear-gradient(140deg, var(--bg) 0%, var(--bg2) 100%);
min-height: 100vh;
padding: 20px;
}
.layout {
max-width: 1280px;
margin: 0 auto;
display: grid;
gap: 16px;
grid-template-columns: repeat(12, 1fr);
animation: appear 500ms ease-out;
}
@keyframes appear {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 16px;
box-shadow: 0 12px 30px rgba(0,0,0,0.22);
padding: 14px;
}
.full { grid-column: 1 / -1; }
.col4 { grid-column: span 4; }
.col6 { grid-column: span 6; }
.col8 { grid-column: span 8; }
h1 { margin: 0; color: #f8fafc; letter-spacing: .02em; }
h2 { margin: 0 0 10px; font-size: 18px; }
.topbar {
grid-column: 1 / -1;
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.tokenbox {
display: flex;
gap: 8px;
width: min(800px, 100%);
}
input, select, button {
border-radius: 10px;
border: 1px solid #c8c2b7;
padding: 8px 10px;
font-size: 14px;
font-family: inherit;
}
input { width: 100%; }
button {
cursor: pointer;
color: #fff;
background: linear-gradient(120deg, var(--accent), #f97316);
border: none;
font-weight: 700;
}
button.secondary {
background: #184f77;
}
button.danger {
background: var(--danger);
}
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.metric {
border: 1px dashed #c9c2b4;
padding: 8px;
border-radius: 12px;
}
.metric .k { font-size: 12px; opacity: .75; }
.metric .v { font-size: 22px; font-weight: 700; }
.mono { font-family: "IBM Plex Mono", monospace; font-size: 12px; }
#logs {
height: 280px;
overflow: auto;
background: #0e1624;
color: #d9f6ea;
border-radius: 10px;
padding: 10px;
white-space: pre-wrap;
font-family: "IBM Plex Mono", monospace;
border: 1px solid #1f3552;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
th, td {
text-align: left;
border-bottom: 1px solid var(--line);
padding: 8px 6px;
}
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
.pill {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
.pill.ok { background: #dcfce7; color: var(--ok); }
.pill.off { background: #fee2e2; color: var(--danger); }
.pill.admin { background: #dbeafe; color: #1d4ed8; }
.pill.user { background: #e2e8f0; color: #334155; }
@media (max-width: 960px) {
.col4, .col6, .col8 { grid-column: 1 / -1; }
.stats { grid-template-columns: repeat(2, 1fr); }
.topbar { flex-direction: column; align-items: stretch; }
}
</style>
</head>
<body>
<div class="layout">
<div class="topbar">
<h1>Hightube Admin Console</h1>
<div class="tokenbox">
<input id="token" placeholder="粘贴 admin JWT token来自 /api/login" />
<button onclick="connectAll()">连接</button>
</div>
</div>
<div class="card col8">
<h2>系统状态</h2>
<div class="stats" id="stats"></div>
<div class="mono" id="health"></div>
</div>
<div class="card col4">
<h2>在线状态</h2>
<div id="online"></div>
</div>
<div class="card col8">
<h2>实时日志</h2>
<div style="display:flex; gap:8px; margin-bottom:8px;">
<button class="secondary" onclick="loadHistory()">加载历史日志</button>
<select id="logLevel">
<option value="">全部级别</option>
<option value="info">info</option>
<option value="warn">warn</option>
<option value="error">error</option>
<option value="audit">audit</option>
</select>
<input id="logKeyword" placeholder="关键词过滤" />
</div>
<div id="logs"></div>
</div>
<div class="card col4">
<h2>操作说明</h2>
<ul>
<li>先在上方输入 admin token 并点击连接。</li>
<li>用户管理支持搜索、角色切换、启用禁用、重置密码、删除。</li>
<li>日志区会持续接收后端实时事件。</li>
</ul>
</div>
<div class="card full">
<h2>用户管理</h2>
<div style="display:flex; gap:8px; margin-bottom:8px;">
<input id="userKeyword" placeholder="按用户名搜索" />
<button class="secondary" onclick="loadUsers()">查询</button>
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>用户名</th>
<th>角色</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="usersBody"></tbody>
</table>
</div>
</div>
<script>
let evt = null;
function authHeaders() {
const token = document.getElementById('token').value.trim();
return {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
};
}
function addLogLine(text) {
const box = document.getElementById('logs');
box.textContent += text + '\n';
box.scrollTop = box.scrollHeight;
}
async function connectAll() {
await Promise.all([loadOverview(), loadHealth(), loadUsers(), loadHistory()]);
connectLiveLogs();
setInterval(loadOverview, 1000);
setInterval(loadHealth, 1000);
}
async function loadOverview() {
const resp = await fetch('/api/admin/overview', { headers: authHeaders() });
if (!resp.ok) {
addLogLine('[error] 概览拉取失败,请检查 token 是否为 admin。');
return;
}
const data = await resp.json();
const sys = data.system || {};
const stream = data.stream || {};
const chat = data.chat || {};
document.getElementById('stats').innerHTML = `
<div class="metric"><div class="k">运行时长(秒)</div><div class="v">${sys.uptime_seconds ?? '-'}</div></div>
<div class="metric"><div class="k">请求总量</div><div class="v">${sys.requests_total ?? '-'}</div></div>
<div class="metric"><div class="k">错误总量</div><div class="v">${sys.errors_total ?? '-'}</div></div>
<div class="metric"><div class="k">Goroutines</div><div class="v">${sys.goroutines ?? '-'}</div></div>
<div class="metric"><div class="k">内存Alloc(MB)</div><div class="v">${(sys.memory_alloc_mb || 0).toFixed(1)}</div></div>
<div class="metric"><div class="k">内存Sys(MB)</div><div class="v">${(sys.memory_sys_mb || 0).toFixed(1)}</div></div>
<div class="metric"><div class="k">CPU核心数</div><div class="v">${sys.cpu_cores ?? '-'}</div></div>
<div class="metric"><div class="k">磁盘剩余/总量(GB)</div><div class="v">${(sys.disk_free_gb || 0).toFixed(1)} / ${(sys.disk_total_gb || 0).toFixed(1)}</div></div>
`;
document.getElementById('online').innerHTML = `
<p>活跃流数量:<b>${stream.active_stream_count ?? 0}</b></p>
<p>活跃聊天室:<b>${chat.room_count ?? 0}</b></p>
<p>在线聊天连接:<b>${chat.total_connected_client ?? 0}</b></p>
<div class="mono">流路径: ${(stream.active_stream_paths || []).join(', ') || '无'}</div>
`;
}
async function loadHealth() {
const resp = await fetch('/api/admin/health', { headers: authHeaders() });
if (!resp.ok) return;
const h = await resp.json();
const dbOk = h.db && h.db.ok;
document.getElementById('health').innerHTML =
`API: <span class="pill ${h.api ? 'ok' : 'off'}">${h.api ? 'UP' : 'DOWN'}</span> ` +
`RTMP: <span class="pill ${h.rtmp ? 'ok' : 'off'}">${h.rtmp ? 'UP' : 'DOWN'}</span> ` +
`DB: <span class="pill ${dbOk ? 'ok' : 'off'}">${dbOk ? 'UP' : 'DOWN'}</span>`;
}
async function loadHistory() {
const level = encodeURIComponent(document.getElementById('logLevel').value || '');
const keyword = encodeURIComponent(document.getElementById('logKeyword').value || '');
const resp = await fetch(`/api/admin/logs?page=1&page_size=100&level=${level}&keyword=${keyword}`, {
headers: authHeaders()
});
if (!resp.ok) return;
const data = await resp.json();
const items = data.items || [];
const box = document.getElementById('logs');
box.textContent = '';
items.forEach(it => addLogLine(`[${it.time}] [${it.level}] ${it.message}`));
}
function connectLiveLogs() {
if (evt) evt.close();
const token = document.getElementById('token').value.trim();
evt = new EventSource('/api/admin/logs/stream?token=' + encodeURIComponent(token));
evt.onmessage = () => {};
evt.addEventListener('log', (e) => {
const item = JSON.parse(e.data);
addLogLine(`[${item.time}] [${item.level}] ${item.message}`);
});
evt.onerror = () => {
addLogLine('[warn] 实时日志连接断开,稍后可重连。');
};
}
async function loadUsers() {
const keyword = encodeURIComponent(document.getElementById('userKeyword').value || '');
const resp = await fetch(`/api/admin/users?page=1&page_size=50&keyword=${keyword}`, {
headers: authHeaders()
});
if (!resp.ok) {
addLogLine('[error] 用户列表拉取失败。');
return;
}
const data = await resp.json();
const body = document.getElementById('usersBody');
body.innerHTML = '';
(data.items || []).forEach(u => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${u.id}</td>
<td>${u.username}</td>
<td><span class="pill ${u.role === 'admin' ? 'admin' : 'user'}">${u.role}</span></td>
<td><span class="pill ${u.enabled ? 'ok' : 'off'}">${u.enabled ? 'enabled' : 'disabled'}</span></td>
<td>${new Date(u.created_at).toLocaleString()}</td>
<td class="actions">
<button class="secondary" onclick="toggleRole(${u.id}, '${u.role}')">切换角色</button>
<button class="secondary" onclick="toggleEnabled(${u.id}, ${u.enabled})">启用/禁用</button>
<button class="secondary" onclick="resetPwd(${u.id})">重置密码</button>
<button class="danger" onclick="deleteUser(${u.id})">删除</button>
</td>
`;
body.appendChild(tr);
});
}
async function toggleRole(id, role) {
const next = role === 'admin' ? 'user' : 'admin';
await fetch(`/api/admin/users/${id}/role`, {
method: 'PATCH',
headers: authHeaders(),
body: JSON.stringify({ role: next })
});
loadUsers();
}
async function toggleEnabled(id, enabled) {
await fetch(`/api/admin/users/${id}/enabled`, {
method: 'PATCH',
headers: authHeaders(),
body: JSON.stringify({ enabled: !enabled })
});
loadUsers();
}
async function resetPwd(id) {
const newPwd = prompt('输入新密码(至少 6 位)');
if (!newPwd) return;
await fetch(`/api/admin/users/${id}/reset-password`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ new_password: newPwd })
});
addLogLine(`[audit] user ${id} password reset requested`);
}
async function deleteUser(id) {
if (!confirm('确认删除该用户?')) return;
await fetch(`/api/admin/users/${id}`, {
method: 'DELETE',
headers: authHeaders()
});
loadUsers();
}
</script>
</body>
</html>