Add admin page

This commit is contained in:
Peter Stockings
2025-12-24 09:39:57 +11:00
parent 78e71b3895
commit 9b96e3ad47
3 changed files with 400 additions and 324 deletions

15
app.py
View File

@@ -344,10 +344,9 @@ def collect():
"warnings": warnings, "warnings": warnings,
} }
def collect_logs_only(): def collect_admin_data():
""" """
Lightweight version that only collects app names and logs. Collects logs and detailed container info for the admin dashboard.
Much faster than collect() since it skips stats, metrics, and system info.
""" """
ps_rows = docker_ps_all() ps_rows = docker_ps_all()
apps = [] apps = []
@@ -361,6 +360,7 @@ def collect_logs_only():
"app": app_name, "app": app_name,
"container": name, "container": name,
"logs": get_container_logs(name, lines=50), "logs": get_container_logs(name, lines=50),
"detail": get_container_detail(name)
}) })
# Sort by app name # Sort by app name
@@ -432,16 +432,9 @@ def logout():
@app.get("/admin") @app.get("/admin")
@login_required @login_required
def admin(): def admin():
data = collect_logs_only() data = collect_admin_data()
return render_template("admin.html", data=data, poll_seconds=POLL_SECONDS) return render_template("admin.html", data=data, poll_seconds=POLL_SECONDS)
# Protected container detail page
@app.get("/admin/container/<container_name>")
@login_required
def container_detail(container_name):
detail = get_container_detail(container_name)
return render_template("container_detail.html", container=detail, container_name=container_name)
# API endpoint for container details (used by admin panel) # API endpoint for container details (used by admin panel)
@app.get("/api/container/<container_name>") @app.get("/api/container/<container_name>")
@login_required @login_required

View File

