Compare commits

...

25 Commits

Author SHA1 Message Date
Peter Stockings
eb36d8bf6b Try to fix multiline sql output splitting incorrectly 2025-12-24 11:53:12 +11:00
Peter Stockings
9c587a3a6c Fix lru cache error 2025-12-24 11:39:45 +11:00
Peter Stockings
3748f042e6 Add optional IP/Country whitelist for admin, configurable by env variables 2025-12-24 11:36:49 +11:00
Peter Stockings
e9a29b7300 Move navigation grid to top of admin page 2025-12-24 11:26:02 +11:00
Peter Stockings
add05dea12 Reorder admin sections 2025-12-24 11:20:05 +11:00
Peter Stockings
e5d650c8a4 Add server shell access 2025-12-24 11:11:53 +11:00
Peter Stockings
4f34696c72 Add root terminal 2025-12-24 11:03:44 +11:00
Peter Stockings
0c89a0f745 Dont show DB containers with .ambassador in the name 2025-12-24 10:50:37 +11:00
Peter Stockings
de24d9f78a Try different approach - => _ 2025-12-24 10:46:42 +11:00
Peter Stockings
9898eb440d Strip '-db' from database names 2025-12-24 10:41:41 +11:00
Peter Stockings
f6547afcad Pass DB name to psql 2025-12-24 10:38:32 +11:00
Peter Stockings
08840b3bc2 Add ability to query databases 2025-12-24 10:13:25 +11:00
Peter Stockings
40cb631975 Whoops didnt set total ram 2025-12-24 10:07:29 +11:00
Peter Stockings
d1bb48138f Set total ram in compute section 2025-12-24 10:04:35 +11:00
Peter Stockings
6f31053b51 Move docker disk usage to bottom of page 2025-12-24 10:01:38 +11:00
Peter Stockings
ab75c61ec7 Add apps tile so you dont need to scroll to particular app in admin section 2025-12-24 09:58:33 +11:00
Peter Stockings
b95962e22c Fix jinja error 2025-12-24 09:52:22 +11:00
Peter Stockings
7ee52102b4 Update admin page styles 2025-12-24 09:49:39 +11:00
Peter Stockings
9b96e3ad47 Add admin page 2025-12-24 09:39:57 +11:00
Peter Stockings
78e71b3895 Add container info as well 2025-12-23 23:28:54 +11:00
Peter Stockings
45bee0504b Require password to access logs page 2025-12-23 23:16:56 +11:00
Peter Stockings
dc20afd0f3 Show recent logs 2025-12-23 23:00:29 +11:00
Peter Stockings
c600adf5cc Fix ram gauge 2025-12-23 22:50:49 +11:00
Peter Stockings
a597930fde Change look of status page 2025-12-23 22:36:44 +11:00
Peter Stockings
c792b0107b Add favicon 2025-12-22 13:59:17 +11:00
7 changed files with 2396 additions and 333 deletions

339
app.py
View File

@@ -1,16 +1,23 @@
import os
import json
import subprocess
from datetime import datetime
from flask import Flask, jsonify, render_template
import re
import ipaddress
import urllib.request
from datetime import datetime
from functools import wraps, lru_cache
from flask import Flask, jsonify, render_template, request, session, redirect, url_for, abort
app = Flask(__name__)
app.secret_key = os.getenv("SECRET_KEY", "change-this-in-production-please")
POLL_SECONDS = int(os.getenv("POLL_SECONDS", "10"))
APP_DOMAIN = os.getenv("APP_DOMAIN", "peterstockings.com")
DOCKER = os.getenv("DOCKER_BIN", "/usr/bin/docker")
SHOW_INFRA = os.getenv("SHOW_INFRA", "1") == "1"
LOGS_PASSWORD = os.getenv("LOGS_PASSWORD", "dokkustatus123")
IP_WHITELIST = os.getenv("IP_WHITELIST", "") # Comma separated CIDRs
ALLOWED_COUNTRIES = os.getenv("ALLOWED_COUNTRIES", "AU") # Comma separated ISO codes
_UNIT = {
"b": 1,
@@ -55,7 +62,6 @@ def docker_stats() -> dict:
}
return stats
import re
def docker_info() -> dict:
# docker info --format "{{json .}}" gives us structured host-level info
@@ -125,6 +131,152 @@ def docker_inspect_restart_count(container_name: str) -> int:
except Exception:
return 0
def get_container_logs(container_name: str, lines: int = 50) -> list[dict]:
"""
Get last N lines of container logs with error detection.
Returns list of dicts with 'text' and 'level' keys.
"""
try:
out = sh([DOCKER, "logs", "--tail", str(lines), container_name])
log_lines = []
for line in out.splitlines():
# Strip ANSI color codes
line_clean = re.sub(r'\x1b\[[0-9;]*m', '', line)
# Detect log level
line_lower = line_clean.lower()
if any(x in line_lower for x in ['error', 'exception', 'fatal', 'critical']):
level = 'error'
elif any(x in line_lower for x in ['warn', 'warning']):
level = 'warn'
else:
level = 'info'
log_lines.append({
'text': line_clean,
'level': level
})
return log_lines
except Exception:
return []
def get_container_detail(container_name: str) -> dict:
"""
Get detailed container information using docker inspect.
Returns parsed container metadata.
"""
try:
out = sh([DOCKER, "inspect", container_name])
inspect_data = json.loads(out)
if not inspect_data:
return {}
container = inspect_data[0]
# Extract useful information
config = container.get("Config", {})
state = container.get("State", {})
network_settings = container.get("NetworkSettings", {})
mounts = container.get("Mounts", [])
return {
"name": container.get("Name", "").lstrip("/"),
"id": container.get("Id", "")[:12],
"image": config.get("Image", ""),
"created": container.get("Created", ""),
"state": {
"status": state.get("Status", ""),
"running": state.get("Running", False),
"paused": state.get("Paused", False),
"restarting": state.get("Restarting", False),
"started_at": state.get("StartedAt", ""),
"finished_at": state.get("FinishedAt", ""),
},
"env": config.get("Env", []),
"cmd": config.get("Cmd", []),
"entrypoint": config.get("Entrypoint", []),
"working_dir": config.get("WorkingDir", ""),
"exposed_ports": list(config.get("ExposedPorts", {}).keys()),
"ports": network_settings.get("Ports", {}),
"networks": list(network_settings.get("Networks", {}).keys()),
"ip_address": network_settings.get("IPAddress", ""),
"mounts": [
{
"type": m.get("Type", ""),
"source": m.get("Source", ""),
"destination": m.get("Destination", ""),
"mode": m.get("Mode", ""),
"rw": m.get("RW", False),
}
for m in mounts
],
"restart_policy": container.get("HostConfig", {}).get("RestartPolicy", {}),
}
except Exception as e:
return {"error": str(e)}
def run_sql_query(container_name: str, query: str) -> dict:
"""
Execute a SQL query inside a database container using docker exec.
Supports Postgres and MySQL/MariaDB.
Infers the database name from the Dokku container name.
"""
try:
# Infer DB name: dokku.postgres.my-service -> my-service_db or my_service
# Dokku typically replaces hyphens with underscores in database names.
parts = container_name.split(".", 2)
service_name = parts[2] if len(parts) >= 3 else None
# Most common Dokku pattern: replace - with _
db_name = service_name.replace("-", "_") if service_name else None
if "postgres" in container_name:
# Postgres: use psql with true CSV output (--csv requires psql 12+)
db_arg = ["-d", db_name] if db_name else []
# -A -F , is the older way, --csv is much better for multi-line/comma data
cmd = [DOCKER, "exec", container_name, "psql", "-U", "postgres", "-X", "--csv"] + db_arg + ["-c", query]
elif "mysql" in container_name or "mariadb" in container_name:
# MySQL: use mysql with tab-separated output
db_arg = [db_name] if db_name else []
cmd = [DOCKER, "exec", container_name, "mysql", "-u", "root", "-e", query, "-B"] + db_arg
else:
return {"error": "Unsupported database type"}
out = sh(cmd)
# Parse output into rows and columns
if not out.strip():
return {"columns": [], "rows": [], "message": "Query executed successfully (no results)"}
if "postgres" in container_name:
import csv
from io import StringIO
# Using StringIO on the full output allows csv.reader to handle multi-line fields
reader = csv.reader(StringIO(out))
rows = list(reader)
if not rows: return {"columns": [], "rows": []}
columns = rows[0]
data = rows[1:]
else:
# MySQL is tab-separated by default with -B
lines = out.splitlines()
rows = [line.split("\t") for line in lines]
columns = rows[0]
data = rows[1:]
return {
"columns": columns,
"rows": data,
"count": len(data)
}
except subprocess.CalledProcessError as e:
return {"error": e.output or str(e)}
except Exception as e:
return {"error": str(e)}
def is_app_web_container(name: str) -> bool:
# Dokku apps typically have containers like "<app>.web.1"
return name.endswith(".web.1") and not name.startswith("dokku.")
@@ -220,6 +372,8 @@ def collect():
host_mem_total = int(sysinfo.get("mem_total") or 0)
ram_pct = (total_mem_used_bytes / host_mem_total * 100.0) if host_mem_total else 0.0
sysinfo["ram_total_h"] = format_bytes(host_mem_total)
# Docker disk: images "Size" and "Reclaimable"
df_images = sysinfo.get("system_df", {}).get("Images", {})
images_size_bytes = parse_human_bytes(df_images.get("size", "0B"))
@@ -235,6 +389,8 @@ def collect():
"cpu_total_pct": clamp(total_cpu_pct), # sum of container CPU%, can exceed 100 if multi-core; we clamp for display
"ram_used_bytes": total_mem_used_bytes,
"ram_total_bytes": host_mem_total,
"ram_used_h": format_bytes(total_mem_used_bytes),
"ram_total_h": format_bytes(host_mem_total),
"ram_pct": clamp(ram_pct),
"docker_images_size_bytes": images_size_bytes,
"docker_images_used_bytes": images_used_bytes,
@@ -252,6 +408,41 @@ def collect():
"warnings": warnings,
}
def collect_admin_data():
"""
Collects logs and detailed container info for the admin dashboard.
Also identifies database containers for the SQL interface.
"""
ps_rows = docker_ps_all()
apps = []
databases = []
for r in ps_rows:
name = r["name"]
if is_app_web_container(name):
app_name = infer_app_name(name)
apps.append({
"app": app_name,
"container": name,
"logs": get_container_logs(name, lines=50),
"detail": get_container_detail(name)
})
elif classify_infra(name) and ("postgres" in name or "mysql" in name) and not name.endswith(".ambassador"):
databases.append({
"name": name,
"type": "postgres" if "postgres" in name else "mysql"
})
# Sort
apps.sort(key=lambda x: x["app"])
databases.sort(key=lambda x: x["name"])
return {
"apps": apps,
"databases": databases,
}
def parse_human_bytes(s: str) -> int:
# Handles "58.84MiB", "145.1MB", "423B"
s = s.strip()
@@ -271,6 +462,57 @@ def pct_str_to_float(p: str) -> float:
def clamp(n: float, lo: float = 0.0, hi: float = 100.0) -> float:
return max(lo, min(hi, n))
@lru_cache(maxsize=1024)
def get_country_from_ip(ip):
"""
Fetch country code for an IP using a public API.
Cached to minimize external requests.
"""
try:
# Using ip-api.com (free for non-commercial, no key for low volume)
with urllib.request.urlopen(f"http://ip-api.com/json/{ip}?fields=status,countryCode", timeout=3) as response:
data = json.loads(response.read().decode())
if data.get("status") == "success":
return data.get("countryCode")
except Exception as e:
print(f"GeoIP Error: {e}")
return None
def get_client_ip():
"""
Extract the real client IP, respecting Dokku/Nginx proxy headers.
"""
if request.headers.get('X-Forwarded-For'):
# Take the first IP in the list (the actual client)
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
return request.remote_addr
def is_ip_allowed(ip):
"""
Verifies if the IP is in the whitelist or allowed country.
"""
# 1. Check Specific Whitelist (CIDR)
if IP_WHITELIST:
try:
client_addr = ipaddress.ip_address(ip)
for entry in IP_WHITELIST.split(','):
entry = entry.strip()
if not entry: continue
if client_addr in ipaddress.ip_network(entry, strict=False):
return True
except ValueError:
pass
# 2. Check GeoIP (Default: Australia)
if ALLOWED_COUNTRIES:
country = get_country_from_ip(ip)
if country in [c.strip() for c in ALLOWED_COUNTRIES.split(',')]:
return True
# Default to deny if no rules matched
return False
@app.get("/")
def index():
return render_template("index.html", poll_seconds=POLL_SECONDS)
@@ -283,3 +525,94 @@ def partial_apps():
@app.get("/api/status")
def api_status():
return jsonify(collect())
# Authentication decorator
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
# 1. IP Check (GeoIP/Whitelist)
client_ip = get_client_ip()
if not is_ip_allowed(client_ip):
abort(403, description=f"Access denied from {client_ip}. Restricted to {ALLOWED_COUNTRIES}.")
# 2. Session Check (Password)
if not session.get("logged_in"):
return redirect(url_for("login"))
return f(*args, **kwargs)
return decorated_function
# Login routes
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
password = request.form.get("password", "")
if password == LOGS_PASSWORD:
session['logged_in'] = True
return redirect(url_for('admin'))
else:
return render_template("login.html", error="Invalid password")
return render_template("login.html", error=None)
@app.get("/logout")
def logout():
session.pop('logged_in', None)
return redirect(url_for('index'))
# Protected admin page (logs + container details)
@app.get("/admin")
@login_required
def admin():
data = collect_admin_data()
return render_template("admin.html", data=data, poll_seconds=POLL_SECONDS)
# API endpoint for container details (used by admin panel)
@app.get("/api/container/<container_name>")
@login_required
def api_container_detail(container_name):
detail = get_container_detail(container_name)
return jsonify(detail)
# API endpoint for SQL queries
@app.post("/api/sql/query")
@login_required
def api_sql_query():
data = request.json
container = data.get("container")
query = data.get("query")
if not container or not query:
return jsonify({"error": "Missing container or query"}), 400
result = run_sql_query(container, query)
return jsonify(result)
# API endpoint for shell commands
@app.post("/api/terminal/exec")
@login_required
def api_terminal_exec():
data = request.json
command = data.get("command")
if not command:
return jsonify({"error": "No command provided"}), 400
try:
# HOST ESCAPE: execute command on the host by mounting host / and using chroot
# We use a tiny alpine container to bridge to the host.
# This requires the flask container to have docker socket access (which it does).
host_cmd = [
DOCKER, "run", "--rm",
"-v", "/:/host",
"alpine", "chroot", "/host", "sh", "-c", command
]
output = sh(host_cmd)
return jsonify({"output": output, "status": "success"})
except subprocess.CalledProcessError as e:
# Cast output to string as it might be bytes
out = e.output
if hasattr(out, "decode"):
out = out.decode("utf-8", errors="replace")
return jsonify({"output": out or str(e), "error": True, "status": "error"})
except Exception as e:
return jsonify({"output": str(e), "error": True, "status": "error"})

