678 lines
19 KiB
HTML
678 lines
19 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<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: #eef4fb;
|
|
--bg-accent: #dbe9f7;
|
|
--surface: #ffffff;
|
|
--surface-soft: #f6f9fc;
|
|
--ink: #19324d;
|
|
--muted: #5a718a;
|
|
--line: #d7e1eb;
|
|
--primary: #2f6fed;
|
|
--primary-strong: #1d4ed8;
|
|
--primary-soft: #dce8ff;
|
|
--danger: #dc2626;
|
|
--danger-soft: #fee2e2;
|
|
--success: #15803d;
|
|
--success-soft: #dcfce7;
|
|
--warning: #b45309;
|
|
--shadow: 0 18px 44px rgba(29, 78, 216, 0.10);
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
body {
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
|
color: var(--ink);
|
|
background:
|
|
radial-gradient(900px 460px at -10% -20%, rgba(47,111,237,0.16) 0%, transparent 60%),
|
|
radial-gradient(800px 420px at 110% 0%, rgba(61,187,167,0.12) 0%, transparent 55%),
|
|
linear-gradient(180deg, var(--bg) 0%, #f8fbff 100%);
|
|
padding: 24px;
|
|
}
|
|
|
|
.shell {
|
|
max-width: 1280px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.hero {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 20px;
|
|
margin-bottom: 18px;
|
|
}
|
|
|
|
.brand h1 {
|
|
margin: 0;
|
|
font-size: clamp(28px, 5vw, 42px);
|
|
letter-spacing: 0.03em;
|
|
}
|
|
|
|
.brand p {
|
|
margin: 8px 0 0;
|
|
color: var(--muted);
|
|
max-width: 620px;
|
|
}
|
|
|
|
.badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 10px 14px;
|
|
border-radius: 999px;
|
|
background: rgba(255,255,255,0.78);
|
|
border: 1px solid rgba(47,111,237,0.12);
|
|
box-shadow: var(--shadow);
|
|
color: var(--primary-strong);
|
|
font-weight: 700;
|
|
}
|
|
|
|
.panel-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(12, 1fr);
|
|
gap: 16px;
|
|
}
|
|
|
|
.card {
|
|
background: rgba(255,255,255,0.88);
|
|
border: 1px solid var(--line);
|
|
border-radius: 20px;
|
|
padding: 18px;
|
|
box-shadow: var(--shadow);
|
|
backdrop-filter: blur(12px);
|
|
}
|
|
|
|
.login-card {
|
|
grid-column: 4 / span 6;
|
|
padding: 22px;
|
|
}
|
|
|
|
.full { grid-column: 1 / -1; }
|
|
.col4 { grid-column: span 4; }
|
|
.col5 { grid-column: span 5; }
|
|
.col7 { grid-column: span 7; }
|
|
.col8 { grid-column: span 8; }
|
|
|
|
h2 {
|
|
margin: 0 0 12px;
|
|
font-size: 19px;
|
|
}
|
|
|
|
.muted {
|
|
color: var(--muted);
|
|
}
|
|
|
|
.hidden {
|
|
display: none !important;
|
|
}
|
|
|
|
.stack {
|
|
display: grid;
|
|
gap: 12px;
|
|
}
|
|
|
|
.row {
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.row.between {
|
|
justify-content: space-between;
|
|
}
|
|
|
|
input, select, button {
|
|
border-radius: 14px;
|
|
border: 1px solid var(--line);
|
|
padding: 10px 12px;
|
|
font-size: 14px;
|
|
font-family: inherit;
|
|
}
|
|
|
|
input, select {
|
|
width: 100%;
|
|
background: var(--surface);
|
|
color: var(--ink);
|
|
}
|
|
|
|
button {
|
|
cursor: pointer;
|
|
border: none;
|
|
color: white;
|
|
background: linear-gradient(120deg, var(--primary), #5994ff);
|
|
font-weight: 700;
|
|
transition: transform 140ms ease, opacity 140ms ease;
|
|
}
|
|
|
|
button:hover {
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
button.secondary {
|
|
background: linear-gradient(120deg, #7ea7ff, #4b7df2);
|
|
}
|
|
|
|
button.subtle {
|
|
color: var(--ink);
|
|
background: var(--surface-soft);
|
|
border: 1px solid var(--line);
|
|
}
|
|
|
|
button.danger {
|
|
background: linear-gradient(120deg, #ef4444, #dc2626);
|
|
}
|
|
|
|
.stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 10px;
|
|
}
|
|
|
|
.metric {
|
|
background: var(--surface-soft);
|
|
border: 1px solid var(--line);
|
|
border-radius: 16px;
|
|
padding: 12px;
|
|
}
|
|
|
|
.metric .k {
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.metric .v {
|
|
margin-top: 8px;
|
|
font-size: 22px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.health-strip {
|
|
display: flex;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.health-chip {
|
|
min-width: 120px;
|
|
padding: 10px 12px;
|
|
border-radius: 14px;
|
|
background: var(--surface-soft);
|
|
border: 1px solid var(--line);
|
|
}
|
|
|
|
.health-chip .k {
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.mono {
|
|
font-family: "IBM Plex Mono", monospace;
|
|
font-size: 12px;
|
|
}
|
|
|
|
#logs {
|
|
height: 300px;
|
|
overflow: auto;
|
|
background: #f5f9ff;
|
|
color: #17304d;
|
|
border-radius: 16px;
|
|
padding: 12px;
|
|
white-space: pre-wrap;
|
|
border: 1px solid var(--line);
|
|
font-family: "IBM Plex Mono", monospace;
|
|
font-size: 12px;
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 14px;
|
|
}
|
|
|
|
th, td {
|
|
text-align: left;
|
|
border-bottom: 1px solid var(--line);
|
|
padding: 10px 8px;
|
|
vertical-align: top;
|
|
}
|
|
|
|
.actions {
|
|
display: flex;
|
|
gap: 6px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.pill {
|
|
display: inline-block;
|
|
padding: 4px 10px;
|
|
border-radius: 999px;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.pill.ok {
|
|
background: var(--success-soft);
|
|
color: var(--success);
|
|
}
|
|
|
|
.pill.off {
|
|
background: var(--danger-soft);
|
|
color: var(--danger);
|
|
}
|
|
|
|
.pill.admin {
|
|
background: var(--primary-soft);
|
|
color: var(--primary-strong);
|
|
}
|
|
|
|
.pill.user {
|
|
background: #edf2f7;
|
|
color: #475569;
|
|
}
|
|
|
|
.notice {
|
|
padding: 12px 14px;
|
|
border-radius: 14px;
|
|
background: #fff7ed;
|
|
border: 1px solid #fed7aa;
|
|
color: var(--warning);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.session-info {
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
}
|
|
|
|
@media (max-width: 960px) {
|
|
.login-card, .col4, .col5, .col7, .col8 {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.stats {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
|
|
.health-strip {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
|
|
.hero {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="shell">
|
|
<div class="hero">
|
|
<div class="brand">
|
|
<div class="badge">Admin Panel</div>
|
|
<h1>Hightube Control Console</h1>
|
|
<p>Lightweight operations dashboard for stream status, runtime health, audit logs, and account management.</p>
|
|
</div>
|
|
<div class="session-info" id="sessionInfo">Not signed in</div>
|
|
</div>
|
|
|
|
<div class="panel-grid" id="loginView">
|
|
<div class="card login-card">
|
|
<h2>Admin Sign In</h2>
|
|
<p class="muted">Use the administrator account to access monitoring, logs, and user controls. Default bootstrap credentials are <b>admin / admin</b> unless changed by environment variables.</p>
|
|
<div class="stack">
|
|
<input id="loginUsername" placeholder="Admin username" value="admin" />
|
|
<input id="loginPassword" type="password" placeholder="Password" value="admin" />
|
|
<div class="row">
|
|
<button onclick="login()">Sign In</button>
|
|
</div>
|
|
<div class="notice">
|
|
Change the default admin password immediately after first login.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel-grid hidden" id="appView">
|
|
<div class="card full">
|
|
<div class="row between">
|
|
<div>
|
|
<h2 style="margin-bottom:6px;">Session</h2>
|
|
<div class="muted">Authenticated through a server-managed admin session cookie.</div>
|
|
</div>
|
|
<div class="row">
|
|
<button class="subtle" onclick="refreshAll()">Refresh Now</button>
|
|
<button class="danger" onclick="logout()">Sign Out</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card col7">
|
|
<h2>System Overview</h2>
|
|
<div class="stats" id="stats"></div>
|
|
</div>
|
|
|
|
<div class="card col5">
|
|
<h2>Admin Password</h2>
|
|
<div class="stack">
|
|
<input id="oldPassword" type="password" placeholder="Current password" />
|
|
<input id="newPassword" type="password" placeholder="New password" />
|
|
<div class="row">
|
|
<button onclick="changePassword()">Update Password</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card col4">
|
|
<h2>Live Status</h2>
|
|
<div id="online"></div>
|
|
<div class="health-strip" id="health" style="margin-top:14px;"></div>
|
|
</div>
|
|
|
|
<div class="card col8">
|
|
<div class="row between" style="margin-bottom:10px;">
|
|
<h2 style="margin:0;">Audit and Runtime Logs</h2>
|
|
<div class="row">
|
|
<select id="logLevel">
|
|
<option value="">All levels</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="Filter keyword" style="width:220px;" />
|
|
<button class="secondary" onclick="loadHistory()">Load History</button>
|
|
</div>
|
|
</div>
|
|
<div id="logs"></div>
|
|
</div>
|
|
|
|
<div class="card full">
|
|
<div class="row between" style="margin-bottom:10px;">
|
|
<h2 style="margin:0;">User Management</h2>
|
|
<div class="row">
|
|
<input id="userKeyword" placeholder="Search by username" style="width:220px;" />
|
|
<button class="secondary" onclick="loadUsers()">Search</button>
|
|
</div>
|
|
</div>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Username</th>
|
|
<th>Role</th>
|
|
<th>Status</th>
|
|
<th>Created</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="usersBody"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let evt = null;
|
|
let overviewTimer = null;
|
|
let healthTimer = null;
|
|
let currentAdmin = null;
|
|
|
|
function setSessionText(text) {
|
|
document.getElementById('sessionInfo').textContent = text;
|
|
}
|
|
|
|
function addLogLine(text) {
|
|
const box = document.getElementById('logs');
|
|
box.textContent += text + '\n';
|
|
box.scrollTop = box.scrollHeight;
|
|
}
|
|
|
|
async function api(path, options = {}) {
|
|
const response = await fetch(path, {
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...(options.headers || {}),
|
|
},
|
|
...options,
|
|
});
|
|
|
|
let data = null;
|
|
const text = await response.text();
|
|
if (text) {
|
|
try {
|
|
data = JSON.parse(text);
|
|
} catch (_) {
|
|
data = { raw: text };
|
|
}
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const message = (data && (data.error || data.message)) || `Request failed (${response.status})`;
|
|
throw new Error(message);
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
function clearPolling() {
|
|
if (overviewTimer) clearInterval(overviewTimer);
|
|
if (healthTimer) clearInterval(healthTimer);
|
|
overviewTimer = null;
|
|
healthTimer = null;
|
|
}
|
|
|
|
function disconnectLogs() {
|
|
if (evt) {
|
|
evt.close();
|
|
evt = null;
|
|
}
|
|
}
|
|
|
|
function showLogin() {
|
|
clearPolling();
|
|
disconnectLogs();
|
|
currentAdmin = null;
|
|
setSessionText('Not signed in');
|
|
document.getElementById('loginView').classList.remove('hidden');
|
|
document.getElementById('appView').classList.add('hidden');
|
|
}
|
|
|
|
function showApp(session) {
|
|
currentAdmin = session;
|
|
setSessionText(`Signed in as ${session.username}`);
|
|
document.getElementById('loginView').classList.add('hidden');
|
|
document.getElementById('appView').classList.remove('hidden');
|
|
}
|
|
|
|
async function ensureSession() {
|
|
try {
|
|
const session = await api('/api/admin/session');
|
|
showApp(session);
|
|
await refreshAll();
|
|
connectLiveLogs();
|
|
clearPolling();
|
|
overviewTimer = setInterval(loadOverview, 1000);
|
|
healthTimer = setInterval(loadHealth, 1000);
|
|
} catch (_) {
|
|
showLogin();
|
|
}
|
|
}
|
|
|
|
async function login() {
|
|
const username = document.getElementById('loginUsername').value.trim();
|
|
const password = document.getElementById('loginPassword').value;
|
|
try {
|
|
await api('/api/admin/login', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ username, password }),
|
|
});
|
|
document.getElementById('loginPassword').value = '';
|
|
await ensureSession();
|
|
} catch (error) {
|
|
alert(error.message);
|
|
}
|
|
}
|
|
|
|
async function logout() {
|
|
try {
|
|
await api('/api/admin/logout', { method: 'POST' });
|
|
} catch (_) {
|
|
} finally {
|
|
showLogin();
|
|
}
|
|
}
|
|
|
|
async function refreshAll() {
|
|
await Promise.all([loadOverview(), loadHealth(), loadUsers(), loadHistory()]);
|
|
}
|
|
|
|
async function loadOverview() {
|
|
const data = await api('/api/admin/overview');
|
|
const sys = data.system || {};
|
|
const stream = data.stream || {};
|
|
const chat = data.chat || {};
|
|
|
|
document.getElementById('stats').innerHTML = `
|
|
<div class="metric"><div class="k">Uptime (s)</div><div class="v">${sys.uptime_seconds ?? '-'}</div></div>
|
|
<div class="metric"><div class="k">Requests</div><div class="v">${sys.requests_total ?? '-'}</div></div>
|
|
<div class="metric"><div class="k">Errors</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">System Mem (MB)</div><div class="v">${(sys.memory_sys_mb || 0).toFixed(1)}</div></div>
|
|
<div class="metric"><div class="k">CPU Cores</div><div class="v">${sys.cpu_cores ?? '-'}</div></div>
|
|
<div class="metric"><div class="k">Disk Free / Total (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>Active streams: <b>${stream.active_stream_count ?? 0}</b></p>
|
|
<p>Active chat rooms: <b>${chat.room_count ?? 0}</b></p>
|
|
<p>Connected chat clients: <b>${chat.total_connected_client ?? 0}</b></p>
|
|
<div class="mono">Stream paths: ${(stream.active_stream_paths || []).join(', ') || 'none'}</div>
|
|
`;
|
|
}
|
|
|
|
async function loadHealth() {
|
|
const h = await api('/api/admin/health');
|
|
const dbOk = h.db && h.db.ok;
|
|
document.getElementById('health').innerHTML =
|
|
`<div class="health-chip"><div class="k">API</div><span class="pill ${h.api ? 'ok' : 'off'}">${h.api ? 'UP' : 'DOWN'}</span></div>` +
|
|
`<div class="health-chip"><div class="k">RTMP</div><span class="pill ${h.rtmp ? 'ok' : 'off'}">${h.rtmp ? 'UP' : 'DOWN'}</span></div>` +
|
|
`<div class="health-chip"><div class="k">Database</div><span class="pill ${dbOk ? 'ok' : 'off'}">${dbOk ? 'UP' : 'DOWN'}</span></div>`;
|
|
}
|
|
|
|
async function loadHistory() {
|
|
const level = encodeURIComponent(document.getElementById('logLevel').value || '');
|
|
const keyword = encodeURIComponent(document.getElementById('logKeyword').value || '');
|
|
const data = await api(`/api/admin/logs?page=1&page_size=100&level=${level}&keyword=${keyword}`);
|
|
const items = data.items || [];
|
|
const box = document.getElementById('logs');
|
|
box.textContent = '';
|
|
items.forEach(it => addLogLine(`[${it.time}] [${it.level}] ${it.message}`));
|
|
}
|
|
|
|
function connectLiveLogs() {
|
|
disconnectLogs();
|
|
evt = new EventSource('/api/admin/logs/stream', { withCredentials: true });
|
|
evt.onmessage = () => {};
|
|
evt.addEventListener('log', (e) => {
|
|
const item = JSON.parse(e.data);
|
|
addLogLine(`[${item.time}] [${item.level}] ${item.message}`);
|
|
});
|
|
evt.onerror = () => {
|
|
addLogLine('[warn] Live log stream disconnected.');
|
|
};
|
|
}
|
|
|
|
async function loadUsers() {
|
|
const keyword = encodeURIComponent(document.getElementById('userKeyword').value || '');
|
|
const data = await api(`/api/admin/users?page=1&page_size=50&keyword=${keyword}`);
|
|
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="subtle" onclick="toggleRole(${u.id}, '${u.role}')">Toggle Role</button>
|
|
<button class="subtle" onclick="toggleEnabled(${u.id}, ${u.enabled})">Enable / Disable</button>
|
|
<button class="secondary" onclick="resetPwd(${u.id})">Reset Password</button>
|
|
<button class="danger" onclick="deleteUser(${u.id})">Delete</button>
|
|
</td>
|
|
`;
|
|
body.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
async function toggleRole(id, role) {
|
|
const next = role === 'admin' ? 'user' : 'admin';
|
|
await api(`/api/admin/users/${id}/role`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ role: next }),
|
|
});
|
|
await loadUsers();
|
|
}
|
|
|
|
async function toggleEnabled(id, enabled) {
|
|
await api(`/api/admin/users/${id}/enabled`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ enabled: !enabled }),
|
|
});
|
|
await loadUsers();
|
|
}
|
|
|
|
async function resetPwd(id) {
|
|
const newPwd = prompt('Enter a new password for this user');
|
|
if (!newPwd) return;
|
|
await api(`/api/admin/users/${id}/reset-password`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ new_password: newPwd }),
|
|
});
|
|
addLogLine(`[audit] password reset requested for user ${id}`);
|
|
}
|
|
|
|
async function deleteUser(id) {
|
|
if (!confirm('Delete this user account?')) return;
|
|
await api(`/api/admin/users/${id}`, { method: 'DELETE' });
|
|
await loadUsers();
|
|
}
|
|
|
|
async function changePassword() {
|
|
const oldPassword = document.getElementById('oldPassword').value;
|
|
const newPassword = document.getElementById('newPassword').value;
|
|
await api('/api/user/change-password', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
old_password: oldPassword,
|
|
new_password: newPassword,
|
|
}),
|
|
});
|
|
document.getElementById('oldPassword').value = '';
|
|
document.getElementById('newPassword').value = '';
|
|
alert('Password updated successfully.');
|
|
}
|
|
|
|
window.addEventListener('load', ensureSession);
|
|
</script>
|
|
</body>
|
|
</html>
|