@@ -4,7 +4,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin - DokkuStatus</title> <title>Admin Control Center - DokkuStatus</title>
<link rel="icon" <link rel="icon"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>⚡</text></svg>" /> href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>⚡</text></svg>" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
@@ -12,6 +12,18 @@
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap"
rel="stylesheet"> rel="stylesheet">
<style> <style>
:root {
--bg-color: #0d1117;
--terminal-bg: #010409;
--accent-cyan: #00d9ff;
--accent-green: #00ff88;
--accent-orange: #ffb86c;
--accent-red: #ff5555;
--border-color: #30363d;
--text-main: #c9d1d9;
--text-dim: #8b949e;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@@ -20,180 +32,276 @@
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
margin: 0; margin: 0;
padding: 0; padding: 0;
background: #0a0e17; background-color: var(--bg-color);
min-height: 100vh; background-image:
color: #c9d1d9; linear-gradient(rgba(0, 217, 255, 0.03) 1px, transparent 1px),
linear-gradient(90(rgba(0, 217, 255, 0.03) 1px, transparent 1px);
background-size: 30px 30px;
color: var(--text-main);
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
} }
.container { /* Scanline effect */
max-width: 1600px; body::after {
margin: 0 auto; content: " ";
padding: 20px; display: block;
position: fixed;
top: 0; left: 0; bottom: 0; right: 0;
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.1) 50%),
linear-gradient(90deg, rgba(255, 0, 0, 0.03), rgba(0, 255, 0, 0.01), rgba(0, 0, 255, 0.03));
z-index: 1000;
background-size: 100% 4px, 3px 100%;
pointer-events: none;
opacity: 0.3;
} }
.header-terminal { header {
background: #161b22; background: rgba(22, 27, 34, 0.95);
border: 2px solid #00d9ff; border-bottom: 2px solid var(--accent-cyan);
box-shadow: 0 0 20px rgba(0, 217, 255, 0.3); padding: 10px 24px;
padding: 20px 24px;
margin-bottom: 20px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
box-shadow: 0 4px 20px rgba(0, 217, 255, 0.2);
z-index: 10;
} }
h1 { .header-title {
display: flex;
align-items: center;
gap: 15px;
}
.header-title h1 {
margin: 0; margin: 0;
font-size: 24px;
font-weight: 700;
color: #00d9ff;
text-transform: uppercase;
letter-spacing: 1px;
}
h2 {
font-size: 18px; font-size: 18px;
font-weight: 700;
letter-spacing: 2px;
color: #00d9ff;
margin: 32px 0 16px 0;
text-transform: uppercase; text-transform: uppercase;
padding-left: 12px; letter-spacing: 2px;
border-left: 3px solid #00ff88; color: var(--accent-cyan);
text-shadow: 0 0 10px rgba(0, 217, 255, 0.5);
} }
.logout-btn { .status-pill {
background: rgba(255, 85, 85, 0.1);
border: 1px solid #ff5555;
color: #ff5555;
padding: 6px 16px;
text-decoration: none;
font-size: 10px; font-size: 10px;
font-weight: 700; padding: 4px 8px;
letter-spacing: 1px;
transition: all 0.2s ease;
display: inline-block;
}
.logout-btn:hover {
background: rgba(255, 85, 85, 0.2);
}
.refresh-btn {
background: rgba(0, 255, 136, 0.1); background: rgba(0, 255, 136, 0.1);
border: 1px solid #00ff88; border: 1px solid var(--accent-green);
color: #00ff88; color: var(--accent-green);
padding: 6px 16px;
text-decoration: none;
font-size: 10px;
font-weight: 700; font-weight: 700;
letter-spacing: 1px;
transition: all 0.2s ease;
display: inline-block;
cursor: pointer;
margin-right: 12px;
} }
.refresh-btn:hover { .nav-actions {
background: rgba(0, 255, 136, 0.2);
box-shadow: 0 0 10px rgba(0, 255, 136, 0.3);
}
.card {
border: 2px solid #30363d;
margin-bottom: 16px;
background: rgba(0, 217, 255, 0.02);
}
.card-header {
display: flex; display: flex;
justify-content: space-between; gap: 15px;
align-items: center;
padding: 12px 16px;
cursor: pointer;
background: rgba(0, 0, 0, 0.2);
border-bottom: 2px solid #30363d;
}
.card-header:hover {
background: rgba(0, 217, 255, 0.05);
}
.app-name {
font-weight: 700;
color: #00d9ff;
font-size: 14px;
}
.btn-group {
display: flex;
gap: 8px;
} }
.btn { .btn {
background: rgba(0, 217, 255, 0.1); background: transparent;
border: 1px solid #00d9ff; border: 1px solid var(--accent-cyan);
color: #00d9ff; color: var(--accent-cyan);
padding: 4px 12px; padding: 6px 15px;
font-size: 10px; font-size: 11px;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
pointer-events: none; text-decoration: none;
text-transform: uppercase;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.btn-clickable { .btn:hover {
pointer-events: auto; background: rgba(0, 217, 255, 0.1);
text-decoration: none; box-shadow: 0 0 15px rgba(0, 217, 255, 0.4);
} }
.btn-clickable:hover { .btn-red {
background: rgba(0, 217, 255, 0.2); border-color: var(--accent-red);
box-shadow: 0 0 10px rgba(0, 217, 255, 0.3); color: var(--accent-red);
} }
.card-content { .btn-red:hover {
display: none; background: rgba(255, 85, 85, 0.1);
padding: 16px; box-shadow: 0 0 15px rgba(255, 85, 85, 0.4);
background: #0a0e17;
} }
.controls { main {
flex: 1;
display: flex;
overflow: hidden;
}
/* Sidebar for app list */
.sidebar {
width: 250px;
background: rgba(1, 4, 9, 0.8);
border-right: 1px solid var(--border-color);
padding: 20px 0;
overflow-y: auto;
}
.sidebar-item {
padding: 12px 24px;
cursor: pointer;
border-left: 3px solid transparent;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 10px;
}
.sidebar-item:hover {
background: rgba(0, 217, 255, 0.05);
}
.sidebar-item.active {
background: rgba(0, 217, 255, 0.1);
border-left-color: var(--accent-cyan);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 5px var(--accent-green);
}
/* Content area */
.content {
flex: 1;
padding: 24px;
overflow-y: auto;
scroll-behavior: smooth;
}
.station {
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 60px;
scroll-margin-top: 24px;
}
.station-header {
display: flex;
align-items: center;
gap: 20px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 10px;
}
.station-header h2 {
margin: 0;
font-size: 22px;
color: var(--accent-cyan);
letter-spacing: 1px;
}
.station-body {
display: grid;
grid-template-columns: 350px 1fr;
gap: 24px;
min-height: 500px;
}
/* Left side: Info Panel */
.panel {
background: var(--terminal-bg);
border: 1px solid var(--border-color);
padding: 20px;
position: relative;
display: flex;
flex-direction: column;
gap: 20px;
}
.panel::before {
content: "[ METADATA ]";
position: absolute;
top: -10px;
left: 15px;
background: var(--terminal-bg);
padding: 0 5px;
font-size: 10px;
color: var(--accent-cyan);
font-weight: 700;
}
.info-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.info-label {
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
font-weight: 700;
}
.info-value {
font-size: 12px;
word-break: break-all;
}
.env-scroll {
max-height: 200px;
overflow-y: auto;
background: #000;
padding: 10px;
border: 1px solid var(--border-color);
font-size: 11px;
}
.env-var {
margin-bottom: 4px;
}
.env-key {
color: var(--accent-cyan);
}
/* Right side: Log Terminal */
.terminal-box {
background: #000;
border: 1px solid var(--border-color);
display: flex;
flex-direction: column;
position: relative;
}
.terminal-box::before {
content: "[ STDOUT/STDERR ]";
position: absolute;
top: -10px;
left: 15px;
background: #000;
padding: 0 5px;
font-size: 10px;
color: var(--accent-orange);
font-weight: 700;
}
.terminal-header {
background: #161b22;
padding: 8px 15px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-bottom: 12px; align-items: center;
border-bottom: 1px solid var(--border-color);
} }
.label { .terminal-body {
color: #00d9ff; flex: 1;
font-size: 10px; padding: 15px;
font-weight: 700;
letter-spacing: 2px;
}
.copy-btn {
background: rgba(0, 255, 136, 0.1);
border: 1px solid #00ff88;
color: #00ff88;
padding: 4px 10px;
font-size: 10px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s ease;
}
.copy-btn:hover {
background: rgba(0, 255, 136, 0.2);
}
.viewer {
background: #000;
border: 1px solid #30363d;
padding: 12px;
max-height: 400px;
overflow-y: auto; overflow-y: auto;
font-size: 11px; font-size: 12px;
line-height: 1.6; line-height: 1.5;
color: #dcdccc;
scrollbar-width: thin;
} }
.log-line { .log-line {
@@ -201,193 +309,168 @@
} }
.log-error { .log-error {
color: #ff5555; color: var(--accent-red);
} }
.log-warn { .log-warn {
color: #ffb86c; color: var(--accent-orange);
} }
.log-info { .log-info {
color: #8b949e; color: var(--text-dim);
} }
.info-grid { .copy-btn {
display: grid; background: transparent;
grid-template-columns: 200px 1fr; border: 1px solid var(--accent-green);
gap: 8px 16px; color: var(--accent-green);
margin-bottom: 12px; padding: 2px 8px;
font-size: 9px;
cursor: pointer;
} }
.info-label { /* Scrollbar styling */
color: #8b949e; ::-webkit-scrollbar {
font-size: 10px; width: 6px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
} }
.info-value { ::-webkit-scrollbar-track {
color: #c9d1d9; background: var(--bg-color);
font-size: 11px;
word-break: break-all;
} }
.status-running { ::-webkit-scrollbar-thumb {
color: #00ff88; background: var(--border-color);
font-weight: 700;
} }
.status-stopped { ::-webkit-scrollbar-thumb:hover {
color: #ff5555; background: var(--accent-cyan);
font-weight: 700;
}
.env-list {
background: #000;
border: 1px solid #30363d;
padding: 8px;
max-height: 300px;
overflow-y: auto;
}
.env-item {
display: grid;
grid-template-columns: 250px 1fr;
gap: 12px;
padding: 4px 0;
border-bottom: 1px solid #30363d;
font-size: 10px;
}
.env-item:last-child {
border-bottom: none;
}
.env-key {
color: #00d9ff;
font-weight: 700;
}
.env-value {
color: #c9d1d9;
word-break: break-all;
} }
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <header>
<div class="header-terminal"> <div class="header-title">
<h1>[ ADMIN PANEL ]</h1> <h1>[ CONTROL CENTER ]</h1>
<div> <span class="status-pill">SYS_UPTIME: LIVE</span>
<button onclick="location.reload()" class="refresh-btn">[REFRESH]</button>
<a href="/logout" class="logout-btn">[LOGOUT]</a>
</div> </div>
<div class="nav-actions">
<a href="/" class="btn">DASHBOARD</a>
<button onclick="location.reload()" class="btn">REFRESH_ALL</button>
<a href="/logout" class="btn btn-red">DISCONNECT</a>
</div> </div>
</header>
<main>
<nav class="sidebar">
{% for r in data.apps %} {% for r in data.apps %}
<div class="card"> <a href="#app-{{ loop.index }}" class="sidebar-item">
<div class="card-header" onclick="toggleSection('app-{{ loop.index }}')"> <span class="dot"></span>
<div class="app-name">[{{ r.app }}]</div> <span style="font-size: 12px;">{{ r.app | upper }}</span>
<div class="btn-group"> </a>
<button class="btn">[EXPAND]</button> {% endfor %}
</nav>
<div class="content">
{% for r in data.apps %}
<section id="app-{{ loop.index }}" class="station">
<div class="station-header">
<h2>{{ r.app }}</h2>
<span class="status-pill">CONTAINER: {{ r.container }}</span>
</div>
<div class="station-body">
<div class="panel">
<div class="info-group">
<div class="info-label">IDENTIFIER</div>
<div class="info-value">{{ r.detail.id or '—' }}</div>
</div>
<div class="info-group">
<div class="info-label">IMAGE_BLOCK</div>
<div class="info-value">{{ r.detail.image or '—' }}</div>
</div>
<div class="info-group">
<div class="info-label">NETWORK_INTERFACE</div>
<div class="info-value">
IP: {{ r.detail.ip_address or '—' }}<br>
NET: {{ (r.detail.networks or []) | join(', ') }}
</div>
</div>
<div class="info-group">
<div class="info-label">PORTS_EXPOSED</div>
<div class="info-value">
{% for port, mappings in (r.detail.ports or {}).items() %}
<span style="color: var(--accent-green);">{{ port }}</span>
{% if mappings %} → {{ mappings[0].HostPort }}{% endif %}<br>
{% endfor %}
</div>
</div>
<div class="info-group">
<div class="info-label">ENVIRONMENT_STORAGE</div>
<div class="env-scroll">
{% for env in (r.detail.env or []) %}
{% set parts = env.split('=', 1) %}
<div class="env-var">
<span class="env-key">{{ parts[0] }}=</span>{{ parts[1] }}
</div>
{% endfor %}
</div>
</div> </div>
</div> </div>
<div id="app-{{ loop.index }}" class="card-content"> <div class="terminal-box">
<!-- Logs Section --> <div class="terminal-header">
<h2>Container Logs</h2> <span style="font-size: 10px; color: var(--text-dim);">LAST_50_EVENTS</span>
<div class="controls"> <button class="copy-btn" onclick="copyLogs('logs-{{ loop.index }}')">COPY_TERM</button>
<div class="label">[LAST 50 LINES]</div>
<button class="copy-btn" onclick="copyText('logs-{{ loop.index }}')">COPY</button>
</div> </div>
<div id="logs-{{ loop.index }}" class="viewer"> <div id="logs-{{ loop.index }}" class="terminal-body">
{% if r.logs %} {% if r.logs %}
{% for log in r.logs %} {% for log in r.logs %}
<div class="log-line log-{{ log.level }}">{{ log.text }}</div> <div class="log-line log-{{ log.level }}">{{ log.text }}</div>
{% endfor %} {% endfor %}
{% else %} {% else %}
<div class="log-info">[no logs available]</div> <div class="log-info">[ NO LOG DATA DETECTED ]</div>
{% endif %} {% endif %}
</div> </div>
<!-- Container Details Section -->
<h2>Container Details</h2>
<div id="details-{{ loop.index }}" data-container="{{ r.container }}" data-loaded="false">
<div style="text-align: center; padding: 20px; color: #8b949e;">
<button onclick="loadDetails('{{ r.container }}', {{ loop.index }})" class="btn btn-clickable">
[LOAD CONTAINER INFO]
</button>
</div>
</div>
</div> </div>
</div> </div>
</section>
{% endfor %} {% endfor %}
</div> </div>
</main>
<script> <script>
function toggleSection(id) { function copyLogs(id) {
const el = document.getElementById(id); const el = document.getElementById(id);
el.style.display = el.style.display === 'none' ? 'block' : 'none'; const text = el.innerText;
} navigator.clipboard.writeText(text).then(() => {
function copyText(id) {
const el = document.getElementById(id);
navigator.clipboard.writeText(el.innerText).then(() => {
const btn = event.target; const btn = event.target;
const orig = btn.textContent; const orig = btn.innerText;
btn.textContent = 'COPIED!'; btn.innerText = 'COPIED';
btn.style.background = 'rgba(0, 255, 136, 0.3)'; setTimeout(() => { btn.innerText = orig; }, 2000);
setTimeout(() => {
btn.textContent = orig;
btn.style.background = 'rgba(0, 255, 136, 0.1)';
}, 1500);
}); });
} }
function loadDetails(containerName, index) { // Auto-scroll logs to bottom
const detailsDiv = document.getElementById(`details-${index}`); document.querySelectorAll('.terminal-body').forEach(el => {
el.scrollTop = el.scrollHeight;
});
if (detailsDiv.dataset.loaded === 'true') { // Active sidebar tracking
return; const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
document.querySelectorAll('.sidebar-item').forEach(item => {
item.classList.remove('active');
if (item.getAttribute('href') === '#' + entry.target.id) {
item.classList.add('active');
} }
detailsDiv.innerHTML = '<div style="text-align: center; padding: 20px; color: #00d9ff;">Loading...</div>';
fetch(`/api/container/${containerName}`)
.then(res => res.json())
.then(data => {
detailsDiv.dataset.loaded = 'true';
detailsDiv.innerHTML = renderContainerDetails(data);
})
.catch(err => {
detailsDiv.innerHTML = `<div style="color: #ff5555; padding: 20px;">Error: ${err.message}</div>`;
}); });
} }
function renderContainerDetails(c) {
let html = '<div class="info-grid">';
html += `<div class="info-label">Container ID</div><div class="info-value">${c.id || '—'}</div>`;
html += `<div class="info-label">Image</div><div class="info-value">${c.image || '—'}</div>`;
html += `<div class="info-label">Status</div><div class="info-value ${c.state?.running ? 'status-running' : 'status-stopped'}">${(c.state?.status || 'unknown').toUpperCase()}</div>`;
html += `<div class="info-label">IP Address</div><div class="info-value">${c.ip_address || '—'}</div>`;
html += `<div class="info-label">Networks</div><div class="info-value">${(c.networks || []).join(', ') || '—'}</div>`;
html += '</div>';
// Environment variables
if (c.env && c.env.length > 0) {
html += '<h2 style="margin-top: 24px;">Environment Variables</h2>';
html += '<div class="env-list">';
c.env.forEach(env => {
const parts = env.split('=', 2);
html += `<div class="env-item"><div class="env-key">${parts[0]}</div><div class="env-value">${parts[1] || ''}</div></div>`;
}); });
html += '</div>'; }, { threshold: 0.5 });
}
return html; document.querySelectorAll('.station').forEach(section => observer.observe(section));
}
</script> </script>
</body> </body>

View File

@@ -154,7 +154,7 @@
required /> required />
</div> </div>
<button type="submit">[ACCESS LOGS]</button> <button type="submit">[ACCESS ADMIN]</button>
</form> </form>
{% if error %} {% if error %}