754
templates/admin.html Normal file
View File

@@ -0,0 +1,754 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin Panel :: 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">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap"
rel="stylesheet">
<style>
* {
box-sizing: border-box;
scroll-behavior: smooth;
}
@keyframes scanline {
0% {
transform: translateY(-100%);
}
100% {
transform: translateY(100vh);
}
}
@keyframes flicker {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.97;
}
}
@keyframes glow {
0%,
100% {
text-shadow: 0 0 10px rgba(0, 217, 255, 0.5);
}
50% {
text-shadow: 0 0 20px rgba(0, 217, 255, 0.8);
}
}
body {
font-family: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
margin: 0;
padding: 0;
background: #0a0e17;
background-image:
linear-gradient(rgba(0, 217, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 217, 255, 0.03) 1px, transparent 1px);
background-size: 50px 50px;
min-height: 100vh;
color: #c9d1d9;
position: relative;
}
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background: linear-gradient(transparent 50%, rgba(0, 217, 255, 0.02) 50%);
background-size: 100% 4px;
pointer-events: none;
z-index: 999;
animation: flicker 0.15s infinite;
}
body::after {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 3px;
background: linear-gradient(transparent, rgba(0, 217, 255, 0.4), transparent);
pointer-events: none;
z-index: 1000;
animation: scanline 8s linear infinite;
}
.container {
max-width: 1600px;
margin: 0 auto;
padding: 20px;
position: relative;
z-index: 1;
}
.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;
}
.terminal-title {
display: flex;
align-items: center;
gap: 12px;
}
.terminal-prompt {
color: #00ff88;
font-weight: 700;
font-size: 18px;
animation: glow 2s ease-in-out infinite;
}
h1 {
margin: 0;
font-size: 24px;
font-weight: 700;
letter-spacing: 1px;
color: #00d9ff;
text-transform: uppercase;
}
.meta-info {
display: flex;
align-items: center;
gap: 16px;
}
.meta-info a,
.meta-info button {
color: #00d9ff;
text-decoration: none;
padding: 6px 12px;
background: rgba(0, 217, 255, 0.1);
border: 1px solid rgba(0, 217, 255, 0.3);
font-family: inherit;
font-size: 12px;
font-weight: 700;
cursor: pointer;
text-transform: uppercase;
transition: all 0.2s ease;
}
.meta-info a:hover,
.meta-info button:hover {
background: rgba(0, 217, 255, 0.2);
border-color: #00d9ff;
box-shadow: 0 0 10px rgba(0, 217, 255, 0.4);
}
.btn-red {
color: #ff5555 !important;
background: rgba(255, 85, 85, 0.1) !important;
border-color: rgba(255, 85, 85, 0.3) !important;
}
.btn-red:hover {
background: rgba(255, 85, 85, 0.2) !important;
border-color: #ff5555 !important;
box-shadow: 0 0 10px rgba(255, 85, 85, 0.4) !important;
}
/* Navigation Grid */
.nav-grid-container {
background: #161b22;
border: 2px solid #30363d;
padding: 20px;
margin-bottom: 40px;
position: relative;
}
.nav-grid-container::before {
content: '[ NAVIGATION_CONTROL_CENTER ]';
position: absolute;
top: -12px;
left: 20px;
background: #161b22;
padding: 0 10px;
color: #00d9ff;
font-size: 11px;
font-weight: 700;
letter-spacing: 2px;
}
.nav-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 10px;
}
.nav-tile {
background: #0d1117;
border: 1px solid #30363d;
padding: 10px;
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
color: inherit;
transition: all 0.2s ease;
position: relative;
overflow: hidden;
}
.nav-tile:hover {
border-color: #00d9ff;
background: rgba(0, 217, 255, 0.05);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.led {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.led-on {
background: #00ff88;
box-shadow: 0 0 8px #00ff88;
}
.led-off {
background: #ff5555;
box-shadow: 0 0 8px #ff5555;
}
.nav-tile-name {
font-size: 11px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-transform: uppercase;
}
/* App Stations */
.station {
background: #161b22;
border: 2px solid #30363d;
margin-bottom: 60px;
position: relative;
padding: 24px;
scroll-margin-top: 40px;
}
.station::before {
content: attr(data-label);
position: absolute;
top: -12px;
left: 20px;
background: #161b22;
padding: 0 10px;
color: #00d9ff;
font-size: 11px;
font-weight: 700;
letter-spacing: 2px;
}
.station-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
h2 {
font-size: 18px;
color: #00ff88;
margin: 0;
text-transform: uppercase;
letter-spacing: 1px;
}
.back-to-top {
font-size: 10px;
color: #8b949e;
text-decoration: none;
border: 1px solid #30363d;
padding: 4px 8px;
text-transform: uppercase;
}
.back-to-top:hover {
color: #00d9ff;
border-color: #00d9ff;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 24px;
}
.panel {
background: #0d1117;
border: 1px solid #30363d;
padding: 16px;
}
.panel-title {
color: #8b949e;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
margin-bottom: 12px;
border-bottom: 1px solid #30363d;
padding-bottom: 4px;
}
.info-row {
display: flex;
justify-content: space-between;
font-size: 12px;
margin-bottom: 8px;
}
.info-label {
color: #8b949e;
}
.info-value {
color: #c9d1d9;
font-weight: 500;
word-break: break-all;
text-align: right;
margin-left: 10px;
}
.env-box {
background: #000;
border: 1px solid #30363d;
height: 150px;
overflow-y: auto;
padding: 10px;
font-size: 11px;
color: #dcdccc;
}
.env-var {
margin-bottom: 4px;
font-family: 'JetBrains Mono', monospace;
}
.env-key {
color: #00d9ff;
}
.terminal-box {
background: #000;
border: 1px solid #00d9ff66;
margin-top: 20px;
}
.terminal-header {
background: #161b22;
padding: 6px 12px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #30363d;
}
.terminal-label {
color: #ffb86c;
font-size: 10px;
font-weight: 700;
}
.terminal-body {
padding: 12px;
height: 350px;
overflow-y: auto;
font-size: 12px;
line-height: 1.5;
color: #dcdccc;
scrollbar-width: thin;
}
.log-line {
margin: 2px 0;
}
.log-error {
color: #ff5555;
}
.log-warn {
color: #ffb86c;
}
.log-info {
color: #8b949e;
}
.copy-btn {
background: transparent;
border: 1px solid #00ff8866;
color: #00ff88;
padding: 2px 8px;
font-size: 9px;
cursor: pointer;
text-transform: uppercase;
}
.copy-btn:hover {
background: rgba(0, 255, 136, 0.1);
}
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #30363d;
}
::-webkit-scrollbar-thumb:hover {
background: #00d9ff;
}
</style>
</head>
<body id="top">
<div class="container">
<div class="header-terminal">
<div class="terminal-title">
<span class="terminal-prompt">root@dokku:~$</span>
<h1>Admin Control Center</h1>
</div>
<div class="meta-info">
<a href="/">DASHBOARD</a>
<button onclick="location.reload()">REFRESH</button>
<a href="/logout" class="btn-red">DISCONNECT</a>
</div>
</div>
<!-- Navigation Grid -->
<div class="nav-grid-container">
<div class="nav-grid">
{% for r in data.apps %}
<a href="#station-{{ loop.index }}" class="nav-tile">
<div class="led {% if r.detail.state.running %}led-on{% else %}led-off{% endif %}"></div>
<span class="nav-tile-name">{{ r.app }}</span>
</a>
{% endfor %}
</div>
</div>
<!-- Root Terminal -->
<section id="root-terminal" class="station" data-label="[ STATION: ROOT_TERMINAL ]">
<div class="station-header">
<h2>Server Shell Access</h2>
<a href="#top" class="back-to-top">[^ BACK_TO_TOP]</a>
</div>
<div class="panel terminal-box" style="margin-top: 0; border-color: #ff555566;">
<div class="terminal-header" style="border-bottom-color: #ff555566;">
<div class="terminal-label" style="color: #ff5555;">ROOT@DOKKU:~$</div>
<button class="copy-btn" onclick="copyLogs('terminal-output')"
style="border-color: #ff555566; color: #ff5555;">COPY BUFFER</button>
</div>
<div id="terminal-output" class="terminal-body"
style="height: 400px; font-size: 13px; background: #000;">
<div class="log-line log-info">Dokku Terminal initialized. Ready for commands.</div>
</div>
<div style="display: flex; background: #000; padding: 10px; border-top: 1px solid #ff555566;">
<span style="color: #00ff88; font-weight: 700; margin-right: 10px;">$</span>
<input type="text" id="terminal-input" placeholder="Enter command..." spellcheck="false"
autocomplete="off"
style="background: transparent; color: #c9d1d9; border: none; outline: none; flex: 1; font-family: inherit; font-size: 13px;"
onkeydown="if(event.key === 'Enter') runCommand()">
</div>
</div>
</section>
<!-- SQL Command Center -->
<section id="sql-center" class="station" data-label="[ STATION: SQL_COMMAND_CENTER ]">
<div class="station-header">
<h2>SQL Query Interface</h2>
<a href="#top" class="back-to-top">[^ BACK_TO_TOP]</a>
</div>
<div class="grid" style="grid-template-columns: 1fr;">
<div class="panel">
<div class="panel-title">Query Console</div>
<div style="display: flex; gap: 10px; margin-bottom: 15px;">
<select id="sql-db-select"
style="background: #000; color: #00ff88; border: 1px solid #30363d; padding: 8px; font-family: inherit; font-size: 12px; flex: 1;">
<option value="">-- SELECT DATABASE CONTAINER --</option>
{% for db in data.databases %}
<option value="{{ db.name }}">{{ db.name }} [{{ db.type|upper }}]</option>
{% endfor %}
</select>
<button onclick="runQuery()"
style="background: #00ff8822; color: #00ff88; border: 1px solid #00ff88; padding: 0 20px; font-family: inherit; font-size: 11px; font-weight: 700; cursor: pointer;">EXECUTE_QUERY</button>
</div>
<textarea id="sql-editor" spellcheck="false" placeholder="SELECT * FROM table_name LIMIT 10;"
style="width: 100%; height: 120px; background: #000; color: #c9d1d9; border: 1px solid #30363d; padding: 12px; font-family: 'JetBrains Mono', monospace; font-size: 13px; resize: vertical;"></textarea>
</div>
<div id="sql-results-panel" class="panel" style="display: none;">
<div class="panel-title">Query Results</div>
<div id="sql-status" style="font-size: 11px; margin-bottom: 10px; color: #8b949e;"></div>
<div style="overflow-x: auto; max-height: 500px;">
<table id="sql-results-table" style="width: 100%; border-collapse: collapse; font-size: 11px;">
<thead id="sql-thead"></thead>
<tbody id="sql-tbody"></tbody>
</table>
</div>
</div>
</div>
</section>
{% for r in data.apps %}
<section id="station-{{ loop.index }}" class="station" data-label="[ STATION: {{ " %02d"|format(loop.index) }}
]">
<div class="station-header">
<h2>{{ r.app }}</h2>
<a href="#top" class="back-to-top">[^ BACK_TO_TOP]</a>
</div>
<div class="grid">
<!-- Metadata Panel -->
<div class="panel">
<div class="panel-title">Container Metadata</div>
<div class="info-row">
<span class="info-label">ID</span>
<span class="info-value">{{ r.detail.id or '—' }}</span>
</div>
<div class="info-row">
<span class="info-label">Name</span>
<span class="info-value">{{ r.container }}</span>
</div>
<div class="info-row">
<span class="info-label">Image</span>
<span class="info-value">{{ r.detail.image or '—' }}</span>
</div>
<div class="info-row">
<span class="info-label">Status</span>
<span class="info-value"
style="color: {% if r.detail.state.running %}#00ff88{% else %}#ff5555{% endif %};">
{{ (r.detail.state.status or 'unknown') | upper }}
</span>
</div>
</div>
<!-- Networking Panel -->
<div class="panel">
<div class="panel-title">Networking & Ports</div>
<div class="info-row">
<span class="info-label">IP Address</span>
<span class="info-value">{{ r.detail.ip_address or '—' }}</span>
</div>
<div class="info-row">
<span class="info-label">Networks</span>
<span class="info-value">{{ (r.detail.networks or []) | join(', ') }}</span>
</div>
<div class="info-row">
<span class="info-label">Port Mappings</span>
<span class="info-value">
{% for port, mappings in (r.detail.ports or {}).items() %}
<span style="color: #00ff88;">{{ port }}</span>{% if mappings %} → {{ mappings[0].HostPort
}}{% endif %}<br>
{% endfor %}
</span>
</div>
</div>
<!-- Environment Panel -->
<div class="panel">
<div class="panel-title">Environment Variables</div>
<div class="env-box">
{% 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>
<!-- Terminal Output -->
<div class="terminal-box">
<div class="terminal-header">
<div class="terminal-label">[ STDOUT / STDERR ]</div>
<button class="copy-btn" onclick="copyLogs('logs-{{ loop.index }}')">Copy Buffer</button>
</div>
<div id="logs-{{ loop.index }}" class="terminal-body" data-autoscroll="true">
{% 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 IN BUFFER ]</div>
{% endif %}
</div>
</div>
</section>
{% endfor %}
</div>
<script>
function copyLogs(id) {
const el = document.getElementById(id);
const text = el.innerText;
navigator.clipboard.writeText(text).then(() => {
const btn = event.currentTarget || event.target;
const orig = btn.innerText;
btn.innerText = 'COPIED';
setTimeout(() => { btn.innerText = orig; }, 2000);
});
}
async function runQuery() {
const container = document.getElementById('sql-db-select').value;
const query = document.getElementById('sql-editor').value;
const resultsPanel = document.getElementById('sql-results-panel');
const status = document.getElementById('sql-status');
const thead = document.getElementById('sql-thead');
const tbody = document.getElementById('sql-tbody');
if (!container || !query) {
alert('Please select a database and enter a query.');
return;
}
resultsPanel.style.display = 'block';
status.innerHTML = '<span style="color: #00d9ff;">EXECUTING...</span>';
thead.innerHTML = '';
tbody.innerHTML = '';
try {
const response = await fetch('/api/sql/query', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ container, query })
});
const result = await response.json();
if (result.error) {
status.innerHTML = `<span style="color: #ff5555;">ERROR: ${result.error}</span>`;
return;
}
status.innerHTML = `<span style="color: #00ff88;">SUCCESS: ${result.count || 0} rows found.</span>`;
if (result.columns && result.columns.length > 0) {
const headerRow = document.createElement('tr');
result.columns.forEach(col => {
const th = document.createElement('th');
th.style.padding = '8px';
th.style.textAlign = 'left';
th.style.border = '1px solid #30363d';
th.style.background = 'rgba(0, 217, 255, 0.1)';
th.style.color = '#00d9ff';
th.innerText = col;
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
}
if (result.rows && result.rows.length > 0) {
result.rows.forEach(row => {
const tr = document.createElement('tr');
row.forEach(cell => {
const td = document.createElement('td');
td.style.padding = '8px';
td.style.border = '1px solid #30363d';
td.innerText = cell;
tr.appendChild(td);
});
tbody.appendChild(tr);
});
} else if (result.message) {
status.innerHTML = `<span style="color: #00ff88;">${result.message}</span>`;
}
} catch (err) {
status.innerHTML = `<span style="color: #ff5555;">CONNECTION ERROR: ${err.message}</span>`;
}
}
async function runCommand() {
const input = document.getElementById('terminal-input');
const output = document.getElementById('terminal-output');
const command = input.value.trim();
if (!command) return;
// Add command to terminal
const cmdLine = document.createElement('div');
cmdLine.className = 'log-line';
cmdLine.innerHTML = `<span style="color: #00ff88;">$ ${command}</span>`;
output.appendChild(cmdLine);
input.value = '';
output.scrollTop = output.scrollHeight;
try {
const response = await fetch('/api/terminal/exec', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command })
});
const result = await response.json();
const resultLine = document.createElement('div');
resultLine.className = result.error ? 'log-line log-error' : 'log-line';
resultLine.style.whiteSpace = 'pre-wrap';
resultLine.innerText = result.output;
output.appendChild(resultLine);
output.scrollTop = output.scrollHeight;
} catch (err) {
const errLine = document.createElement('div');
errLine.className = 'log-line log-error';
errLine.innerText = `FATAL ERROR: ${err.message}`;
output.appendChild(errLine);
output.scrollTop = output.scrollHeight;
}
}
// Auto-scroll logic
document.querySelectorAll('.terminal-body').forEach(el => {
el.scrollTop = el.scrollHeight;
});
</script>
</body>
</html>

