Compare commits
27 Commits
ee7d2e2538
...
master
| 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 | ||
|
|
c600adf5cc | ||
|
|
a597930fde | ||
|
|
c792b0107b | ||
|
|
7f782f1ee7 | ||
|
|
e343124530 |
221
README.md
Normal file
221
README.md
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
# DokkuStatus 📊
|
||||||
|
|
||||||
|
A beautiful, real-time status dashboard for monitoring Dokku-deployed applications and infrastructure. Built with Flask and HTMX, featuring a modern glassmorphic UI with auto-refreshing metrics.
|
||||||
|
|
||||||
|
🌐 **Live Demo**: https://status.peterstockings.com/
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
✨ **Real-time Monitoring**
|
||||||
|
- Auto-refreshing dashboard (configurable interval)
|
||||||
|
- Live CPU, RAM, and Docker disk usage gauges
|
||||||
|
- Container-level metrics for all Dokku apps
|
||||||
|
- Infrastructure monitoring (PostgreSQL, Redis, MySQL, MongoDB, MinIO, etc.)
|
||||||
|
|
||||||
|
🎨 **Modern UI**
|
||||||
|
- Glassmorphic design with gradient backgrounds
|
||||||
|
- Responsive grid layouts
|
||||||
|
- Smooth animations and hover effects
|
||||||
|
- Interactive donut charts for resource usage
|
||||||
|
- Status badges and visual indicators
|
||||||
|
|
||||||
|
📈 **Comprehensive Metrics**
|
||||||
|
- System information (host, CPU, RAM, Docker version)
|
||||||
|
- Per-app resource usage (CPU%, RAM, restart count)
|
||||||
|
- Docker disk usage breakdown
|
||||||
|
- Automatic warnings for high RAM usage or excessive restarts
|
||||||
|
- JSON API endpoint for programmatic access
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
The dashboard displays:
|
||||||
|
- **Live Usage**: Circular progress indicators for CPU, RAM, and Docker disk
|
||||||
|
- **System**: Host information, compute resources, and Docker stats
|
||||||
|
- **Apps**: Detailed table of all Dokku applications with links, status, and metrics
|
||||||
|
- **Infra**: Infrastructure containers (databases, caches, etc.)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Python 3.11+
|
||||||
|
- Docker (with permissions to run `docker` commands)
|
||||||
|
- Dokku deployment platform (optional, but recommended)
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
1. **Clone the repository**
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd DokkuStatus
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Install dependencies**
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Run the application**
|
||||||
|
```bash
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Access the dashboard**
|
||||||
|
Open http://localhost:5000 in your browser
|
||||||
|
|
||||||
|
### Dokku Deployment
|
||||||
|
|
||||||
|
This application is designed to run on the same server as your Dokku apps:
|
||||||
|
|
||||||
|
1. **Create the Dokku app**
|
||||||
|
```bash
|
||||||
|
dokku apps:create status
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Set environment variables** (optional)
|
||||||
|
```bash
|
||||||
|
dokku config:set status POLL_SECONDS=10
|
||||||
|
dokku config:set status APP_DOMAIN=peterstockings.com
|
||||||
|
dokku config:set status SHOW_INFRA=1
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Deploy via Git**
|
||||||
|
```bash
|
||||||
|
git remote add dokku dokku@your-server:status
|
||||||
|
git push dokku main
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Configure domain**
|
||||||
|
```bash
|
||||||
|
dokku domains:add status status.peterstockings.com
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Enable SSL** (recommended)
|
||||||
|
```bash
|
||||||
|
dokku letsencrypt:enable status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configure the application using environment variables:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `POLL_SECONDS` | `10` | Auto-refresh interval in seconds |
|
||||||
|
| `APP_DOMAIN` | `peterstockings.com` | Base domain for inferring app URLs |
|
||||||
|
| `DOCKER_BIN` | `/usr/bin/docker` | Path to Docker binary |
|
||||||
|
| `SHOW_INFRA` | `1` | Show infrastructure containers (0=hide, 1=show) |
|
||||||
|
| `APP_URL_OVERRIDES` | `{}` | JSON map of app name to custom URL overrides |
|
||||||
|
|
||||||
|
### URL Overrides Example
|
||||||
|
|
||||||
|
If you have apps with custom domains that don't follow the `<app>.<domain>` pattern:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dokku config:set status APP_URL_OVERRIDES='{"gitea":"https://gitea.peterstockings.com","bp":"https://bloodpressure.example.com"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### Container Detection
|
||||||
|
|
||||||
|
The app automatically detects:
|
||||||
|
- **Dokku Apps**: Containers named `<app>.web.1`
|
||||||
|
- **Infrastructure**: Containers like `dokku.postgres.*`, `dokku.redis.*`, `logspout`, etc.
|
||||||
|
|
||||||
|
### Metrics Collection
|
||||||
|
|
||||||
|
Uses Docker commands to gather:
|
||||||
|
- `docker ps` - Container list
|
||||||
|
- `docker stats` - Real-time resource usage
|
||||||
|
- `docker info` - Host system information
|
||||||
|
- `docker system df` - Disk usage breakdown
|
||||||
|
- `docker inspect` - Restart counts
|
||||||
|
|
||||||
|
### Warning System
|
||||||
|
|
||||||
|
Automatically alerts when:
|
||||||
|
- App RAM usage ≥ 85%
|
||||||
|
- Container restarts ≥ 3
|
||||||
|
|
||||||
|
## API Endpoint
|
||||||
|
|
||||||
|
Access raw JSON data programmatically:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://status.peterstockings.com/api/status
|
||||||
|
```
|
||||||
|
|
||||||
|
Response includes:
|
||||||
|
- `system` - Host and Docker information
|
||||||
|
- `gauges` - Real-time metrics (CPU, RAM, disk)
|
||||||
|
- `apps` - Array of Dokku applications
|
||||||
|
- `infra` - Infrastructure containers
|
||||||
|
- `warnings` - Array of warning messages
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Backend**: Flask 2.2.2
|
||||||
|
- **Frontend**: HTML + HTMX (auto-refresh)
|
||||||
|
- **Styling**: Vanilla CSS with modern features
|
||||||
|
- **Typography**: Inter font (Google Fonts)
|
||||||
|
- **Server**: Gunicorn
|
||||||
|
- **Deployment**: Dokku (Heroku-compatible buildpack)
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
DokkuStatus/
|
||||||
|
├── app.py # Flask application & Docker integration
|
||||||
|
├── requirements.txt # Python dependencies
|
||||||
|
├── Procfile # Dokku/Heroku process definition
|
||||||
|
├── runtime.txt # Python version specification
|
||||||
|
├── templates/
|
||||||
|
│ ├── index.html # Main page with glassmorphic layout
|
||||||
|
│ └── apps_table.html # Data display with donut charts & tables
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Adding New Metric Gauges
|
||||||
|
|
||||||
|
1. Update `collect()` in `app.py` to gather new metrics
|
||||||
|
2. Add the metric to the `gauges` dictionary
|
||||||
|
3. Update `apps_table.html` to display the new donut chart
|
||||||
|
|
||||||
|
### Customizing the UI
|
||||||
|
|
||||||
|
- **Colors**: Modify the gradient values in `index.html` (default: `#667eea` to `#764ba2`)
|
||||||
|
- **Donut charts**: Edit the `donut()` macro in `apps_table.html`
|
||||||
|
- **Refresh rate**: Set `POLL_SECONDS` environment variable
|
||||||
|
|
||||||
|
## Docker Permissions
|
||||||
|
|
||||||
|
The application requires Docker socket access. If running in a container, you may need to:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Mount Docker socket (if containerized)
|
||||||
|
dokku docker-options:add status deploy "-v /var/run/docker.sock:/var/run/docker.sock"
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **Security Note**: This gives the container full Docker access. Only use on trusted servers.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - feel free to use and modify as needed.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions welcome! Please:
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Submit a pull request
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues or questions, please open an issue on the repository.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Made with ❤️ for monitoring Dokku deployments**
|
||||||
339
app.py
339
app.py
@@ -1,16 +1,23 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
from datetime import datetime
|
|
||||||
from flask import Flask, jsonify, render_template
|
|
||||||
import re
|
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 = Flask(__name__)
|
||||||
|
app.secret_key = os.getenv("SECRET_KEY", "change-this-in-production-please")
|
||||||
|
|
||||||
POLL_SECONDS = int(os.getenv("POLL_SECONDS", "10"))
|
POLL_SECONDS = int(os.getenv("POLL_SECONDS", "10"))
|
||||||
APP_DOMAIN = os.getenv("APP_DOMAIN", "peterstockings.com")
|
APP_DOMAIN = os.getenv("APP_DOMAIN", "peterstockings.com")
|
||||||
DOCKER = os.getenv("DOCKER_BIN", "/usr/bin/docker")
|
DOCKER = os.getenv("DOCKER_BIN", "/usr/bin/docker")
|
||||||
SHOW_INFRA = os.getenv("SHOW_INFRA", "1") == "1"
|
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 = {
|
_UNIT = {
|
||||||
"b": 1,
|
"b": 1,
|
||||||
@@ -55,7 +62,6 @@ def docker_stats() -> dict:
|
|||||||
}
|
}
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
def docker_info() -> dict:
|
def docker_info() -> dict:
|
||||||
# docker info --format "{{json .}}" gives us structured host-level info
|
# 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:
|
except Exception:
|
||||||
return 0
|
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:
|
def is_app_web_container(name: str) -> bool:
|
||||||
# Dokku apps typically have containers like "<app>.web.1"
|
# Dokku apps typically have containers like "<app>.web.1"
|
||||||
return name.endswith(".web.1") and not name.startswith("dokku.")
|
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)
|
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
|
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"
|
# Docker disk: images "Size" and "Reclaimable"
|
||||||
df_images = sysinfo.get("system_df", {}).get("Images", {})
|
df_images = sysinfo.get("system_df", {}).get("Images", {})
|
||||||
images_size_bytes = parse_human_bytes(df_images.get("size", "0B"))
|
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
|
"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_used_bytes": total_mem_used_bytes,
|
||||||
"ram_total_bytes": host_mem_total,
|
"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),
|
"ram_pct": clamp(ram_pct),
|
||||||
"docker_images_size_bytes": images_size_bytes,
|
"docker_images_size_bytes": images_size_bytes,
|
||||||
"docker_images_used_bytes": images_used_bytes,
|
"docker_images_used_bytes": images_used_bytes,
|
||||||
@@ -252,6 +408,41 @@ def collect():
|
|||||||
"warnings": warnings,
|
"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:
|
def parse_human_bytes(s: str) -> int:
|
||||||
# Handles "58.84MiB", "145.1MB", "423B"
|
# Handles "58.84MiB", "145.1MB", "423B"
|
||||||
s = s.strip()
|
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:
|
def clamp(n: float, lo: float = 0.0, hi: float = 100.0) -> float:
|
||||||
return max(lo, min(hi, n))
|
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("/")
|
@app.get("/")
|
||||||
def index():
|
def index():
|
||||||
return render_template("index.html", poll_seconds=POLL_SECONDS)
|
return render_template("index.html", poll_seconds=POLL_SECONDS)
|
||||||
@@ -283,3 +525,94 @@ def partial_apps():
|
|||||||
@app.get("/api/status")
|
@app.get("/api/status")
|
||||||
def api_status():
|
def api_status():
|
||||||
return jsonify(collect())
|
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>
|
||||||
@@ -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 %}
|
{% 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 < 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" %}
|
{% if p < 60 %} {% set col="#00ff88" %} {% set status="OK" %} {% elif p < 85 %} {% set col="#ffb86c" %} {% set
|
||||||
{% endif %} {% set r=24 %} {% set stroke=10 %} {% set c=2 * 3.1415926 * r %} {% set dash=(p / 100.0) * c %} {%
|
status="WARN" %} {% else %} {% set col="#ff5555" %} {% set status="CRIT" %} {% endif %} {% set txt=value_text if
|
||||||
set txt=value_text if value_text else (p|round(0) ~ "%" ) %} <div style="
|
value_text else (p|round(0)|int ~ "%" ) %} <div style="
|
||||||
border:1px solid rgba(0,0,0,.08);
|
border: 2px solid {{ col }};
|
||||||
border-radius: 16px;
|
background: rgba({{ '0, 255, 136' if p < 60 else ('255, 184, 108' if p < 85 else '255, 85, 85') }}, 0.05);
|
||||||
padding: 14px;
|
padding: 16px;
|
||||||
background: linear-gradient(180deg, rgba(255,255,255,.95), rgba(255,255,255,.80));
|
position: relative;
|
||||||
box-shadow: 0 8px 24px rgba(0,0,0,.06);
|
transition: all 0.3s ease;
|
||||||
display:flex;
|
">
|
||||||
align-items:center;
|
<!-- Status indicator -->
|
||||||
gap: 14px;
|
<div style="
|
||||||
min-height: 92px;
|
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);
|
||||||
">
|
">
|
||||||
<svg width="72" height="72" viewBox="0 0 72 72" style="flex: 0 0 auto;">
|
[{{ status }}]
|
||||||
<defs>
|
</div>
|
||||||
<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 -->
|
<!-- Label -->
|
||||||
<circle cx="36" cy="36" r="{{ r }}" fill="none" stroke="rgba(0,0,0,.08)" stroke-width="{{ stroke }}"
|
<div style="
|
||||||
stroke-linecap="round" />
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
color: #8b949e;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
">
|
||||||
|
> {{ label }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- progress -->
|
<!-- Value -->
|
||||||
<circle cx="36" cy="36" r="{{ r }}" fill="none" stroke="{{ col }}" stroke-width="{{ stroke }}"
|
<div style="
|
||||||
stroke-linecap="round" stroke-dasharray="{{ dash }} {{ c - dash }}" transform="rotate(-90 36 36)"
|
font-size: 28px;
|
||||||
filter="url(#softShadow)" />
|
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>
|
||||||
|
|
||||||
<!-- center -->
|
<!-- Subtitle -->
|
||||||
<text x="36" y="40" text-anchor="middle" font-size="14" font-weight="800"
|
<div style="
|
||||||
font-family="system-ui, -apple-system, Segoe UI, Roboto" fill="rgba(0,0,0,.82)">{{ txt }}</text>
|
font-size: 11px;
|
||||||
</svg>
|
color: #8b949e;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
">
|
||||||
|
{{ subtitle }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="min-width: 0; width: 100%;">
|
<!-- Progress bar -->
|
||||||
<div style="display:flex; align-items:center; justify-content:space-between; gap: 10px;">
|
<div style="
|
||||||
<div style="font-weight:800; letter-spacing:-0.2px;">{{ label }}</div>
|
height: 6px;
|
||||||
<div style="
|
background: #30363d;
|
||||||
font-size:12px;
|
position: relative;
|
||||||
padding: 2px 8px;
|
overflow: hidden;
|
||||||
border-radius: 999px;
|
">
|
||||||
background: rgba(0,0,0,.04);
|
<div style="
|
||||||
color: rgba(0,0,0,.65);
|
height: 100%;
|
||||||
flex: 0 0 auto;">
|
width: {{ p }}%;
|
||||||
live
|
background: {{ col }};
|
||||||
</div>
|
box-shadow: 0 0 10px {{ col }};
|
||||||
</div>
|
transition: width 0.5s ease;
|
||||||
|
"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<!-- ASCII bar representation -->
|
||||||
style="margin-top:4px; font-size:12.5px; color: rgba(0,0,0,.62); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
|
<div style="
|
||||||
{{ subtitle }}
|
font-size: 10px;
|
||||||
</div>
|
color: {{ col }};
|
||||||
|
margin-top: 8px;
|
||||||
<div
|
font-family: 'JetBrains Mono', monospace;
|
||||||
style="margin-top:10px; height:8px; border-radius:999px; background: rgba(0,0,0,.07); overflow:hidden;">
|
letter-spacing: 0;
|
||||||
<div style="height:100%; width: {{ p }}%; background: {{ col }}; border-radius:999px;"></div>
|
">
|
||||||
</div>
|
{% 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>
|
||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
<h2
|
<h2>[ LIVE METRICS ]</h2>
|
||||||
style="margin-top: 0; font-size: 22px; font-weight: 800; letter-spacing: -0.3px; color: #1e293b; margin-bottom: 16px;">
|
|
||||||
📊 Live Usage</h2>
|
|
||||||
<div
|
<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(280px, 1fr)); gap: 20px; margin: 0 0 32px 0;">
|
||||||
{{ donut("CPU", data.gauges.cpu_total_pct, "Sum of container CPU% (clamped)") }}
|
{{ gauge("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 ~ " / " ~
|
{{ gauge("RAM", data.gauges.ram_pct, "All containers vs host RAM", (data.gauges.ram_used_h ~ " / " ~
|
||||||
data.gauges.ram_total_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>
|
</div>
|
||||||
|
|
||||||
<h2 style="font-size: 22px; font-weight: 800; letter-spacing: -0.3px; color: #1e293b; margin-bottom: 16px;">💻
|
<h2>[ SYSTEM INFO ]</h2>
|
||||||
System</h2>
|
|
||||||
<div
|
<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="
|
<div style="
|
||||||
border: 1px solid rgba(102, 126, 234, 0.15);
|
border: 2px solid #30363d;
|
||||||
border-radius: 16px;
|
background: rgba(0, 217, 255, 0.02);
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.03) 0%, rgba(118, 75, 162, 0.03) 100%);
|
transition: all 0.3s ease;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
|
" onmouseover="this.style.borderColor='#00d9ff'; this.style.boxShadow='0 0 20px rgba(0, 217, 255, 0.3)';"
|
||||||
transition: all 0.3s ease;
|
onmouseout="this.style.borderColor='#30363d'; this.style.boxShadow='none';">
|
||||||
" onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 8px 20px rgba(0,0,0,0.08)';"
|
<div style="
|
||||||
onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 4px 12px rgba(0,0,0,0.04)';">
|
color: #00d9ff;
|
||||||
<div
|
font-size: 10px;
|
||||||
style="color: #64748b; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px;">
|
font-weight: 700;
|
||||||
🖥️ Host</div>
|
letter-spacing: 2px;
|
||||||
<div style="font-size: 18px; font-weight: 800; color: #1e293b; margin-bottom: 6px;">{{ data.system.name
|
text-transform: uppercase;
|
||||||
or "—" }}</div>
|
margin-bottom: 12px;
|
||||||
<div style="color: #64748b; font-size: 13px; line-height: 1.5;">{{ data.system.operating_system }} · {{
|
">
|
||||||
data.system.kernel_version }}</div>
|
[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>
|
||||||
|
|
||||||
<div style="
|
<div style="
|
||||||
border: 1px solid rgba(102, 126, 234, 0.15);
|
border: 2px solid #30363d;
|
||||||
border-radius: 16px;
|
background: rgba(0, 255, 136, 0.02);
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.03) 0%, rgba(118, 75, 162, 0.03) 100%);
|
transition: all 0.3s ease;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
|
" onmouseover="this.style.borderColor='#00ff88'; this.style.boxShadow='0 0 20px rgba(0, 255, 136, 0.3)';"
|
||||||
transition: all 0.3s ease;
|
onmouseout="this.style.borderColor='#30363d'; this.style.boxShadow='none';">
|
||||||
" onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 8px 20px rgba(0,0,0,0.08)';"
|
<div style="
|
||||||
onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 4px 12px rgba(0,0,0,0.04)';">
|
color: #00ff88;
|
||||||
<div
|
font-size: 10px;
|
||||||
style="color: #64748b; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px;">
|
font-weight: 700;
|
||||||
⚡ Compute</div>
|
letter-spacing: 2px;
|
||||||
<div style="font-size: 15px; font-weight: 600; color: #334155; margin-bottom: 4px;"><strong
|
text-transform: uppercase;
|
||||||
style="color: #667eea; font-size: 18px;">{{ data.system.cpus or "—" }}</strong> CPUs</div>
|
margin-bottom: 12px;
|
||||||
<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>
|
[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>
|
||||||
|
|
||||||
<div style="
|
<div style="
|
||||||
border: 1px solid rgba(102, 126, 234, 0.15);
|
border: 2px solid #30363d;
|
||||||
border-radius: 16px;
|
background: rgba(255, 184, 108, 0.02);
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.03) 0%, rgba(118, 75, 162, 0.03) 100%);
|
transition: all 0.3s ease;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
|
" onmouseover="this.style.borderColor='#ffb86c'; this.style.boxShadow='0 0 20px rgba(255, 184, 108, 0.3)';"
|
||||||
transition: all 0.3s ease;
|
onmouseout="this.style.borderColor='#30363d'; this.style.boxShadow='none';">
|
||||||
" onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 8px 20px rgba(0,0,0,0.08)';"
|
<div style="
|
||||||
onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 4px 12px rgba(0,0,0,0.04)';">
|
color: #ffb86c;
|
||||||
<div
|
font-size: 10px;
|
||||||
style="color: #64748b; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px;">
|
font-weight: 700;
|
||||||
🐳 Docker</div>
|
letter-spacing: 2px;
|
||||||
<div style="font-size: 13px; color: #334155; margin-bottom: 4px; line-height: 1.6;">Engine: <strong
|
text-transform: uppercase;
|
||||||
style="color: #667eea;">{{ data.system.server_version or "—" }}</strong></div>
|
margin-bottom: 12px;
|
||||||
<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>
|
[DOCKER]
|
||||||
<div style="font-size: 13px; color: #334155; line-height: 1.6;">Containers: <strong
|
</div>
|
||||||
style="color: #16a34a;">{{ data.system.containers_running or "—" }}</strong> running / <strong
|
<div style="font-size: 12px; color: #c9d1d9; margin-bottom: 6px; line-height: 1.7;">
|
||||||
style="color: #64748b;">{{ data.system.containers_stopped or "—" }}</strong> stopped</div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style="
|
||||||
<h3 style="font-size: 18px; font-weight: 700; letter-spacing: -0.2px; color: #334155; margin: 32px 0 16px 0;">💾
|
font-size: 11px;
|
||||||
Docker Disk Usage</h3>
|
color: #8b949e;
|
||||||
<div style="overflow-x: auto; border-radius: 12px; border: 1px solid rgba(0,0,0,0.06);">
|
margin-bottom: 32px;
|
||||||
<table style="margin: 0;">
|
font-weight: 500;
|
||||||
<thead>
|
">
|
||||||
<tr>
|
<span style="color: #00d9ff;">[TIMESTAMP]</span> {{ data.generated_at }}
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<div style="margin-top: 16px; color: #64748b; font-size: 13px; font-weight: 500;">⏱️ Generated at: {{
|
|
||||||
data.generated_at }}</div>
|
|
||||||
|
|
||||||
{% if data.warnings %}
|
{% if data.warnings %}
|
||||||
<div style="
|
<div style="
|
||||||
margin: 20px 0;
|
margin: 24px 0;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
border: 1px solid #fbbf24;
|
border: 2px solid #ffb86c;
|
||||||
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
background: rgba(255, 184, 108, 0.05);
|
||||||
border-radius: 12px;
|
position: relative;
|
||||||
box-shadow: 0 4px 12px rgba(251, 191, 36, 0.15);
|
">
|
||||||
">
|
<div style="
|
||||||
<strong
|
position: absolute;
|
||||||
style="color: #92400e; font-size: 16px; display: flex; align-items: center; gap: 8px; margin-bottom: 12px;">⚠️
|
top: -12px;
|
||||||
Warnings</strong>
|
left: 16px;
|
||||||
<ul style="margin: 0; padding-left: 20px; color: #78350f;">
|
background: #161b22;
|
||||||
|
padding: 0 8px;
|
||||||
|
color: #ffb86c;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
">
|
||||||
|
[!! WARNINGS !!]
|
||||||
|
</div>
|
||||||
|
<ul style="margin: 8px 0 0 0; padding-left: 24px; color: #ffb86c;">
|
||||||
{% for w in data.warnings %}
|
{% for w in data.warnings %}
|
||||||
<li style="margin: 6px 0;">{{ w }}</li>
|
<li style="margin: 6px 0; font-size: 13px;">{{ w }}</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<h2 style="font-size: 22px; font-weight: 800; letter-spacing: -0.3px; color: #1e293b; margin: 40px 0 16px 0;">🚀
|
<h2>[ APPLICATIONS ]</h2>
|
||||||
Apps</h2>
|
<div style="overflow-x: auto; margin-bottom: 32px;">
|
||||||
<div style="overflow-x: auto; border-radius: 12px; border: 1px solid rgba(0,0,0,0.06);">
|
<table>
|
||||||
<table style="margin: 0;">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th>APP</th>
|
||||||
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);">
|
<th>URL</th>
|
||||||
App</th>
|
<th>STATUS</th>
|
||||||
<th
|
<th>CPU</th>
|
||||||
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);">
|
<th>RAM</th>
|
||||||
URL</th>
|
<th>RESTARTS</th>
|
||||||
<th
|
<th>IMAGE</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>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for r in data.apps %}
|
{% for r in data.apps %}
|
||||||
<tr style="transition: background-color 0.2s ease;"
|
<tr>
|
||||||
onmouseover="this.style.backgroundColor='rgba(102, 126, 234, 0.04)';"
|
<td><span style="color: #00d9ff; font-weight: 700;">{{ r.app }}</span></td>
|
||||||
onmouseout="this.style.backgroundColor='transparent';">
|
<td>
|
||||||
<td><strong style="color: #667eea;">{{ r.app }}</strong></td>
|
<a href="{{ r.url }}" style="
|
||||||
<td><a href="{{ r.url }}"
|
color: #00ff88;
|
||||||
style="color: #667eea; text-decoration: none; transition: all 0.2s ease;"
|
text-decoration: none;
|
||||||
onmouseover="this.style.textDecoration='underline'; this.style.color='#764ba2';"
|
transition: all 0.2s ease;
|
||||||
onmouseout="this.style.textDecoration='none'; this.style.color='#667eea';">{{ r.url
|
" onmouseover="this.style.color='#00d9ff'; this.style.textShadow='0 0 10px rgba(0, 217, 255, 0.5)';"
|
||||||
}}</a></td>
|
onmouseout="this.style.color='#00ff88'; this.style.textShadow='none';">
|
||||||
<td><span
|
{{ r.url }}
|
||||||
style="padding: 4px 10px; background: rgba(102, 126, 234, 0.1); color: #667eea; border-radius: 6px; font-size: 12px; font-weight: 600;">{{
|
</a>
|
||||||
r.status }}</span></td>
|
</td>
|
||||||
<td style="color: #334155; font-weight: 600;">{{ r.cpu or "—" }}</td>
|
<td>
|
||||||
<td style="color: #334155; font-size: 13px;">
|
<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 %}
|
{% if r.mem_used %}
|
||||||
{{ r.mem_used }} / {{ r.mem_limit }} <span style="color: #667eea; font-weight: 600;">({{
|
{{ r.mem_used }} / {{ r.mem_limit }}
|
||||||
r.mem_pct }})</span>
|
<span style="color: #00d9ff; font-weight: 700;">({{ r.mem_pct }})</span>
|
||||||
{% else %} — {% endif %}
|
{% else %} — {% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td style="color: #334155; font-weight: 600;">{{ r.restarts }}</td>
|
<td style="
|
||||||
<td style="color: #64748b; font-size: 12px;">{{ r.image }}</td>
|
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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -259,53 +291,79 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if data.infra %}
|
{% if data.infra %}
|
||||||
<h2 style="font-size: 22px; font-weight: 800; letter-spacing: -0.3px; color: #1e293b; margin: 40px 0 16px 0;">
|
<h2>[ INFRASTRUCTURE ]</h2>
|
||||||
🏗️ Infra</h2>
|
<div style="overflow-x: auto;">
|
||||||
<div style="overflow-x: auto; border-radius: 12px; border: 1px solid rgba(0,0,0,0.06);">
|
<table>
|
||||||
<table style="margin: 0;">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th>CONTAINER</th>
|
||||||
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);">
|
<th>STATUS</th>
|
||||||
Container</th>
|
<th>CPU</th>
|
||||||
<th
|
<th>RAM</th>
|
||||||
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);">
|
<th>RESTARTS</th>
|
||||||
Status</th>
|
<th>IMAGE</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>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for r in data.infra %}
|
{% for r in data.infra %}
|
||||||
<tr style="transition: background-color 0.2s ease;"
|
<tr>
|
||||||
onmouseover="this.style.backgroundColor='rgba(102, 126, 234, 0.04)';"
|
<td><span style="color: #ffb86c; font-weight: 700;">{{ r.container }}</span></td>
|
||||||
onmouseout="this.style.backgroundColor='transparent';">
|
<td>
|
||||||
<td><strong style="color: #667eea;">{{ r.container }}</strong></td>
|
<span style="
|
||||||
<td><span
|
padding: 3px 8px;
|
||||||
style="padding: 4px 10px; background: rgba(102, 126, 234, 0.1); color: #667eea; border-radius: 6px; font-size: 12px; font-weight: 600;">{{
|
background: rgba(0, 255, 136, 0.1);
|
||||||
r.status }}</span></td>
|
color: #00ff88;
|
||||||
<td style="color: #334155; font-weight: 600;">{{ r.cpu or "—" }}</td>
|
border: 1px solid #00ff88;
|
||||||
<td style="color: #334155; font-size: 13px;">
|
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 %}
|
{% if r.mem_used %}
|
||||||
{{ r.mem_used }} / {{ r.mem_limit }} <span style="color: #667eea; font-weight: 600;">({{
|
{{ r.mem_used }} / {{ r.mem_limit }}
|
||||||
r.mem_pct }})</span>
|
<span style="color: #00d9ff; font-weight: 700;">({{ r.mem_pct }})</span>
|
||||||
{% else %} — {% endif %}
|
{% else %} — {% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td style="color: #334155; font-weight: 600;">{{ r.restarts }}</td>
|
<td style="
|
||||||
<td style="color: #64748b; font-size: 12px;">{{ r.image }}</td>
|
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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
||||||
@@ -4,99 +4,230 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>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>
|
<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.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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>
|
<style>
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
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 {
|
body {
|
||||||
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
font-family: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 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;
|
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 {
|
.container {
|
||||||
max-width: 1400px;
|
max-width: 1600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 32px 24px;
|
padding: 20px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-card {
|
.header-terminal {
|
||||||
background: rgba(255, 255, 255, 0.98);
|
background: #161b22;
|
||||||
backdrop-filter: blur(10px);
|
border: 2px solid #00d9ff;
|
||||||
border-radius: 20px;
|
box-shadow:
|
||||||
padding: 28px 32px;
|
0 0 20px rgba(0, 217, 255, 0.3),
|
||||||
margin-bottom: 24px;
|
inset 0 0 20px rgba(0, 217, 255, 0.05);
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15),
|
padding: 20px 24px;
|
||||||
0 0 0 1px rgba(255, 255, 255, 0.3) inset;
|
margin-bottom: 20px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
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 {
|
h1 {
|
||||||
margin: 0 0 8px 0;
|
margin: 0;
|
||||||
font-size: 36px;
|
font-size: 24px;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
letter-spacing: -0.5px;
|
letter-spacing: 1px;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
color: #00d9ff;
|
||||||
-webkit-background-clip: text;
|
text-transform: uppercase;
|
||||||
-webkit-text-fill-color: transparent;
|
display: inline-block;
|
||||||
background-clip: text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-info {
|
.meta-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 16px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
color: #64748b;
|
font-size: 13px;
|
||||||
font-size: 14px;
|
color: #8b949e;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meta-info span {
|
||||||
|
color: #00ff88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-info .separator {
|
||||||
|
color: #30363d;
|
||||||
|
}
|
||||||
|
|
||||||
.meta-info a {
|
.meta-info a {
|
||||||
color: #667eea;
|
color: #00d9ff;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 4px 12px;
|
padding: 4px 10px;
|
||||||
background: rgba(102, 126, 234, 0.1);
|
background: rgba(0, 217, 255, 0.1);
|
||||||
border-radius: 6px;
|
border: 1px solid rgba(0, 217, 255, 0.3);
|
||||||
font-weight: 600;
|
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-info a:hover {
|
.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);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-card {
|
.content-terminal {
|
||||||
background: rgba(255, 255, 255, 0.98);
|
background: #161b22;
|
||||||
backdrop-filter: blur(10px);
|
border: 2px solid #30363d;
|
||||||
border-radius: 20px;
|
box-shadow: 0 0 30px rgba(0, 0, 0, 0.5);
|
||||||
padding: 32px;
|
padding: 24px;
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15),
|
position: relative;
|
||||||
0 0 0 1px rgba(255, 255, 255, 0.3) inset;
|
}
|
||||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
|
||||||
|
.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 {
|
.loading {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 60px 20px;
|
padding: 60px 20px;
|
||||||
color: #64748b;
|
color: #00ff88;
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading::before {
|
||||||
|
content: '[ ';
|
||||||
|
color: #00d9ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading::after {
|
.loading::after {
|
||||||
content: '...';
|
content: ' ]';
|
||||||
|
color: #00d9ff;
|
||||||
animation: dots 1.5s steps(4, end) infinite;
|
animation: dots 1.5s steps(4, end) infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,101 +235,145 @@
|
|||||||
|
|
||||||
0%,
|
0%,
|
||||||
20% {
|
20% {
|
||||||
content: '.';
|
content: ' .]';
|
||||||
}
|
}
|
||||||
|
|
||||||
40% {
|
40% {
|
||||||
content: '..';
|
content: ' ..]';
|
||||||
}
|
}
|
||||||
|
|
||||||
60%,
|
60%,
|
||||||
100% {
|
100% {
|
||||||
content: '...';
|
content: ' ...]';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
border: 1px solid #30363d;
|
||||||
}
|
}
|
||||||
|
|
||||||
th,
|
th,
|
||||||
td {
|
td {
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
border: 1px solid #30363d;
|
||||||
padding: 12px;
|
padding: 12px 16px;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
text-align: left;
|
background: rgba(0, 217, 255, 0.05);
|
||||||
|
color: #00d9ff;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1e293b;
|
font-size: 11px;
|
||||||
font-size: 13px;
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 1px;
|
||||||
background: rgba(102, 126, 234, 0.04);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
color: #334155;
|
color: #c9d1d9;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody tr {
|
tbody tr {
|
||||||
transition: background-color 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody tr:hover {
|
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 {
|
.muted {
|
||||||
color: #64748b;
|
color: #8b949e;
|
||||||
font-size: 0.9em;
|
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 {
|
h2 {
|
||||||
font-size: 22px;
|
font-size: 16px;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
letter-spacing: -0.3px;
|
letter-spacing: 2px;
|
||||||
color: #1e293b;
|
color: #00d9ff;
|
||||||
margin: 0 0 16px 0;
|
margin: 0 0 16px 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding-left: 12px;
|
||||||
|
border-left: 3px solid #00ff88;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
font-size: 18px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: -0.2px;
|
letter-spacing: 1px;
|
||||||
color: #334155;
|
color: #8b949e;
|
||||||
margin: 24px 0 12px 0;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header-card">
|
<div class="header-terminal">
|
||||||
<h1>Status Dashboard</h1>
|
<div class="terminal-title">
|
||||||
|
<span class="terminal-prompt">root@dokku:~$</span>
|
||||||
|
<h1>DokkuStatus</h1>
|
||||||
|
</div>
|
||||||
<div class="meta-info">
|
<div class="meta-info">
|
||||||
<span>🔄 Auto-refresh every {{ poll_seconds }}s</span>
|
<span>[LIVE]</span>
|
||||||
<span>•</span>
|
<span class="separator">|</span>
|
||||||
<a href="/api/status">📊 JSON API</a>
|
<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">
|
||||||
|
<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
|
||||||
|
</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>
|
</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 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>
|
</div>
|
||||||
</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