Files
Hightube/backend/internal/api/static/admin/index.html
2026-04-09 00:14:57 +08:00

379 lines
13 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>