Initial commit

This commit is contained in:
Sundog Garage Studio 2026-03-17 16:17:53 +02:00
commit 411038582a
15 changed files with 1895 additions and 0 deletions

30
.dockerignore Normal file
View File

@ -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

27
.gitignore vendored Normal file
View File

@ -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

30
Dockerfile Normal file
View File

@ -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"]

173
README.md Normal file
View File

@ -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

3
app/__init__.py Normal file
View File

@ -0,0 +1,3 @@
"""RedUnits Control Panel - Service Management Dashboard"""
__version__ = "1.0.0"

146
app/docker_monitor.py Normal file
View File

@ -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"

298
app/main.py Normal file
View File

@ -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)

30
config.yaml Normal file
View File

@ -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)

28
docker-compose.yml Normal file
View File

@ -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

9
requirements.txt Normal file
View File

@ -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

645
static/css/style.css Normal file
View File

@ -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;
}
}

5
static/js/alpine.min.js vendored Normal file

File diff suppressed because one or more lines are too long

193
static/js/app.js Normal file
View File

@ -0,0 +1,193 @@
function dashboard() {
return {
services: [],
system: {
cpu: { percent: 0 },
memory: { percent: 0, used_gb: 0, total_gb: 0 },
disk: { percent: 0, used_gb: 0, total_gb: 0 },
containers: { running: 0, total: 0 },
uptime: { days: 0, seconds: 0 }
},
lastUpdate: 'Never',
loading: true,
wsConnected: false,
ws: null,
wsReconnectTimeout: null,
actionMessage: null,
actionError: false,
// ──────────────────── Documents ────────────────────
documents: [],
showDocModal: false,
docTitle: '',
docContent: '',
docLoading: false,
init() {
console.log('Initializing RedUnits Control Panel...');
this.fetchDocuments();
this.connectWebSocket();
},
// ──────────────────── WebSocket ────────────────────
connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
console.log(`Connecting to WebSocket: ${wsUrl}`);
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('WebSocket connected');
this.wsConnected = true;
if (this.wsReconnectTimeout) {
clearTimeout(this.wsReconnectTimeout);
this.wsReconnectTimeout = null;
}
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'update') {
this.services = data.services;
this.system = data.system;
this.loading = false;
this.updateLastUpdate();
}
} catch (e) {
console.error('Error parsing WS message:', e);
}
};
this.ws.onclose = () => {
console.warn('WebSocket disconnected. Reconnecting in 5s...');
this.wsConnected = false;
this.scheduleReconnect();
};
this.ws.onerror = (err) => {
console.error('WebSocket error:', err);
this.wsConnected = false;
this.ws.close();
};
},
scheduleReconnect() {
if (this.wsReconnectTimeout) return;
this.wsReconnectTimeout = setTimeout(() => {
this.wsReconnectTimeout = null;
this.connectWebSocket();
}, 5000);
},
// ──────────────────── Manual refresh (REST fallback) ────────────────────
async refresh() {
this.loading = true;
try {
const [svcRes, sysRes] = await Promise.all([
fetch('/api/services'),
fetch('/api/system')
]);
const svcData = await svcRes.json();
const sysData = await sysRes.json();
this.services = svcData.services;
this.system = sysData;
this.updateLastUpdate();
} catch (error) {
console.error('Error refreshing data:', error);
} finally {
this.loading = false;
}
},
// ──────────────────── Documents ────────────────────
async fetchDocuments() {
try {
const response = await fetch('/api/documents');
if (response.ok) {
const data = await response.json();
this.documents = data.documents;
}
} catch (error) {
console.error('Error fetching documents:', error);
}
},
async openDocument(doc) {
this.showDocModal = true;
this.docTitle = doc.title;
this.docContent = '';
this.docLoading = true;
try {
const res = await fetch(`/api/document/${doc.id}`);
const data = await res.json();
if (res.ok) {
this.docContent = marked.parse(data.content);
} else {
this.docContent = `<div style="color:var(--red)">Failed to load document: ${data.detail || 'Unknown error'}</div>`;
}
} catch (error) {
this.docContent = `<div style="color:var(--red)">Network error while loading document</div>`;
} finally {
this.docLoading = false;
}
},
closeDocument() {
this.showDocModal = false;
},
// ──────────────────── Container actions ────────────────────
async restartService(serviceId) {
if (!confirm(`Restart service "${serviceId}"?`)) return;
await this._serviceAction(serviceId, 'restart');
},
async stopService(serviceId) {
if (!confirm(`Stop service "${serviceId}"? It will go offline.`)) return;
await this._serviceAction(serviceId, 'stop');
},
async _serviceAction(serviceId, action) {
try {
const res = await fetch(`/api/services/${serviceId}/${action}`, { method: 'POST' });
const data = await res.json();
if (res.ok) {
this.showMessage(`${data.message}`, false);
} else {
this.showMessage(`${data.detail || data.message}`, true);
}
} catch (e) {
this.showMessage(`❌ Network error: ${e.message}`, true);
}
},
showMessage(msg, isError) {
this.actionMessage = msg;
this.actionError = isError;
setTimeout(() => { this.actionMessage = null; }, 4000);
},
// ──────────────────── Helpers ────────────────────
updateLastUpdate() {
this.lastUpdate = new Date().toLocaleTimeString('en-GB', { timeZone: 'Europe/Riga' });
},
allServicesOnline() {
if (this.services.length === 0) return false;
return this.services.every(s => s.status.online);
},
getProgressColor(percent) {
if (percent < 60) return 'green';
if (percent < 80) return 'yellow';
return 'red';
}
};
}

