Yay
This commit is contained in:
11
app/Dockerfile
Normal file
11
app/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5000
|
||||
CMD ["python", "app.py"]
|
||||
370
app/app.py
Normal file
370
app/app.py
Normal file
@@ -0,0 +1,370 @@
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import paramiko
|
||||
import pymysql
|
||||
pymysql.install_as_MySQLdb()
|
||||
|
||||
from flask import Flask, render_template, jsonify
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = (
|
||||
f"mysql+pymysql://{os.environ['MYSQL_USER']}:{os.environ['MYSQL_PASSWORD']}"
|
||||
f"@{os.environ.get('MYSQL_HOST', 'db')}/{os.environ['MYSQL_DATABASE']}"
|
||||
)
|
||||
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {'pool_recycle': 280, 'pool_pre_ping': True}
|
||||
db = SQLAlchemy(app)
|
||||
|
||||
COLLECTION_INTERVAL = int(os.environ.get('COLLECTION_INTERVAL', 300))
|
||||
MAX_CONCURRENT_SSH = int(os.environ.get('MAX_CONCURRENT_SSH', 5))
|
||||
SSH_KEY_PATH = '/app/ssh_key'
|
||||
INFRA_CONF_PATH = '/app/infrastructure.conf'
|
||||
|
||||
|
||||
# --- Database Model ---
|
||||
|
||||
class Server(db.Model):
|
||||
__tablename__ = 'servers'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
group_name = db.Column(db.String(255), nullable=False)
|
||||
username = db.Column(db.String(255), nullable=False)
|
||||
hostname = db.Column(db.String(255), nullable=False)
|
||||
primary_ip = db.Column(db.String(45), default='')
|
||||
is_online = db.Column(db.Boolean, default=False)
|
||||
last_collected = db.Column(db.DateTime, nullable=True)
|
||||
details = db.Column(db.JSON, nullable=True)
|
||||
__table_args__ = (db.UniqueConstraint('username', 'hostname', name='uq_user_host'),)
|
||||
|
||||
|
||||
# --- Config Parsing ---
|
||||
|
||||
def parse_infrastructure_conf():
|
||||
servers = []
|
||||
current_group = None
|
||||
try:
|
||||
with open(INFRA_CONF_PATH) as f:
|
||||
for line in f:
|
||||
line = line.rstrip('\n')
|
||||
if not line.strip() or line.strip().startswith('#'):
|
||||
continue
|
||||
if line[0] not in (' ', '\t'):
|
||||
current_group = line.strip()
|
||||
else:
|
||||
entry = line.strip()
|
||||
if '@' in entry:
|
||||
user, host = entry.split('@', 1)
|
||||
servers.append({
|
||||
'group': current_group or 'Default',
|
||||
'username': user.strip(),
|
||||
'hostname': host.strip(),
|
||||
})
|
||||
except FileNotFoundError:
|
||||
logger.error("infrastructure.conf not found at %s", INFRA_CONF_PATH)
|
||||
return servers
|
||||
|
||||
|
||||
# --- SSH Collection ---
|
||||
|
||||
def load_ssh_key():
|
||||
for key_class in [paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey]:
|
||||
try:
|
||||
return key_class.from_private_key_file(SSH_KEY_PATH)
|
||||
except Exception:
|
||||
continue
|
||||
raise RuntimeError(f"Could not load SSH key from {SSH_KEY_PATH}")
|
||||
|
||||
|
||||
def collect_one(entry, ssh_key):
|
||||
"""SSH into a single server, run the gather script, return parsed data."""
|
||||
try:
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect(
|
||||
entry['hostname'],
|
||||
username=entry['username'],
|
||||
pkey=ssh_key,
|
||||
timeout=15,
|
||||
banner_timeout=15,
|
||||
auth_timeout=15,
|
||||
)
|
||||
|
||||
with open('/app/gather_info.sh') as f:
|
||||
script = f.read()
|
||||
|
||||
stdin, stdout, stderr = ssh.exec_command('bash -s', timeout=60)
|
||||
stdin.write(script)
|
||||
stdin.channel.shutdown_write()
|
||||
output = stdout.read().decode('utf-8', errors='replace')
|
||||
ssh.close()
|
||||
|
||||
data = parse_gather_output(output)
|
||||
data['is_online'] = True
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.warning("Failed to collect from %s@%s: %s", entry['username'], entry['hostname'], e)
|
||||
return {'is_online': False, 'error': str(e)}
|
||||
|
||||
|
||||
def parse_gather_output(output):
|
||||
"""Parse the [section] key=value output from gather_info.sh."""
|
||||
data = {}
|
||||
current_section = None
|
||||
|
||||
for line in output.split('\n'):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Section header: [name] or [name:id]
|
||||
m = re.match(r'^\[(.+)\]$', line)
|
||||
if m:
|
||||
section = m.group(1)
|
||||
if section == 'end':
|
||||
break
|
||||
if ':' in section:
|
||||
base, name = section.split(':', 1)
|
||||
if base not in data:
|
||||
data[base] = []
|
||||
item = {'_name': name}
|
||||
data[base].append(item)
|
||||
current_section = ('list', item)
|
||||
else:
|
||||
if section not in data:
|
||||
data[section] = {}
|
||||
current_section = ('dict', data[section])
|
||||
continue
|
||||
|
||||
# Key=value
|
||||
if '=' in line and current_section:
|
||||
key, _, value = line.partition('=')
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
|
||||
if current_section[0] == 'dict':
|
||||
section_data = current_section[1]
|
||||
# Handle repeated keys (e.g., dns server=)
|
||||
if key in section_data:
|
||||
if not isinstance(section_data[key], list):
|
||||
section_data[key] = [section_data[key]]
|
||||
section_data[key].append(value)
|
||||
else:
|
||||
section_data[key] = value
|
||||
elif current_section[0] == 'list':
|
||||
current_section[1][key] = value
|
||||
|
||||
return data
|
||||
|
||||
|
||||
# --- Collection Loop ---
|
||||
|
||||
def collect_all():
|
||||
entries = parse_infrastructure_conf()
|
||||
if not entries:
|
||||
logger.info("No servers configured in infrastructure.conf")
|
||||
return
|
||||
|
||||
try:
|
||||
ssh_key = load_ssh_key()
|
||||
except Exception as e:
|
||||
logger.error("SSH key error: %s", e)
|
||||
return
|
||||
|
||||
logger.info("Collecting from %d servers (max %d concurrent)", len(entries), MAX_CONCURRENT_SSH)
|
||||
results = {}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=MAX_CONCURRENT_SSH) as pool:
|
||||
futures = {pool.submit(collect_one, e, ssh_key): e for e in entries}
|
||||
for future in as_completed(futures):
|
||||
entry = futures[future]
|
||||
key = f"{entry['username']}@{entry['hostname']}"
|
||||
try:
|
||||
results[key] = (entry, future.result(timeout=90))
|
||||
except Exception as e:
|
||||
results[key] = (entry, {'is_online': False, 'error': str(e)})
|
||||
|
||||
# Update database (all in main collector thread)
|
||||
with app.app_context():
|
||||
for key, (entry, result) in results.items():
|
||||
server = Server.query.filter_by(
|
||||
username=entry['username'],
|
||||
hostname=entry['hostname'],
|
||||
).first()
|
||||
|
||||
if not server:
|
||||
server = Server(
|
||||
group_name=entry['group'],
|
||||
username=entry['username'],
|
||||
hostname=entry['hostname'],
|
||||
)
|
||||
db.session.add(server)
|
||||
|
||||
server.group_name = entry['group']
|
||||
server.is_online = result.get('is_online', False)
|
||||
server.last_collected = datetime.now(timezone.utc)
|
||||
server.details = result
|
||||
|
||||
# Extract primary IP: prefer the interface carrying the default route
|
||||
default_iface = ''
|
||||
routing = result.get('routing', {})
|
||||
if isinstance(routing, dict):
|
||||
default_iface = routing.get('interface', '')
|
||||
|
||||
primary_ip = ''
|
||||
for iface in result.get('net', []):
|
||||
ipv4 = iface.get('ipv4', '')
|
||||
if not ipv4 or ipv4.startswith('127.'):
|
||||
continue
|
||||
iface_name = iface.get('name', '') or iface.get('_name', '')
|
||||
if iface_name == default_iface:
|
||||
primary_ip = ipv4
|
||||
break
|
||||
if not primary_ip:
|
||||
primary_ip = ipv4
|
||||
server.primary_ip = primary_ip
|
||||
|
||||
db.session.commit()
|
||||
logger.info("Collection complete, updated %d servers", len(results))
|
||||
|
||||
|
||||
def collector_loop():
|
||||
time.sleep(10) # Let the app start up
|
||||
while True:
|
||||
try:
|
||||
collect_all()
|
||||
except Exception as e:
|
||||
logger.error("Collection loop error: %s", e)
|
||||
time.sleep(COLLECTION_INTERVAL)
|
||||
|
||||
|
||||
# --- Web Routes ---
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
servers = Server.query.order_by(Server.group_name, Server.primary_ip).all()
|
||||
groups = {}
|
||||
for s in servers:
|
||||
g = s.group_name or 'Default'
|
||||
if g not in groups:
|
||||
groups[g] = []
|
||||
groups[g].append(s)
|
||||
|
||||
# Sort servers within each group by IP (numerically)
|
||||
for g in groups:
|
||||
groups[g].sort(key=lambda s: _ip_sort_key(s.primary_ip))
|
||||
|
||||
return render_template('index.html', groups=groups)
|
||||
|
||||
|
||||
@app.route('/api/servers')
|
||||
def api_servers():
|
||||
servers = Server.query.all()
|
||||
result = []
|
||||
for s in servers:
|
||||
result.append({
|
||||
'id': s.id,
|
||||
'group_name': s.group_name,
|
||||
'username': s.username,
|
||||
'hostname': s.hostname,
|
||||
'primary_ip': s.primary_ip,
|
||||
'is_online': s.is_online,
|
||||
'last_collected': s.last_collected.isoformat() if s.last_collected else None,
|
||||
'details': s.details,
|
||||
})
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
def _ip_sort_key(ip_str):
|
||||
if not ip_str:
|
||||
return [999, 999, 999, 999]
|
||||
try:
|
||||
return [int(x) for x in ip_str.split('.')]
|
||||
except (ValueError, AttributeError):
|
||||
return [999, 999, 999, 999]
|
||||
|
||||
|
||||
# --- Jinja2 Helpers ---
|
||||
|
||||
@app.template_filter('format_bytes')
|
||||
def format_bytes(value):
|
||||
try:
|
||||
b = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return value
|
||||
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
||||
if abs(b) < 1024:
|
||||
return f"{b:.1f} {unit}"
|
||||
b /= 1024
|
||||
return f"{b:.1f} PB"
|
||||
|
||||
|
||||
@app.template_filter('format_mb')
|
||||
def format_mb(value):
|
||||
try:
|
||||
mb = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return value
|
||||
if mb >= 1024:
|
||||
return f"{mb / 1024:.1f} GB"
|
||||
return f"{mb} MB"
|
||||
|
||||
|
||||
@app.template_filter('format_uptime')
|
||||
def format_uptime(seconds):
|
||||
try:
|
||||
s = int(seconds)
|
||||
except (TypeError, ValueError):
|
||||
return 'Unknown'
|
||||
days = s // 86400
|
||||
hours = (s % 86400) // 3600
|
||||
if days > 0:
|
||||
return f"{days}d {hours}h"
|
||||
minutes = (s % 3600) // 60
|
||||
return f"{hours}h {minutes}m"
|
||||
|
||||
|
||||
@app.template_filter('usage_color')
|
||||
def usage_color(percent):
|
||||
try:
|
||||
p = float(percent)
|
||||
except (TypeError, ValueError):
|
||||
return '#64748b'
|
||||
if p >= 90:
|
||||
return '#ef4444'
|
||||
if p >= 75:
|
||||
return '#f97316'
|
||||
if p >= 60:
|
||||
return '#eab308'
|
||||
return '#22c55e'
|
||||
|
||||
|
||||
# --- Main ---
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Wait for database
|
||||
for attempt in range(30):
|
||||
try:
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
logger.info("Database ready")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.info("Waiting for database... (%s)", e)
|
||||
time.sleep(2)
|
||||
else:
|
||||
logger.error("Could not connect to database after 30 attempts")
|
||||
exit(1)
|
||||
|
||||
# Start collector thread
|
||||
collector_thread = threading.Thread(target=collector_loop, daemon=True)
|
||||
collector_thread.start()
|
||||
|
||||
app.run(host='0.0.0.0', port=5000, threaded=True)
|
||||
136
app/gather_info.sh
Executable file
136
app/gather_info.sh
Executable file
@@ -0,0 +1,136 @@
|
||||
#!/bin/bash
|
||||
# Gather system information from a remote server
|
||||
# Output format: [section] headers followed by key=value pairs
|
||||
|
||||
echo "[system]"
|
||||
echo "hostname=$(hostname)"
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
echo "os_name=$NAME"
|
||||
echo "os_version=$VERSION_ID"
|
||||
echo "os_pretty=$PRETTY_NAME"
|
||||
fi
|
||||
echo "kernel=$(uname -r)"
|
||||
echo "arch=$(uname -m)"
|
||||
echo "uptime_seconds=$(cut -d' ' -f1 /proc/uptime 2>/dev/null | cut -d. -f1)"
|
||||
|
||||
# Motherboard (readable without root on most systems)
|
||||
echo "board_vendor=$(cat /sys/class/dmi/id/board_vendor 2>/dev/null || echo 'Unknown')"
|
||||
echo "board_name=$(cat /sys/class/dmi/id/board_name 2>/dev/null || echo 'Unknown')"
|
||||
echo "board_version=$(cat /sys/class/dmi/id/board_version 2>/dev/null || echo 'Unknown')"
|
||||
echo "bios_version=$(cat /sys/class/dmi/id/bios_version 2>/dev/null || echo 'Unknown')"
|
||||
echo "bios_date=$(cat /sys/class/dmi/id/bios_date 2>/dev/null || echo 'Unknown')"
|
||||
|
||||
echo "[cpu]"
|
||||
echo "model=$(lscpu 2>/dev/null | grep 'Model name' | sed 's/Model name:[[:space:]]*//')"
|
||||
echo "cores=$(nproc 2>/dev/null || echo 0)"
|
||||
echo "sockets=$(lscpu 2>/dev/null | grep 'Socket(s)' | awk '{print $2}')"
|
||||
echo "threads_per_core=$(lscpu 2>/dev/null | grep 'Thread(s) per core' | awk '{print $2}')"
|
||||
|
||||
# CPU usage - sample /proc/stat with 1 second interval
|
||||
read -r label user1 nice1 system1 idle1 iowait1 irq1 softirq1 steal1 < /proc/stat
|
||||
sleep 1
|
||||
read -r label user2 nice2 system2 idle2 iowait2 irq2 softirq2 steal2 < /proc/stat
|
||||
|
||||
total1=$((user1 + nice1 + system1 + idle1 + iowait1 + irq1 + softirq1 + steal1))
|
||||
total2=$((user2 + nice2 + system2 + idle2 + iowait2 + irq2 + softirq2 + steal2))
|
||||
diff_total=$((total2 - total1))
|
||||
diff_idle=$((idle2 - idle1))
|
||||
if [ $diff_total -gt 0 ]; then
|
||||
usage_x10=$(( (diff_total - diff_idle) * 1000 / diff_total ))
|
||||
whole=$((usage_x10 / 10))
|
||||
frac=$((usage_x10 % 10))
|
||||
echo "usage_percent=${whole}.${frac}"
|
||||
else
|
||||
echo "usage_percent=0.0"
|
||||
fi
|
||||
|
||||
echo "[memory]"
|
||||
total_kb=$(grep MemTotal /proc/meminfo 2>/dev/null | awk '{print $2}')
|
||||
available_kb=$(grep MemAvailable /proc/meminfo 2>/dev/null | awk '{print $2}')
|
||||
if [ -n "$total_kb" ] && [ -n "$available_kb" ]; then
|
||||
used_kb=$((total_kb - available_kb))
|
||||
total_mb=$((total_kb / 1024))
|
||||
used_mb=$((used_kb / 1024))
|
||||
available_mb=$((available_kb / 1024))
|
||||
if [ "$total_kb" -gt 0 ]; then
|
||||
usage_x10=$((used_kb * 1000 / total_kb))
|
||||
whole=$((usage_x10 / 10))
|
||||
frac=$((usage_x10 % 10))
|
||||
echo "usage_percent=${whole}.${frac}"
|
||||
else
|
||||
echo "usage_percent=0.0"
|
||||
fi
|
||||
echo "total_mb=$total_mb"
|
||||
echo "used_mb=$used_mb"
|
||||
echo "available_mb=$available_mb"
|
||||
fi
|
||||
|
||||
# Physical disks
|
||||
lsblk -b -n -o NAME,SIZE,TYPE 2>/dev/null | while read -r name size type; do
|
||||
if [ "$type" = "disk" ]; then
|
||||
echo "[disk:$name]"
|
||||
echo "name=$name"
|
||||
echo "size_bytes=$size"
|
||||
fi
|
||||
done
|
||||
|
||||
# Mounted filesystem usage
|
||||
df -B1 --output=target,size,used,avail,pcent 2>/dev/null | tail -n +2 | while read -r mount total used avail percent; do
|
||||
case "$mount" in
|
||||
/|/home|/var|/tmp|/boot|/data*|/mnt*|/srv*|/opt*)
|
||||
safename=$(echo "$mount" | tr '/' '_')
|
||||
echo "[disk_usage:${safename}]"
|
||||
echo "mount=$mount"
|
||||
echo "total_bytes=$total"
|
||||
echo "used_bytes=$used"
|
||||
echo "available_bytes=$avail"
|
||||
echo "usage_percent=${percent%\%}"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# GPUs
|
||||
gpu_idx=0
|
||||
lspci 2>/dev/null | grep -iE 'vga|3d|display' | while read -r line; do
|
||||
echo "[gpu:$gpu_idx]"
|
||||
echo "description=$line"
|
||||
gpu_idx=$((gpu_idx + 1))
|
||||
done
|
||||
|
||||
# Network interfaces
|
||||
for iface in $(ls /sys/class/net/ 2>/dev/null); do
|
||||
[ "$iface" = "lo" ] && continue
|
||||
|
||||
echo "[net:$iface]"
|
||||
echo "name=$iface"
|
||||
echo "mac=$(cat /sys/class/net/$iface/address 2>/dev/null)"
|
||||
echo "ipv4=$(ip -4 addr show "$iface" 2>/dev/null | grep -oP 'inet \K[0-9.]+' | head -1)"
|
||||
echo "ipv6=$(ip -6 addr show "$iface" 2>/dev/null | grep -oP 'inet6 \K[0-9a-f:]+' | grep -v '^fe80' | head -1)"
|
||||
echo "state=$(cat /sys/class/net/$iface/operstate 2>/dev/null)"
|
||||
|
||||
speed=$(cat /sys/class/net/$iface/speed 2>/dev/null)
|
||||
[ -n "$speed" ] && [ "$speed" != "-1" ] && echo "speed_mbps=$speed"
|
||||
|
||||
driver=$(readlink /sys/class/net/$iface/device/driver 2>/dev/null | xargs basename 2>/dev/null)
|
||||
[ -n "$driver" ] && echo "driver=$driver"
|
||||
done
|
||||
|
||||
echo "[routing]"
|
||||
default_line=$(ip route 2>/dev/null | grep default | head -1)
|
||||
echo "gateway=$(echo "$default_line" | awk '{print $3}')"
|
||||
echo "interface=$(echo "$default_line" | awk '{print $5}')"
|
||||
|
||||
echo "[dns]"
|
||||
grep -E '^nameserver' /etc/resolv.conf 2>/dev/null | awk '{print "server=" $2}'
|
||||
|
||||
echo "[tailscale]"
|
||||
if command -v tailscale &>/dev/null; then
|
||||
echo "installed=true"
|
||||
echo "ipv4=$(tailscale ip -4 2>/dev/null)"
|
||||
echo "hostname=$(tailscale status --self --json 2>/dev/null | grep -o '"DNSName":"[^"]*"' | head -1 | cut -d'"' -f4)"
|
||||
else
|
||||
echo "installed=false"
|
||||
fi
|
||||
|
||||
echo "[end]"
|
||||
5
app/requirements.txt
Normal file
5
app/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
flask==3.1.*
|
||||
flask-sqlalchemy==3.1.*
|
||||
pymysql==1.1.*
|
||||
paramiko==3.5.*
|
||||
cryptography>=43.0
|
||||
318
app/static/style.css
Normal file
318
app/static/style.css
Normal file
@@ -0,0 +1,318 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
header {
|
||||
background: #1e293b;
|
||||
border-bottom: 1px solid #334155;
|
||||
padding: 20px 32px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* --- Group --- */
|
||||
|
||||
.group {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #3b82f6;
|
||||
padding: 8px 0 12px 0;
|
||||
border-bottom: 2px solid #1e3a5f;
|
||||
margin-bottom: 16px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.server-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* --- Server Card --- */
|
||||
|
||||
.server-card {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.server-card:hover {
|
||||
border-color: #475569;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.server-card.expanded {
|
||||
grid-column: 1 / -1;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.server-card.offline .card-summary {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-dot.online {
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 6px rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
|
||||
.status-dot.offline {
|
||||
background: #ef4444;
|
||||
box-shadow: 0 0 6px rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
.server-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: #f1f5f9;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.server-ip {
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 2px;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.server-os {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
margin-bottom: 10px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.offline-label {
|
||||
font-size: 0.8rem;
|
||||
color: #ef4444;
|
||||
font-style: italic;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* --- Usage Bars --- */
|
||||
|
||||
.usage-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.usage-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.usage-label {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
width: 30px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.usage-bar-bg {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: #334155;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.usage-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.5s ease;
|
||||
min-width: 2px;
|
||||
}
|
||||
|
||||
.usage-pct {
|
||||
font-size: 0.7rem;
|
||||
color: #94a3b8;
|
||||
width: 30px;
|
||||
text-align: right;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
/* --- Card Details --- */
|
||||
|
||||
.card-details {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #334155;
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
background: #0f172a;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid #1e3a5f;
|
||||
}
|
||||
|
||||
.detail-section.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.detail-section h4 {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #3b82f6;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.detail-section table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.detail-section td {
|
||||
padding: 3px 8px 3px 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.detail-section td:first-child {
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
width: 1%;
|
||||
}
|
||||
|
||||
.detail-section td:last-child {
|
||||
color: #cbd5e1;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.table-header td {
|
||||
font-weight: 600;
|
||||
color: #94a3b8 !important;
|
||||
border-bottom: 1px solid #334155;
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.disk-pct {
|
||||
font-weight: 600;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #ef4444;
|
||||
font-size: 0.8rem;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.last-updated {
|
||||
margin-top: 12px;
|
||||
font-size: 0.7rem;
|
||||
color: #475569;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* --- Empty State --- */
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 24px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 8px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.empty-state code {
|
||||
background: #1e293b;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* --- Responsive --- */
|
||||
|
||||
@media (max-width: 600px) {
|
||||
header {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.server-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
241
app/templates/index.html
Normal file
241
app/templates/index.html
Normal file
@@ -0,0 +1,241 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="refresh" content="60">
|
||||
<title>Infrastructure Map</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Infrastructure Map</h1>
|
||||
<span class="subtitle">Auto-refreshes every 60s</span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{% for group_name, servers in groups.items() %}
|
||||
<section class="group">
|
||||
<h2 class="group-header">{{ group_name }}</h2>
|
||||
<div class="server-grid">
|
||||
{% for server in servers %}
|
||||
{% set d = server.details or {} %}
|
||||
{% set sys = d.get('system', {}) if d.get('system') else {} %}
|
||||
{% set cpu = d.get('cpu', {}) if d.get('cpu') else {} %}
|
||||
{% set mem = d.get('memory', {}) if d.get('memory') else {} %}
|
||||
{% set cpu_pct = cpu.get('usage_percent', '0')|float %}
|
||||
{% set mem_pct = mem.get('usage_percent', '0')|float %}
|
||||
{% set disk_usages = d.get('disk_usage', []) if d.get('disk_usage') else [] %}
|
||||
{% set root_disk = namespace(pct=0.0) %}
|
||||
{% for du in disk_usages %}
|
||||
{% if du.get('mount') == '/' %}
|
||||
{% set root_disk.pct = du.get('usage_percent', '0')|float %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if root_disk.pct == 0.0 and disk_usages|length > 0 %}
|
||||
{% set root_disk.pct = disk_usages[0].get('usage_percent', '0')|float %}
|
||||
{% endif %}
|
||||
|
||||
<div class="server-card {% if not server.is_online %}offline{% endif %}"
|
||||
onclick="toggleDetails(this)">
|
||||
<div class="card-summary">
|
||||
<div class="card-header">
|
||||
<span class="status-dot {% if server.is_online %}online{% else %}offline{% endif %}"></span>
|
||||
<span class="server-name">{{ server.hostname }}</span>
|
||||
</div>
|
||||
<div class="server-ip">{{ server.primary_ip or 'No IP' }}</div>
|
||||
<div class="server-os">{{ sys.get('os_pretty', '') }}</div>
|
||||
|
||||
{% if server.is_online %}
|
||||
<div class="usage-bars">
|
||||
<div class="usage-row">
|
||||
<span class="usage-label">CPU</span>
|
||||
<div class="usage-bar-bg">
|
||||
<div class="usage-bar-fill" style="width: {{ cpu_pct }}%; background: {{ cpu_pct|usage_color }};"></div>
|
||||
</div>
|
||||
<span class="usage-pct">{{ '%.0f'|format(cpu_pct) }}%</span>
|
||||
</div>
|
||||
<div class="usage-row">
|
||||
<span class="usage-label">RAM</span>
|
||||
<div class="usage-bar-bg">
|
||||
<div class="usage-bar-fill" style="width: {{ mem_pct }}%; background: {{ mem_pct|usage_color }};"></div>
|
||||
</div>
|
||||
<span class="usage-pct">{{ '%.0f'|format(mem_pct) }}%</span>
|
||||
</div>
|
||||
<div class="usage-row">
|
||||
<span class="usage-label">DISK</span>
|
||||
<div class="usage-bar-bg">
|
||||
<div class="usage-bar-fill" style="width: {{ root_disk.pct }}%; background: {{ root_disk.pct|usage_color }};"></div>
|
||||
</div>
|
||||
<span class="usage-pct">{{ '%.0f'|format(root_disk.pct) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="offline-label">Unreachable</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Expanded Details -->
|
||||
<div class="card-details" style="display: none;">
|
||||
<div class="details-grid">
|
||||
<!-- System Info -->
|
||||
<div class="detail-section">
|
||||
<h4>System</h4>
|
||||
<table>
|
||||
<tr><td>Hostname</td><td>{{ sys.get('hostname', '-') }}</td></tr>
|
||||
<tr><td>OS</td><td>{{ sys.get('os_pretty', '-') }}</td></tr>
|
||||
<tr><td>Kernel</td><td>{{ sys.get('kernel', '-') }}</td></tr>
|
||||
<tr><td>Arch</td><td>{{ sys.get('arch', '-') }}</td></tr>
|
||||
<tr><td>Uptime</td><td>{{ sys.get('uptime_seconds', '')|format_uptime }}</td></tr>
|
||||
<tr><td>Board</td><td>{{ sys.get('board_vendor', '') }} {{ sys.get('board_name', '') }}</td></tr>
|
||||
<tr><td>Board Version</td><td>{{ sys.get('board_version', '-') }}</td></tr>
|
||||
<tr><td>BIOS</td><td>{{ sys.get('bios_version', '-') }} ({{ sys.get('bios_date', '-') }})</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- CPU -->
|
||||
<div class="detail-section">
|
||||
<h4>CPU</h4>
|
||||
<table>
|
||||
<tr><td>Model</td><td>{{ cpu.get('model', '-') }}</td></tr>
|
||||
<tr><td>Cores</td><td>{{ cpu.get('cores', '-') }}</td></tr>
|
||||
<tr><td>Sockets</td><td>{{ cpu.get('sockets', '-') }}</td></tr>
|
||||
<tr><td>Threads/Core</td><td>{{ cpu.get('threads_per_core', '-') }}</td></tr>
|
||||
<tr><td>Usage</td><td>{{ cpu.get('usage_percent', '-') }}%</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Memory -->
|
||||
<div class="detail-section">
|
||||
<h4>Memory</h4>
|
||||
<table>
|
||||
<tr><td>Total</td><td>{{ mem.get('total_mb', '')|format_mb }}</td></tr>
|
||||
<tr><td>Used</td><td>{{ mem.get('used_mb', '')|format_mb }}</td></tr>
|
||||
<tr><td>Available</td><td>{{ mem.get('available_mb', '')|format_mb }}</td></tr>
|
||||
<tr><td>Usage</td><td>{{ mem.get('usage_percent', '-') }}%</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- GPUs -->
|
||||
{% set gpus = d.get('gpu', []) if d.get('gpu') else [] %}
|
||||
{% if gpus %}
|
||||
<div class="detail-section">
|
||||
<h4>GPUs</h4>
|
||||
<table>
|
||||
{% for gpu in gpus %}
|
||||
<tr><td>GPU {{ loop.index0 }}</td><td>{{ gpu.get('description', '-') }}</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Network -->
|
||||
<div class="detail-section wide">
|
||||
<h4>Network Interfaces</h4>
|
||||
<table>
|
||||
<tr class="table-header"><td>Interface</td><td>IPv4</td><td>IPv6</td><td>MAC</td><td>State</td><td>Speed</td><td>Driver</td></tr>
|
||||
{% set nets = d.get('net', []) if d.get('net') else [] %}
|
||||
{% for iface in nets %}
|
||||
<tr>
|
||||
<td>{{ iface.get('name', iface.get('_name', '-')) }}</td>
|
||||
<td>{{ iface.get('ipv4', '-') or '-' }}</td>
|
||||
<td>{{ iface.get('ipv6', '-') or '-' }}</td>
|
||||
<td>{{ iface.get('mac', '-') or '-' }}</td>
|
||||
<td>{{ iface.get('state', '-') }}</td>
|
||||
<td>{% if iface.get('speed_mbps') %}{{ iface.speed_mbps }} Mbps{% else %}-{% endif %}</td>
|
||||
<td>{{ iface.get('driver', '-') or '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
{% set routing = d.get('routing', {}) if d.get('routing') else {} %}
|
||||
{% set dns = d.get('dns', {}) if d.get('dns') else {} %}
|
||||
{% set ts = d.get('tailscale', {}) if d.get('tailscale') else {} %}
|
||||
<table style="margin-top: 8px;">
|
||||
<tr><td>Gateway</td><td>{{ routing.get('gateway', '-') }} ({{ routing.get('interface', '-') }})</td></tr>
|
||||
<tr><td>DNS</td><td>
|
||||
{% set servers_val = dns.get('server', '-') %}
|
||||
{% if servers_val is string %}{{ servers_val }}{% elif servers_val is iterable %}{{ servers_val|join(', ') }}{% else %}-{% endif %}
|
||||
</td></tr>
|
||||
{% if ts.get('installed') == 'true' %}
|
||||
<tr><td>Tailscale IP</td><td>{{ ts.get('ipv4', '-') }}</td></tr>
|
||||
<tr><td>Tailscale Name</td><td>{{ ts.get('hostname', '-') }}</td></tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Disks -->
|
||||
<div class="detail-section wide">
|
||||
<h4>Storage</h4>
|
||||
{% set disks = d.get('disk', []) if d.get('disk') else [] %}
|
||||
{% if disks %}
|
||||
<table>
|
||||
<tr class="table-header"><td>Device</td><td>Size</td></tr>
|
||||
{% for disk in disks %}
|
||||
<tr>
|
||||
<td>{{ disk.get('name', '-') }}</td>
|
||||
<td>{{ disk.get('size_bytes', '')|format_bytes }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if disk_usages %}
|
||||
<table style="margin-top: 8px;">
|
||||
<tr class="table-header"><td>Mount</td><td>Total</td><td>Used</td><td>Available</td><td>Usage</td></tr>
|
||||
{% for du in disk_usages %}
|
||||
<tr>
|
||||
<td>{{ du.get('mount', '-') }}</td>
|
||||
<td>{{ du.get('total_bytes', '')|format_bytes }}</td>
|
||||
<td>{{ du.get('used_bytes', '')|format_bytes }}</td>
|
||||
<td>{{ du.get('available_bytes', '')|format_bytes }}</td>
|
||||
<td>
|
||||
<span class="disk-pct" style="color: {{ du.get('usage_percent', '0')|float|usage_color }}">
|
||||
{{ du.get('usage_percent', '-') }}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if d.get('error') %}
|
||||
<div class="detail-section wide">
|
||||
<h4>Error</h4>
|
||||
<p class="error-text">{{ d.error }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="last-updated">
|
||||
Last collected: {{ server.last_collected.strftime('%Y-%m-%d %H:%M:%S UTC') if server.last_collected else 'Never' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<h2>No servers configured</h2>
|
||||
<p>Edit <code>infrastructure.conf</code> to add your servers.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</main>
|
||||
|
||||
<script>
|
||||
function toggleDetails(card) {
|
||||
const details = card.querySelector('.card-details');
|
||||
const isOpen = details.style.display !== 'none';
|
||||
// Close all other details
|
||||
document.querySelectorAll('.card-details').forEach(d => d.style.display = 'none');
|
||||
document.querySelectorAll('.server-card').forEach(c => c.classList.remove('expanded'));
|
||||
if (!isOpen) {
|
||||
details.style.display = 'block';
|
||||
card.classList.add('expanded');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
12
backup.sh
Executable file
12
backup.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
source "${AGENT_PATH}/common.sh"
|
||||
_check_required_env_vars "CONTAINER_NAME" "DATA_VOLUME" "BACKUP_FILE" "TEMP_DIR"
|
||||
|
||||
mkdir -p "${TEMP_DIR}/backup"
|
||||
|
||||
docker run --rm -v "${DATA_VOLUME}":/source -v "${TEMP_DIR}/backup":/backup \
|
||||
debian bash -c "tar -czf /backup/data.tgz -C /source ." || _die "Failed to backup data volume"
|
||||
|
||||
tar -czf "${BACKUP_FILE}" -C "${TEMP_DIR}/backup" . || _die "Failed to create backup archive"
|
||||
|
||||
echo "Backup completed successfully"
|
||||
20
config/infrastructure.conf
Normal file
20
config/infrastructure.conf
Normal file
@@ -0,0 +1,20 @@
|
||||
# Infrastructure Map Configuration
|
||||
#
|
||||
# Format:
|
||||
# GROUPNAME
|
||||
# USERNAME@SERVERNAME
|
||||
# USERNAME@SERVERNAME
|
||||
# ...
|
||||
#
|
||||
# GROUPNAME is a freeform label to group servers together.
|
||||
# USERNAME@SERVERNAME is the SSH user and hostname/IP to connect to.
|
||||
#
|
||||
# Example:
|
||||
#
|
||||
# Production
|
||||
# root@prod-web-01
|
||||
# root@prod-db-01
|
||||
#
|
||||
# Development
|
||||
# deploy@dev-01
|
||||
# deploy@dev-02
|
||||
18
config/service.env
Normal file
18
config/service.env
Normal file
@@ -0,0 +1,18 @@
|
||||
CONTAINER_NAME=infmap
|
||||
SSH_USER="root"
|
||||
|
||||
# Web UI port (HTTP)
|
||||
WEB_PORT=8080
|
||||
|
||||
# Path to SSH private key on the host (used to connect to monitored servers)
|
||||
SSH_KEY_PATH=/root/.ssh/id_ed25519
|
||||
|
||||
# Collection settings
|
||||
COLLECTION_INTERVAL=300
|
||||
MAX_CONCURRENT_SSH=5
|
||||
|
||||
# MySQL credentials
|
||||
MYSQL_ROOT_PASSWORD=changeme_root
|
||||
MYSQL_DATABASE=infmap
|
||||
MYSQL_USER=infmap
|
||||
MYSQL_PASSWORD=changeme
|
||||
13
destroy.sh
Executable file
13
destroy.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
source "${AGENT_PATH}/common.sh"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
_check_required_env_vars "CONTAINER_NAME" "DATA_VOLUME"
|
||||
|
||||
echo "WARNING: This will PERMANENTLY DELETE all data for ${CONTAINER_NAME}"
|
||||
|
||||
docker compose -p "${CONTAINER_NAME}" down 2>/dev/null || true
|
||||
_remove_volume "$DATA_VOLUME"
|
||||
|
||||
echo "Service and all data destroyed"
|
||||
40
docker-compose.yml
Normal file
40
docker-compose.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
services:
|
||||
db:
|
||||
image: mysql:8
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE}
|
||||
MYSQL_USER: ${MYSQL_USER}
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
app:
|
||||
build: ./app
|
||||
ports:
|
||||
- "${WEB_PORT}:5000"
|
||||
environment:
|
||||
MYSQL_HOST: db
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE}
|
||||
MYSQL_USER: ${MYSQL_USER}
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
|
||||
COLLECTION_INTERVAL: ${COLLECTION_INTERVAL}
|
||||
MAX_CONCURRENT_SSH: ${MAX_CONCURRENT_SSH}
|
||||
volumes:
|
||||
- ${SSH_KEY_PATH}:/app/ssh_key:ro
|
||||
- ${CONFIG_PATH}/infrastructure.conf:/app/infrastructure.conf:ro
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
external: true
|
||||
name: ${CONTAINER_NAME}_db_data
|
||||
10
install-pre.sh
Executable file
10
install-pre.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
source "${AGENT_PATH}/common.sh"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
_check_required_env_vars "CONTAINER_NAME"
|
||||
|
||||
docker compose -p "${CONTAINER_NAME}" pull || echo "Warning: pre-pull failed, install.sh will retry"
|
||||
|
||||
echo "Pre-install complete"
|
||||
22
install.sh
Executable file
22
install.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
source "${AGENT_PATH}/common.sh"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
_check_required_env_vars "CONTAINER_NAME" "WEB_PORT" "SSH_KEY_PATH" "MYSQL_ROOT_PASSWORD" "MYSQL_DATABASE" "MYSQL_USER" "MYSQL_PASSWORD" "DATA_VOLUME"
|
||||
_check_docker_installed || _die "Docker test failed"
|
||||
|
||||
# Check SSH key exists
|
||||
[ -f "${SSH_KEY_PATH}" ] || _die "SSH key not found at ${SSH_KEY_PATH}"
|
||||
|
||||
# Check infrastructure.conf exists
|
||||
[ -f "${CONFIG_PATH}/infrastructure.conf" ] || _die "infrastructure.conf not found at ${CONFIG_PATH}/infrastructure.conf"
|
||||
|
||||
# Create data volume
|
||||
docker volume create "${DATA_VOLUME}" 2>/dev/null || true
|
||||
|
||||
bash ./stop.sh || true
|
||||
docker compose -p "${CONTAINER_NAME}" up -d --build || _die "Failed to start services"
|
||||
|
||||
echo "Installation of ${CONTAINER_NAME} complete"
|
||||
echo "Web UI available at http://localhost:${WEB_PORT}"
|
||||
8
logs.sh
Executable file
8
logs.sh
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
source "${AGENT_PATH}/common.sh"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
_check_required_env_vars "CONTAINER_NAME"
|
||||
|
||||
docker compose -p "${CONTAINER_NAME}" logs "$@"
|
||||
5
ports.sh
Executable file
5
ports.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
source "${AGENT_PATH}/common.sh"
|
||||
_check_required_env_vars "WEB_PORT"
|
||||
|
||||
echo "${WEB_PORT}"
|
||||
13
restore.sh
Executable file
13
restore.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
source "${AGENT_PATH}/common.sh"
|
||||
_check_required_env_vars "CONTAINER_NAME" "DATA_VOLUME" "BACKUP_FILE" "TEMP_DIR"
|
||||
|
||||
mkdir -p "${TEMP_DIR}/restore"
|
||||
tar -xzf "${BACKUP_FILE}" -C "${TEMP_DIR}/restore" || _die "Failed to extract backup archive"
|
||||
|
||||
docker volume rm "${DATA_VOLUME}" 2>/dev/null || true
|
||||
docker volume create "${DATA_VOLUME}" || _die "Failed to create data volume"
|
||||
docker run --rm -v "${DATA_VOLUME}":/target -v "${TEMP_DIR}/restore":/backup \
|
||||
debian bash -c "tar -xzf /backup/data.tgz -C /target" || _die "Failed to restore data volume"
|
||||
|
||||
echo "Restore completed successfully"
|
||||
11
start.sh
Executable file
11
start.sh
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
source "${AGENT_PATH}/common.sh"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
_check_required_env_vars "CONTAINER_NAME" "DATA_VOLUME"
|
||||
|
||||
docker volume create "${DATA_VOLUME}" 2>/dev/null || true
|
||||
docker compose -p "${CONTAINER_NAME}" up -d || _die "Failed to start services"
|
||||
|
||||
echo "${CONTAINER_NAME} started"
|
||||
23
status.sh
Executable file
23
status.sh
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
source "${AGENT_PATH}/common.sh"
|
||||
_check_required_env_vars "CONTAINER_NAME"
|
||||
|
||||
APP_CONTAINER="${CONTAINER_NAME}-app-1"
|
||||
|
||||
if ! docker ps -a --format "{{.Names}}" | grep -q "^${APP_CONTAINER}$"; then
|
||||
echo "Unknown"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
STATE=$(docker inspect -f '{{.State.Status}}' "$APP_CONTAINER" 2>/dev/null)
|
||||
case "$STATE" in
|
||||
running)
|
||||
echo "Running"
|
||||
;;
|
||||
exited|stopped)
|
||||
echo "Stopped"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown"
|
||||
;;
|
||||
esac
|
||||
10
stop.sh
Executable file
10
stop.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
source "${AGENT_PATH}/common.sh"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
_check_required_env_vars "CONTAINER_NAME"
|
||||
|
||||
docker compose -p "${CONTAINER_NAME}" down 2>/dev/null || true
|
||||
|
||||
echo "${CONTAINER_NAME} stopped"
|
||||
5
template_info.env
Normal file
5
template_info.env
Normal file
@@ -0,0 +1,5 @@
|
||||
REQUIRES_HOST_ROOT=false
|
||||
REQUIRES_DOCKER=true
|
||||
REQUIRES_DOCKER_ROOT=false
|
||||
|
||||
DATA_VOLUME="${CONTAINER_NAME}_db_data"
|
||||
12
uninstall.sh
Executable file
12
uninstall.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
source "${AGENT_PATH}/common.sh"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
_check_required_env_vars "CONTAINER_NAME"
|
||||
|
||||
docker compose -p "${CONTAINER_NAME}" down || _die "Failed to stop services"
|
||||
|
||||
# DO NOT remove volumes here! Data is preserved for reinstallation.
|
||||
echo "Uninstallation of ${CONTAINER_NAME} complete"
|
||||
echo "Note: Data volumes have been preserved. To remove all data, use destroy.sh"
|
||||
Reference in New Issue
Block a user