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,
}
def collect_logs_only():
def collect_admin_data():
"""
Lightweight version that only collects app names and logs.
Much faster than collect() since it skips stats, metrics, and system info.
Collects logs and detailed container info for the admin dashboard.
"""
ps_rows = docker_ps_all()
apps = []
@@ -361,6 +360,7 @@ def collect_logs_only():
"app": app_name,
"container": name,
"logs": get_container_logs(name, lines=50),
"detail": get_container_detail(name)
})
# Sort by app name
@@ -432,16 +432,9 @@ def logout():
@app.get("/admin")
@login_required
def admin():
data = collect_logs_only()
data = collect_admin_data()
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)
@app.get("/api/container/<container_name>")
@login_required

View File

@@ -4,7 +4,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin - DokkuStatus</title>
<title>Admin Control Center - DokkuStatus</title>
<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>" />
<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"
rel="stylesheet">
<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;
}
@@ -20,374 +32,445 @@
font-family: 'JetBrains Mono', monospace;
margin: 0;
padding: 0;
background: #0a0e17;
min-height: 100vh;
color: #c9d1d9;
}
background-color: var(--bg-color);
background-image:
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 {
max-width: 1600px;
margin: 0 auto;
padding: 20px;
}
/* Scanline effect */
body::after {
content: " ";
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 {
background: #161b22;
border: 2px solid #00d9ff;
box-shadow: 0 0 20px rgba(0, 217, 255, 0.3);
padding: 20px 24px;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
header {
background: rgba(22, 27, 34, 0.95);
border-bottom: 2px solid var(--accent-cyan);
padding: 10px 24px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 4px 20px rgba(0, 217, 255, 0.2);
z-index: 10;
}
h1 {
margin: 0;
font-size: 24px;
font-weight: 700;
color: #00d9ff;
text-transform: uppercase;
letter-spacing: 1px;
}
.header-title {
display: flex;
align-items: center;
gap: 15px;
}
h2 {
font-size: 18px;
font-weight: 700;
letter-spacing: 2px;
color: #00d9ff;
margin: 32px 0 16px 0;
text-transform: uppercase;
padding-left: 12px;
border-left: 3px solid #00ff88;
}
.header-title h1 {
margin: 0;
font-size: 18px;
text-transform: uppercase;
letter-spacing: 2px;
color: var(--accent-cyan);
text-shadow: 0 0 10px rgba(0, 217, 255, 0.5);
}
.logout-btn {
background: rgba(255, 85, 85, 0.1);
border: 1px solid #ff5555;
color: #ff5555;
padding: 6px 16px;
text-decoration: none;
font-size: 10px;
font-weight: 700;
letter-spacing: 1px;
transition: all 0.2s ease;
display: inline-block;
}
.status-pill {
font-size: 10px;
padding: 4px 8px;
background: rgba(0, 255, 136, 0.1);
border: 1px solid var(--accent-green);
color: var(--accent-green);
font-weight: 700;
}
.logout-btn:hover {
background: rgba(255, 85, 85, 0.2);
}
.nav-actions {
display: flex;
gap: 15px;
}
.refresh-btn {
background: rgba(0, 255, 136, 0.1);
border: 1px solid #00ff88;
color: #00ff88;
padding: 6px 16px;
text-decoration: none;
font-size: 10px;
font-weight: 700;
letter-spacing: 1px;
transition: all 0.2s ease;
display: inline-block;
cursor: pointer;
margin-right: 12px;
}
.btn {
background: transparent;
border: 1px solid var(--accent-cyan);
color: var(--accent-cyan);
padding: 6px 15px;
font-size: 11px;
font-weight: 700;
cursor: pointer;
text-decoration: none;
text-transform: uppercase;
transition: all 0.2s ease;
}
.refresh-btn:hover {
background: rgba(0, 255, 136, 0.2);
box-shadow: 0 0 10px rgba(0, 255, 136, 0.3);
}
.btn:hover {
background: rgba(0, 217, 255, 0.1);
box-shadow: 0 0 15px rgba(0, 217, 255, 0.4);
}
.card {
border: 2px solid #30363d;
margin-bottom: 16px;
background: rgba(0, 217, 255, 0.02);
}
.btn-red {
border-color: var(--accent-red);
color: var(--accent-red);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
cursor: pointer;
background: rgba(0, 0, 0, 0.2);
border-bottom: 2px solid #30363d;
}
.btn-red:hover {
background: rgba(255, 85, 85, 0.1);
box-shadow: 0 0 15px rgba(255, 85, 85, 0.4);
}
.card-header:hover {
background: rgba(0, 217, 255, 0.05);
}
main {
flex: 1;
display: flex;
overflow: hidden;
}
.app-name {
font-weight: 700;
color: #00d9ff;
font-size: 14px;
}
/* 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;
}
.btn-group {
display: flex;
gap: 8px;
}
.sidebar-item {
padding: 12px 24px;
cursor: pointer;
border-left: 3px solid transparent;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 10px;
}
.btn {
background: rgba(0, 217, 255, 0.1);
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;
}
.sidebar-item:hover {
background: rgba(0, 217, 255, 0.05);
}
.btn-clickable {
pointer-events: auto;
text-decoration: none;
}
.sidebar-item.active {
background: rgba(0, 217, 255, 0.1);
border-left-color: var(--accent-cyan);
}
.btn-clickable:hover {
background: rgba(0, 217, 255, 0.2);
box-shadow: 0 0 10px rgba(0, 217, 255, 0.3);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 5px var(--accent-green);
}
.card-content {
display: none;
padding: 16px;
background: #0a0e17;
}
/* Content area */
.content {
flex: 1;
padding: 24px;
overflow-y: auto;
scroll-behavior: smooth;
}
.controls {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
}
.station {
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 60px;
scroll-margin-top: 24px;
}
.label {
color: #00d9ff;
font-size: 10px;
font-weight: 700;
letter-spacing: 2px;
}
.station-header {
display: flex;
align-items: center;
gap: 20px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 10px;
}
.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;
}
.station-header h2 {
margin: 0;
font-size: 22px;
color: var(--accent-cyan);
letter-spacing: 1px;
}
.copy-btn:hover {
background: rgba(0, 255, 136, 0.2);
}
.station-body {
display: grid;
grid-template-columns: 350px 1fr;
gap: 24px;
min-height: 500px;
}
.viewer {
background: #000;
border: 1px solid #30363d;
padding: 12px;
max-height: 400px;
overflow-y: auto;
font-size: 11px;
line-height: 1.6;
}
/* 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;
}
.log-line {
margin: 2px 0;
}
.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;
}
.log-error {
color: #ff5555;
}
.info-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.log-warn {
color: #ffb86c;
}
.info-label {
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
font-weight: 700;
}
.log-info {
color: #8b949e;
}
.info-value {
font-size: 12px;
word-break: break-all;
}
.info-grid {
display: grid;
grid-template-columns: 200px 1fr;
gap: 8px 16px;
margin-bottom: 12px;
}
.env-scroll {
max-height: 200px;
overflow-y: auto;
background: #000;
padding: 10px;
border: 1px solid var(--border-color);
font-size: 11px;
}
.info-label {
color: #8b949e;
font-size: 10px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
}
.env-var {
margin-bottom: 4px;
}
.info-value {
color: #c9d1d9;
font-size: 11px;
word-break: break-all;
}
.env-key {
color: var(--accent-cyan);
}
.status-running {
color: #00ff88;
font-weight: 700;
}
/* Right side: Log Terminal */
.terminal-box {
background: #000;
border: 1px solid var(--border-color);
display: flex;
flex-direction: column;
position: relative;
}
.status-stopped {
color: #ff5555;
font-weight: 700;
}
.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;
}
.env-list {
background: #000;
border: 1px solid #30363d;
padding: 8px;
max-height: 300px;
overflow-y: auto;
}
.terminal-header {
background: #161b22;
padding: 8px 15px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border-color);
}
.env-item {
display: grid;
grid-template-columns: 250px 1fr;
gap: 12px;
padding: 4px 0;
border-bottom: 1px solid #30363d;
font-size: 10px;
}
.terminal-body {
flex: 1;
padding: 15px;
overflow-y: auto;
font-size: 12px;
line-height: 1.5;
color: #dcdccc;
scrollbar-width: thin;
}
.env-item:last-child {
border-bottom: none;
}
.log-line {
margin: 2px 0;
}
.env-key {
color: #00d9ff;
font-weight: 700;
}
.log-error {
color: var(--accent-red);
}
.env-value {
color: #c9d1d9;
word-break: break-all;
}
.log-warn {
color: var(--accent-orange);
}
.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>
</head>
<body>
<div class="container">
<div class="header-terminal">
<h1>[ ADMIN PANEL ]</h1>
<div>
<button onclick="location.reload()" class="refresh-btn">[REFRESH]</button>
<a href="/logout" class="logout-btn">[LOGOUT]</a>
</div>
<header>
<div class="header-title">
<h1>[ CONTROL CENTER ]</h1>
<span class="status-pill">SYS_UPTIME: LIVE</span>
</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 %}
<div class="card">
<div class="card-header" onclick="toggleSection('app-{{ loop.index }}')">
<div class="app-name">[{{ r.app }}]</div>
<div class="btn-group">
<button class="btn">[EXPAND]</button>
</div>
</div>
<main>
<nav class="sidebar">
{% for r in data.apps %}
<a href="#app-{{ loop.index }}" class="sidebar-item">
<span class="dot"></span>
<span style="font-size: 12px;">{{ r.app | upper }}</span>
</a>
{% endfor %}
</nav>
<div id="app-{{ loop.index }}" class="card-content">
<!-- Logs Section -->
<h2>Container Logs</h2>
<div class="controls">
<div class="label">[LAST 50 LINES]</div>
<button class="copy-btn" onclick="copyText('logs-{{ loop.index }}')">COPY</button>
</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 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>
<!-- 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 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 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>
</section>
{% endfor %}
</div>
{% endfor %}
</div>
</main>
<script>
function toggleSection(id) {
function copyLogs(id) {
const el = document.getElementById(id);
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
function copyText(id) {
const el = document.getElementById(id);
navigator.clipboard.writeText(el.innerText).then(() => {
const text = el.innerText;
navigator.clipboard.writeText(text).then(() => {
const btn = event.target;
const orig = btn.textContent;
btn.textContent = 'COPIED!';
btn.style.background = 'rgba(0, 255, 136, 0.3)';
setTimeout(() => {
btn.textContent = orig;
btn.style.background = 'rgba(0, 255, 136, 0.1)';
}, 1500);
const orig = btn.innerText;
btn.innerText = 'COPIED';
setTimeout(() => { btn.innerText = orig; }, 2000);
});
}
function loadDetails(containerName, index) {
const detailsDiv = document.getElementById(`details-${index}`);
// Auto-scroll logs to bottom
document.querySelectorAll('.terminal-body').forEach(el => {
el.scrollTop = el.scrollHeight;
});
if (detailsDiv.dataset.loaded === 'true') {
return;
}
// Active sidebar tracking
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>';
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;
}
document.querySelectorAll('.station').forEach(section => observer.observe(section));
</script>
</body>

View File

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