Compare commits
22 Commits
c600adf5cc
...
eb36d8bf6b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb36d8bf6b | ||
|
|
9c587a3a6c | ||
|
|
3748f042e6 | ||
|
|
e9a29b7300 | ||
|
|
add05dea12 | ||
|
|
e5d650c8a4 | ||
|
|
4f34696c72 | ||
|
|
0c89a0f745 | ||
|
|
de24d9f78a | ||
|
|
9898eb440d | ||
|
|
f6547afcad | ||
|
|
08840b3bc2 | ||
|
|
40cb631975 | ||
|
|
d1bb48138f | ||
|
|
6f31053b51 | ||
|
|
ab75c61ec7 | ||
|
|
b95962e22c | ||
|
|
7ee52102b4 | ||
|
|
9b96e3ad47 | ||
|
|
78e71b3895 | ||
|
|
45bee0504b | ||
|
|
dc20afd0f3 |
337
app.py
337
app.py
@@ -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"))
|
||||
@@ -254,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()
|
||||
@@ -273,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)
|
||||
@@ -285,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
754
templates/admin.html
Normal 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>
|
||||
@@ -154,7 +154,7 @@
|
||||
}}</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.mem_total_h or "—"
|
||||
<span style="color: #00ff88; font-size: 20px; font-weight: 700;">{{ data.system.ram_total_h or "—"
|
||||
}}</span> RAM
|
||||
</div>
|
||||
</div>
|
||||
@@ -191,32 +191,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div style="
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
@@ -366,4 +340,30 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% 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>
|
||||
319
templates/container_detail.html
Normal file
319
templates/container_detail.html
Normal 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>
|
||||
@@ -361,6 +361,13 @@
|
||||
</svg>
|
||||
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>
|
||||
|
||||
|
||||
169
templates/login.html
Normal file
169
templates/login.html
Normal 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
265
templates/logs.html
Normal 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>
|
||||
Reference in New Issue
Block a user