feat: telemetry dashboard v1.36.0 - live ops command centre
This commit is contained in:
parent
f7097e8954
commit
bacd671149
7 changed files with 2236 additions and 18 deletions
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
618
api/telemetry_routes.py
Normal file
618
api/telemetry_routes.py
Normal 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
101
api/telemetry_snapshot.py
Normal 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
617
css/telemetry.css
Normal 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; }
|
||||
|
|
@ -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 rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="/css/section.css">
|
||||
<link rel="stylesheet" href="/css/telemetry.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="scanline-overlay"></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">
|
||||
<div class="nav-container">
|
||||
<a href="/" class="nav-logo">
|
||||
|
|
@ -39,32 +49,182 @@
|
|||
<section class="section-header" style="padding-top: calc(var(--nav-height) + 1.5rem);">
|
||||
<div class="section-header-label">HQ // HEADQUARTERS</div>
|
||||
<h1 class="section-header-title">TELEMETRY</h1>
|
||||
<p class="section-header-sub">> Live facility systems status, tech stack, and operational metrics.</p>
|
||||
<p class="section-header-sub">> Live ops feed — every metric, every service, every packet.</p>
|
||||
</section>
|
||||
|
||||
<section class="subpage-content">
|
||||
<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>
|
||||
<main class="tm-dashboard">
|
||||
|
||||
<footer class="footer">
|
||||
<div class="footer-container">
|
||||
<div class="footer-left">
|
||||
<span class="footer-logo">[JAE]</span>
|
||||
<span class="footer-copy">© 2026 JAESWIFT.XYZ</span>
|
||||
<!-- Alert banner (hidden until alerts exist) -->
|
||||
<div class="tm-alerts" id="tmAlerts">
|
||||
<div class="tm-alerts-content" id="tmAlertsContent"></div>
|
||||
</div>
|
||||
|
||||
<!-- 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 class="footer-right">
|
||||
<span class="footer-signal">SIGNAL ████<span class="signal-flicker">█</span></span>
|
||||
<div class="tm-visitors">
|
||||
<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>
|
||||
</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/nav.js"></script>
|
||||
<script src="/js/clock.js"></script>
|
||||
<script src="/js/telemetry.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
685
js/telemetry.js
Normal file
685
js/telemetry.js
Normal 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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();
|
||||
}
|
||||
})();
|
||||
Loading…
Add table
Reference in a new issue