69
static/js/marked.min.js vendored Normal file

File diff suppressed because one or more lines are too long

209
templates/index.html Normal file
View File

@ -0,0 +1,209 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RedUnits Control Panel</title>
<link rel="stylesheet" href="/static/css/style.css">
<!-- Alpine.js served locally (no CDN dependency) -->
<script defer src="/static/js/alpine.min.js"></script>
<script src="/static/js/marked.min.js"></script>
</head>
<body>
<div class="container" x-data="dashboard()" x-init="init()">
<!-- Toast notification -->
<div class="toast" x-show="actionMessage" x-transition :class="actionError ? 'toast-error' : 'toast-success'"
style="display: none;">
<span x-text="actionMessage"></span>
</div>
<header>
<div class="header-left">
<div class="logo-dot"></div>
<h1>RedUnits Control Panel</h1>
</div>
<div class="header-right">
<!-- Live WS indicator -->
<div class="indicator-group" :class="wsConnected ? 'ws-live' : 'ws-offline'">
<span class="ws-dot"></span>
<span x-text="wsConnected ? 'live' : 'reconnecting...'"></span>
</div>
<div class="last-update" x-text="lastUpdate"></div>
<button @click="refresh()" class="refresh-btn" :disabled="loading">
<span x-show="!loading">↻ refresh</span>
<span x-show="loading">⏳...</span>
</button>
</div>
</header>
<!-- SYSTEM STATUS SECTION -->
<section class="section">
<h2 class="section-title">SYSTEM STATUS</h2>
<div class="system-grid">
<div class="stat-card">
<div class="stat-label">CPU usage</div>
<div class="stat-value" x-text="system.cpu.percent + '%'"></div>
<div class="progress-bar">
<div class="progress-fill" :style="`width: ${system.cpu.percent}%`"
:class="getProgressColor(system.cpu.percent)"></div>
</div>
</div>
<div class="stat-card">
<div class="stat-label">Memory usage</div>
<div class="stat-value" x-text="system.memory.percent + '%'"></div>
<div class="progress-bar">
<div class="progress-fill" :style="`width: ${system.memory.percent}%`"
:class="getProgressColor(system.memory.percent)"></div>
</div>
<div class="stat-detail" x-text="`${system.memory.used_gb} GB / ${system.memory.total_gb} GB`">
</div>
</div>
<div class="stat-card">
<div class="stat-label">Docker containers</div>
<div class="stat-value" x-text="`${system.containers.running} / ${system.containers.total}`"></div>
<div class="progress-bar">
<div class="progress-fill"
:style="`width: ${system.containers.total > 0 ? (system.containers.running / system.containers.total * 100) : 0}%`"
:class="system.containers.running === system.containers.total ? 'green' : 'red'"></div>
</div>
<div class="stat-detail">
<span x-show="system.containers.running < system.containers.total"
x-text="`${system.containers.total - system.containers.running} container${(system.containers.total - system.containers.running) > 1 ? 's' : ''} offline`"></span>
<span x-show="system.containers.running === system.containers.total">All containers
running</span>
</div>
</div>
<div class="stat-card">
<div class="stat-label">System uptime</div>
<div class="stat-value" x-text="system.uptime.days + ' days'"></div>
<div class="stat-status" :class="allServicesOnline() ? 'status-ok' : 'status-warn'">
<span x-show="allServicesOnline()">✓ All services operational</span>
<span x-show="!allServicesOnline()" style="display: flex; align-items: center; gap: 6px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
Some services offline
</span>
</div>
</div>
</div>
</section>
<!-- SERVICES SECTION -->
<section class="section">
<h2 class="section-title">SERVICES</h2>
<div class="services-grid">
<template x-for="service in services" :key="service.id">
<div class="service-card" :class="!service.status.online ? 'card-offline' : ''">
<div class="service-header">
<div class="service-name">
<span class="service-icon" x-text="service.icon"></span>
<span x-text="service.name"></span>
</div>
<div class="status-badge"
:class="service.status.online ? 'status-online' : 'status-offline'">
<span x-text="service.status.online ? 'online' : 'offline'"></span>
</div>
</div>
<div class="service-description" x-text="service.description"></div>
<div class="service-url" x-text="service.url"></div>
<!-- Online State Info -->
<template x-if="service.status.online">
<div class="service-metrics">
<div class="metric">
<span class="metric-label">CPU</span>
<span class="metric-value"><span x-text="service.status.cpu_percent"></span>%</span>
</div>
<div class="metric">
<span class="metric-label">RAM</span>
<span class="metric-value">
<span x-text="service.status.memory_mb"></span> <span
class="metric-unit">MB</span>
</span>
</div>
<div class="metric">
<span class="metric-label">Uptime</span>
<span class="metric-value" x-text="service.status.uptime"></span>
</div>
</div>
</template>
<!-- Offline State Info -->
<template x-if="!service.status.online">
<div class="offline-message">
Container is not running
</div>
</template>
<!-- Actions for Online -->
<div class="service-actions" x-show="service.status.online" style="display: none;">
<a :href="service.url" target="_blank" class="btn btn-primary">Open</a>
<button class="btn btn-icon" @click="restartService(service.id)" title="Restart container">
</button>
<button class="btn btn-outline" @click="stopService(service.id)" title="Stop container">
Stop
</button>
</div>
<!-- Actions for Offline -->
<div class="service-actions-center" x-show="!service.status.online" style="display: none;">
<button class="btn btn-outline btn-full" @click="restartService(service.id)">
↻ Restart service
</button>
</div>
</div>
</template>
</div>
</section>
<!-- DOCUMENTS SECTION -->
<section class="section" x-show="documents.length > 0" style="display: none;">
<h2 class="section-title">QUICK LINKS & DOCS</h2>
<div class="docs-list">
<template x-for="doc in documents" :key="doc.id">
<div class="doc-item" @click="openDocument(doc)">
<span class="doc-icon">📄</span>
<div class="doc-info">
<div class="doc-title" x-text="doc.title"></div>
<div class="doc-desc" x-text="doc.description"></div>
</div>
</div>
</template>
</div>
</section>
<!-- DOCUMENT MODAL -->
<div class="modal-overlay" x-show="showDocModal" style="display: none;">
<div class="modal-content" @click.away="closeDocument()">
<div class="modal-header">
<h3 x-text="docTitle"></h3>
<button class="modal-close" @click="closeDocument()"></button>
</div>
<div class="modal-body">
<div x-show="docLoading" class="doc-loading">⏳ Loading document...</div>
<div class="markdown-body" x-html="docContent" x-show="!docLoading"></div>
</div>
</div>
</div>
<footer>
<p>RedUnits Control Panel v1.3</p>
</footer>
</div>
<script src="/static/js/app.js"></script>
</body>
</html>