View File

@@ -1,257 +1,289 @@
{% macro donut(label, pct, subtitle, value_text=None) %}
{% macro gauge(label, pct, subtitle, value_text=None) %}
{% set p = pct if pct is not none else 0 %}
{% if p < 0 %}{% set p=0 %}{% endif %} {% if p> 100 %}{% set p = 100 %}{% endif %}
{% if p < 60 %} {% set col="#16a34a" %} {% elif p < 85 %} {% set col="#f59e0b" %} {% else %} {% set col="#ef4444" %}
{% endif %} {% set r=24 %} {% set stroke=10 %} {% set c=2 * 3.1415926 * r %} {% set dash=(p / 100.0) * c %} {%
set txt=value_text if value_text else (p|round(0) ~ "%" ) %} <div style="
border:1px solid rgba(0,0,0,.08);
border-radius: 16px;
padding: 14px;
background: linear-gradient(180deg, rgba(255,255,255,.95), rgba(255,255,255,.80));
box-shadow: 0 8px 24px rgba(0,0,0,.06);
display:flex;
align-items:center;
gap: 14px;
min-height: 92px;
">
<svg width="72" height="72" viewBox="0 0 72 72" style="flex: 0 0 auto;">
<defs>
<filter id="softShadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-opacity="0.18" />
</filter>
</defs>
<!-- track -->
<circle cx="36" cy="36" r="{{ r }}" fill="none" stroke="rgba(0,0,0,.08)" stroke-width="{{ stroke }}"
stroke-linecap="round" />
<!-- progress -->
<circle cx="36" cy="36" r="{{ r }}" fill="none" stroke="{{ col }}" stroke-width="{{ stroke }}"
stroke-linecap="round" stroke-dasharray="{{ dash }} {{ c - dash }}" transform="rotate(-90 36 36)"
filter="url(#softShadow)" />
<!-- center -->
<text x="36" y="40" text-anchor="middle" font-size="14" font-weight="800"
font-family="system-ui, -apple-system, Segoe UI, Roboto" fill="rgba(0,0,0,.82)">{{ txt }}</text>
</svg>
<div style="min-width: 0; width: 100%;">
<div style="display:flex; align-items:center; justify-content:space-between; gap: 10px;">
<div style="font-weight:800; letter-spacing:-0.2px;">{{ label }}</div>
{% if p < 60 %} {% set col="#00ff88" %} {% set status="OK" %} {% elif p < 85 %} {% set col="#ffb86c" %} {% set
status="WARN" %} {% else %} {% set col="#ff5555" %} {% set status="CRIT" %} {% endif %} {% set txt=value_text if
value_text else (p|round(0)|int ~ "%" ) %} <div style="
border: 2px solid {{ col }};
background: rgba({{ '0, 255, 136' if p < 60 else ('255, 184, 108' if p < 85 else '255, 85, 85') }}, 0.05);
padding: 16px;
position: relative;
transition: all 0.3s ease;
">
<!-- Status indicator -->
<div style="
font-size:12px;
padding: 2px 8px;
border-radius: 999px;
background: rgba(0,0,0,.04);
color: rgba(0,0,0,.65);
flex: 0 0 auto;">
live
</div>
position: absolute;
top: 8px;
right: 8px;
font-size: 9px;
font-weight: 700;
letter-spacing: 1px;
color: {{ col }};
padding: 2px 6px;
border: 1px solid {{ col }};
background: rgba({{ '0, 255, 136' if p < 60 else ('255, 184, 108' if p < 85 else '255, 85, 85') }}, 0.1);
">
[{{ status }}]
</div>
<div
style="margin-top:4px; font-size:12.5px; color: rgba(0,0,0,.62); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
<!-- Label -->
<div style="
font-size: 11px;
font-weight: 700;
letter-spacing: 2px;
color: #8b949e;
text-transform: uppercase;
margin-bottom: 8px;
">
> {{ label }}
</div>
<!-- Value -->
<div style="
font-size: 28px;
font-weight: 700;
color: {{ col }};
margin-bottom: 8px;
text-shadow: 0 0 10px rgba({{ '0, 255, 136' if p < 60 else ('255, 184, 108' if p < 85 else '255, 85, 85') }}, 0.5);
">
{{ txt }}
</div>
<!-- Subtitle -->
<div style="
font-size: 11px;
color: #8b949e;
margin-bottom: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
">
{{ subtitle }}
</div>
<div
style="margin-top:10px; height:8px; border-radius:999px; background: rgba(0,0,0,.07); overflow:hidden;">
<div style="height:100%; width: {{ p }}%; background: {{ col }}; border-radius:999px;"></div>
<!-- Progress bar -->
<div style="
height: 6px;
background: #30363d;
position: relative;
overflow: hidden;
">
<div style="
height: 100%;
width: {{ p }}%;
background: {{ col }};
box-shadow: 0 0 10px {{ col }};
transition: width 0.5s ease;
"></div>
</div>
<!-- ASCII bar representation -->
<div style="
font-size: 10px;
color: {{ col }};
margin-top: 8px;
font-family: 'JetBrains Mono', monospace;
letter-spacing: 0;
">
{% set blocks = (p / 5)|round(0)|int %}
{% set empty = 20 - blocks %}
[{% for i in range(blocks) %}█{% endfor %}{% for i in range(empty) %}░{% endfor %}]
</div>
</div>
{% endmacro %}
<h2
style="margin-top: 0; font-size: 22px; font-weight: 800; letter-spacing: -0.3px; color: #1e293b; margin-bottom: 16px;">
📊 Live Usage</h2>
<h2>[ LIVE METRICS ]</h2>
<div
style="display:grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; margin: 0 0 32px 0;">
{{ donut("CPU", data.gauges.cpu_total_pct, "Sum of container CPU% (clamped)") }}
{{ donut("RAM", data.gauges.ram_pct, "All containers vs host RAM", (data.gauges.ram_used_h ~ " / " ~
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; margin: 0 0 32px 0;">
{{ gauge("CPU", data.gauges.cpu_total_pct, "Sum of container CPU% (clamped)") }}
{{ gauge("RAM", data.gauges.ram_pct, "All containers vs host RAM", (data.gauges.ram_used_h ~ " / " ~
data.gauges.ram_total_h)) }}
{{ donut("Docker disk", data.gauges.docker_images_pct, "Images used vs total store") }}
{{ gauge("DOCKER_DISK", data.gauges.docker_images_pct, "Images used vs total store") }}
</div>
<h2 style="font-size: 22px; font-weight: 800; letter-spacing: -0.3px; color: #1e293b; margin-bottom: 16px;">💻
System</h2>
<h2>[ SYSTEM INFO ]</h2>
<div
style="display:grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; margin: 0 0 32px 0;">
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; margin: 0 0 32px 0;">
<div style="
border: 1px solid rgba(102, 126, 234, 0.15);
border-radius: 16px;
border: 2px solid #30363d;
background: rgba(0, 217, 255, 0.02);
padding: 20px;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.03) 0%, rgba(118, 75, 162, 0.03) 100%);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
transition: all 0.3s ease;
" onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 8px 20px rgba(0,0,0,0.08)';"
onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 4px 12px rgba(0,0,0,0.04)';">
<div
style="color: #64748b; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px;">
🖥️ Host</div>
<div style="font-size: 18px; font-weight: 800; color: #1e293b; margin-bottom: 6px;">{{ data.system.name
or "—" }}</div>
<div style="color: #64748b; font-size: 13px; line-height: 1.5;">{{ data.system.operating_system }} · {{
data.system.kernel_version }}</div>
" onmouseover="this.style.borderColor='#00d9ff'; this.style.boxShadow='0 0 20px rgba(0, 217, 255, 0.3)';"
onmouseout="this.style.borderColor='#30363d'; this.style.boxShadow='none';">
<div style="
color: #00d9ff;
font-size: 10px;
font-weight: 700;
letter-spacing: 2px;
text-transform: uppercase;
margin-bottom: 12px;
">
[HOST]
</div>
<div style="font-size: 18px; font-weight: 700; color: #c9d1d9; margin-bottom: 8px;">
{{ data.system.name or "—" }}
</div>
<div style="color: #8b949e; font-size: 12px; line-height: 1.6;">
{{ data.system.operating_system }}<br>
Kernel: {{ data.system.kernel_version }}
</div>
</div>
<div style="
border: 1px solid rgba(102, 126, 234, 0.15);
border-radius: 16px;
border: 2px solid #30363d;
background: rgba(0, 255, 136, 0.02);
padding: 20px;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.03) 0%, rgba(118, 75, 162, 0.03) 100%);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
transition: all 0.3s ease;
" onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 8px 20px rgba(0,0,0,0.08)';"
onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 4px 12px rgba(0,0,0,0.04)';">
<div
style="color: #64748b; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px;">
⚡ Compute</div>
<div style="font-size: 15px; font-weight: 600; color: #334155; margin-bottom: 4px;"><strong
style="color: #667eea; font-size: 18px;">{{ data.system.cpus or "—" }}</strong> CPUs</div>
<div style="font-size: 15px; font-weight: 600; color: #334155;"><strong
style="color: #667eea; font-size: 18px;">{{ data.system.mem_total_h or "—" }}</strong> RAM</div>
" onmouseover="this.style.borderColor='#00ff88'; this.style.boxShadow='0 0 20px rgba(0, 255, 136, 0.3)';"
onmouseout="this.style.borderColor='#30363d'; this.style.boxShadow='none';">
<div style="
color: #00ff88;
font-size: 10px;
font-weight: 700;
letter-spacing: 2px;
text-transform: uppercase;
margin-bottom: 12px;
">
[COMPUTE]
</div>
<div style="font-size: 14px; font-weight: 600; color: #c9d1d9; margin-bottom: 6px;">
<span style="color: #00ff88; font-size: 20px; font-weight: 700;">{{ data.system.cpus or "—"
}}</span> CPUs
</div>
<div style="font-size: 14px; font-weight: 600; color: #c9d1d9;">
<span style="color: #00ff88; font-size: 20px; font-weight: 700;">{{ data.system.ram_total_h or "—"
}}</span> RAM
</div>
</div>
<div style="
border: 1px solid rgba(102, 126, 234, 0.15);
border-radius: 16px;
border: 2px solid #30363d;
background: rgba(255, 184, 108, 0.02);
padding: 20px;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.03) 0%, rgba(118, 75, 162, 0.03) 100%);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
transition: all 0.3s ease;
" onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 8px 20px rgba(0,0,0,0.08)';"
onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 4px 12px rgba(0,0,0,0.04)';">
<div
style="color: #64748b; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px;">
🐳 Docker</div>
<div style="font-size: 13px; color: #334155; margin-bottom: 4px; line-height: 1.6;">Engine: <strong
style="color: #667eea;">{{ data.system.server_version or "—" }}</strong></div>
<div style="font-size: 13px; color: #334155; margin-bottom: 4px; line-height: 1.6;">Images: <strong
style="color: #667eea;">{{ data.system.images or "—" }}</strong></div>
<div style="font-size: 13px; color: #334155; line-height: 1.6;">Containers: <strong
style="color: #16a34a;">{{ data.system.containers_running or "—" }}</strong> running / <strong
style="color: #64748b;">{{ data.system.containers_stopped or "—" }}</strong> stopped</div>
" onmouseover="this.style.borderColor='#ffb86c'; this.style.boxShadow='0 0 20px rgba(255, 184, 108, 0.3)';"
onmouseout="this.style.borderColor='#30363d'; this.style.boxShadow='none';">
<div style="
color: #ffb86c;
font-size: 10px;
font-weight: 700;
letter-spacing: 2px;
text-transform: uppercase;
margin-bottom: 12px;
">
[DOCKER]
</div>
<div style="font-size: 12px; color: #c9d1d9; margin-bottom: 6px; line-height: 1.7;">
Engine: <span style="color: #ffb86c; font-weight: 700;">{{ data.system.server_version or "—"
}}</span>
</div>
<div style="font-size: 12px; color: #c9d1d9; margin-bottom: 6px; line-height: 1.7;">
Images: <span style="color: #ffb86c; font-weight: 700;">{{ data.system.images or "—" }}</span>
</div>
<div style="font-size: 12px; color: #c9d1d9; line-height: 1.7;">
Containers: <span style="color: #00ff88; font-weight: 700;">{{ data.system.containers_running or "—"
}}</span> up / <span style="color: #8b949e; font-weight: 700;">{{ data.system.containers_stopped
or "—" }}</span> down
</div>
</div>
</div>
<h3 style="font-size: 18px; font-weight: 700; letter-spacing: -0.2px; color: #334155; margin: 32px 0 16px 0;">💾
Docker Disk Usage</h3>
<div style="overflow-x: auto; border-radius: 12px; border: 1px solid rgba(0,0,0,0.06);">
<table style="margin: 0;">
<thead>
<tr>
<th
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);">
Type</th>
<th
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);">
Total</th>
<th
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);">
Active</th>
<th
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);">
Size</th>
<th
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);">
Reclaimable</th>
</tr>
</thead>
<tbody>
{% for typ, r in data.system.system_df.items() %}
<tr style="transition: background-color 0.2s ease;"
onmouseover="this.style.backgroundColor='rgba(102, 126, 234, 0.04)';"
onmouseout="this.style.backgroundColor='transparent';">
<td><strong style="color: #667eea;">{{ typ }}</strong></td>
<td style="color: #334155;">{{ r.total }}</td>
<td style="color: #334155;">{{ r.active }}</td>
<td style="color: #334155;">{{ r.size }}</td>
<td style="color: #334155;">{{ r.reclaimable }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div style="
font-size: 11px;
color: #8b949e;
margin-bottom: 32px;
font-weight: 500;
">
<span style="color: #00d9ff;">[TIMESTAMP]</span> {{ data.generated_at }}
</div>
<div style="margin-top: 16px; color: #64748b; font-size: 13px; font-weight: 500;">⏱️ Generated at: {{
data.generated_at }}</div>
{% if data.warnings %}
<div style="
margin: 20px 0;
margin: 24px 0;
padding: 16px 20px;
border: 1px solid #fbbf24;
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
border-radius: 12px;
box-shadow: 0 4px 12px rgba(251, 191, 36, 0.15);
border: 2px solid #ffb86c;
background: rgba(255, 184, 108, 0.05);
position: relative;
">
<div style="
position: absolute;
top: -12px;
left: 16px;
background: #161b22;
padding: 0 8px;
color: #ffb86c;
font-size: 11px;
font-weight: 700;
letter-spacing: 2px;
">
<strong
style="color: #92400e; font-size: 16px; display: flex; align-items: center; gap: 8px; margin-bottom: 12px;">⚠️
Warnings</strong>
<ul style="margin: 0; padding-left: 20px; color: #78350f;">
[!! WARNINGS !!]
</div>
<ul style="margin: 8px 0 0 0; padding-left: 24px; color: #ffb86c;">
{% for w in data.warnings %}
<li style="margin: 6px 0;">{{ w }}</li>
<li style="margin: 6px 0; font-size: 13px;">{{ w }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<h2 style="font-size: 22px; font-weight: 800; letter-spacing: -0.3px; color: #1e293b; margin: 40px 0 16px 0;">🚀
Apps</h2>
<div style="overflow-x: auto; border-radius: 12px; border: 1px solid rgba(0,0,0,0.06);">
<table style="margin: 0;">
<h2>[ APPLICATIONS ]</h2>
<div style="overflow-x: auto; margin-bottom: 32px;">
<table>
<thead>
<tr>
<th
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);">
App</th>
<th
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);">
URL</th>
<th
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);">
Status</th>
<th
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);">
CPU</th>
<th
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);">
RAM</th>
<th
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);">
Restarts</th>
<th
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);">
Image</th>
<th>APP</th>
<th>URL</th>
<th>STATUS</th>
<th>CPU</th>
<th>RAM</th>
<th>RESTARTS</th>
<th>IMAGE</th>
</tr>
</thead>
<tbody>
{% for r in data.apps %}
<tr style="transition: background-color 0.2s ease;"
onmouseover="this.style.backgroundColor='rgba(102, 126, 234, 0.04)';"
onmouseout="this.style.backgroundColor='transparent';">
<td><strong style="color: #667eea;">{{ r.app }}</strong></td>
<td><a href="{{ r.url }}"
style="color: #667eea; text-decoration: none; transition: all 0.2s ease;"
onmouseover="this.style.textDecoration='underline'; this.style.color='#764ba2';"
onmouseout="this.style.textDecoration='none'; this.style.color='#667eea';">{{ r.url
}}</a></td>
<td><span
style="padding: 4px 10px; background: rgba(102, 126, 234, 0.1); color: #667eea; border-radius: 6px; font-size: 12px; font-weight: 600;">{{
r.status }}</span></td>
<td style="color: #334155; font-weight: 600;">{{ r.cpu or "—" }}</td>
<td style="color: #334155; font-size: 13px;">
<tr>
<td><span style="color: #00d9ff; font-weight: 700;">{{ r.app }}</span></td>
<td>
<a href="{{ r.url }}" style="
color: #00ff88;
text-decoration: none;
transition: all 0.2s ease;
" onmouseover="this.style.color='#00d9ff'; this.style.textShadow='0 0 10px rgba(0, 217, 255, 0.5)';"
onmouseout="this.style.color='#00ff88'; this.style.textShadow='none';">
{{ r.url }}
</a>
</td>
<td>
<span style="
padding: 3px 8px;
background: rgba(0, 255, 136, 0.1);
color: #00ff88;
border: 1px solid #00ff88;
font-size: 10px;
font-weight: 700;
letter-spacing: 1px;
">
{{ r.status }}
</span>
</td>
<td style="color: #00ff88; font-weight: 600;">{{ r.cpu or "—" }}</td>
<td style="font-size: 12px;">
{% if r.mem_used %}
{{ r.mem_used }} / {{ r.mem_limit }} <span style="color: #667eea; font-weight: 600;">({{
r.mem_pct }})</span>
{{ r.mem_used }} / {{ r.mem_limit }}
<span style="color: #00d9ff; font-weight: 700;">({{ r.mem_pct }})</span>
{% else %} — {% endif %}
</td>
<td style="color: #334155; font-weight: 600;">{{ r.restarts }}</td>
<td style="color: #64748b; font-size: 12px;">{{ r.image }}</td>
<td style="
color: {% if r.restarts >= 3 %}#ff5555{% else %}#8b949e{% endif %};
font-weight: 700;
">
{{ r.restarts }}
</td>
<td style="color: #8b949e; font-size: 11px;">{{ r.image }}</td>
</tr>
{% endfor %}
</tbody>
@@ -259,53 +291,79 @@
</div>
{% if data.infra %}
<h2 style="font-size: 22px; font-weight: 800; letter-spacing: -0.3px; color: #1e293b; margin: 40px 0 16px 0;">
🏗️ Infra</h2>
<div style="overflow-x: auto; border-radius: 12px; border: 1px solid rgba(0,0,0,0.06);">
<table style="margin: 0;">
<h2>[ INFRASTRUCTURE ]</h2>
<div style="overflow-x: auto;">
<table>
<thead>
<tr>
<th
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);">
Container</th>
<th
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);">
Status</th>
<th
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);">
CPU</th>
<th
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);">
RAM</th>
<th
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);">
Restarts</th>
<th
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);">
Image</th>
<th>CONTAINER</th>
<th>STATUS</th>
<th>CPU</th>
<th>RAM</th>
<th>RESTARTS</th>
<th>IMAGE</th>
</tr>
</thead>
<tbody>
{% for r in data.infra %}
<tr style="transition: background-color 0.2s ease;"
onmouseover="this.style.backgroundColor='rgba(102, 126, 234, 0.04)';"
onmouseout="this.style.backgroundColor='transparent';">
<td><strong style="color: #667eea;">{{ r.container }}</strong></td>
<td><span
style="padding: 4px 10px; background: rgba(102, 126, 234, 0.1); color: #667eea; border-radius: 6px; font-size: 12px; font-weight: 600;">{{
r.status }}</span></td>
<td style="color: #334155; font-weight: 600;">{{ r.cpu or "—" }}</td>
<td style="color: #334155; font-size: 13px;">
<tr>
<td><span style="color: #ffb86c; font-weight: 700;">{{ r.container }}</span></td>
<td>
<span style="
padding: 3px 8px;
background: rgba(0, 255, 136, 0.1);
color: #00ff88;
border: 1px solid #00ff88;
font-size: 10px;
font-weight: 700;
letter-spacing: 1px;
">
{{ r.status }}
</span>
</td>
<td style="color: #00ff88; font-weight: 600;">{{ r.cpu or "—" }}</td>
<td style="font-size: 12px;">
{% if r.mem_used %}
{{ r.mem_used }} / {{ r.mem_limit }} <span style="color: #667eea; font-weight: 600;">({{
r.mem_pct }})</span>
{{ r.mem_used }} / {{ r.mem_limit }}
<span style="color: #00d9ff; font-weight: 700;">({{ r.mem_pct }})</span>
{% else %} — {% endif %}
</td>
<td style="color: #334155; font-weight: 600;">{{ r.restarts }}</td>
<td style="color: #64748b; font-size: 12px;">{{ r.image }}</td>
<td style="
color: {% if r.restarts >= 3 %}#ff5555{% else %}#8b949e{% endif %};
font-weight: 700;
">
{{ r.restarts }}
</td>
<td style="color: #8b949e; font-size: 11px;">{{ r.image }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<h3>DOCKER DISK USAGE</h3>
<div style="overflow-x: auto; margin-bottom: 24px;">
<table>
<thead>
<tr>
<th>TYPE</th>
<th>TOTAL</th>
<th>ACTIVE</th>
<th>SIZE</th>
<th>RECLAIMABLE</th>
</tr>
</thead>
<tbody>
{% for typ, r in data.system.system_df.items() %}
<tr>
<td><span style="color: #00d9ff; font-weight: 700;">[{{ typ }}]</span></td>
<td>{{ r.total }}</td>
<td>{{ r.active }}</td>
<td>{{ r.size }}</td>
<td>{{ r.reclaimable }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>

View File

@@ -0,0 +1,319 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Container: {{ container_name }} - 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">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap"
rel="stylesheet">
<style>
* {
box-sizing: border-box;
}
body {
font-family: 'JetBrains Mono', monospace;
margin: 0;
padding: 0;
background: #0a0e17;
min-height: 100vh;
color: #c9d1d9;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.header {
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;
}
h1 {
margin: 0;
font-size: 20px;
font-weight: 700;
color: #00d9ff;
letter-spacing: 1px;
}
.back-btn {
background: rgba(0, 217, 255, 0.1);
border: 1px solid #00d9ff;
color: #00d9ff;
padding: 6px 16px;
text-decoration: none;
font-size: 10px;
font-weight: 700;
letter-spacing: 1px;
transition: all 0.2s ease;
}
.back-btn:hover {
background: rgba(0, 217, 255, 0.2);
}
.section {
background: #161b22;
border: 2px solid #30363d;
margin-bottom: 20px;
position: relative;
}
.section-title {
position: absolute;
top: -12px;
left: 20px;
background: #161b22;
padding: 0 10px;
color: #00d9ff;
font-size: 11px;
font-weight: 700;
letter-spacing: 2px;
}
.section-content {
padding: 24px;
}
.info-grid {
display: grid;
grid-template-columns: 200px 1fr;
gap: 12px;
margin-bottom: 12px;
}
.label {
color: #8b949e;
font-size: 11px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
}
.value {
color: #c9d1d9;
font-size: 12px;
word-break: break-all;
}
.value-code {
background: #0a0e17;
border: 1px solid #30363d;
padding: 8px;
font-size: 11px;
overflow-x: auto;
}
.status-running {
color: #00ff88;
font-weight: 700;
}
.status-stopped {
color: #ff5555;
font-weight: 700;
}
.list {
background: #0a0e17;
border: 1px solid #30363d;
padding: 12px;
margin: 8px 0;
}
.list-item {
padding: 6px 0;
border-bottom: 1px solid #30363d;
font-size: 11px;
}
.list-item:last-child {
border-bottom: none;
}
.env-var {
display: grid;
grid-template-columns: 250px 1fr;
gap: 12px;
}
.env-key {
color: #00d9ff;
font-weight: 700;
}
.env-value {
color: #c9d1d9;
word-break: break-all;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>[ CONTAINER: {{ container_name }} ]</h1>
<a href="/logs" class="back-btn">← BACK TO LOGS</a>
</div>
{% if container.error %}
<div class="section">
<div class="section-title">[ERROR]</div>
<div class="section-content">
<div style="color: #ff5555;">{{ container.error }}</div>
</div>
</div>
{% else %}
<!-- Basic Info -->
<div class="section">
<div class="section-title">[BASIC INFO]</div>
<div class="section-content">
<div class="info-grid">
<div class="label">Container ID</div>
<div class="value">{{ container.id }}</div>
<div class="label">Image</div>
<div class="value">{{ container.image }}</div>
<div class="label">Status</div>
<div
class="value {% if container.state.running %}status-running{% else %}status-stopped{% endif %}">
{{ container.state.status | upper }}
</div>
<div class="label">Started At</div>
<div class="value">{{ container.state.started_at }}</div>
<div class="label">Working Directory</div>
<div class="value">{{ container.working_dir or "—" }}</div>
<div class="label">IP Address</div>
<div class="value">{{ container.ip_address or "—" }}</div>
<div class="label">Networks</div>
<div class="value">{{ container.networks | join(", ") or "—" }}</div>
</div>
</div>
</div>
<!-- Environment Variables -->
<div class="section">
<div class="section-title">[ENVIRONMENT VARIABLES]</div>
<div class="section-content">
{% if container.env %}
<div class="list">
{% for env in container.env %}
<div class="list-item env-var">
{% set parts = env.split('=', 1) %}
<div class="env-key">{{ parts[0] }}</div>
<div class="env-value">{{ parts[1] if parts|length > 1 else "" }}</div>
</div>
{% endfor %}
</div>
{% else %}
<div style="color: #8b949e;">No environment variables</div>
{% endif %}
</div>
</div>
<!-- Ports -->
<div class="section">
<div class="section-title">[PORT MAPPINGS]</div>
<div class="section-content">
{% if container.ports %}
<div class="list">
{% for port, mappings in container.ports.items() %}
<div class="list-item">
<span style="color: #00d9ff; font-weight: 700;">{{ port }}</span>
{% if mappings %}
{% for mapping in mappings %}
<span style="color: #00ff88;">{{ mapping.HostIp or "0.0.0.0" }}:{{ mapping.HostPort }}</span>
{% endfor %}
{% else %}
<span style="color: #8b949e;">(not mapped)</span>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div style="color: #8b949e;">No port mappings</div>
{% endif %}
</div>
</div>
<!-- Volumes/Mounts -->
<div class="section">
<div class="section-title">[VOLUMES & MOUNTS]</div>
<div class="section-content">
{% if container.mounts %}
<div class="list">
{% for mount in container.mounts %}
<div class="list-item">
<div style="margin-bottom: 4px;">
<span style="color: #ffb86c; font-weight: 700;">[{{ mount.type | upper }}]</span>
<span
style="color: {% if mount.rw %}#00ff88{% else %}#ff5555{% endif %}; margin-left: 8px;">
{% if mount.rw %}[RW]{% else %}[RO]{% endif %}
</span>
</div>
<div style="color: #8b949e; font-size: 10px;">
{{ mount.source }} → {{ mount.destination }}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div style="color: #8b949e;">No mounts</div>
{% endif %}
</div>
</div>
<!-- Command -->
<div class="section">
<div class="section-title">[COMMAND & ENTRYPOINT]</div>
<div class="section-content">
<div class="info-grid">
<div class="label">Entrypoint</div>
<div class="value-code">{{ container.entrypoint | join(" ") or "—" }}</div>
<div class="label">Command</div>
<div class="value-code">{{ container.cmd | join(" ") or "—" }}</div>
</div>
</div>
</div>
<!-- Restart Policy -->
<div class="section">
<div class="section-title">[RESTART POLICY]</div>
<div class="section-content">
<div class="info-grid">
<div class="label">Name</div>
<div class="value">{{ container.restart_policy.Name or "no" }}</div>
<div class="label">Max Retry Count</div>
<div class="value">{{ container.restart_policy.MaximumRetryCount or "0" }}</div>
</div>
</div>
</div>
{% endif %}
</div>
</body>
</html>

View File

@@ -4,99 +4,230 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Status - peterstockings.com</title>
<title>DokkuStatus :: Terminal</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>" />
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<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=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap"
rel="stylesheet">
<style>
* {
box-sizing: border-box;
}
@keyframes scanline {
0% {
transform: translateY(-100%);
}
100% {
transform: translateY(100vh);
}
}
@keyframes flicker {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.97;
}
}
@keyframes glow {
0%,
100% {
text-shadow: 0 0 10px rgba(0, 217, 255, 0.5);
}
50% {
text-shadow: 0 0 20px rgba(0, 217, 255, 0.8);
}
}
body {
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
font-family: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
margin: 0;
padding: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: #0a0e17;
background-image:
linear-gradient(rgba(0, 217, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 217, 255, 0.03) 1px, transparent 1px);
background-size: 50px 50px;
min-height: 100vh;
color: #c9d1d9;
position: relative;
}
/* Scanline effect */
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background: linear-gradient(transparent 50%,
rgba(0, 217, 255, 0.02) 50%);
background-size: 100% 4px;
pointer-events: none;
z-index: 999;
animation: flicker 0.15s infinite;
}
/* Moving scanline */
body::after {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 3px;
background: linear-gradient(transparent, rgba(0, 217, 255, 0.4), transparent);
pointer-events: none;
z-index: 1000;
animation: scanline 8s linear infinite;
}
.container {
max-width: 1400px;
max-width: 1600px;
margin: 0 auto;
padding: 32px 24px;
padding: 20px;
position: relative;
z-index: 1;
}
.header-card {
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 28px 32px;
margin-bottom: 24px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15),
0 0 0 1px rgba(255, 255, 255, 0.3) inset;
border: 1px solid rgba(255, 255, 255, 0.5);
.header-terminal {
background: #161b22;
border: 2px solid #00d9ff;
box-shadow:
0 0 20px rgba(0, 217, 255, 0.3),
inset 0 0 20px rgba(0, 217, 255, 0.05);
padding: 20px 24px;
margin-bottom: 20px;
position: relative;
overflow: hidden;
}
.header-terminal::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 30px;
background: linear-gradient(90deg,
#00d9ff 0%,
#00ff88 50%,
#00d9ff 100%);
opacity: 0.1;
}
.terminal-title {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.terminal-prompt {
color: #00ff88;
font-weight: 700;
font-size: 18px;
animation: glow 2s ease-in-out infinite;
}
h1 {
margin: 0 0 8px 0;
font-size: 36px;
font-weight: 800;
letter-spacing: -0.5px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0;
font-size: 24px;
font-weight: 700;
letter-spacing: 1px;
color: #00d9ff;
text-transform: uppercase;
display: inline-block;
}
.meta-info {
display: flex;
align-items: center;
gap: 12px;
gap: 16px;
flex-wrap: wrap;
color: #64748b;
font-size: 14px;
font-size: 13px;
color: #8b949e;
font-weight: 500;
}
.meta-info span {
color: #00ff88;
}
.meta-info .separator {
color: #30363d;
}
.meta-info a {
color: #667eea;
color: #00d9ff;
text-decoration: none;
padding: 4px 12px;
background: rgba(102, 126, 234, 0.1);
border-radius: 6px;
font-weight: 600;
padding: 4px 10px;
background: rgba(0, 217, 255, 0.1);
border: 1px solid rgba(0, 217, 255, 0.3);
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 6px;
}
.meta-info a:hover {
background: rgba(102, 126, 234, 0.2);
background: rgba(0, 217, 255, 0.2);
border-color: #00d9ff;
box-shadow: 0 0 10px rgba(0, 217, 255, 0.4);
transform: translateY(-1px);
}
.content-card {
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 32px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15),
0 0 0 1px rgba(255, 255, 255, 0.3) inset;
border: 1px solid rgba(255, 255, 255, 0.5);
.content-terminal {
background: #161b22;
border: 2px solid #30363d;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.5);
padding: 24px;
position: relative;
}
.content-terminal::before {
content: '[ STATUS MONITOR ]';
position: absolute;
top: -12px;
left: 20px;
background: #161b22;
padding: 0 10px;
color: #00d9ff;
font-size: 11px;
font-weight: 700;
letter-spacing: 2px;
}
.loading {
text-align: center;
padding: 60px 20px;
color: #64748b;
font-size: 16px;
font-weight: 500;
color: #00ff88;
font-size: 14px;
font-weight: 600;
letter-spacing: 2px;
}
.loading::before {
content: '[ ';
color: #00d9ff;
}
.loading::after {
content: '...';
content: ' ]';
color: #00d9ff;
animation: dots 1.5s steps(4, end) infinite;
}
@@ -104,111 +235,145 @@
0%,
20% {
content: '.';
content: ' .]';
}
40% {
content: '..';
content: ' ..]';
}
60%,
100% {
content: '...';
content: ' ...]';
}
}
table {
border-collapse: collapse;
width: 100%;
border: 1px solid #30363d;
}
th,
td {
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
padding: 12px;
border: 1px solid #30363d;
padding: 12px 16px;
vertical-align: top;
text-align: left;
}
th {
text-align: left;
background: rgba(0, 217, 255, 0.05);
color: #00d9ff;
font-weight: 700;
color: #1e293b;
font-size: 13px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
background: rgba(102, 126, 234, 0.04);
letter-spacing: 1px;
}
td {
color: #334155;
font-size: 14px;
color: #c9d1d9;
font-size: 13px;
}
tbody tr {
transition: background-color 0.2s ease;
transition: all 0.2s ease;
background: transparent;
}
tbody tr:hover {
background: rgba(102, 126, 234, 0.03);
background: rgba(0, 217, 255, 0.05);
box-shadow: inset 2px 0 0 #00d9ff;
}
.muted {
color: #64748b;
color: #8b949e;
font-size: 0.9em;
}
.pill {
display: inline-block;
padding: 4px 10px;
border-radius: 999px;
background: rgba(102, 126, 234, 0.1);
color: #667eea;
font-size: 12px;
font-weight: 600;
}
h2 {
font-size: 22px;
font-weight: 800;
letter-spacing: -0.3px;
color: #1e293b;
font-size: 16px;
font-weight: 700;
letter-spacing: 2px;
color: #00d9ff;
margin: 0 0 16px 0;
text-transform: uppercase;
padding-left: 12px;
border-left: 3px solid #00ff88;
}
h3 {
font-size: 18px;
font-size: 14px;
font-weight: 700;
letter-spacing: -0.2px;
color: #334155;
letter-spacing: 1px;
color: #8b949e;
margin: 24px 0 12px 0;
text-transform: uppercase;
}
h3::before {
content: '> ';
color: #00d9ff;
}
/* Responsive */
@media (max-width: 768px) {
.container {
padding: 12px;
}
h1 {
font-size: 18px;
}
.meta-info {
font-size: 11px;
gap: 8px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header-card">
<h1>Status Dashboard</h1>
<div class="header-terminal">
<div class="terminal-title">
<span class="terminal-prompt">root@dokku:~$</span>
<h1>DokkuStatus</h1>
</div>
<div class="meta-info">
<span>🔄 Auto-refresh every {{ poll_seconds }}s</span>
<span></span>
<a href="/api/status">📊 JSON API</a>
<span></span>
<span>[LIVE]</span>
<span class="separator">|</span>
<span>REFRESH: {{ poll_seconds }}s</span>
<span class="separator">|</span>
<a href="/api/status">
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
<path d="M2 2h12v2H2V2zm0 3h12v2H2V5zm0 3h12v2H2V8zm0 3h12v2H2v-2z" />
</svg>
JSON_API
</a>
<span class="separator">|</span>
<a href="https://gitea.peterstockings.com/peterstockings/DokkuStatus" target="_blank"
rel="noopener noreferrer" style="display: inline-flex; align-items: center; gap: 6px;">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"
xmlns="http://www.w3.org/2000/svg">
rel="noopener noreferrer">
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
<path
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
Source
SOURCE
</a>
<span class="separator">|</span>
<a href="/admin">
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
<path d="M0 2h16v2H0V2zm0 6h16v2H0V8zm0 6h16v2H0v-2z" />
</svg>
ADMIN
</a>
</div>
</div>
<div class="content-card">
<div class="content-terminal">
<div hx-get="/partial/apps" hx-trigger="load, every {{ poll_seconds }}s" hx-swap="innerHTML">
<div class="loading">Loading status data</div>
<div class="loading">INITIALIZING SYSTEM</div>
</div>
</div>
</div>

169
templates/login.html Normal file
View File

@@ -0,0 +1,169 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Login - 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">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap"
rel="stylesheet">
<style>
* {
box-sizing: border-box;
}
body {
font-family: 'JetBrains Mono', monospace;
margin: 0;
padding: 0;
background: #0a0e17;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: #c9d1d9;
}
.login-container {
width: 100%;
max-width: 400px;
padding: 20px;
}
.login-box {
background: #161b22;
border: 2px solid #00d9ff;
box-shadow: 0 0 30px rgba(0, 217, 255, 0.3);
padding: 32px;
}
.login-box::before {
content: '[LOGIN REQUIRED]';
display: block;
background: #161b22;
color: #00d9ff;
font-size: 11px;
font-weight: 700;
letter-spacing: 2px;
text-align: center;
margin: -32px -32px 24px -32px;
padding: 12px;
border-bottom: 2px solid #00d9ff;
}
h1 {
margin: 0 0 24px 0;
font-size: 24px;
font-weight: 700;
color: #00d9ff;
text-transform: uppercase;
letter-spacing: 1px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
color: #8b949e;
font-size: 12px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
}
input[type="password"] {
width: 100%;
padding: 12px;
background: #0a0e17;
border: 2px solid #30363d;
color: #c9d1d9;
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
transition: all 0.2s ease;
}
input[type="password"]:focus {
outline: none;
border-color: #00d9ff;
box-shadow: 0 0 10px rgba(0, 217, 255, 0.3);
}
button {
width: 100%;
padding: 12px;
background: rgba(0, 217, 255, 0.1);
border: 2px solid #00d9ff;
color: #00d9ff;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 700;
letter-spacing: 2px;
cursor: pointer;
transition: all 0.2s ease;
text-transform: uppercase;
}
button:hover {
background: rgba(0, 217, 255, 0.2);
box-shadow: 0 0 20px rgba(0, 217, 255, 0.4);
}
.error {
margin-top: 16px;
padding: 12px;
background: rgba(255, 85, 85, 0.1);
border: 2px solid #ff5555;
color: #ff5555;
font-size: 12px;
font-weight: 700;
letter-spacing: 1px;
}
.back-link {
display: block;
margin-top: 20px;
text-align: center;
color: #8b949e;
text-decoration: none;
font-size: 12px;
transition: color 0.2s ease;
}
.back-link:hover {
color: #00d9ff;
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-box">
<h1>DokkuStatus</h1>
<form method="POST" action="/login">
<div class="form-group">
<label for="password">[PASSWORD]</label>
<input type="password" id="password" name="password" placeholder="Enter password..." autofocus
required />
</div>
<button type="submit">[ACCESS ADMIN]</button>
</form>
{% if error %}
<div class="error">[!] {{ error }}</div>
{% endif %}
<a href="/" class="back-link">← Back to Dashboard</a>
</div>
</div>
</body>
</html>

265
templates/logs.html Normal file
View File

@@ -0,0 +1,265 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Logs - 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">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap"
rel="stylesheet">
<style>
* {
box-sizing: border-box;
}
body {
font-family: 'JetBrains Mono', monospace;
margin: 0;
padding: 0;
background: #0a0e17;
min-height: 100vh;
color: #c9d1d9;
}
.container {
max-width: 1600px;
margin: 0 auto;
padding: 20px;
}
.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;
}
h1 {
margin: 0;
font-size: 24px;
font-weight: 700;
color: #00d9ff;
text-transform: uppercase;
letter-spacing: 1px;
}
.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;
}
.logout-btn:hover {
background: rgba(255, 85, 85, 0.2);
}
.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;
}
.refresh-btn:hover {
background: rgba(0, 255, 136, 0.2);
box-shadow: 0 0 10px rgba(0, 255, 136, 0.3);
}
h2 {
font-size: 16px;
font-weight: 700;
letter-spacing: 2px;
color: #00d9ff;
margin: 0 0 16px 0;
text-transform: uppercase;
padding-left: 12px;
border-left: 3px solid #00ff88;
}
.log-card {
border: 2px solid #30363d;
margin-bottom: 16px;
background: rgba(0, 217, 255, 0.02);
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
cursor: pointer;
background: rgba(0, 0, 0, 0.2);
}
.log-header:hover {
background: rgba(0, 217, 255, 0.05);
}
.app-name {
font-weight: 700;
color: #00d9ff;
}
.expand-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;
}
.log-content {
display: none;
border-top: 2px solid #30363d;
padding: 16px;
background: #0a0e17;
}
.log-controls {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
}
.log-title {
color: #00d9ff;
font-size: 10px;
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);
}
.log-viewer {
background: #000;
border: 1px solid #30363d;
padding: 12px;
max-height: 500px;
overflow-y: auto;
font-size: 11px;
line-height: 1.6;
}
.log-line {
margin: 2px 0;
}
.log-error {
color: #ff5555;
}
.log-warn {
color: #ffb86c;
}
.log-info {
color: #8b949e;
}
</style>
</head>
<body>
<div class="container">
<div class="header-terminal">
<h1>[ APPLICATION LOGS ]</h1>
<div>
<button onclick="location.reload()" class="refresh-btn">[REFRESH]</button>
<a href="/logout" class="logout-btn">[LOGOUT]</a>
</div>
</div>
{% for r in data.apps %}
<div class="log-card">
<div class="log-header" onclick="toggleLogs('logs-{{ loop.index }}')">
<div class="app-name">[{{ r.app }}]</div>
<div style="display: flex; gap: 8px; align-items: center;">
<a href="/admin/container/{{ r.container }}" class="expand-btn" onclick="event.stopPropagation();"
style="text-decoration: none;">[DETAILS]</a>
<button class="expand-btn">[EXPAND]</button>
</div>
</div>
<div id="logs-{{ loop.index }}" class="log-content">
<div class="log-controls">
<div class="log-title">[LAST 50 LINES]</div>
<button class="copy-btn" onclick="event.stopPropagation(); copyLogs('logs-text-{{ loop.index }}')">
COPY
</button>
</div>
<div id="logs-text-{{ loop.index }}" class="log-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>
</div>
{% endfor %}
</div>
<script>
function toggleLogs(id) {
const el = document.getElementById(id);
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
function copyLogs(id) {
const el = document.getElementById(id);
navigator.clipboard.writeText(el.innerText).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);
});
}
</script>
</body>
</html>