feat: telemetry dashboard v1.36.0 - live ops command centre

This commit is contained in:
jae 2026-04-19 23:55:51 +00:00
parent f7097e8954
commit bacd671149
7 changed files with 2236 additions and 18 deletions

View file

@ -17,6 +17,13 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
app = Flask(__name__) app = Flask(__name__)
CORS(app) CORS(app)
# Register telemetry blueprint (provides /api/telemetry/* endpoints)
try:
from telemetry_routes import telemetry_bp
app.register_blueprint(telemetry_bp)
except Exception as _tb_err:
print(f'[WARN] telemetry_routes not loaded: {_tb_err}')
DATA_DIR = Path(__file__).parent / 'data' DATA_DIR = Path(__file__).parent / 'data'
JWT_SECRET = 'jaeswift-hud-s3cr3t-2026!x' JWT_SECRET = 'jaeswift-hud-s3cr3t-2026!x'
ADMIN_USER = 'jae' ADMIN_USER = 'jae'

View file

@ -1,6 +1,36 @@
{ {
"site": "jaeswift.xyz", "site": "jaeswift.xyz",
"entries": [ "entries": [
{
"version": "1.36.0",
"date": "19/04/2026",
"category": "FEATURE",
"title": "TELEMETRY Dashboard — Live Ops Command Centre",
"changes": [
"New /hq/telemetry page replacing placeholder — full live ops dashboard with boot sequence animation, CRT aesthetic, real-time metrics",
"Backend blueprint api/telemetry_routes.py — 6 new endpoints: /api/telemetry/overview, /history, /nginx-tail, /geo, /alerts, /visitors, /commits",
"System metrics: CPU gauge + 24h sparkline, memory gauge + sparkline, per-mount disk ASCII bars, network RX/TX rates + history graph, uptime, load avg, kernel",
"Services panel: systemctl state for jaeswift-api, matty-lol, nginx, filebrowser, caddy, n8n, docker, ssh — plus PM2 processes — with uptime, mem, CPU per service",
"Cron tracker: schedule + last run + status (ok/fail/unknown) for contraband_sync, awesomelist_sync, govdomains_sync, sitrep, telemetry_snapshot — expandable log tails",
"Nginx 24h analytics: total requests, bandwidth, avg RPM, top 10 pages, top 10 IPs (masked), 4xx/5xx counts, avg response time — 60s cache",
"Security panel: fail2ban jails + ban counts, UFW rules, SSH bruteforce attempts 24h, last reboot timestamp",
"SSL expiry tracker: per-domain days-remaining with colour coding (red <14d, amber <30d, green otherwise) for jaeswift.xyz, git, files, plex, agentzero",
"Stack inventory: auto-detected versions of Python, Node, nginx, Docker, ffmpeg, kernel, OS — cached at startup",
"Git activity panel: repo name, last commit SHA, message, timestamp, dirty flag for /var/www/jaeswift-homepage",
"Geo-distribution: parses nginx IPs through GeoLite2 mmdb (if installed) — horizontal bar chart of top 15 countries by traffic, graceful empty state if mmdb missing",
"Live nginx tail widget: last 20 requests auto-scrolling every 5s with fade-in animation, colour-coded status codes, masked IPs",
"Alert engine: real-time /api/telemetry/alerts computes CPU/mem/disk/service/cron/5xx/SSL/SSH thresholds — red/amber/info levels — scrolling marquee banner on page",
"Visitor counter: active unique IPs in last 5min + req/min — animated ONLINE NOW strong number",
"Burn rate panel: free disk GB, 24h bandwidth totals (↑/↓), load avg breakdown, kernel@hostname",
"Deployment ticker: horizontal marquee of last 10 git commits (SHA + message + ago)",
"Boot animation: 1.5s cinematic sequence — INITIALISING → SECURE CHANNEL → DECRYPTING → AUTHORISED — with progress bar, fades out to reveal grid",
"Alarm sound toggle: Web Audio API generates beep on new alert (urgent=880Hz for red, 440Hz for amber), default OFF, speaker icon top-right",
"Blinking LIVE indicator showing last-sync seconds, dot colour shifts green→amber→red as staleness grows",
"Mobile responsive — all 7 rows stack to single column below 768px, alerts shrink, grid reflows",
"New api/telemetry_snapshot.py ring-buffer cron (every 5min) appends CPU/mem/net to api/data/telemetry_history.json (288 points = 24h)",
"New styles: css/telemetry.css (~620 lines) with full dashboard grid, canvas gauges, ASCII disk bars, terminal tail, geo chart, marquee banners — all theming existing var(--tm-green/amber/red/sol) CRT scheme"
]
},
{ {
"version": "1.35.0", "version": "1.35.0",
"date": "19/04/2026", "date": "19/04/2026",

618
api/telemetry_routes.py Normal file
View file

@ -0,0 +1,618 @@
#!/usr/bin/env python3
"""JAESWIFT Telemetry Dashboard Endpoints
Provides /api/telemetry/* endpoints for the live ops dashboard at /hq/telemetry
"""
import json, os, time, subprocess, re, socket, platform
from datetime import datetime, timezone
from pathlib import Path
from functools import lru_cache
from collections import Counter, defaultdict
from concurrent.futures import ThreadPoolExecutor
from flask import Blueprint, jsonify
telemetry_bp = Blueprint('telemetry', __name__)
DATA_DIR = Path(__file__).parent / 'data'
HISTORY_FILE = DATA_DIR / 'telemetry_history.json'
NGINX_LOG = '/var/log/nginx/access.log'
# ─── In-memory state ────────────────────────────────
_prev_net = {'rx': 0, 'tx': 0, 'ts': 0}
_nginx_cache = {'ts': 0, 'data': None}
_geo_cache = {'ts': 0, 'data': []}
_stack_cache = None
def shell(cmd, timeout=5):
try:
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
return r.stdout.strip()
except Exception:
return ''
def iso_now():
return datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
# ─── SYSTEM METRICS ─────────────────────────────────
def gather_system():
load_raw = shell("cat /proc/loadavg")
load_parts = load_raw.split()
load_1 = float(load_parts[0]) if load_parts else 0.0
load_5 = float(load_parts[1]) if len(load_parts) > 1 else 0.0
load_15 = float(load_parts[2]) if len(load_parts) > 2 else 0.0
ncpu_raw = shell("nproc")
ncpu = int(ncpu_raw) if ncpu_raw.isdigit() else 1
cpu_pct = round(min(load_1 / max(ncpu, 1) * 100, 100), 1)
mem_raw = shell("free -b | awk '/Mem:/{printf \"%d %d %d\", $2,$3,$7}'").split()
if len(mem_raw) >= 3:
mem_total = int(mem_raw[0])
mem_used = int(mem_raw[1])
mem_pct = round(mem_used / mem_total * 100, 1) if mem_total else 0
else:
mem_total = mem_used = 0
mem_pct = 0
disk_raw = shell("df -BG --output=target,used,size,pcent -x tmpfs -x devtmpfs -x squashfs -x overlay 2>/dev/null | tail -n +2")
disks = []
for line in disk_raw.split('\n'):
parts = line.split()
if len(parts) >= 4 and parts[0].startswith('/'):
try:
used_gb = int(parts[1].rstrip('G'))
total_gb = int(parts[2].rstrip('G'))
pct = int(parts[3].rstrip('%'))
disks.append({'mount': parts[0], 'used_gb': used_gb, 'total_gb': total_gb, 'pct': pct})
except Exception:
pass
# Network rate
net_raw = shell("cat /proc/net/dev | awk '/eth0|ens|enp/{print $1,$2,$10; exit}'")
net_parts = net_raw.split()
rx_bps = tx_bps = 0
if len(net_parts) >= 3:
try:
rx = int(net_parts[1]); tx = int(net_parts[2])
now = time.time()
global _prev_net
if _prev_net['ts'] and now > _prev_net['ts']:
dt = now - _prev_net['ts']
rx_bps = max(0, int((rx - _prev_net['rx']) / dt))
tx_bps = max(0, int((tx - _prev_net['tx']) / dt))
_prev_net = {'rx': rx, 'tx': tx, 'ts': now}
except Exception:
pass
up_raw = shell("cat /proc/uptime")
uptime = float(up_raw.split()[0]) if up_raw else 0
return {
'cpu_percent': cpu_pct,
'mem_percent': mem_pct,
'mem_used_bytes': mem_used,
'mem_total_bytes': mem_total,
'disk_per_mount': disks,
'net_rx_bps': rx_bps,
'net_tx_bps': tx_bps,
'uptime_seconds': uptime,
'load_1': load_1,
'load_5': load_5,
'load_15': load_15,
'ncpu': ncpu,
'kernel': shell("uname -r"),
'hostname': socket.gethostname(),
}
# ─── SERVICES ───────────────────────────────────────
SYSTEMD_SERVICES = ['jaeswift-api', 'matty-lol', 'nginx', 'filebrowser', 'caddy', 'n8n', 'docker', 'ssh']
def check_systemd(svc):
status = shell(f"systemctl is-active {svc} 2>/dev/null") or 'unknown'
result = {'name': svc, 'status': 'up' if status == 'active' else ('down' if status in ('inactive', 'failed') else 'unknown'), 'uptime_seconds': 0, 'memory_mb': 0, 'cpu_percent': 0.0, 'type': 'systemd'}
if result['status'] != 'up':
return result
ts = shell(f"systemctl show -p ActiveEnterTimestamp --value {svc} 2>/dev/null")
if ts and ts != 'n/a':
try:
dt = datetime.strptime(ts.split(' UTC')[0].split(' GMT')[0].split(' BST')[0], '%a %Y-%m-%d %H:%M:%S')
result['uptime_seconds'] = max(0, int(time.time() - dt.timestamp()))
except Exception:
pass
pid = shell(f"systemctl show -p MainPID --value {svc} 2>/dev/null")
if pid and pid.isdigit() and pid != '0':
ps = shell(f"ps -o rss,%cpu -p {pid} --no-headers 2>/dev/null")
parts = ps.split()
if len(parts) >= 2:
try:
result['memory_mb'] = round(int(parts[0]) / 1024, 1)
result['cpu_percent'] = float(parts[1])
except Exception:
pass
return result
def check_pm2():
out = shell("pm2 jlist 2>/dev/null")
results = []
if not out:
return results
try:
data = json.loads(out)
for proc in data:
status = proc.get('pm2_env', {}).get('status', 'unknown')
results.append({
'name': f"pm2:{proc.get('name', 'unknown')}",
'status': 'up' if status == 'online' else 'down',
'uptime_seconds': max(0, int((time.time() * 1000 - proc.get('pm2_env', {}).get('pm_uptime', 0)) / 1000)) if status == 'online' else 0,
'memory_mb': round(proc.get('monit', {}).get('memory', 0) / 1024 / 1024, 1),
'cpu_percent': float(proc.get('monit', {}).get('cpu', 0)),
'type': 'pm2',
})
except Exception:
pass
return results
def gather_services():
results = []
with ThreadPoolExecutor(max_workers=8) as ex:
for r in ex.map(check_systemd, SYSTEMD_SERVICES):
results.append(r)
results.extend(check_pm2())
return results
# ─── CRONS ──────────────────────────────────────────
CRON_JOBS = [
{'name': 'contraband_sync', 'schedule': '0 3 * * 0', 'log': '/var/log/contraband_sync.log'},
{'name': 'awesomelist_sync', 'schedule': '0 4 * * 0', 'log': '/var/log/awesomelist_sync.log'},
{'name': 'govdomains_sync', 'schedule': '0 5 * * 0', 'log': '/var/log/govdomains_sync.log'},
{'name': 'sitrep_generator', 'schedule': '0 7 * * *', 'log': '/var/log/sitrep.log'},
{'name': 'telemetry_snapshot', 'schedule': '*/5 * * * *', 'log': '/var/log/telemetry_snapshot.log'},
]
def gather_crons():
out = []
for job in CRON_JOBS:
entry = {'name': job['name'], 'schedule': job['schedule'], 'last_run_iso': None, 'last_status': 'unknown', 'last_output_tail': ''}
p = Path(job['log'])
if p.exists():
try:
mtime = p.stat().st_mtime
entry['last_run_iso'] = datetime.fromtimestamp(mtime, tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
tail = shell(f"tail -n 5 {job['log']} 2>/dev/null")
entry['last_output_tail'] = tail[-500:] if tail else ''
low = tail.lower()
if any(w in low for w in ['error', 'fail', 'traceback', 'exception']):
entry['last_status'] = 'fail'
elif tail:
entry['last_status'] = 'ok'
except Exception:
pass
out.append(entry)
return out
# ─── NGINX 24h STATS ────────────────────────────────
def parse_nginx_24h():
now = time.time()
if _nginx_cache['data'] and (now - _nginx_cache['ts']) < 60:
return _nginx_cache['data']
result = {
'total_requests': 0, 'total_bytes': 0, 'avg_rpm': 0,
'top_pages': [], 'top_ips_redacted': [],
'status_4xx_count': 0, 'status_5xx_count': 0, 'error_rate_pct': 0.0,
'response_time_avg_ms': 0,
}
if not os.path.exists(NGINX_LOG):
_nginx_cache.update({'ts': now, 'data': result})
return result
cutoff = now - 86400
pages = Counter()
ips = Counter()
total_bytes = 0
total_reqs = 0
s4 = s5 = 0
rt_sum = 0.0
rt_count = 0
# Parse last N lines (tail is efficient)
log_data = shell(f"tail -n 50000 {NGINX_LOG} 2>/dev/null", timeout=10)
# Regex for combined format + optional request_time at end
pat = re.compile(
r'^(\S+) \S+ \S+ \[([^\]]+)\] "(\S+) (\S+) \S+" (\d+) (\d+) "[^"]*" "[^"]*"(?: (\S+))?'
)
for line in log_data.split('\n'):
m = pat.match(line)
if not m:
continue
try:
ts_str = m.group(2)
# format: 19/Apr/2026:12:34:56 +0000
dt = datetime.strptime(ts_str.split()[0], '%d/%b/%Y:%H:%M:%S')
ts = dt.replace(tzinfo=timezone.utc).timestamp()
if ts < cutoff:
continue
ip = m.group(1)
path = m.group(4)
status = int(m.group(5))
size = int(m.group(6))
rt = m.group(7)
total_reqs += 1
total_bytes += size
# Skip noisy paths
if len(path) <= 200:
pages[path[:120]] += 1
# Mask last octet
parts = ip.split('.')
if len(parts) == 4:
ip_masked = f"{parts[0]}.{parts[1]}.{parts[2]}.xxx"
else:
ip_masked = ip[:20] + ''
ips[ip_masked] += 1
if 400 <= status < 500: s4 += 1
elif status >= 500: s5 += 1
if rt and rt != '-':
try:
rt_sum += float(rt) * 1000
rt_count += 1
except Exception:
pass
except Exception:
continue
result['total_requests'] = total_reqs
result['total_bytes'] = total_bytes
result['avg_rpm'] = round(total_reqs / 1440, 1) if total_reqs else 0
result['top_pages'] = [{'path': p, 'count': c} for p, c in pages.most_common(10)]
result['top_ips_redacted'] = [{'ip': i, 'count': c} for i, c in ips.most_common(10)]
result['status_4xx_count'] = s4
result['status_5xx_count'] = s5
result['error_rate_pct'] = round((s4 + s5) / total_reqs * 100, 2) if total_reqs else 0
result['response_time_avg_ms'] = round(rt_sum / rt_count, 1) if rt_count else 0
_nginx_cache.update({'ts': now, 'data': result})
return result
# ─── SECURITY ───────────────────────────────────────
def gather_security():
jails = []
total_bans = 0
f2b_status = shell("fail2ban-client status 2>/dev/null")
jail_list = []
m = re.search(r'Jail list:\s*(.+)', f2b_status)
if m:
jail_list = [j.strip() for j in m.group(1).split(',') if j.strip()]
for jail in jail_list:
js = shell(f"fail2ban-client status {jail} 2>/dev/null")
banned = 0
m2 = re.search(r'Currently banned:\s*(\d+)', js)
if m2:
banned = int(m2.group(1))
total_bans += banned
jails.append({'name': jail, 'banned': banned})
ufw_out = shell("ufw status numbered 2>/dev/null")
ufw_rules = len([l for l in ufw_out.split('\n') if re.match(r'^\s*\[\s*\d+', l)])
ssh_fails = shell("journalctl -u ssh --since '24 hours ago' --no-pager 2>/dev/null | grep -c 'Failed password'", timeout=8)
try:
ssh_attempts = int(ssh_fails) if ssh_fails.isdigit() else 0
except Exception:
ssh_attempts = 0
reboot = shell("who -b 2>/dev/null | awk '{print $3,$4}'")
reboot_iso = None
try:
dt = datetime.strptime(reboot.strip(), '%Y-%m-%d %H:%M')
reboot_iso = dt.replace(tzinfo=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
except Exception:
pass
return {
'fail2ban_jails': jails,
'fail2ban_bans_total': total_bans,
'ufw_rules_count': ufw_rules,
'ssh_attempts_24h': ssh_attempts,
'last_reboot_iso': reboot_iso,
}
# ─── SSL ────────────────────────────────────────────
SSL_DOMAINS = ['jaeswift.xyz', 'git.jaeswift.xyz', 'files.jaeswift.xyz', 'plex.jaeswift.xyz', 'agentzero.jaeswift.xyz']
def check_ssl(domain):
try:
out = shell(
f"echo | timeout 4 openssl s_client -servername {domain} -connect {domain}:443 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null",
timeout=6
)
m = re.search(r'notAfter=(.+)', out)
if m:
dt = datetime.strptime(m.group(1).strip(), '%b %d %H:%M:%S %Y %Z')
days_left = int((dt.timestamp() - time.time()) / 86400)
return {'domain': domain, 'expires_iso': dt.strftime('%Y-%m-%dT%H:%M:%SZ'), 'days_left': days_left}
except Exception:
pass
return {'domain': domain, 'expires_iso': None, 'days_left': -1}
def gather_ssl():
with ThreadPoolExecutor(max_workers=5) as ex:
return list(ex.map(check_ssl, SSL_DOMAINS))
# ─── STACK INVENTORY (cached forever) ───────────────
def gather_stack():
global _stack_cache
if _stack_cache is not None:
return _stack_cache
_stack_cache = {
'python_version': shell("python3 --version 2>&1").replace('Python ', ''),
'node_version': shell("node --version 2>/dev/null").lstrip('v'),
'nginx_version': shell("nginx -v 2>&1 | awk -F/ '{print $2}'"),
'docker_version': shell("docker --version 2>/dev/null | awk '{print $3}' | tr -d ','"),
'ffmpeg_version': shell("ffmpeg -version 2>/dev/null | head -1 | awk '{print $3}'"),
'kernel': shell("uname -r"),
'os': shell("lsb_release -ds 2>/dev/null || cat /etc/os-release | grep PRETTY_NAME | cut -d= -f2 | tr -d '\"'"),
'uname': platform.uname().system + ' ' + platform.uname().machine,
}
return _stack_cache
# ─── REPOS ──────────────────────────────────────────
REPO_PATHS = ['/var/www/jaeswift-homepage']
def gather_repos():
repos = []
for path in REPO_PATHS:
if not os.path.isdir(os.path.join(path, '.git')):
continue
try:
sha = shell(f"cd {path} && git rev-parse --short HEAD 2>/dev/null")
msg = shell(f"cd {path} && git log -1 --pretty=%s 2>/dev/null")
iso = shell(f"cd {path} && git log -1 --pretty=%cI 2>/dev/null")
dirty = bool(shell(f"cd {path} && git status --porcelain 2>/dev/null"))
repos.append({
'name': os.path.basename(path),
'path': path,
'last_commit_sha': sha,
'last_commit_msg': msg[:120],
'last_commit_iso': iso,
'dirty': dirty,
})
except Exception:
pass
return repos
def gather_recent_commits(limit=10):
"""Recent deployment ticker"""
commits = []
path = REPO_PATHS[0]
if os.path.isdir(os.path.join(path, '.git')):
out = shell(f"cd {path} && git log -n {limit} --pretty=format:'%h|%cI|%s' 2>/dev/null")
for line in out.split('\n'):
parts = line.split('|', 2)
if len(parts) == 3:
commits.append({'sha': parts[0], 'iso': parts[1], 'msg': parts[2][:100]})
return commits
# ─── ALERTS ─────────────────────────────────────────
def compute_alerts(system, services_data, crons_data, nginx_data, security_data, ssl_data):
alerts = []
now_iso = iso_now()
if system['cpu_percent'] > 90:
alerts.append({'level': 'red', 'message': f"CPU CRITICAL {system['cpu_percent']}%", 'since_iso': now_iso})
elif system['cpu_percent'] > 80:
alerts.append({'level': 'amber', 'message': f"CPU HIGH {system['cpu_percent']}%", 'since_iso': now_iso})
if system['mem_percent'] > 90:
alerts.append({'level': 'red', 'message': f"MEMORY CRITICAL {system['mem_percent']}%", 'since_iso': now_iso})
elif system['mem_percent'] > 80:
alerts.append({'level': 'amber', 'message': f"MEMORY HIGH {system['mem_percent']}%", 'since_iso': now_iso})
for d in system.get('disk_per_mount', []):
if d['pct'] > 90:
alerts.append({'level': 'red', 'message': f"DISK {d['mount']} CRITICAL {d['pct']}%", 'since_iso': now_iso})
elif d['pct'] > 85:
alerts.append({'level': 'amber', 'message': f"DISK {d['mount']} HIGH {d['pct']}%", 'since_iso': now_iso})
for s in services_data:
if s['status'] == 'down':
alerts.append({'level': 'red', 'message': f"SERVICE DOWN: {s['name']}", 'since_iso': now_iso})
for c in crons_data:
if c['last_status'] == 'fail':
alerts.append({'level': 'amber', 'message': f"CRON FAILED: {c['name']}", 'since_iso': now_iso})
if nginx_data.get('error_rate_pct', 0) > 1:
alerts.append({'level': 'red', 'message': f"5xx RATE {nginx_data['error_rate_pct']}%", 'since_iso': now_iso})
for s in ssl_data:
if 0 <= s['days_left'] < 14:
alerts.append({'level': 'amber', 'message': f"SSL {s['domain']} EXPIRES {s['days_left']}d", 'since_iso': now_iso})
elif s['days_left'] == -1:
pass # fetch failed, skip
if security_data['ssh_attempts_24h'] > 200:
alerts.append({'level': 'info', 'message': f"SSH brute-force: {security_data['ssh_attempts_24h']}/24h", 'since_iso': now_iso})
return alerts
# ─── ENDPOINTS ──────────────────────────────────────
@telemetry_bp.route('/api/telemetry/overview')
def overview():
try:
system = gather_system()
services_data = gather_services()
crons_data = gather_crons()
nginx = parse_nginx_24h()
security_data = gather_security()
ssl_data = gather_ssl()
stack = gather_stack()
repos = gather_repos()
return jsonify({
'system': system,
'services': services_data,
'crons': crons_data,
'nginx_24h': nginx,
'security': security_data,
'ssl': ssl_data,
'stack': stack,
'repos': repos,
'timestamp': iso_now(),
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@telemetry_bp.route('/api/telemetry/history')
def history():
default = {'cpu': [], 'mem': [], 'net_rx': [], 'net_tx': [], 'timestamps': []}
if not HISTORY_FILE.exists():
return jsonify(default)
try:
with open(HISTORY_FILE) as f:
return jsonify(json.load(f))
except Exception:
return jsonify(default)
@telemetry_bp.route('/api/telemetry/nginx-tail')
def nginx_tail():
if not os.path.exists(NGINX_LOG):
return jsonify([])
raw = shell(f"tail -n 20 {NGINX_LOG} 2>/dev/null", timeout=3)
pat = re.compile(
r'^(\S+) \S+ \S+ \[([^\]]+)\] "(\S+) (\S+) \S+" (\d+) (\d+) "[^"]*" "([^"]*)"'
)
out = []
for line in raw.split('\n'):
m = pat.match(line)
if not m:
continue
ip = m.group(1)
parts = ip.split('.')
ip_masked = f"{parts[0]}.{parts[1]}.{parts[2]}.xxx" if len(parts) == 4 else ip[:20] + ''
ua = m.group(7)
ua_short = ua[:40]
out.append({
'time': m.group(2),
'ip_masked': ip_masked,
'method': m.group(3),
'path': m.group(4)[:80],
'status': int(m.group(5)),
'size': int(m.group(6)),
'ua_short': ua_short,
})
return jsonify(out)
@telemetry_bp.route('/api/telemetry/geo')
def geo():
now = time.time()
if _geo_cache['data'] and (now - _geo_cache['ts']) < 60:
return jsonify(_geo_cache['data'])
mmdb_paths = ['/usr/share/GeoIP/GeoLite2-Country.mmdb', '/var/lib/GeoIP/GeoLite2-Country.mmdb']
mmdb = next((p for p in mmdb_paths if os.path.exists(p)), None)
if not mmdb or not os.path.exists(NGINX_LOG):
_geo_cache.update({'ts': now, 'data': []})
return jsonify([])
try:
import geoip2.database
except ImportError:
_geo_cache.update({'ts': now, 'data': []})
return jsonify([])
raw = shell(f"tail -n 20000 {NGINX_LOG} 2>/dev/null | awk '{{print $1}}' | sort -u", timeout=8)
ips = [i for i in raw.split('\n') if i and re.match(r'^\d+\.\d+\.\d+\.\d+$', i)]
counts = Counter()
names = {}
try:
reader = geoip2.database.Reader(mmdb)
for ip in ips[:5000]:
try:
rec = reader.country(ip)
cc = rec.country.iso_code or 'XX'
counts[cc] += 1
names[cc] = rec.country.name or cc
except Exception:
continue
reader.close()
except Exception:
_geo_cache.update({'ts': now, 'data': []})
return jsonify([])
data = [{'country_code': cc, 'country_name': names.get(cc, cc), 'count': c} for cc, c in counts.most_common(50)]
_geo_cache.update({'ts': now, 'data': data})
return jsonify(data)
@telemetry_bp.route('/api/telemetry/alerts')
def alerts():
try:
system = gather_system()
services_data = gather_services()
crons_data = gather_crons()
nginx = parse_nginx_24h()
security_data = gather_security()
ssl_data = gather_ssl()
return jsonify(compute_alerts(system, services_data, crons_data, nginx, security_data, ssl_data))
except Exception as e:
return jsonify({'error': str(e), 'alerts': []}), 500
@telemetry_bp.route('/api/telemetry/visitors')
def visitors():
if not os.path.exists(NGINX_LOG):
return jsonify({'active': 0, 'requests_5min': 0, 'req_per_min': 0})
cutoff = time.time() - 300
raw = shell(f"tail -n 5000 {NGINX_LOG} 2>/dev/null", timeout=4)
pat = re.compile(r'^(\S+) \S+ \S+ \[([^\]]+)\]')
ips = set()
reqs = 0
for line in raw.split('\n'):
m = pat.match(line)
if not m:
continue
try:
ts_str = m.group(2).split()[0]
dt = datetime.strptime(ts_str, '%d/%b/%Y:%H:%M:%S')
ts = dt.replace(tzinfo=timezone.utc).timestamp()
if ts < cutoff:
continue
reqs += 1
parts = m.group(1).split('.')
ips.add('.'.join(parts[:3]) if len(parts) == 4 else m.group(1))
except Exception:
continue
return jsonify({
'active': len(ips),
'requests_5min': reqs,
'req_per_min': round(reqs / 5, 1),
})
@telemetry_bp.route('/api/telemetry/commits')
def commits():
return jsonify(gather_recent_commits(10))

101
api/telemetry_snapshot.py Normal file
View file

@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Telemetry ring-buffer snapshot — runs every 5min via cron.
Appends system metrics to telemetry_history.json (288 points = 24h).
"""
import json, os, time, subprocess
from datetime import datetime, timezone
from pathlib import Path
DATA_DIR = Path(__file__).parent / 'data'
HISTORY_FILE = DATA_DIR / 'telemetry_history.json'
STATE_FILE = DATA_DIR / 'telemetry_netstate.json'
MAX_POINTS = 288 # 24h @ 5min
def shell(cmd, timeout=5):
try:
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
return r.stdout.strip()
except Exception:
return ''
def gather():
load = shell("cat /proc/loadavg | awk '{print $1}'")
ncpu = shell("nproc")
try:
cpu = round(min(float(load) / max(int(ncpu), 1) * 100, 100), 1)
except Exception:
cpu = 0
mem = shell("free | awk '/Mem:/{printf \"%.1f\", $3/$2*100}'")
try:
mem = float(mem)
except Exception:
mem = 0
net_raw = shell("cat /proc/net/dev | awk '/eth0|ens|enp/{print $2,$10; exit}'")
parts = net_raw.split()
rx = int(parts[0]) if len(parts) >= 2 else 0
tx = int(parts[1]) if len(parts) >= 2 else 0
# Rate calc from previous state
prev = {}
if STATE_FILE.exists():
try:
prev = json.loads(STATE_FILE.read_text())
except Exception:
prev = {}
now = time.time()
rx_bps = tx_bps = 0
if prev.get('ts') and now > prev['ts']:
dt = now - prev['ts']
rx_bps = max(0, int((rx - prev.get('rx', 0)) / dt))
tx_bps = max(0, int((tx - prev.get('tx', 0)) / dt))
STATE_FILE.write_text(json.dumps({'rx': rx, 'tx': tx, 'ts': now}))
return {
'cpu': cpu,
'mem': mem,
'net_rx': rx_bps,
'net_tx': tx_bps,
'ts': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
}
def append_history(point):
data = {'cpu': [], 'mem': [], 'net_rx': [], 'net_tx': [], 'timestamps': []}
if HISTORY_FILE.exists():
try:
data = json.loads(HISTORY_FILE.read_text())
# Ensure all keys exist
for k in ('cpu', 'mem', 'net_rx', 'net_tx', 'timestamps'):
data.setdefault(k, [])
except Exception:
pass
data['cpu'].append(point['cpu'])
data['mem'].append(point['mem'])
data['net_rx'].append(point['net_rx'])
data['net_tx'].append(point['net_tx'])
data['timestamps'].append(point['ts'])
# Ring buffer trim
for k in ('cpu', 'mem', 'net_rx', 'net_tx', 'timestamps'):
if len(data[k]) > MAX_POINTS:
data[k] = data[k][-MAX_POINTS:]
DATA_DIR.mkdir(parents=True, exist_ok=True)
HISTORY_FILE.write_text(json.dumps(data))
if __name__ == '__main__':
try:
point = gather()
append_history(point)
print(f"[{point['ts']}] cpu={point['cpu']}% mem={point['mem']}% rx={point['net_rx']}bps tx={point['net_tx']}bps")
except Exception as e:
print(f"[ERROR] {e}")
raise

617
css/telemetry.css Normal file
View file

@ -0,0 +1,617 @@
/* ============================================================
TELEMETRY DASHBOARD live ops for jaeswift.xyz
============================================================ */
:root {
--tm-green: #00cc33;
--tm-green-dim: #00cc33aa;
--tm-green-glow: rgba(0,204,51,0.4);
--tm-amber: #c9a227;
--tm-red: #8b0000;
--tm-red-bright: #ff2d2d;
--tm-sol: #14F195;
--tm-bg: #0a0a0a;
--tm-panel: #111111;
--tm-panel-alt: #161616;
--tm-border: #1c1c1c;
--tm-border-hot: #00cc3333;
--tm-text: #c9c9c9;
--tm-muted: #6b6b6b;
}
/* ─── Boot animation ──────────────────────────────── */
.tm-boot {
position: fixed; inset: 0;
background: #000;
z-index: 9999;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
font-family: 'JetBrains Mono', monospace;
color: var(--tm-green);
gap: 1.2rem;
text-shadow: 0 0 8px var(--tm-green-glow);
transition: opacity 0.5s;
}
.tm-boot.hidden { opacity: 0; pointer-events: none; }
.tm-boot-line {
font-size: 1rem;
opacity: 0;
letter-spacing: 0.12em;
animation: tm-bootfade 0.4s forwards;
}
.tm-boot-line::before { content: '> '; color: var(--tm-green-dim); }
@keyframes tm-bootfade {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
.tm-boot-bar {
width: 300px; height: 3px;
background: #0a2a0a;
border: 1px solid var(--tm-border-hot);
position: relative;
overflow: hidden;
}
.tm-boot-bar::after {
content: ''; position: absolute;
top: 0; left: 0; height: 100%; width: 0;
background: var(--tm-green);
box-shadow: 0 0 10px var(--tm-green);
animation: tm-bootbar 1.3s forwards;
}
@keyframes tm-bootbar { to { width: 100%; } }
/* ─── Dashboard container ─────────────────────────── */
.tm-dashboard {
padding: 0 1.5rem 4rem;
max-width: 1800px;
margin: 0 auto;
font-family: 'JetBrains Mono', monospace;
color: var(--tm-text);
position: relative;
}
/* ─── Alert banner ────────────────────────────────── */
.tm-alerts {
position: sticky; top: calc(var(--nav-height) + 8px);
z-index: 100;
display: none;
margin-bottom: 1rem;
border: 1px solid var(--tm-red);
background: rgba(139,0,0,0.15);
padding: 0.5rem 0.8rem;
overflow: hidden;
font-size: 0.82rem;
letter-spacing: 0.08em;
}
.tm-alerts.visible { display: block; animation: tm-pulse-red 2s infinite; }
.tm-alerts.amber {
border-color: var(--tm-amber);
background: rgba(201,162,39,0.1);
animation: none;
}
@keyframes tm-pulse-red {
0%,100% { box-shadow: 0 0 0 0 rgba(255,45,45,0.3); }
50% { box-shadow: 0 0 0 6px rgba(255,45,45,0); }
}
.tm-alerts-content {
display: inline-block;
white-space: nowrap;
animation: tm-marquee 30s linear infinite;
padding-left: 100%;
}
.tm-alert-item {
display: inline-block;
margin-right: 2.5rem;
}
.tm-alert-item.red { color: var(--tm-red-bright); }
.tm-alert-item.amber { color: var(--tm-amber); }
.tm-alert-item.info { color: var(--tm-green); }
.tm-alert-item::before { content: '▲ '; }
@keyframes tm-marquee {
from { transform: translateX(0); }
to { transform: translateX(-100%); }
}
/* ─── Top status bar (LIVE indicator) ─────────────── */
.tm-statusbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.6rem 0.9rem;
background: var(--tm-panel);
border: 1px solid var(--tm-border);
margin-bottom: 1.2rem;
font-size: 0.8rem;
}
.tm-live {
display: flex; align-items: center; gap: 0.6rem;
letter-spacing: 0.15em;
}
.tm-live-dot {
width: 10px; height: 10px; border-radius: 50%;
background: var(--tm-green);
box-shadow: 0 0 12px var(--tm-green);
animation: tm-blink 1.2s infinite;
}
.tm-live-dot.amber { background: var(--tm-amber); box-shadow: 0 0 8px var(--tm-amber); }
.tm-live-dot.red { background: var(--tm-red-bright); box-shadow: 0 0 12px var(--tm-red-bright); }
@keyframes tm-blink {
0%,100% { opacity: 1; }
50% { opacity: 0.25; }
}
.tm-visitors {
display: flex; align-items: center; gap: 0.8rem;
color: var(--tm-muted);
}
.tm-visitors strong {
color: var(--tm-sol);
font-size: 1.1rem;
text-shadow: 0 0 8px rgba(20,241,149,0.5);
}
.tm-sound-toggle {
background: none; border: 1px solid var(--tm-border);
color: var(--tm-muted); cursor: pointer;
padding: 0.3rem 0.6rem;
font-family: inherit; font-size: 0.75rem;
transition: all 0.2s;
}
.tm-sound-toggle:hover { color: var(--tm-green); border-color: var(--tm-green); }
.tm-sound-toggle.on { color: var(--tm-green); border-color: var(--tm-green); }
/* ─── Grid layout ─────────────────────────────────── */
.tm-row {
display: grid;
gap: 1rem;
margin-bottom: 1rem;
}
.tm-row.r1 { grid-template-columns: repeat(4, 1fr); }
.tm-row.r2 { grid-template-columns: 1.2fr 1fr; }
.tm-row.r3 { grid-template-columns: 1fr; }
.tm-row.r4 { grid-template-columns: 1.3fr 1fr; }
.tm-row.r5 { grid-template-columns: 1fr 1.3fr; }
.tm-row.r6 { grid-template-columns: 1.3fr 1fr; }
.tm-row.r7 { grid-template-columns: 1fr 1.3fr; }
@media (max-width: 1200px) {
.tm-row.r1 { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 768px) {
.tm-dashboard { padding: 0 0.6rem 3rem; }
.tm-row,
.tm-row.r1, .tm-row.r2, .tm-row.r4,
.tm-row.r5, .tm-row.r6, .tm-row.r7 {
grid-template-columns: 1fr;
}
.tm-alerts { font-size: 0.72rem; }
}
/* ─── Panel base ──────────────────────────────────── */
.tm-panel {
background: var(--tm-panel);
border: 1px solid var(--tm-border);
padding: 0.8rem 1rem 1rem;
position: relative;
min-height: 0;
overflow: hidden;
transition: border-color 0.3s;
}
.tm-panel:hover { border-color: var(--tm-border-hot); }
.tm-panel-title {
font-size: 0.72rem;
letter-spacing: 0.2em;
color: var(--tm-green-dim);
margin-bottom: 0.7rem;
padding-bottom: 0.4rem;
border-bottom: 1px dashed var(--tm-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.tm-panel-title::before { content: '▸ '; color: var(--tm-green); }
.tm-panel-title .badge {
background: rgba(0,204,51,0.1);
color: var(--tm-green);
padding: 1px 6px;
font-size: 0.65rem;
letter-spacing: 0.1em;
border: 1px solid var(--tm-border-hot);
}
/* ─── Gauges (CPU/MEM) ────────────────────────────── */
.tm-gauge-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
}
.tm-gauge-canvas {
width: 140px; height: 140px;
max-width: 100%;
}
.tm-gauge-value {
font-family: 'Orbitron', sans-serif;
font-size: 1.6rem;
font-weight: 700;
color: var(--tm-green);
text-shadow: 0 0 10px var(--tm-green-glow);
letter-spacing: 0.05em;
}
.tm-gauge-value.warn { color: var(--tm-amber); text-shadow: 0 0 10px rgba(201,162,39,0.5); }
.tm-gauge-value.crit { color: var(--tm-red-bright); text-shadow: 0 0 10px rgba(255,45,45,0.6); }
.tm-gauge-label {
font-size: 0.7rem;
color: var(--tm-muted);
letter-spacing: 0.12em;
}
.tm-spark {
width: 100%; height: 40px;
margin-top: 0.5rem;
background: #000;
border: 1px solid var(--tm-border);
}
/* ─── Disk bars ──────────────────────────────────── */
.tm-disks { display: flex; flex-direction: column; gap: 0.7rem; }
.tm-disk {
display: flex; flex-direction: column; gap: 0.2rem;
font-size: 0.72rem;
}
.tm-disk-head {
display: flex; justify-content: space-between;
color: var(--tm-text);
}
.tm-disk-head .mount { color: var(--tm-green); }
.tm-disk-bar {
font-family: 'JetBrains Mono', monospace;
color: var(--tm-green);
letter-spacing: -0.5px;
font-size: 0.9rem;
line-height: 1;
}
.tm-disk-bar.warn { color: var(--tm-amber); }
.tm-disk-bar.crit { color: var(--tm-red-bright); }
/* ─── Network ────────────────────────────────────── */
.tm-net {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.8rem;
}
.tm-net-row {
display: flex; justify-content: space-between;
align-items: baseline;
padding: 0.2rem 0;
}
.tm-net-row .label { color: var(--tm-muted); font-size: 0.7rem; letter-spacing: 0.15em; }
.tm-net-row .val {
font-family: 'Orbitron', sans-serif;
font-size: 1.25rem;
color: var(--tm-sol);
text-shadow: 0 0 8px rgba(20,241,149,0.4);
}
.tm-net-row .val.tx { color: var(--tm-green); text-shadow: 0 0 8px var(--tm-green-glow); }
/* ─── Tables (services, crons) ─────────────────────── */
.tm-table {
width: 100%;
border-collapse: collapse;
font-size: 0.74rem;
}
.tm-table th {
text-align: left;
padding: 0.35rem 0.4rem;
border-bottom: 1px solid var(--tm-border);
color: var(--tm-muted);
letter-spacing: 0.1em;
font-weight: 400;
}
.tm-table td {
padding: 0.4rem;
border-bottom: 1px dashed #1a1a1a;
}
.tm-table tr:hover td { background: rgba(0,204,51,0.04); }
.tm-table .svc-name { color: var(--tm-text); font-weight: 500; }
.tm-table .dot {
display: inline-block;
width: 8px; height: 8px; border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
}
.dot.up { background: var(--tm-green); box-shadow: 0 0 8px var(--tm-green); animation: tm-blink 1.6s infinite; }
.dot.down { background: var(--tm-red-bright); box-shadow: 0 0 8px var(--tm-red-bright); }
.dot.unknown { background: var(--tm-muted); }
.dot.warn { background: var(--tm-amber); box-shadow: 0 0 6px var(--tm-amber); }
.tm-table .cron-status.ok { color: var(--tm-green); }
.tm-table .cron-status.fail { color: var(--tm-red-bright); }
.tm-table .cron-status.unknown { color: var(--tm-muted); }
.tm-expandable { cursor: pointer; }
.tm-expanded-row {
background: #0d0d0d;
padding: 0.6rem 0.8rem;
color: var(--tm-muted);
font-size: 0.68rem;
white-space: pre-wrap;
max-height: 160px;
overflow: auto;
}
/* ─── Nginx stats ────────────────────────────────── */
.tm-nginx-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.8rem;
margin-bottom: 0.8rem;
}
@media (max-width: 768px) { .tm-nginx-grid { grid-template-columns: repeat(2, 1fr); } }
.tm-stat {
background: var(--tm-panel-alt);
padding: 0.6rem 0.8rem;
border-left: 2px solid var(--tm-green);
text-align: left;
}
.tm-stat .v {
font-family: 'Orbitron', sans-serif;
font-size: 1.15rem;
color: var(--tm-green);
text-shadow: 0 0 6px var(--tm-green-glow);
display: block;
}
.tm-stat .l {
font-size: 0.65rem;
color: var(--tm-muted);
letter-spacing: 0.1em;
margin-top: 0.2rem;
}
.tm-stat.err { border-left-color: var(--tm-red-bright); }
.tm-stat.err .v { color: var(--tm-red-bright); text-shadow: 0 0 6px rgba(255,45,45,0.4); }
.tm-toppages {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.6rem;
font-size: 0.72rem;
}
@media (max-width: 768px) { .tm-toppages { grid-template-columns: 1fr; } }
.tm-toppages ul { list-style: none; padding: 0; margin: 0; }
.tm-toppages li {
display: flex; justify-content: space-between;
padding: 0.25rem 0;
border-bottom: 1px dashed #1a1a1a;
}
.tm-toppages li .path {
color: var(--tm-text);
overflow: hidden; text-overflow: ellipsis;
white-space: nowrap;
max-width: 70%;
}
.tm-toppages li .c {
color: var(--tm-sol);
font-family: 'Orbitron', sans-serif;
}
.tm-toppages h4 {
color: var(--tm-green-dim);
font-size: 0.7rem;
letter-spacing: 0.15em;
margin: 0 0 0.3rem;
font-weight: 400;
}
/* ─── Security / SSL panels ───────────────────────── */
.tm-security-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.8rem;
margin-bottom: 0.8rem;
}
.tm-jails {
font-size: 0.72rem;
margin-top: 0.5rem;
}
.tm-jails .jail {
display: flex; justify-content: space-between;
padding: 0.2rem 0;
border-bottom: 1px dashed #1a1a1a;
}
.tm-ssl { font-size: 0.74rem; }
.tm-ssl .ssl-row {
display: flex; justify-content: space-between;
padding: 0.4rem 0;
border-bottom: 1px dashed #1a1a1a;
}
.tm-ssl .days {
font-family: 'Orbitron', sans-serif;
color: var(--tm-green);
}
.tm-ssl .days.warn { color: var(--tm-amber); }
.tm-ssl .days.crit { color: var(--tm-red-bright); }
.tm-ssl .days.fail { color: var(--tm-muted); }
/* ─── Stack inventory ─────────────────────────────── */
.tm-stack {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.5rem;
}
.tm-stack-item {
background: var(--tm-panel-alt);
border: 1px solid var(--tm-border);
padding: 0.45rem 0.6rem;
font-size: 0.72rem;
}
.tm-stack-item .k {
color: var(--tm-muted); font-size: 0.62rem;
letter-spacing: 0.1em; display: block;
}
.tm-stack-item .v {
color: var(--tm-green);
font-family: 'Orbitron', sans-serif;
overflow: hidden; text-overflow: ellipsis;
white-space: nowrap;
display: block;
margin-top: 0.15rem;
}
/* ─── Repos ───────────────────────────────────────── */
.tm-repo {
background: var(--tm-panel-alt);
padding: 0.6rem 0.8rem;
border-left: 2px solid var(--tm-sol);
margin-bottom: 0.5rem;
font-size: 0.75rem;
}
.tm-repo-head {
display: flex; justify-content: space-between;
align-items: center;
margin-bottom: 0.3rem;
}
.tm-repo-head .name { color: var(--tm-sol); font-weight: 600; }
.tm-repo-head .sha {
font-family: 'JetBrains Mono', monospace;
color: var(--tm-amber);
background: rgba(201,162,39,0.08);
padding: 1px 6px;
font-size: 0.68rem;
}
.tm-repo .msg { color: var(--tm-text); margin-bottom: 0.2rem; }
.tm-repo .meta { color: var(--tm-muted); font-size: 0.68rem; }
.tm-repo .dirty-badge {
color: var(--tm-red-bright);
margin-left: 0.5rem;
animation: tm-blink 1.4s infinite;
}
/* ─── Geo map ─────────────────────────────────────── */
.tm-geo {
width: 100%;
min-height: 280px;
position: relative;
}
.tm-geo svg { width: 100%; height: auto; display: block; }
.tm-geo .country { fill: #1a2a1a; stroke: #0a0a0a; stroke-width: 0.5; transition: fill 0.3s; }
.tm-geo .country.hot { fill: var(--tm-green); }
.tm-geo .country:hover { fill: var(--tm-sol); cursor: pointer; }
.tm-geo-legend {
position: absolute;
bottom: 0.5rem; left: 0.5rem;
font-size: 0.65rem;
color: var(--tm-muted);
background: rgba(0,0,0,0.7);
padding: 0.3rem 0.5rem;
border: 1px solid var(--tm-border);
}
.tm-geo-empty {
text-align: center;
padding: 3rem 1rem;
color: var(--tm-muted);
font-size: 0.75rem;
}
.tm-geo-top {
margin-top: 0.6rem;
font-size: 0.7rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.3rem;
}
.tm-geo-top .c-item {
display: flex; justify-content: space-between;
padding: 0.15rem 0.4rem;
background: rgba(0,204,51,0.05);
border-left: 2px solid var(--tm-green);
}
.tm-geo-top .c-item .n { color: var(--tm-green); font-family: 'Orbitron', sans-serif; }
/* ─── Terminal tail ────────────────────────────────── */
.tm-tail {
background: #000;
border: 1px solid var(--tm-border);
padding: 0.6rem;
min-height: 280px;
max-height: 400px;
overflow-y: auto;
font-size: 0.7rem;
line-height: 1.4;
font-family: 'JetBrains Mono', monospace;
}
.tm-tail::-webkit-scrollbar { width: 6px; }
.tm-tail::-webkit-scrollbar-thumb { background: #1a2a1a; }
.tm-tail-line {
opacity: 0;
animation: tm-fadein 0.4s forwards;
padding: 1px 0;
border-bottom: 1px dotted #111;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@keyframes tm-fadein {
from { opacity: 0; transform: translateX(-8px); }
to { opacity: 1; transform: none; }
}
.tm-tail-line .st-2xx { color: var(--tm-green); }
.tm-tail-line .st-3xx { color: var(--tm-sol); }
.tm-tail-line .st-4xx { color: var(--tm-amber); }
.tm-tail-line .st-5xx { color: var(--tm-red-bright); }
.tm-tail-line .method { color: var(--tm-green-dim); }
.tm-tail-line .path { color: var(--tm-text); }
.tm-tail-line .ip { color: var(--tm-muted); }
.tm-tail-line .time { color: #3a3a3a; font-size: 0.62rem; }
/* ─── Burn rate / deploys ─────────────────────────── */
.tm-burn {
font-size: 0.85rem;
color: var(--tm-text);
line-height: 1.9;
}
.tm-burn .big {
font-family: 'Orbitron', sans-serif;
color: var(--tm-amber);
font-size: 1.1rem;
text-shadow: 0 0 6px rgba(201,162,39,0.4);
}
.tm-burn .label { color: var(--tm-muted); font-size: 0.72rem; letter-spacing: 0.1em; display: block; }
.tm-ticker {
overflow: hidden;
white-space: nowrap;
font-size: 0.75rem;
padding: 0.4rem 0;
}
.tm-ticker-inner {
display: inline-block;
animation: tm-marquee 60s linear infinite;
padding-left: 100%;
}
.tm-ticker-item {
display: inline-block;
margin-right: 2rem;
color: var(--tm-text);
}
.tm-ticker-item .sha {
color: var(--tm-amber);
font-family: 'JetBrains Mono', monospace;
margin-right: 0.5rem;
}
.tm-ticker-item .iso { color: var(--tm-muted); margin-left: 0.6rem; font-size: 0.65rem; }
/* ─── Loading & error states ──────────────────────── */
.tm-loading {
color: var(--tm-muted);
font-size: 0.72rem;
padding: 1rem 0;
text-align: center;
letter-spacing: 0.1em;
}
.tm-loading::after {
content: '...';
animation: tm-dots 1.4s infinite;
}
@keyframes tm-dots {
0%,20% { content: '.'; }
40% { content: '..'; }
60% { content: '...'; }
80%,100% { content: '....'; }
}
.tm-err { color: var(--tm-red-bright); font-size: 0.72rem; padding: 0.5rem 0; }

View file

@ -8,11 +8,21 @@
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=JetBrains+Mono:wght@300;400;500;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=JetBrains+Mono:wght@300;400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/section.css"> <link rel="stylesheet" href="/css/section.css">
<link rel="stylesheet" href="/css/telemetry.css">
</head> </head>
<body> <body>
<div class="scanline-overlay"></div> <div class="scanline-overlay"></div>
<div class="grid-bg"></div> <div class="grid-bg"></div>
<!-- Boot sequence overlay -->
<div class="tm-boot" id="tmBoot">
<div class="tm-boot-line" style="animation-delay:0.0s">INITIALISING TELEMETRY UPLINK</div>
<div class="tm-boot-line" style="animation-delay:0.4s">NEGOTIATING SECURE CHANNEL // VPS.MCR01</div>
<div class="tm-boot-line" style="animation-delay:0.8s">DECRYPTING OPS FEED — KEY ROLL OK</div>
<div class="tm-boot-bar"></div>
<div class="tm-boot-line" style="animation-delay:1.3s">AUTHORISED — OPERATOR GRADE A</div>
</div>
<nav class="nav-main" id="navbar"> <nav class="nav-main" id="navbar">
<div class="nav-container"> <div class="nav-container">
<a href="/" class="nav-logo"> <a href="/" class="nav-logo">
@ -39,32 +49,182 @@
<section class="section-header" style="padding-top: calc(var(--nav-height) + 1.5rem);"> <section class="section-header" style="padding-top: calc(var(--nav-height) + 1.5rem);">
<div class="section-header-label">HQ // HEADQUARTERS</div> <div class="section-header-label">HQ // HEADQUARTERS</div>
<h1 class="section-header-title">TELEMETRY</h1> <h1 class="section-header-title">TELEMETRY</h1>
<p class="section-header-sub">&gt; Live facility systems status, tech stack, and operational metrics.</p> <p class="section-header-sub">&gt; Live ops feed — every metric, every service, every packet.</p>
</section> </section>
<section class="subpage-content"> <main class="tm-dashboard">
<div class="subpage-placeholder">
<div class="placeholder-icon"></div>
<div class="placeholder-status">UNDER CONSTRUCTION</div>
<div class="placeholder-text">This section is being prepared. Content deployment imminent.</div>
<div class="placeholder-classification">CLASSIFICATION: PENDING // STATUS: STANDBY</div>
</div>
</section>
<footer class="footer"> <!-- Alert banner (hidden until alerts exist) -->
<div class="footer-container"> <div class="tm-alerts" id="tmAlerts">
<div class="footer-left"> <div class="tm-alerts-content" id="tmAlertsContent"></div>
<span class="footer-logo">[JAE]</span> </div>
<span class="footer-copy">&copy; 2026 JAESWIFT.XYZ</span>
<!-- Status bar: LIVE indicator + visitors + sound toggle -->
<div class="tm-statusbar">
<div class="tm-live">
<span class="tm-live-dot" id="tmLiveDot"></span>
<span>LIVE</span>
<span style="color:var(--tm-muted);margin-left:0.4rem;">last sync <span id="tmLastSync"></span>s ago</span>
</div> </div>
<div class="footer-right"> <div class="tm-visitors">
<span class="footer-signal">SIGNAL ████<span class="signal-flicker"></span></span> <span>ONLINE NOW</span>
<strong id="tmVisitorsActive"></strong>
<span style="font-size:0.7rem;">(<span id="tmVisitorsRpm"></span> req/min)</span>
<button class="tm-sound-toggle" id="tmSoundToggle" title="Alarm sound">🔇 ALARM OFF</button>
</div> </div>
</div> </div>
</footer>
<!-- Row 1: CPU | MEM | DISK | NET -->
<div class="tm-row r1">
<div class="tm-panel">
<div class="tm-panel-title">CPU <span class="badge" id="tmCpuBadge"></span></div>
<div class="tm-gauge-wrap">
<canvas class="tm-gauge-canvas" id="tmCpuGauge" width="280" height="280"></canvas>
<div class="tm-gauge-value" id="tmCpuVal"></div>
<div class="tm-gauge-label" id="tmCpuLoad">load —</div>
</div>
<canvas class="tm-spark" id="tmCpuSpark" width="400" height="40"></canvas>
</div>
<div class="tm-panel">
<div class="tm-panel-title">MEMORY <span class="badge" id="tmMemBadge"></span></div>
<div class="tm-gauge-wrap">
<canvas class="tm-gauge-canvas" id="tmMemGauge" width="280" height="280"></canvas>
<div class="tm-gauge-value" id="tmMemVal"></div>
<div class="tm-gauge-label" id="tmMemSize">— / —</div>
</div>
<canvas class="tm-spark" id="tmMemSpark" width="400" height="40"></canvas>
</div>
<div class="tm-panel">
<div class="tm-panel-title">STORAGE</div>
<div class="tm-disks" id="tmDisks">
<div class="tm-loading">READING MOUNTS</div>
</div>
</div>
<div class="tm-panel">
<div class="tm-panel-title">NETWORK</div>
<div class="tm-net">
<div class="tm-net-row">
<span class="label">↓ RX</span>
<span class="val" id="tmNetRx"></span>
</div>
<div class="tm-net-row">
<span class="label">↑ TX</span>
<span class="val tx" id="tmNetTx"></span>
</div>
</div>
<canvas class="tm-spark" id="tmNetSpark" width="400" height="80" style="height:80px;"></canvas>
<div class="tm-gauge-label" id="tmUptime" style="margin-top:0.5rem;text-align:center;">uptime —</div>
</div>
</div>
<!-- Row 2: Services | Crons -->
<div class="tm-row r2">
<div class="tm-panel">
<div class="tm-panel-title">SERVICES <span class="badge" id="tmSvcBadge"></span></div>
<table class="tm-table">
<thead><tr><th>STATE</th><th>NAME</th><th>UPTIME</th><th>MEM</th><th>CPU</th></tr></thead>
<tbody id="tmServicesBody"><tr><td colspan="5" class="tm-loading">QUERYING SYSTEMD</td></tr></tbody>
</table>
</div>
<div class="tm-panel">
<div class="tm-panel-title">SCHEDULED TASKS</div>
<table class="tm-table">
<thead><tr><th>NAME</th><th>SCHEDULE</th><th>LAST RUN</th><th>STATUS</th></tr></thead>
<tbody id="tmCronsBody"><tr><td colspan="4" class="tm-loading">READING CRON LOGS</td></tr></tbody>
</table>
</div>
</div>
<!-- Row 3: Nginx full-width -->
<div class="tm-row r3">
<div class="tm-panel">
<div class="tm-panel-title">NGINX TRAFFIC // 24h</div>
<div class="tm-nginx-grid" id="tmNginxStats">
<div class="tm-loading">PARSING ACCESS LOG</div>
</div>
<div class="tm-toppages" id="tmTopPages"></div>
</div>
</div>
<!-- Row 4: Security | SSL -->
<div class="tm-row r4">
<div class="tm-panel">
<div class="tm-panel-title">SECURITY</div>
<div class="tm-security-grid" id="tmSecurityStats">
<div class="tm-loading">SCANNING</div>
</div>
<div class="tm-jails" id="tmJails"></div>
</div>
<div class="tm-panel">
<div class="tm-panel-title">SSL CERTIFICATES</div>
<div class="tm-ssl" id="tmSsl">
<div class="tm-loading">CHECKING CERTS</div>
</div>
</div>
</div>
<!-- Row 5: Stack | Repos -->
<div class="tm-row r5">
<div class="tm-panel">
<div class="tm-panel-title">STACK INVENTORY</div>
<div class="tm-stack" id="tmStack">
<div class="tm-loading">DETECTING BINARIES</div>
</div>
</div>
<div class="tm-panel">
<div class="tm-panel-title">GIT ACTIVITY</div>
<div id="tmRepos">
<div class="tm-loading">READING REPOS</div>
</div>
</div>
</div>
<!-- Row 6: Geo | Tail -->
<div class="tm-row r6">
<div class="tm-panel">
<div class="tm-panel-title">GEO DISTRIBUTION // 24h</div>
<div class="tm-geo" id="tmGeo">
<div class="tm-loading">LOCATING</div>
</div>
<div class="tm-geo-top" id="tmGeoTop"></div>
</div>
<div class="tm-panel">
<div class="tm-panel-title">LIVE TAIL // nginx</div>
<div class="tm-tail" id="tmTail">
<div class="tm-loading">WAITING FOR REQUESTS</div>
</div>
</div>
</div>
<!-- Row 7: Burn | Deploys -->
<div class="tm-row r7">
<div class="tm-panel">
<div class="tm-panel-title">BURN RATE</div>
<div class="tm-burn" id="tmBurn">
<div class="tm-loading">COMPUTING</div>
</div>
</div>
<div class="tm-panel">
<div class="tm-panel-title">DEPLOYMENT TICKER</div>
<div class="tm-ticker" id="tmTicker">
<div class="tm-loading">READING COMMIT LOG</div>
</div>
</div>
</div>
</main>
<script src="/js/wallet-connect.js"></script> <script src="/js/wallet-connect.js"></script>
<script src="/js/nav.js"></script> <script src="/js/nav.js"></script>
<script src="/js/clock.js"></script> <script src="/js/clock.js"></script>
<script src="/js/telemetry.js"></script>
</body> </body>
</html> </html>

685
js/telemetry.js Normal file
View file

@ -0,0 +1,685 @@
/* ============================================================
TELEMETRY DASHBOARD live ops frontend
============================================================ */
(() => {
'use strict';
const API_BASE = '/api/telemetry';
const POLL_OVERVIEW = 5000;
const POLL_HISTORY = 60000;
const POLL_TAIL = 5000;
const POLL_GEO = 60000;
const POLL_ALERTS = 10000;
const POLL_VISITORS = 15000;
const POLL_COMMITS = 120000;
// ─── State ──────────────────────────────────────────
const state = {
lastOverview: null,
lastHistory: null,
lastAlertSig: '',
lastSync: 0,
alarm: false,
audioCtx: null,
seenTailKeys: new Set(),
};
// ─── Utils ──────────────────────────────────────────
const $ = (id) => document.getElementById(id);
const fmt = {
bytes(n) {
if (!n) return '0 B';
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), u.length - 1);
return (n / Math.pow(1024, i)).toFixed(1) + ' ' + u[i];
},
bps(n) {
return fmt.bytes(n) + '/s';
},
uptime(s) {
if (!s) return '—';
s = Math.floor(s);
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
},
numGrp(n) {
return (n || 0).toLocaleString('en-GB');
},
iso(iso) {
if (!iso) return '—';
try {
const d = new Date(iso);
return d.toLocaleString('en-GB', { hour12: false });
} catch { return iso; }
},
ago(iso) {
if (!iso) return '—';
try {
const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (s < 60) return `${s}s ago`;
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
} catch { return iso; }
},
escape(s) {
return String(s || '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
};
async function fetchJSON(url) {
try {
const r = await fetch(url, { credentials: 'same-origin' });
if (!r.ok) throw new Error('HTTP ' + r.status);
return await r.json();
} catch (e) {
console.warn('[telemetry] fetch failed', url, e);
return null;
}
}
// ─── Canvas gauge ───────────────────────────────────
function drawGauge(canvas, value, label) {
if (!canvas) return;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const size = canvas.clientWidth;
canvas.width = size * dpr;
canvas.height = size * dpr;
ctx.scale(dpr, dpr);
const cx = size / 2, cy = size / 2;
const r = size / 2 - 6;
ctx.clearRect(0, 0, size, size);
// Background arc
ctx.beginPath();
ctx.arc(cx, cy, r, Math.PI * 0.75, Math.PI * 2.25);
ctx.lineWidth = 6;
ctx.strokeStyle = '#1a1a1a';
ctx.stroke();
// Tick marks
for (let i = 0; i <= 10; i++) {
const a = Math.PI * 0.75 + (Math.PI * 1.5 * i / 10);
const r1 = r - 2, r2 = r + 4;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * r1, cy + Math.sin(a) * r1);
ctx.lineTo(cx + Math.cos(a) * r2, cy + Math.sin(a) * r2);
ctx.lineWidth = 1;
ctx.strokeStyle = '#2a2a2a';
ctx.stroke();
}
// Value arc
const pct = Math.max(0, Math.min(100, value)) / 100;
const endA = Math.PI * 0.75 + Math.PI * 1.5 * pct;
const color = value > 90 ? '#ff2d2d' : value > 80 ? '#c9a227' : '#00cc33';
ctx.beginPath();
ctx.arc(cx, cy, r, Math.PI * 0.75, endA);
ctx.lineWidth = 6;
ctx.strokeStyle = color;
ctx.shadowColor = color;
ctx.shadowBlur = 8;
ctx.stroke();
ctx.shadowBlur = 0;
// Needle
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + Math.cos(endA) * (r - 12), cy + Math.sin(endA) * (r - 12));
ctx.lineWidth = 2;
ctx.strokeStyle = color;
ctx.stroke();
// Hub
ctx.beginPath();
ctx.arc(cx, cy, 4, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
}
// ─── Sparkline ──────────────────────────────────────
function drawSpark(canvas, values, { color = '#00cc33', fill = true, max = null } = {}) {
if (!canvas) return;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth;
const h = canvas.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
if (!values || values.length < 2) {
ctx.fillStyle = '#333';
ctx.font = '10px JetBrains Mono';
ctx.fillText('awaiting history...', 6, h / 2 + 3);
return;
}
const peak = max !== null ? max : Math.max(...values, 1);
const step = w / (values.length - 1);
ctx.beginPath();
values.forEach((v, i) => {
const x = i * step;
const y = h - (v / peak) * (h - 4) - 2;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.strokeStyle = color;
ctx.lineWidth = 1.2;
ctx.shadowColor = color;
ctx.shadowBlur = 4;
ctx.stroke();
ctx.shadowBlur = 0;
if (fill) {
ctx.lineTo(w, h);
ctx.lineTo(0, h);
ctx.closePath();
ctx.fillStyle = color + '22';
ctx.fill();
}
}
// ─── Disk ASCII bars ────────────────────────────────
function renderDisks(disks) {
const el = $('tmDisks');
if (!el) return;
if (!disks || !disks.length) { el.innerHTML = '<div class="tm-err">NO MOUNTS</div>'; return; }
const bars = 18;
el.innerHTML = disks.map(d => {
const filled = Math.round(d.pct / 100 * bars);
const cls = d.pct > 90 ? 'crit' : d.pct > 85 ? 'warn' : '';
const bar = '█'.repeat(filled) + '░'.repeat(bars - filled);
return `
<div class="tm-disk">
<div class="tm-disk-head">
<span class="mount">${fmt.escape(d.mount)}</span>
<span>${d.used_gb}G / ${d.total_gb}G · ${d.pct}%</span>
</div>
<div class="tm-disk-bar ${cls}">[${bar}]</div>
</div>`;
}).join('');
}
// ─── Services ──────────────────────────────────────
function renderServices(services) {
const el = $('tmServicesBody');
if (!el) return;
if (!services || !services.length) { el.innerHTML = '<tr><td colspan="5" class="tm-err">NO SERVICES</td></tr>'; return; }
const sorted = [...services].sort((a, b) => {
const rank = { down: 0, unknown: 1, up: 2 };
return (rank[a.status] ?? 3) - (rank[b.status] ?? 3);
});
el.innerHTML = sorted.map(s => `
<tr>
<td><span class="dot ${s.status}"></span>${s.status.toUpperCase()}</td>
<td class="svc-name">${fmt.escape(s.name)}</td>
<td>${fmt.uptime(s.uptime_seconds)}</td>
<td>${s.memory_mb ? s.memory_mb.toFixed(0) + ' MB' : '—'}</td>
<td>${s.cpu_percent ? s.cpu_percent.toFixed(1) + '%' : '—'}</td>
</tr>
`).join('');
const upCount = services.filter(s => s.status === 'up').length;
const badge = $('tmSvcBadge');
if (badge) badge.textContent = `${upCount}/${services.length} UP`;
}
// ─── Crons ─────────────────────────────────────────
function renderCrons(crons) {
const el = $('tmCronsBody');
if (!el) return;
if (!crons || !crons.length) { el.innerHTML = '<tr><td colspan="4" class="tm-err">NO CRONS</td></tr>'; return; }
el.innerHTML = crons.map((c, i) => `
<tr class="tm-expandable" data-cron-idx="${i}">
<td class="svc-name">${fmt.escape(c.name)}</td>
<td><code>${fmt.escape(c.schedule)}</code></td>
<td>${fmt.ago(c.last_run_iso)}</td>
<td class="cron-status ${c.last_status}">${c.last_status === 'ok' ? '✓ OK' : c.last_status === 'fail' ? '✗ FAIL' : '? UNKNOWN'}</td>
</tr>
<tr style="display:none" data-cron-exp="${i}">
<td colspan="4"><div class="tm-expanded-row">${fmt.escape(c.last_output_tail || '(no output)')}</div></td>
</tr>
`).join('');
el.querySelectorAll('[data-cron-idx]').forEach(row => {
row.addEventListener('click', () => {
const idx = row.getAttribute('data-cron-idx');
const exp = el.querySelector(`[data-cron-exp="${idx}"]`);
if (exp) exp.style.display = exp.style.display === 'none' ? 'table-row' : 'none';
});
});
}
// ─── Nginx ─────────────────────────────────────────
function renderNginx(nginx) {
const s = $('tmNginxStats');
const t = $('tmTopPages');
if (!s || !t) return;
if (!nginx || nginx.total_requests === 0) {
s.innerHTML = '<div class="tm-err" style="grid-column:1/-1">NO ACCESS LOG DATA YET</div>';
t.innerHTML = '';
return;
}
s.innerHTML = `
<div class="tm-stat"><span class="v">${fmt.numGrp(nginx.total_requests)}</span><span class="l">REQUESTS / 24h</span></div>
<div class="tm-stat"><span class="v">${fmt.bytes(nginx.total_bytes)}</span><span class="l">BANDWIDTH</span></div>
<div class="tm-stat"><span class="v">${nginx.avg_rpm}</span><span class="l">AVG REQ / MIN</span></div>
<div class="tm-stat ${nginx.status_4xx_count > 100 ? 'err' : ''}"><span class="v">${fmt.numGrp(nginx.status_4xx_count)}</span><span class="l">4xx ERRORS</span></div>
<div class="tm-stat ${nginx.status_5xx_count > 0 ? 'err' : ''}"><span class="v">${fmt.numGrp(nginx.status_5xx_count)}</span><span class="l">5xx ERRORS</span></div>
`;
const top = (list, head) => `
<div><h4>${head}</h4><ul>
${list.slice(0, 8).map(x => `<li><span class="path" title="${fmt.escape(x.path || x.ip)}">${fmt.escape(x.path || x.ip)}</span><span class="c">${fmt.numGrp(x.count)}</span></li>`).join('')}
</ul></div>`;
t.innerHTML = top(nginx.top_pages || [], 'TOP PAGES') + top(nginx.top_ips_redacted || [], 'TOP IPs (REDACTED)');
}
// ─── Security ──────────────────────────────────────
function renderSecurity(sec) {
const s = $('tmSecurityStats');
const j = $('tmJails');
if (!s) return;
if (!sec) { s.innerHTML = '<div class="tm-err">NO DATA</div>'; return; }
s.innerHTML = `
<div class="tm-stat"><span class="v">${sec.ufw_rules_count}</span><span class="l">UFW RULES</span></div>
<div class="tm-stat ${sec.fail2ban_bans_total > 0 ? '' : ''}"><span class="v">${sec.fail2ban_bans_total}</span><span class="l">BANNED IPs</span></div>
<div class="tm-stat ${sec.ssh_attempts_24h > 200 ? 'err' : ''}"><span class="v">${sec.ssh_attempts_24h}</span><span class="l">SSH BRUTEFORCE 24h</span></div>
<div class="tm-stat"><span class="v" style="font-size:0.85rem">${sec.last_reboot_iso ? fmt.ago(sec.last_reboot_iso) : '—'}</span><span class="l">LAST REBOOT</span></div>
`;
if (j) {
if (!sec.fail2ban_jails || !sec.fail2ban_jails.length) {
j.innerHTML = '<div style="color:var(--tm-muted);font-size:0.7rem">no fail2ban jails detected</div>';
} else {
j.innerHTML = '<h4 style="color:var(--tm-green-dim);font-size:0.68rem;letter-spacing:0.15em;margin:0.5rem 0 0.3rem;font-weight:400">JAILS</h4>' +
sec.fail2ban_jails.map(x => `<div class="jail"><span>${fmt.escape(x.name)}</span><span style="color:var(--tm-amber);font-family:'Orbitron',sans-serif">${x.banned}</span></div>`).join('');
}
}
}
// ─── SSL ──────────────────────────────────────────
function renderSSL(ssl) {
const el = $('tmSsl');
if (!el) return;
if (!ssl || !ssl.length) { el.innerHTML = '<div class="tm-err">NO CERT DATA</div>'; return; }
el.innerHTML = ssl.map(s => {
const cls = s.days_left < 0 ? 'fail' : s.days_left < 14 ? 'crit' : s.days_left < 30 ? 'warn' : '';
const daysTxt = s.days_left < 0 ? 'FAIL' : s.days_left + 'd';
return `<div class="ssl-row">
<span>${fmt.escape(s.domain)}</span>
<span class="days ${cls}">${daysTxt}</span>
</div>`;
}).join('');
}
// ─── Stack ─────────────────────────────────────────
function renderStack(stack) {
const el = $('tmStack');
if (!el) return;
if (!stack) { el.innerHTML = '<div class="tm-err">NO DATA</div>'; return; }
const keys = [
['OS', stack.os], ['kernel', stack.kernel],
['python', stack.python_version], ['node', stack.node_version],
['nginx', stack.nginx_version], ['docker', stack.docker_version],
['ffmpeg', stack.ffmpeg_version], ['arch', stack.uname],
];
el.innerHTML = keys.filter(([_, v]) => v).map(([k, v]) =>
`<div class="tm-stack-item"><span class="k">${k}</span><span class="v" title="${fmt.escape(v)}">${fmt.escape(v)}</span></div>`
).join('');
}
// ─── Repos ─────────────────────────────────────────
function renderRepos(repos) {
const el = $('tmRepos');
if (!el) return;
if (!repos || !repos.length) { el.innerHTML = '<div class="tm-err">NO REPOS</div>'; return; }
el.innerHTML = repos.map(r => `
<div class="tm-repo">
<div class="tm-repo-head">
<span class="name">${fmt.escape(r.name)}</span>
<span class="sha">${fmt.escape(r.last_commit_sha)}${r.dirty ? '<span class="dirty-badge"> ● DIRTY</span>' : ''}</span>
</div>
<div class="msg">${fmt.escape(r.last_commit_msg)}</div>
<div class="meta">${fmt.ago(r.last_commit_iso)} · ${fmt.escape(r.path)}</div>
</div>
`).join('');
}
// ─── Tail ──────────────────────────────────────────
function renderTail(lines) {
const el = $('tmTail');
if (!el) return;
if (!lines || !lines.length) return;
// Filter to only new lines
const newOnes = [];
for (const l of lines) {
const key = `${l.time}|${l.ip_masked}|${l.path}|${l.status}`;
if (!state.seenTailKeys.has(key)) {
state.seenTailKeys.add(key);
newOnes.push(l);
}
}
if (state.seenTailKeys.size > 200) {
// Trim
const arr = [...state.seenTailKeys].slice(-100);
state.seenTailKeys = new Set(arr);
}
if (!newOnes.length && el.querySelector('.tm-loading')) {
el.innerHTML = '<div style="color:var(--tm-muted);font-size:0.7rem">(no recent requests)</div>';
return;
}
// Clear loading once we have data
if (el.querySelector('.tm-loading')) el.innerHTML = '';
newOnes.forEach(l => {
const stCls = l.status >= 500 ? 'st-5xx' : l.status >= 400 ? 'st-4xx' : l.status >= 300 ? 'st-3xx' : 'st-2xx';
const div = document.createElement('div');
div.className = 'tm-tail-line';
div.innerHTML = `<span class="time">${fmt.escape(l.time.split(' ')[0].split(':').slice(1).join(':'))}</span> <span class="ip">${fmt.escape(l.ip_masked)}</span> <span class="method">${fmt.escape(l.method)}</span> <span class="path">${fmt.escape(l.path)}</span> <span class="${stCls}">${l.status}</span> <span style="color:#3a3a3a">${fmt.bytes(l.size)}</span>`;
el.appendChild(div);
});
// Trim DOM to last 40 lines
while (el.children.length > 40) el.removeChild(el.firstChild);
// Scroll to bottom
el.scrollTop = el.scrollHeight;
}
// ─── Geo ───────────────────────────────────────────
function renderGeo(geo) {
const el = $('tmGeo');
const top = $('tmGeoTop');
if (!el) return;
if (!geo || !geo.length) {
el.innerHTML = '<div class="tm-geo-empty">GEO INTEL UNAVAILABLE<br><span style="font-size:0.65rem">(GeoIP mmdb not installed on VPS)</span></div>';
if (top) top.innerHTML = '';
return;
}
const max = geo[0].count;
// Simple horizontal bar chart instead of a world map (keeps dep-free, no external svg)
el.innerHTML = `
<div style="display:flex;flex-direction:column;gap:0.35rem;padding:0.3rem 0;">
${geo.slice(0, 15).map(g => {
const pct = Math.round(g.count / max * 100);
return `
<div style="display:flex;align-items:center;gap:0.6rem;font-size:0.72rem;">
<span style="width:40px;color:var(--tm-sol);font-family:'Orbitron',sans-serif;">${fmt.escape(g.country_code)}</span>
<div style="flex:1;background:#0a0a0a;height:14px;border:1px solid #1a1a1a;position:relative;">
<div style="height:100%;width:${pct}%;background:linear-gradient(90deg,#00cc33,#14F195);box-shadow:0 0 6px rgba(0,204,51,0.4);"></div>
</div>
<span style="color:var(--tm-text);min-width:100px;font-size:0.68rem;">${fmt.escape(g.country_name)}</span>
<span style="color:var(--tm-green);font-family:'Orbitron',sans-serif;min-width:45px;text-align:right;">${fmt.numGrp(g.count)}</span>
</div>`;
}).join('')}
</div>
`;
if (top) {
top.innerHTML = geo.slice(0, 12).map(g =>
`<div class="c-item"><span>${fmt.escape(g.country_code)}</span><span class="n">${fmt.numGrp(g.count)}</span></div>`
).join('');
}
}
// ─── Burn rate ──────────────────────────────────────
function renderBurn(overview, history) {
const el = $('tmBurn');
if (!el) return;
if (!overview) { el.innerHTML = '<div class="tm-err">NO DATA</div>'; return; }
// Disk burn: find root /, compute daily growth from history if available
const root = (overview.system.disk_per_mount || []).find(d => d.mount === '/') || (overview.system.disk_per_mount || [])[0];
const lines = [];
if (root) {
const freeGb = root.total_gb - root.used_gb;
lines.push(`<div><span class="label">ROOT DISK FREE</span><span class="big">${freeGb} GB</span> of ${root.total_gb} GB (${root.pct}% used)</div>`);
}
// Network today — sum of last 24h net_tx from history
if (history && history.net_tx && history.net_tx.length) {
const sumTx = history.net_tx.reduce((a, b) => a + b, 0);
const sumRx = history.net_rx.reduce((a, b) => a + b, 0);
const avgInterval = 300; // 5min
const totalTx = sumTx * avgInterval;
const totalRx = sumRx * avgInterval;
lines.push(`<div><span class="label">BANDWIDTH 24h</span><span class="big">↑ ${fmt.bytes(totalTx)}</span> · <span class="big">↓ ${fmt.bytes(totalRx)}</span></div>`);
}
lines.push(`<div><span class="label">LOAD AVG</span><span class="big">${overview.system.load_1.toFixed(2)}</span> / ${overview.system.load_5.toFixed(2)} / ${overview.system.load_15.toFixed(2)} (${overview.system.ncpu} CPU)</div>`);
lines.push(`<div><span class="label">KERNEL</span>${fmt.escape(overview.system.kernel)} @ ${fmt.escape(overview.system.hostname)}</div>`);
el.innerHTML = lines.join('');
}
// ─── Ticker ─────────────────────────────────────────
function renderTicker(commits) {
const el = $('tmTicker');
if (!el) return;
if (!commits || !commits.length) { el.innerHTML = '<div class="tm-err">NO COMMITS</div>'; return; }
el.innerHTML = `<div class="tm-ticker-inner">${commits.map(c =>
`<span class="tm-ticker-item"><span class="sha">${fmt.escape(c.sha)}</span>${fmt.escape(c.msg)}<span class="iso">${fmt.ago(c.iso)}</span></span>`
).join('')}</div>`;
}
// ─── Alerts ─────────────────────────────────────────
function renderAlerts(alerts) {
const banner = $('tmAlerts');
const content = $('tmAlertsContent');
if (!banner || !content) return;
if (!alerts || !alerts.length) {
banner.classList.remove('visible', 'amber');
state.lastAlertSig = '';
return;
}
const sig = alerts.map(a => a.level + a.message).join('|');
const hasRed = alerts.some(a => a.level === 'red');
banner.classList.add('visible');
banner.classList.toggle('amber', !hasRed);
if (sig !== state.lastAlertSig) {
content.innerHTML = alerts.map(a =>
`<span class="tm-alert-item ${a.level}">${fmt.escape(a.message)}</span>`
).join('') + alerts.map(a =>
`<span class="tm-alert-item ${a.level}">${fmt.escape(a.message)}</span>`
).join(''); // doubled for smooth marquee
state.lastAlertSig = sig;
if (state.alarm) playBeep(hasRed);
}
}
// ─── Alarm beep (Web Audio) ─────────────────────────
function playBeep(urgent) {
try {
if (!state.audioCtx) state.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const ctx = state.audioCtx;
const o = ctx.createOscillator();
const g = ctx.createGain();
o.connect(g); g.connect(ctx.destination);
o.type = 'square';
o.frequency.value = urgent ? 880 : 440;
g.gain.setValueAtTime(0.15, ctx.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.2);
o.start(ctx.currentTime);
o.stop(ctx.currentTime + 0.22);
} catch (e) { }
}
// ─── Overview render ────────────────────────────────
function applyOverview(data) {
if (!data) return;
state.lastOverview = data;
state.lastSync = Date.now();
const sys = data.system || {};
// CPU
drawGauge($('tmCpuGauge'), sys.cpu_percent);
const cpuVal = $('tmCpuVal');
if (cpuVal) {
cpuVal.textContent = sys.cpu_percent + '%';
cpuVal.className = 'tm-gauge-value ' + (sys.cpu_percent > 90 ? 'crit' : sys.cpu_percent > 80 ? 'warn' : '');
}
const cpuLoad = $('tmCpuLoad');
if (cpuLoad) cpuLoad.textContent = `load ${sys.load_1?.toFixed(2)} · ${sys.ncpu} cores`;
const cpuBadge = $('tmCpuBadge');
if (cpuBadge) cpuBadge.textContent = `${sys.ncpu} CPU`;
// MEM
drawGauge($('tmMemGauge'), sys.mem_percent);
const memVal = $('tmMemVal');
if (memVal) {
memVal.textContent = sys.mem_percent + '%';
memVal.className = 'tm-gauge-value ' + (sys.mem_percent > 90 ? 'crit' : sys.mem_percent > 80 ? 'warn' : '');
}
const memSize = $('tmMemSize');
if (memSize) memSize.textContent = `${fmt.bytes(sys.mem_used_bytes)} / ${fmt.bytes(sys.mem_total_bytes)}`;
const memBadge = $('tmMemBadge');
if (memBadge) memBadge.textContent = fmt.bytes(sys.mem_total_bytes);
// Disks
renderDisks(sys.disk_per_mount);
// Net
const rx = $('tmNetRx'); if (rx) rx.textContent = fmt.bps(sys.net_rx_bps);
const tx = $('tmNetTx'); if (tx) tx.textContent = fmt.bps(sys.net_tx_bps);
const up = $('tmUptime'); if (up) up.textContent = `uptime ${fmt.uptime(sys.uptime_seconds)}`;
// Tables & sections
renderServices(data.services);
renderCrons(data.crons);
renderNginx(data.nginx_24h);
renderSecurity(data.security);
renderSSL(data.ssl);
renderStack(data.stack);
renderRepos(data.repos);
renderBurn(data, state.lastHistory);
}
function applyHistory(data) {
if (!data) return;
state.lastHistory = data;
drawSpark($('tmCpuSpark'), data.cpu, { color: '#00cc33', max: 100 });
drawSpark($('tmMemSpark'), data.mem, { color: '#c9a227', max: 100 });
// Combined net: overlay
const canvas = $('tmNetSpark');
if (canvas && (data.net_rx?.length || data.net_tx?.length)) {
drawSpark(canvas, data.net_rx, { color: '#14F195', fill: true });
// Overlay tx on top without clearing
const ctx = canvas.getContext('2d');
ctx.globalCompositeOperation = 'source-over';
// redraw-ish: tx overlay
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth, h = canvas.clientHeight;
const values = data.net_tx || [];
if (values.length > 1) {
const peak = Math.max(...(data.net_rx || []), ...values, 1);
const step = w / (values.length - 1);
ctx.beginPath();
values.forEach((v, i) => {
const x = i * step;
const y = h - (v / peak) * (h - 4) - 2;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.strokeStyle = '#00cc33';
ctx.lineWidth = 1.2;
ctx.shadowColor = '#00cc33';
ctx.shadowBlur = 4;
ctx.stroke();
ctx.shadowBlur = 0;
}
}
// Re-render burn with fresh history
if (state.lastOverview) renderBurn(state.lastOverview, data);
}
function applyVisitors(data) {
if (!data) return;
const a = $('tmVisitorsActive'); if (a) a.textContent = fmt.numGrp(data.active);
const r = $('tmVisitorsRpm'); if (r) r.textContent = data.req_per_min;
}
// ─── Live indicator ─────────────────────────────────
function tickLive() {
const secs = state.lastSync ? Math.floor((Date.now() - state.lastSync) / 1000) : 0;
const el = $('tmLastSync');
const dot = $('tmLiveDot');
if (el) el.textContent = state.lastSync ? secs : '—';
if (dot) {
dot.className = 'tm-live-dot ' + (secs < 30 ? '' : secs < 90 ? 'amber' : 'red');
}
}
// ─── Polling loops ──────────────────────────────────
async function pollOverview() {
const d = await fetchJSON(API_BASE + '/overview');
if (d) applyOverview(d);
}
async function pollHistory() {
const d = await fetchJSON(API_BASE + '/history');
if (d) applyHistory(d);
}
async function pollTail() {
const d = await fetchJSON(API_BASE + '/nginx-tail');
if (d) renderTail(d);
}
async function pollGeo() {
const d = await fetchJSON(API_BASE + '/geo');
renderGeo(d || []);
}
async function pollAlerts() {
const d = await fetchJSON(API_BASE + '/alerts');
if (Array.isArray(d)) renderAlerts(d);
}
async function pollVisitors() {
const d = await fetchJSON(API_BASE + '/visitors');
if (d) applyVisitors(d);
}
async function pollCommits() {
const d = await fetchJSON(API_BASE + '/commits');
if (d) renderTicker(d);
}
// ─── Sound toggle ───────────────────────────────────
function bindSoundToggle() {
const btn = $('tmSoundToggle');
if (!btn) return;
btn.addEventListener('click', () => {
state.alarm = !state.alarm;
btn.classList.toggle('on', state.alarm);
btn.textContent = state.alarm ? '🔊 ALARM ON' : '🔇 ALARM OFF';
if (state.alarm) {
// Init audio context on user gesture
if (!state.audioCtx) state.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
playBeep(false);
}
});
}
// ─── Boot ──────────────────────────────────────────
function hideBoot() {
const boot = $('tmBoot');
if (boot) setTimeout(() => { boot.classList.add('hidden'); setTimeout(() => boot.remove(), 600); }, 1500);
}
async function init() {
hideBoot();
bindSoundToggle();
// Initial fetches — parallel
await Promise.all([pollOverview(), pollHistory(), pollCommits(), pollGeo(), pollVisitors(), pollAlerts(), pollTail()]);
// Intervals
setInterval(pollOverview, POLL_OVERVIEW);
setInterval(pollHistory, POLL_HISTORY);
setInterval(pollTail, POLL_TAIL);
setInterval(pollGeo, POLL_GEO);
setInterval(pollAlerts, POLL_ALERTS);
setInterval(pollVisitors, POLL_VISITORS);
setInterval(pollCommits, POLL_COMMITS);
setInterval(tickLive, 1000);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();