From 411038582a003f72e88cd16e36c54a7ed2df5df1 Mon Sep 17 00:00:00 2001 From: Sundog Garage Studio Date: Tue, 17 Mar 2026 16:17:53 +0200 Subject: [PATCH] Initial commit --- .dockerignore | 30 ++ .gitignore | 27 ++ Dockerfile | 30 ++ README.md | 173 +++++++++++ app/__init__.py | 3 + app/docker_monitor.py | 146 +++++++++ app/main.py | 298 +++++++++++++++++++ config.yaml | 30 ++ docker-compose.yml | 28 ++ requirements.txt | 9 + static/css/style.css | 645 ++++++++++++++++++++++++++++++++++++++++ static/js/alpine.min.js | 5 + static/js/app.js | 193 ++++++++++++ static/js/marked.min.js | 69 +++++ templates/index.html | 209 +++++++++++++ 15 files changed, 1895 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/docker_monitor.py create mode 100644 app/main.py create mode 100644 config.yaml create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 static/css/style.css create mode 100644 static/js/alpine.min.js create mode 100644 static/js/app.js create mode 100644 static/js/marked.min.js create mode 100644 templates/index.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..841f7a4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,30 @@ +# Python +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +venv/ +env/ +.venv + +# Git +.git +.gitignore + +# IDEs +.vscode +.idea +*.swp + +# Docker +Dockerfile +docker-compose.yml +.dockerignore + +# Documentation +README.md +*.md + +# OS +.DS_Store diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..37d95e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +env/ +ENV/ +.venv + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Docker +docker-compose.override.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..20fe1db --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements and install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY app/ ./app/ +COPY static/ ./static/ +COPY templates/ ./templates/ + +# Note: Running as root for Docker socket access +# In production, consider using rootless Docker or proper socket permissions + +# Expose port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" + +# Run application +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..4d84ead --- /dev/null +++ b/README.md @@ -0,0 +1,173 @@ +# RedUnits Control Panel + +A beautiful, lightweight service management dashboard for monitoring Docker containers and system resources. + +## Features + +- Real-time service monitoring +- Docker container status and metrics +- System resource usage (CPU, Memory, Disk) +- Auto-refresh every 30 seconds +- Responsive dark theme design +- Minimal dependencies (FastAPI + Alpine.js) + +## Project Structure + +``` +~/repo/redunits/www/ +├── app/ +│ ├── __init__.py +│ ├── main.py # FastAPI application +│ └── docker_monitor.py # Docker monitoring module +├── static/ +│ ├── css/ +│ │ └── style.css # Styles +│ └── js/ +│ └── app.js # Alpine.js frontend logic +├── templates/ +│ └── index.html # Main dashboard template +├── Dockerfile +├── docker-compose.yml +├── requirements.txt +└── README.md +``` + +## Installation + +### Prerequisites + +- Docker and Docker Compose installed +- Python 3.11+ (for local development) + +### Quick Start with Docker + +1. Build and run the container: +```bash +cd ~/repo/redunits/www +docker-compose up -d --build +``` + +2. Access the dashboard: +``` +http://localhost:8000 +``` + +### Local Development + +1. Create virtual environment: +```bash +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +``` + +2. Install dependencies: +```bash +pip install -r requirements.txt +``` + +3. Run the application: +```bash +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +## Deployment on VPS + +### 1. Copy files to VPS: +```bash +rsync -avz ~/repo/redunits/www/ vps:/opt/redunits/www/ +``` + +### 2. SSH into VPS and deploy: +```bash +ssh vps +cd /opt/redunits/www +sudo docker-compose up -d --build +``` + +### 3. Add to Caddy configuration: + +Edit `/etc/caddy/Caddyfile`: +``` +www.redunits.net, redunits.net { + encode zstd gzip + reverse_proxy 127.0.0.1:8000 +} +``` + +Reload Caddy: +```bash +sudo systemctl reload caddy +``` + +## API Endpoints + +- `GET /` - Main dashboard page +- `GET /api/services` - Get all services with status +- `GET /api/system` - Get system statistics +- `GET /api/health` - Health check endpoint + +## Configuration + +### Adding New Services + +Edit `app/main.py` and add to the `SERVICES` list: + +```python +{ + "id": "service-id", + "name": "Service Name", + "icon": "🔧", + "description": "Service description", + "url": "https://service.domain.com", + "container_name": "docker-container-name", + "port": 8080 +} +``` + +## Security Notes + +- The application requires read-only access to Docker socket +- Run with non-root user inside container +- Use reverse proxy (Caddy) for HTTPS in production +- Consider adding authentication for production use + +## Monitoring + +The dashboard automatically monitors: + +- Container status (running/stopped) +- Container CPU usage +- Container memory usage +- Container uptime +- System CPU usage +- System memory usage +- System disk usage +- Total and running container count + +## Tech Stack + +- **Backend**: FastAPI (Python) +- **Frontend**: Alpine.js + Vanilla CSS +- **Monitoring**: Docker SDK for Python + psutil +- **Container**: Docker with multi-stage build + +## Development + +### Running tests: +```bash +# TODO: Add tests +pytest +``` + +### Code formatting: +```bash +black app/ +``` + +## License + +MIT + +## Author + +RedUnits Team diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..1581aa2 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,3 @@ +"""RedUnits Control Panel - Service Management Dashboard""" + +__version__ = "1.0.0" diff --git a/app/docker_monitor.py b/app/docker_monitor.py new file mode 100644 index 0000000..860cf1b --- /dev/null +++ b/app/docker_monitor.py @@ -0,0 +1,146 @@ +import docker +from typing import Dict, Optional +import logging + +logger = logging.getLogger(__name__) + + +class DockerMonitor: + """Monitor and manage Docker containers""" + + def __init__(self): + try: + self.client = docker.from_env() + self.client.ping() + self.available = True + logger.info("Docker client initialized successfully") + except Exception as e: + logger.warning(f"Docker not available: {e}") + self.client = None + self.available = False + + def get_container_status(self, container_name: str) -> Dict: + """ + Get status of a specific container. + + Returns: + Dict with: online, state, uptime, cpu_percent, memory_mb + """ + if not self.available: + return {"online": False, "state": "unavailable", "message": "Docker not available"} + + try: + container = self.client.containers.get(container_name) + stats = container.stats(stream=False) + + # Calculate CPU percentage + cpu_delta = ( + stats["cpu_stats"]["cpu_usage"]["total_usage"] + - stats["precpu_stats"]["cpu_usage"]["total_usage"] + ) + system_delta = ( + stats["cpu_stats"]["system_cpu_usage"] + - stats["precpu_stats"]["system_cpu_usage"] + ) + cpu_percent = 0.0 + if system_delta > 0: + cpu_percent = (cpu_delta / system_delta) * 100.0 + + # Calculate memory usage + memory_usage = stats["memory_stats"].get("usage", 0) + memory_mb = memory_usage / (1024 * 1024) + + return { + "online": container.status == "running", + "state": container.status, + "uptime": self._format_uptime(container), + "cpu_percent": round(cpu_percent, 2), + "memory_mb": round(memory_mb, 2), + } + + except docker.errors.NotFound: + return { + "online": False, + "state": "not_found", + "message": f"Container '{container_name}' not found", + } + except Exception as e: + logger.error(f"Error getting container status: {e}") + return {"online": False, "state": "error", "message": str(e)} + + def restart_container(self, container_name: str) -> Dict: + """Restart a specific container.""" + if not self.available: + return {"success": False, "message": "Docker not available"} + + try: + container = self.client.containers.get(container_name) + container.restart(timeout=10) + logger.info(f"Container '{container_name}' restarted") + return {"success": True, "message": f"Container '{container_name}' restarted"} + except docker.errors.NotFound: + return {"success": False, "message": f"Container '{container_name}' not found"} + except Exception as e: + logger.error(f"Error restarting container '{container_name}': {e}") + return {"success": False, "message": str(e)} + + def stop_container(self, container_name: str) -> Dict: + """Stop a specific container.""" + if not self.available: + return {"success": False, "message": "Docker not available"} + + try: + container = self.client.containers.get(container_name) + container.stop(timeout=10) + logger.info(f"Container '{container_name}' stopped") + return {"success": True, "message": f"Container '{container_name}' stopped"} + except docker.errors.NotFound: + return {"success": False, "message": f"Container '{container_name}' not found"} + except Exception as e: + logger.error(f"Error stopping container '{container_name}': {e}") + return {"success": False, "message": str(e)} + + def get_containers_count(self) -> int: + """Get total number of containers.""" + if not self.available: + return 0 + try: + return len(self.client.containers.list(all=True)) + except Exception as e: + logger.error(f"Error counting containers: {e}") + return 0 + + def get_running_containers_count(self) -> int: + """Get number of running containers.""" + if not self.available: + return 0 + try: + return len(self.client.containers.list()) + except Exception as e: + logger.error(f"Error counting running containers: {e}") + return 0 + + def _format_uptime(self, container) -> str: + """Format container uptime.""" + try: + from datetime import datetime, timezone + + started_at = container.attrs["State"]["StartedAt"] + started = datetime.fromisoformat(started_at.replace("Z", "+00:00")) + now = datetime.now(timezone.utc) + uptime = now - started + + days = uptime.days + hours = uptime.seconds // 3600 + minutes = (uptime.seconds % 3600) // 60 + + if days > 0: + return f"{days}d {hours}h" + elif hours > 0: + return f"{hours}h {minutes}m" + else: + return f"{minutes}m" + + except Exception as e: + logger.error(f"Error formatting uptime: {e}") + return "unknown" diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..020dbf0 --- /dev/null +++ b/app/main.py @@ -0,0 +1,298 @@ +import asyncio +import json +import logging +import time +from pathlib import Path +from typing import List + +import psutil +import yaml +from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect, HTTPException +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from .docker_monitor import DockerMonitor + +logger = logging.getLogger(__name__) + +app = FastAPI(title="RedUnits Control Panel") + +# Setup paths +BASE_DIR = Path(__file__).resolve().parent.parent +app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static") +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + +# Initialize Docker monitor +docker_monitor = DockerMonitor() + + +# ────────────────────────────────────────────── +# Config loader +# ────────────────────────────────────────────── + +def load_services() -> List[dict]: + """Load services from config.yaml. Falls back to empty list on error.""" + config_path = BASE_DIR / "config.yaml" + try: + with open(config_path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) + return data.get("services", []) + except FileNotFoundError: + logger.warning(f"config.yaml not found at {config_path}. No services loaded.") + return [] + except Exception as e: + logger.error(f"Error loading config.yaml: {e}") + return [] + +def load_documents() -> List[dict]: + """Load documents from config.yaml.""" + config_path = BASE_DIR / "config.yaml" + try: + with open(config_path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) + return data.get("documents", []) + except Exception: + return [] + +SERVICES = load_services() + + + +# ────────────────────────────────────────────── +# WebSocket connection manager +# ────────────────────────────────────────────── + +class ConnectionManager: + def __init__(self): + self.active_connections: List[WebSocket] = [] + + async def connect(self, websocket: WebSocket): + await websocket.accept() + self.active_connections.append(websocket) + logger.info(f"WS client connected. Total: {len(self.active_connections)}") + + def disconnect(self, websocket: WebSocket): + if websocket in self.active_connections: + self.active_connections.remove(websocket) + logger.info(f"WS client disconnected. Total: {len(self.active_connections)}") + + async def broadcast(self, data: dict): + if not self.active_connections: + return + message = json.dumps(data) + dead = [] + for ws in self.active_connections: + try: + await ws.send_text(message) + except Exception: + dead.append(ws) + for ws in dead: + self.disconnect(ws) + + +manager = ConnectionManager() + + +# ────────────────────────────────────────────── +# Background metrics collector +# ────────────────────────────────────────────── + +async def collect_and_broadcast(): + """Collect system + service metrics and broadcast to all WS clients.""" + try: + services_data = [] + for service in SERVICES: + status = await asyncio.to_thread( + docker_monitor.get_container_status, service["container_name"] + ) + services_data.append({**service, "status": status}) + + cpu_percent = await asyncio.to_thread(psutil.cpu_percent, 1) + memory = psutil.virtual_memory() + disk = psutil.disk_usage("/") + containers_total = await asyncio.to_thread(docker_monitor.get_containers_count) + containers_running = await asyncio.to_thread(docker_monitor.get_running_containers_count) + uptime_seconds = time.time() - psutil.boot_time() + + payload = { + "type": "update", + "services": services_data, + "system": { + "cpu": {"percent": round(cpu_percent, 1)}, + "memory": { + "percent": round(memory.percent, 1), + "used_gb": round(memory.used / (1024 ** 3), 2), + "total_gb": round(memory.total / (1024 ** 3), 2), + }, + "disk": { + "percent": round(disk.percent, 1), + "used_gb": round(disk.used / (1024 ** 3), 2), + "total_gb": round(disk.total / (1024 ** 3), 2), + }, + "containers": { + "running": containers_running, + "total": containers_total, + }, + "uptime": { + "days": int(uptime_seconds / 86400), + "seconds": int(uptime_seconds), + }, + }, + } + await manager.broadcast(payload) + except Exception as e: + logger.error(f"Error in collect_and_broadcast: {e}") + + +async def metrics_loop(): + """Background loop: collect and broadcast metrics every 5 seconds.""" + while True: + await collect_and_broadcast() + await asyncio.sleep(5) + + +# ────────────────────────────────────────────── +# App lifecycle +# ────────────────────────────────────────────── + +@app.on_event("startup") +async def startup_event(): + asyncio.create_task(metrics_loop()) + logger.info("Metrics background task started") + + +# ────────────────────────────────────────────── +# HTTP routes +# ────────────────────────────────────────────── + +@app.get("/", response_class=HTMLResponse) +async def root(request: Request): + """Render main dashboard page.""" + return templates.TemplateResponse("index.html", {"request": request}) + + +@app.get("/api/services") +async def get_services(): + """Get all services with their current status (one-shot REST fallback).""" + services_with_status = [] + for service in SERVICES: + status = await asyncio.to_thread( + docker_monitor.get_container_status, service["container_name"] + ) + services_with_status.append({**service, "status": status}) + return {"services": services_with_status} + + +@app.get("/api/system") +async def get_system_stats(): + """Get system statistics (one-shot REST fallback).""" + cpu_percent = await asyncio.to_thread(psutil.cpu_percent, 1) + memory = psutil.virtual_memory() + disk = psutil.disk_usage("/") + containers_total = await asyncio.to_thread(docker_monitor.get_containers_count) + containers_running = await asyncio.to_thread(docker_monitor.get_running_containers_count) + uptime_seconds = time.time() - psutil.boot_time() + + return { + "cpu": {"percent": round(cpu_percent, 1)}, + "memory": { + "percent": round(memory.percent, 1), + "used_gb": round(memory.used / (1024 ** 3), 2), + "total_gb": round(memory.total / (1024 ** 3), 2), + }, + "disk": { + "percent": round(disk.percent, 1), + "used_gb": round(disk.used / (1024 ** 3), 2), + "total_gb": round(disk.total / (1024 ** 3), 2), + }, + "containers": { + "running": containers_running, + "total": containers_total, + }, + "uptime": { + "days": int(uptime_seconds / 86400), + "seconds": int(uptime_seconds), + }, + } + + +@app.get("/api/health") +async def health_check(): + """Health check endpoint.""" + return {"status": "ok", "message": "RedUnits Control Panel is running"} + + +@app.post("/api/services/{service_id}/restart") +async def restart_service(service_id: str): + """Restart a service container.""" + service = next((s for s in SERVICES if s["id"] == service_id), None) + if not service: + raise HTTPException(status_code=404, detail=f"Service '{service_id}' not found") + result = await asyncio.to_thread( + docker_monitor.restart_container, service["container_name"] + ) + if not result["success"]: + raise HTTPException(status_code=500, detail=result["message"]) + return result + + +@app.post("/api/services/{service_id}/stop") +async def stop_service(service_id: str): + """Stop a service container.""" + service = next((s for s in SERVICES if s["id"] == service_id), None) + if not service: + raise HTTPException(status_code=404, detail=f"Service '{service_id}' not found") + result = await asyncio.to_thread( + docker_monitor.stop_container, service["container_name"] + ) + if not result["success"]: + raise HTTPException(status_code=500, detail=result["message"]) + return result + + +@app.get("/api/documents") +async def get_documents(): + """Get list of available documents.""" + return {"documents": load_documents()} + + +@app.get("/api/document/{doc_id}") +async def get_document_content(doc_id: str): + """Fetch raw document content (acts as a proxy).""" + docs = load_documents() + doc = next((d for d in docs if d["id"] == doc_id), None) + if not doc: + raise HTTPException(status_code=404, detail=f"Document '{doc_id}' not found") + + import requests + try: + resp = await asyncio.to_thread(requests.get, doc["url"], timeout=10) + resp.raise_for_status() + return {"content": resp.text} + except Exception as e: + logger.error(f"Error fetching document '{doc_id}': {e}") + raise HTTPException(status_code=500, detail=str(e)) + + + +# ────────────────────────────────────────────── +# WebSocket endpoint +# ────────────────────────────────────────────── + +@app.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket): + await manager.connect(websocket) + # Send initial data immediately on connect + await collect_and_broadcast() + try: + while True: + # Keep connection alive; client messages are ignored + await websocket.receive_text() + except WebSocketDisconnect: + manager.disconnect(websocket) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..a2a1a1b --- /dev/null +++ b/config.yaml @@ -0,0 +1,30 @@ +services: + - id: vault + name: Vault + icon: "🗄️" + description: "Gitea - Self-hosted Git service for repositories and collaboration" + url: "https://vault.redunits.net" + container_name: gitea + port: 3080 + + - id: cell + name: Cell + icon: "🧠" + description: "Qdrant - Vector database for AI embeddings and semantic search" + url: "https://cell.redunits.net" + container_name: qdrant + port: 6333 + + - id: aevyra + name: Aevyra + icon: "🌐" + description: "Aevyra Web - Main web application and public-facing site" + url: "https://www.aevyra.net" + container_name: aevyra-web + port: 3001 + +documents: + - id: naming-rules + title: "Правила именования устройств" + description: "Стандарт формирования имён для хостов и оборудования" + url: "https://vault.redunits.net/user/repo/raw/branch/main/naming.md" # Замени на реальную ссылку (Raw format) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e0046b8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +version: '3.8' + +services: + redunits-panel: + build: . + container_name: redunits-control-panel + ports: + - "8000:8000" + volumes: + # Mount Docker socket for container monitoring + - /var/run/docker.sock:/var/run/docker.sock:ro + # Mount config file for easy editing without rebuild + - ./config.yaml:/app/config.yaml:ro + environment: + - PYTHONUNBUFFERED=1 + # GID 988 = docker group on the VPS host — needed for docker.sock access + group_add: + - "988" + restart: unless-stopped + networks: + - redunits-network + labels: + - "com.redunits.service=control-panel" + - "com.redunits.version=1.0.0" + +networks: + redunits-network: + driver: bridge diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0c07810 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +jinja2==3.1.3 +docker==7.0.0 +requests==2.31.0 +urllib3<2 +psutil==5.9.8 +python-multipart==0.0.9 +pyyaml==6.0.1 diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..5111d0e --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,645 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); + +:root { + /* Colors inspired by the mock-up */ + --bg-main: #fbfbf9; + --sys-card-bg: #f6f5ef; + --svc-online-bg: #ffffff; + --svc-offline-bg: #f6f5ef; + + --text-main: #111827; + --text-muted: #6b7280; + --text-light: #9ca3af; + + --red-main: #f05252; + --red-hover: #e02424; + + --green: #10b981; + --green-bg: #ecfdf5; + --red: #ef4444; + --red-bg: #fef2f2; + --yellow: #f59e0b; + + --border-color: #e5e7eb; + --border-card: #f3f4f6; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Inter', system-ui, -apple-system, sans-serif; + background-color: var(--bg-main); + color: var(--text-main); + padding: 30px 40px; + min-height: 100vh; +} + +.container { + max-width: 1400px; + margin: 0 auto; +} + +/* ── HEADER ── */ +header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 24px; + border-bottom: 1px solid var(--border-color); + margin-bottom: 40px; +} + +.header-left { + display: flex; + align-items: center; + gap: 16px; +} + +.logo-dot { + width: 16px; + height: 16px; + background-color: #ff9fb2; + border-radius: 50%; +} + +h1 { + font-size: 1.5rem; + font-weight: 600; + color: #8c8c8c; +} + +.header-right { + display: flex; + align-items: center; + gap: 16px; + font-size: 0.9rem; + color: var(--text-muted); +} + +.indicator-group { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 4px 12px; + border-radius: 99px; + font-weight: 500; +} + +.ws-live { + color: var(--green); + border: 1px solid var(--green); + background: var(--green-bg); +} + +.ws-offline { + color: var(--yellow); + border: 1px solid var(--yellow); +} + +.ws-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background-color: currentColor; + animation: pulse 1.5s infinite; +} + +@keyframes pulse { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.4; + } +} + +.refresh-btn { + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-muted); + padding: 6px 14px; + border-radius: 8px; + cursor: pointer; + font-weight: 500; + transition: all 0.2s; +} + +.refresh-btn:hover:not(:disabled) { + background: #f3f4f6; + color: var(--text-main); +} + +/* ── SECTIONS ── */ +.section { + margin-bottom: 48px; +} + +.section-title { + font-size: 0.85rem; + font-weight: 700; + color: var(--text-muted); + letter-spacing: 0.08em; + margin-bottom: 20px; +} + +/* ── SYSTEM STATUS ── */ +.system-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; +} + +.stat-card { + background-color: var(--sys-card-bg); + padding: 24px; + border-radius: 16px; + border: 1px solid transparent; +} + +.stat-label { + font-size: 0.95rem; + color: var(--text-main); + margin-bottom: 12px; + font-weight: 500; +} + +.stat-value { + font-size: 2.2rem; + font-weight: 600; + color: var(--text-main); + margin-bottom: 16px; +} + +.progress-bar { + width: 100%; + height: 4px; + background: #e5e5e5; + border-radius: 2px; + margin-bottom: 12px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + border-radius: 2px; + transition: width 0.5s ease; +} + +.progress-fill { + background-color: var(--text-main); +} + +.progress-fill.green { + background-color: var(--green); +} + +.progress-fill.red { + background-color: var(--red); +} + +.stat-detail { + font-size: 0.85rem; + color: var(--text-muted); +} + +.stat-status { + font-size: 0.9rem; + font-weight: 500; + margin-top: 24px; +} + +.status-ok { + color: #d97706; +} + +/* Warning color from mockup for "Some services offline" */ +.status-warn { + color: var(--yellow); +} + +/* ── SERVICES ── */ +.services-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + gap: 24px; +} + +.service-card { + background-color: var(--svc-online-bg); + border-radius: 20px; + padding: 28px; + border: 1px solid var(--border-card); + display: flex; + flex-direction: column; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.02); +} + +.card-offline { + background-color: var(--svc-offline-bg); + border: 1px solid var(--border-color); + box-shadow: none; +} + +.service-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.service-name { + display: flex; + align-items: center; + gap: 12px; + font-size: 1.4rem; + font-weight: 600; + color: var(--text-main); +} + +.status-badge { + padding: 4px 12px; + border-radius: 99px; + font-size: 0.8rem; + font-weight: 500; +} + +.status-online { + color: var(--green); + border: 1px solid var(--green); + background-color: var(--green-bg); +} + +.status-offline { + color: var(--red); + border: 1px solid var(--red); + background-color: var(--red-bg); +} + +.service-description { + color: var(--text-muted); + font-size: 0.95rem; + line-height: 1.5; + margin-bottom: 16px; + min-height: 44px; + /* Align cards if description differs slightly */ +} + +.service-url { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.85rem; + color: var(--text-light); + margin-bottom: 24px; + letter-spacing: 0.05em; +} + +.service-metrics { + background-color: var(--sys-card-bg); + border-radius: 12px; + padding: 16px 20px; + display: flex; + justify-content: space-between; + margin-bottom: 24px; + margin-top: auto; + /* push to bottom */ +} + +.metric { + display: flex; + flex-direction: column; + gap: 6px; +} + +.metric-label { + font-size: 0.75rem; + color: var(--text-muted); + text-transform: uppercase; + font-weight: 600; +} + +.metric-value { + font-size: 1.05rem; + font-weight: 700; + color: var(--text-main); + display: flex; + flex-direction: row; + align-items: baseline; + gap: 4px; +} + +.metric-unit { + display: inline; + font-size: 0.8rem; + color: var(--text-muted); +} + +.offline-message { + color: var(--text-muted); + font-size: 1.1rem; + text-align: center; + margin: 40px 0; + margin-top: auto; +} + +/* ── ACTIONS ── */ +.service-actions { + display: flex; + gap: 12px; + align-items: stretch; +} + +.service-actions-center { + display: flex; + justify-content: center; + align-items: center; +} + +.btn { + border-radius: 12px; + font-weight: 600; + font-size: 0.95rem; + cursor: pointer; + transition: all 0.2s; + text-align: center; + text-decoration: none; + display: inline-flex; + justify-content: center; + align-items: center; + padding: 12px 24px; + border: none; +} + +.btn-primary { + background-color: var(--red-main); + color: #ffffff; + padding: 12px 32px; + /* Wider open button */ + font-size: 1.1rem; +} + +.btn-primary:hover { + background-color: var(--red-hover); +} + +.btn-outline { + background-color: #ffffff; + border: 1px solid var(--border-color); + color: var(--text-main); +} + +.btn-outline:hover { + background-color: #f9fafb; + border-color: #d1d5db; +} + +.btn-icon { + background-color: #ffffff; + border: 1px solid var(--border-color); + color: var(--text-main); + width: 48px; + padding: 0; + font-size: 1.2rem; +} + +.btn-icon:hover { + background-color: #f9fafb; +} + +.btn-full { + padding: 14px 24px; + font-size: 1rem; + background-color: #ffffff; + border: 1px solid var(--border-color); + margin-bottom: 15px; +} + +/* ── TOAST ── */ +.toast { + position: fixed; + top: 24px; + right: 24px; + z-index: 1000; + padding: 16px 24px; + border-radius: 12px; + font-weight: 600; + font-size: 0.95rem; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); +} + +.toast-success { + background-color: var(--green-bg); + color: var(--green); + border: 1px solid var(--green); +} + +.toast-error { + background-color: var(--red-bg); + color: var(--red); + border: 1px solid var(--red); +} + +/* ── DOCUMENTS ── */ +.docs-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 16px; +} + +.doc-item { + background-color: var(--svc-online-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 16px 20px; + display: flex; + align-items: center; + gap: 14px; + cursor: pointer; + transition: all 0.2s; +} + +.doc-item:hover { + border-color: #d1d5db; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03); + transform: translateY(-2px); +} + +.doc-icon { + font-size: 1.5rem; +} + +.doc-title { + font-weight: 600; + color: var(--text-main); + margin-bottom: 4px; + font-size: 0.95rem; +} + +.doc-desc { + font-size: 0.8rem; + color: var(--text-muted); +} + +/* ── MODAL ── */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(17, 24, 39, 0.4); + backdrop-filter: blur(4px); + z-index: 2000; + display: flex; + justify-content: center; + align-items: center; + padding: 20px; +} + +.modal-content { + background: #fff; + border-radius: 16px; + width: 100%; + max-width: 800px; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid var(--border-color); +} + +.modal-header h3 { + font-size: 1.2rem; + color: var(--text-main); + font-weight: 600; +} + +.modal-close { + background: transparent; + border: none; + font-size: 1.2rem; + color: var(--text-muted); + cursor: pointer; +} + +.modal-close:hover { + color: var(--text-main); +} + +.modal-body { + padding: 24px; + overflow-y: auto; +} + +.doc-loading { + text-align: center; + padding: 40px; + color: var(--text-muted); +} + +/* ── MARKDOWN BASIC STYLES ── */ +.markdown-body { + font-family: inherit; + font-size: 0.95rem; + line-height: 1.6; + color: #333; +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3 { + margin-top: 1.5em; + margin-bottom: 1em; + font-weight: 600; + color: #111; +} + +.markdown-body h1 { + font-size: 1.8em; + border-bottom: 1px solid #eee; + padding-bottom: 0.3em; +} + +.markdown-body h2 { + font-size: 1.4em; +} + +.markdown-body p, +.markdown-body ul, +.markdown-body ol { + margin-bottom: 1em; +} + +.markdown-body code { + background: #f6f8fa; + padding: 0.2em 0.4em; + border-radius: 3px; + font-family: monospace; +} + +.markdown-body pre { + background: #f6f8fa; + padding: 16px; + border-radius: 8px; + overflow-x: auto; + margin-bottom: 1em; +} + +.markdown-body pre code { + background: transparent; + padding: 0; +} + +.markdown-body blockquote { + border-left: 4px solid #dfe2e5; + padding: 0 1em; + color: #6a737d; + margin-left: 0; +} + +.markdown-body table { + border-collapse: collapse; + width: 100%; + margin-bottom: 1em; +} + +.markdown-body th, +.markdown-body td { + border: 1px solid #dfe2e5; + padding: 6px 13px; +} + +footer { + text-align: center; + padding: 40px 0; + color: var(--text-light); + font-size: 0.85rem; +} + +@media (max-width: 768px) { + .system-grid { + grid-template-columns: 1fr; + } + + .services-grid { + grid-template-columns: 1fr; + } + + header { + flex-direction: column; + gap: 20px; + align-items: flex-start; + } +} \ No newline at end of file diff --git a/static/js/alpine.min.js b/static/js/alpine.min.js new file mode 100644 index 0000000..2ca4827 --- /dev/null +++ b/static/js/alpine.min.js @@ -0,0 +1,5 @@ +(()=>{var rt=!1,nt=!1,U=[],it=-1;function qt(e){Cn(e)}function Cn(e){U.includes(e)||U.push(e),Tn()}function Ee(e){let t=U.indexOf(e);t!==-1&&t>it&&U.splice(t,1)}function Tn(){!nt&&!rt&&(rt=!0,queueMicrotask(Rn))}function Rn(){rt=!1,nt=!0;for(let e=0;ee.effect(t,{scheduler:r=>{ot?qt(r):r()}}),st=e.raw}function at(e){D=e}function Gt(e){let t=()=>{};return[n=>{let i=D(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),L(i))},i},()=>{t()}]}function ve(e,t){let r=!0,n,i=D(()=>{let o=e();JSON.stringify(o),r?n=o:queueMicrotask(()=>{t(o,n),n=o}),r=!1});return()=>L(i)}var Jt=[],Yt=[],Xt=[];function Zt(e){Xt.push(e)}function ee(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Yt.push(t))}function Ae(e){Jt.push(e)}function Oe(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function ct(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function Qt(e){if(e._x_cleanups)for(;e._x_cleanups.length;)e._x_cleanups.pop()()}var lt=new MutationObserver(pt),ut=!1;function le(){lt.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ut=!0}function ft(){Mn(),lt.disconnect(),ut=!1}var ce=[];function Mn(){let e=lt.takeRecords();ce.push(()=>e.length>0&&pt(e));let t=ce.length;queueMicrotask(()=>{if(ce.length===t)for(;ce.length>0;)ce.shift()()})}function _(e){if(!ut)return e();ft();let t=e();return le(),t}var dt=!1,Se=[];function er(){dt=!0}function tr(){dt=!1,pt(Se),Se=[]}function pt(e){if(dt){Se=Se.concat(e);return}let t=new Set,r=new Set,n=new Map,i=new Map;for(let o=0;os.nodeType===1&&t.add(s)),e[o].removedNodes.forEach(s=>s.nodeType===1&&r.add(s))),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{ct(s,o)}),n.forEach((o,s)=>{Jt.forEach(a=>a(s,o))});for(let o of r)t.has(o)||Yt.forEach(s=>s(o));t.forEach(o=>{o._x_ignoreSelf=!0,o._x_ignore=!0});for(let o of t)r.has(o)||o.isConnected&&(delete o._x_ignoreSelf,delete o._x_ignore,Xt.forEach(s=>s(o)),o._x_ignore=!0,o._x_ignoreSelf=!0);t.forEach(o=>{delete o._x_ignoreSelf,delete o._x_ignore}),t=null,r=null,n=null,i=null}function Ce(e){return F(j(e))}function P(e,t,r){return e._x_dataStack=[t,...j(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function j(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?j(e.host):e.parentNode?j(e.parentNode):[]}function F(e){return new Proxy({objects:e},Nn)}var Nn={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?Dn:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i=e.find(s=>Object.prototype.hasOwnProperty.call(s,t))||e[e.length-1],o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,t,r)}};function Dn(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function Te(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function Re(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>Pn(n,i),s=>mt(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function Pn(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function mt(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),mt(e[t[0]],t.slice(1),r)}}var rr={};function y(e,t){rr[e]=t}function ue(e,t){return Object.entries(rr).forEach(([r,n])=>{let i=null;function o(){if(i)return i;{let[s,a]=_t(t);return i={interceptor:Re,...s},ee(t,a),i}}Object.defineProperty(e,`$${r}`,{get(){return n(t,o())},enumerable:!1})}),e}function nr(e,t,r,...n){try{return r(...n)}catch(i){te(i,e,t)}}function te(e,t,r=void 0){e=Object.assign(e??{message:"No error message given."},{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message} + +${r?'Expression: "'+r+`" + +`:""}`,t),setTimeout(()=>{throw e},0)}var Me=!0;function De(e){let t=Me;Me=!1;let r=e();return Me=t,r}function M(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return ir(...e)}var ir=gt;function or(e){ir=e}function gt(e,t){let r={};ue(r,e);let n=[r,...j(e)],i=typeof t=="function"?In(n,t):Ln(n,t,e);return nr.bind(null,e,t,i)}function In(e,t){return(r=()=>{},{scope:n={},params:i=[]}={})=>{let o=t.apply(F([n,...e]),i);Ne(r,o)}}var ht={};function kn(e,t){if(ht[e])return ht[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return te(s,t,e),Promise.resolve()}})();return ht[e]=o,o}function Ln(e,t,r){let n=kn(t,r);return(i=()=>{},{scope:o={},params:s=[]}={})=>{n.result=void 0,n.finished=!1;let a=F([o,...e]);if(typeof n=="function"){let c=n(n,a).catch(l=>te(l,r,t));n.finished?(Ne(i,n.result,a,s,r),n.result=void 0):c.then(l=>{Ne(i,l,a,s,r)}).catch(l=>te(l,r,t)).finally(()=>n.result=void 0)}}}function Ne(e,t,r,n,i){if(Me&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>Ne(e,s,r,n)).catch(s=>te(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}var bt="x-";function C(e=""){return bt+e}function sr(e){bt=e}var Pe={};function d(e,t){return Pe[e]=t,{before(r){if(!Pe[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=W.indexOf(r);W.splice(n>=0?n:W.indexOf("DEFAULT"),0,e)}}}function ar(e){return Object.keys(Pe).includes(e)}function de(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=wt(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(ur((o,s)=>n[o]=s)).filter(dr).map(jn(n,r)).sort(Fn).map(o=>$n(e,o))}function wt(e){return Array.from(e).map(ur()).filter(t=>!dr(t))}var xt=!1,fe=new Map,cr=Symbol();function lr(e){xt=!0;let t=Symbol();cr=t,fe.set(t,[]);let r=()=>{for(;fe.get(t).length;)fe.get(t).shift()();fe.delete(t)},n=()=>{xt=!1,r()};e(r),n()}function _t(e){let t=[],r=a=>t.push(a),[n,i]=Gt(e);return t.push(i),[{Alpine:B,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:M.bind(M,e)},()=>t.forEach(a=>a())]}function $n(e,t){let r=()=>{},n=Pe[t.type]||r,[i,o]=_t(e);Oe(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),xt?fe.get(cr).push(n):n())};return s.runCleanups=o,s}var Ie=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),ke=e=>e;function ur(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=fr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var fr=[];function re(e){fr.push(e)}function dr({name:e}){return pr().test(e)}var pr=()=>new RegExp(`^${bt}([^:^.]+)\\b`);function jn(e,t){return({name:r,value:n})=>{let i=r.match(pr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var yt="DEFAULT",W=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",yt,"teleport"];function Fn(e,t){let r=W.indexOf(e.type)===-1?yt:e.type,n=W.indexOf(t.type)===-1?yt:t.type;return W.indexOf(r)-W.indexOf(n)}function G(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function T(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>T(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)T(n,t,!1),n=n.nextElementSibling}function E(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var mr=!1;function _r(){mr&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),mr=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's ` + + + + +
+ + + + +
+
+
+

RedUnits Control Panel

+
+
+ +
+ + +
+
+ +
+
+ + +
+

SYSTEM STATUS

+
+
+
CPU usage
+
+
+
+
+
+ +
+
Memory usage
+
+
+
+
+
+
+
+ +
+
Docker containers
+
+
+
+
+
+ + All containers + running +
+
+ +
+
System uptime
+
+
+ ✓ All services operational + + + + + + + Some services offline + +
+
+
+
+ + +
+

SERVICES

+
+ +
+
+ + + + + + + +
+

RedUnits Control Panel v1.3

+
+
+ + + + + \ No newline at end of file