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,374 +32,445 @@
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; display: flex;
margin-bottom: 20px; justify-content: space-between;
display: flex; align-items: center;
justify-content: space-between; box-shadow: 0 4px 20px rgba(0, 217, 255, 0.2);
align-items: center; z-index: 10;
} }
h1 { .header-title {
margin: 0; display: flex;
font-size: 24px; align-items: center;
font-weight: 700; gap: 15px;
color: #00d9ff; }
text-transform: uppercase;
letter-spacing: 1px;
}
h2 { .header-title h1 {
font-size: 18px; margin: 0;
font-weight: 700; font-size: 18px;
letter-spacing: 2px; text-transform: uppercase;
color: #00d9ff; letter-spacing: 2px;
margin: 32px 0 16px 0; color: var(--accent-cyan);
text-transform: uppercase; text-shadow: 0 0 10px rgba(0, 217, 255, 0.5);
padding-left: 12px; }
border-left: 3px solid #00ff88;
}
.logout-btn { .status-pill {
background: rgba(255, 85, 85, 0.1); font-size: 10px;
border: 1px solid #ff5555; padding: 4px 8px;
color: #ff5555; background: rgba(0, 255, 136, 0.1);
padding: 6px 16px; border: 1px solid var(--accent-green);
text-decoration: none; color: var(--accent-green);
font-size: 10px; font-weight: 700;
font-weight: 700; }
letter-spacing: 1px;
transition: all 0.2s ease;
display: inline-block;
}
.logout-btn:hover { .nav-actions {
background: rgba(255, 85, 85, 0.2); display: flex;
} gap: 15px;
}
.refresh-btn { .btn {
background: rgba(0, 255, 136, 0.1); background: transparent;
border: 1px solid #00ff88; border: 1px solid var(--accent-cyan);
color: #00ff88; color: var(--accent-cyan);
padding: 6px 16px; padding: 6px 15px;
text-decoration: none; font-size: 11px;
font-size: 10px; font-weight: 700;
font-weight: 700; cursor: pointer;
letter-spacing: 1px; text-decoration: none;
transition: all 0.2s ease; text-transform: uppercase;
display: inline-block; transition: all 0.2s ease;
cursor: pointer; }
margin-right: 12px;
}
.refresh-btn:hover { .btn:hover {
background: rgba(0, 255, 136, 0.2); background: rgba(0, 217, 255, 0.1);
box-shadow: 0 0 10px rgba(0, 255, 136, 0.3); box-shadow: 0 0 15px rgba(0, 217, 255, 0.4);
} }
.card { .btn-red {
border: 2px solid #30363d; border-color: var(--accent-red);
margin-bottom: 16px; color: var(--accent-red);
background: rgba(0, 217, 255, 0.02); }
}
.card-header { .btn-red:hover {
display: flex; background: rgba(255, 85, 85, 0.1);
justify-content: space-between; box-shadow: 0 0 15px rgba(255, 85, 85, 0.4);
align-items: center; }
padding: 12px 16px;
cursor: pointer;
background: rgba(0, 0, 0, 0.2);
border-bottom: 2px solid #30363d;
}
.card-header:hover { main {
background: rgba(0, 217, 255, 0.05); flex: 1;
} display: flex;
overflow: hidden;
}
.app-name { /* Sidebar for app list */
font-weight: 700; .sidebar {
color: #00d9ff; width: 250px;
font-size: 14px; background: rgba(1, 4, 9, 0.8);
} border-right: 1px solid var(--border-color);
padding: 20px 0;
overflow-y: auto;
}
.btn-group { .sidebar-item {
display: flex; padding: 12px 24px;
gap: 8px; cursor: pointer;
} border-left: 3px solid transparent;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 10px;
}
.btn { .sidebar-item:hover {
background: rgba(0, 217, 255, 0.1); background: rgba(0, 217, 255, 0.05);
border: 1px solid #00d9ff; }
color: #00d9ff;
padding: 4px 12px;
font-size: 10px;
font-weight: 700;
cursor: pointer;
pointer-events: none;
transition: all 0.2s ease;
}
.btn-clickable { .sidebar-item.active {
pointer-events: auto; background: rgba(0, 217, 255, 0.1);
text-decoration: none; border-left-color: var(--accent-cyan);
} }
.btn-clickable:hover { .dot {
background: rgba(0, 217, 255, 0.2); width: 8px;
box-shadow: 0 0 10px rgba(0, 217, 255, 0.3); height: 8px;
} border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 5px var(--accent-green);
}
.card-content { /* Content area */
display: none; .content {
padding: 16px; flex: 1;
background: #0a0e17; padding: 24px;
} overflow-y: auto;
scroll-behavior: smooth;
}
.controls { .station {
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
margin-bottom: 12px; gap: 20px;
} margin-bottom: 60px;
scroll-margin-top: 24px;
}
.label { .station-header {
color: #00d9ff; display: flex;
font-size: 10px; align-items: center;
font-weight: 700; gap: 20px;
letter-spacing: 2px; border-bottom: 1px solid var(--border-color);
} padding-bottom: 10px;
}
.copy-btn { .station-header h2 {
background: rgba(0, 255, 136, 0.1); margin: 0;
border: 1px solid #00ff88; font-size: 22px;
color: #00ff88; color: var(--accent-cyan);
padding: 4px 10px; letter-spacing: 1px;
font-size: 10px; }
font-weight: 700;
cursor: pointer;
transition: all 0.2s ease;
}
.copy-btn:hover { .station-body {
background: rgba(0, 255, 136, 0.2); display: grid;
} grid-template-columns: 350px 1fr;
gap: 24px;
min-height: 500px;
}
.viewer { /* Left side: Info Panel */
background: #000; .panel {
border: 1px solid #30363d; background: var(--terminal-bg);
padding: 12px; border: 1px solid var(--border-color);
max-height: 400px; padding: 20px;
overflow-y: auto; position: relative;
font-size: 11px; display: flex;
line-height: 1.6; flex-direction: column;
} gap: 20px;
}
.log-line { .panel::before {
margin: 2px 0; 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;
}
.log-error { .info-group {
color: #ff5555; display: flex;
} flex-direction: column;
gap: 5px;
}
.log-warn { .info-label {
color: #ffb86c; font-size: 9px;
} color: var(--text-dim);
text-transform: uppercase;
font-weight: 700;
}
.log-info { .info-value {
color: #8b949e; font-size: 12px;
} word-break: break-all;
}
.info-grid { .env-scroll {
display: grid; max-height: 200px;
grid-template-columns: 200px 1fr; overflow-y: auto;
gap: 8px 16px; background: #000;
margin-bottom: 12px; padding: 10px;
} border: 1px solid var(--border-color);
font-size: 11px;
}
.info-label { .env-var {
color: #8b949e; margin-bottom: 4px;
font-size: 10px; }
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
}
.info-value { .env-key {
color: #c9d1d9; color: var(--accent-cyan);
font-size: 11px; }
word-break: break-all;
}
.status-running { /* Right side: Log Terminal */
color: #00ff88; .terminal-box {
font-weight: 700; background: #000;
} border: 1px solid var(--border-color);
display: flex;
flex-direction: column;
position: relative;
}
.status-stopped { .terminal-box::before {
color: #ff5555; content: "[ STDOUT/STDERR ]";
font-weight: 700; position: absolute;
} top: -10px;
left: 15px;
background: #000;
padding: 0 5px;
font-size: 10px;
color: var(--accent-orange);
font-weight: 700;
}
.env-list { .terminal-header {
background: #000; background: #161b22;
border: 1px solid #30363d; padding: 8px 15px;
padding: 8px; display: flex;
max-height: 300px; justify-content: space-between;
overflow-y: auto; align-items: center;
} border-bottom: 1px solid var(--border-color);
}
.env-item { .terminal-body {
display: grid; flex: 1;
grid-template-columns: 250px 1fr; padding: 15px;
gap: 12px; overflow-y: auto;
padding: 4px 0; font-size: 12px;
border-bottom: 1px solid #30363d; line-height: 1.5;
font-size: 10px; color: #dcdccc;
} scrollbar-width: thin;
}
.env-item:last-child { .log-line {
border-bottom: none; margin: 2px 0;
} }
.env-key { .log-error {
color: #00d9ff; color: var(--accent-red);
font-weight: 700; }
}
.env-value { .log-warn {
color: #c9d1d9; color: var(--accent-orange);
word-break: break-all; }
}
.log-info {
color: var(--text-dim);
}
.copy-btn {
background: transparent;
border: 1px solid var(--accent-green);
color: var(--accent-green);
padding: 2px 8px;
font-size: 9px;
cursor: pointer;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg-color);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
}
::-webkit-scrollbar-thumb:hover {
background: var(--accent-cyan);
}
</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>
<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>
</header>
{% for r in data.apps %} <main>
<div class="card"> <nav class="sidebar">
<div class="card-header" onclick="toggleSection('app-{{ loop.index }}')"> {% for r in data.apps %}
<div class="app-name">[{{ r.app }}]</div> <a href="#app-{{ loop.index }}" class="sidebar-item">
<div class="btn-group"> <span class="dot"></span>
<button class="btn">[EXPAND]</button> <span style="font-size: 12px;">{{ r.app | upper }}</span>
</div> </a>
</div> {% endfor %}
</nav>
<div id="app-{{ loop.index }}" class="card-content"> <div class="content">
<!-- Logs Section --> {% for r in data.apps %}
<h2>Container Logs</h2> <section id="app-{{ loop.index }}" class="station">
<div class="controls"> <div class="station-header">
<div class="label">[LAST 50 LINES]</div> <h2>{{ r.app }}</h2>
<button class="copy-btn" onclick="copyText('logs-{{ loop.index }}')">COPY</button> <span class="status-pill">CONTAINER: {{ r.container }}</span>
</div>
<div id="logs-{{ loop.index }}" class="viewer">
{% if r.logs %}
{% for log in r.logs %}
<div class="log-line log-{{ log.level }}">{{ log.text }}</div>
{% endfor %}
{% else %}
<div class="log-info">[no logs available]</div>
{% endif %}
</div> </div>
<!-- Container Details Section --> <div class="station-body">
<h2>Container Details</h2> <div class="panel">
<div id="details-{{ loop.index }}" data-container="{{ r.container }}" data-loaded="false"> <div class="info-group">
<div style="text-align: center; padding: 20px; color: #8b949e;"> <div class="info-label">IDENTIFIER</div>
<button onclick="loadDetails('{{ r.container }}', {{ loop.index }})" class="btn btn-clickable"> <div class="info-value">{{ r.detail.id or '—' }}</div>
[LOAD CONTAINER INFO] </div>
</button> <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 class="terminal-box">
<div class="terminal-header">
<span style="font-size: 10px; color: var(--text-dim);">LAST_50_EVENTS</span>
<button class="copy-btn" onclick="copyLogs('logs-{{ loop.index }}')">COPY_TERM</button>
</div>
<div id="logs-{{ loop.index }}" class="terminal-body">
{% if r.logs %}
{% for log in r.logs %}
<div class="log-line log-{{ log.level }}">{{ log.text }}</div>
{% endfor %}
{% else %}
<div class="log-info">[ NO LOG DATA DETECTED ]</div>
{% endif %}
</div>
</div> </div>
</div> </div>
</div> </section>
{% endfor %}
</div> </div>
{% endfor %} </main>
</div>
<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');
}
});
}
});
}, { threshold: 0.5 });
detailsDiv.innerHTML = '<div style="text-align: center; padding: 20px; color: #00d9ff;">Loading...</div>'; document.querySelectorAll('.station').forEach(section => observer.observe(section));
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>';
}
return html;
}
</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 %}