diff --git a/api/app.py b/api/app.py index 1adfad0..4bce094 100644 --- a/api/app.py +++ b/api/app.py @@ -17,6 +17,13 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) app = Flask(__name__) 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' JWT_SECRET = 'jaeswift-hud-s3cr3t-2026!x' ADMIN_USER = 'jae' diff --git a/api/data/changelog.json b/api/data/changelog.json index d56e4f9..8e87a4c 100644 --- a/api/data/changelog.json +++ b/api/data/changelog.json @@ -1,6 +1,36 @@ { "site": "jaeswift.xyz", "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", "date": "19/04/2026", diff --git a/api/telemetry_routes.py b/api/telemetry_routes.py new file mode 100644 index 0000000..958c514 --- /dev/null +++ b/api/telemetry_routes.py @@ -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)) diff --git a/api/telemetry_snapshot.py b/api/telemetry_snapshot.py new file mode 100644 index 0000000..38c060c --- /dev/null +++ b/api/telemetry_snapshot.py @@ -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 diff --git a/css/telemetry.css b/css/telemetry.css new file mode 100644 index 0000000..8e48380 --- /dev/null +++ b/css/telemetry.css @@ -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; } diff --git a/hq/telemetry.html b/hq/telemetry.html index 7037bbd..9ed6065 100644 --- a/hq/telemetry.html +++ b/hq/telemetry.html @@ -8,11 +8,21 @@ +
+ +
+
INITIALISING TELEMETRY UPLINK
+
NEGOTIATING SECURE CHANNEL // VPS.MCR01
+
DECRYPTING OPS FEED — KEY ROLL OK
+
+
AUTHORISED — OPERATOR GRADE A
+
+