remove infra.md.example, infra.md is the source of truth

This commit is contained in:
Azreen Jamal
2026-03-03 03:06:13 +08:00
parent 1ad3033cc1
commit a3c6d09350
86 changed files with 17093 additions and 39 deletions

View File

@@ -0,0 +1,7 @@
"""AYN Antivirus - Live Web Dashboard."""
from ayn_antivirus.dashboard.collector import MetricsCollector
from ayn_antivirus.dashboard.server import DashboardServer
from ayn_antivirus.dashboard.store import DashboardStore
__all__ = ["DashboardServer", "DashboardStore", "MetricsCollector"]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,181 @@
"""Background metrics collector for the AYN Antivirus dashboard."""
from __future__ import annotations
import asyncio
import logging
import os
import random
from datetime import datetime
from typing import Any, Dict, Optional
import psutil
from ayn_antivirus.constants import DASHBOARD_COLLECTOR_INTERVAL
logger = logging.getLogger("ayn_antivirus.dashboard.collector")
class MetricsCollector:
"""Periodically sample system metrics and store them in the dashboard DB.
Parameters
----------
store:
A :class:`DashboardStore` instance to write metrics into.
interval:
Seconds between samples.
"""
def __init__(self, store: Any, interval: int = DASHBOARD_COLLECTOR_INTERVAL) -> None:
self.store = store
self.interval = interval
self._task: Optional[asyncio.Task] = None
self._running = False
async def start(self) -> None:
"""Begin collecting metrics on a background asyncio task."""
self._running = True
self._task = asyncio.create_task(self._collect_loop())
logger.info("Metrics collector started (interval=%ds)", self.interval)
async def stop(self) -> None:
"""Cancel the background task and wait for it to finish."""
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
logger.info("Metrics collector stopped")
# ------------------------------------------------------------------
# Internal loop
# ------------------------------------------------------------------
async def _collect_loop(self) -> None:
while self._running:
try:
await asyncio.to_thread(self._sample)
except Exception as exc:
logger.error("Collector error: %s", exc)
await asyncio.sleep(self.interval)
def _sample(self) -> None:
"""Take a single metric snapshot and persist it."""
cpu = psutil.cpu_percent(interval=1)
mem = psutil.virtual_memory()
disks = []
for part in psutil.disk_partitions(all=False):
try:
usage = psutil.disk_usage(part.mountpoint)
disks.append({
"mount": part.mountpoint,
"device": part.device,
"total": usage.total,
"used": usage.used,
"free": usage.free,
"percent": usage.percent,
})
except (PermissionError, OSError):
continue
try:
load = list(os.getloadavg())
except (OSError, AttributeError):
load = [0.0, 0.0, 0.0]
try:
net_conns = len(psutil.net_connections(kind="inet"))
except (psutil.AccessDenied, OSError):
net_conns = 0
self.store.record_metric(
cpu=cpu,
mem_pct=mem.percent,
mem_used=mem.used,
mem_total=mem.total,
disk_usage=disks,
load_avg=load,
net_conns=net_conns,
)
# Periodic cleanup (~1 in 100 samples).
if random.randint(1, 100) == 1:
self.store.cleanup_old_metrics()
# ------------------------------------------------------------------
# One-shot snapshot (no storage)
# ------------------------------------------------------------------
@staticmethod
def get_snapshot() -> Dict[str, Any]:
"""Return a live system snapshot without persisting it."""
cpu = psutil.cpu_percent(interval=0.1)
cpu_per_core = psutil.cpu_percent(interval=0.1, percpu=True)
cpu_freq = psutil.cpu_freq(percpu=False)
mem = psutil.virtual_memory()
swap = psutil.swap_memory()
disks = []
for part in psutil.disk_partitions(all=False):
try:
usage = psutil.disk_usage(part.mountpoint)
disks.append({
"mount": part.mountpoint,
"device": part.device,
"total": usage.total,
"used": usage.used,
"percent": usage.percent,
})
except (PermissionError, OSError):
continue
try:
load = list(os.getloadavg())
except (OSError, AttributeError):
load = [0.0, 0.0, 0.0]
try:
net_conns = len(psutil.net_connections(kind="inet"))
except (psutil.AccessDenied, OSError):
net_conns = 0
# Top processes by CPU
top_procs = []
try:
for p in sorted(psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent']),
key=lambda x: x.info.get('cpu_percent', 0) or 0, reverse=True)[:8]:
info = p.info
if (info.get('cpu_percent') or 0) > 0.1:
top_procs.append({
"pid": info['pid'],
"name": info['name'] or '?',
"cpu": round(info.get('cpu_percent', 0) or 0, 1),
"mem": round(info.get('memory_percent', 0) or 0, 1),
})
except Exception:
pass
return {
"cpu_percent": cpu,
"cpu_per_core": cpu_per_core,
"cpu_cores": psutil.cpu_count(logical=True),
"cpu_freq_mhz": round(cpu_freq.current) if cpu_freq else 0,
"mem_percent": mem.percent,
"mem_used": mem.used,
"mem_total": mem.total,
"mem_available": mem.available,
"mem_cached": getattr(mem, 'cached', 0),
"mem_buffers": getattr(mem, 'buffers', 0),
"swap_percent": swap.percent,
"swap_used": swap.used,
"swap_total": swap.total,
"disk_usage": disks,
"load_avg": load,
"net_connections": net_conns,
"top_processes": top_procs,
"timestamp": datetime.utcnow().isoformat(),
}

View File

@@ -0,0 +1,427 @@
"""AYN Antivirus Dashboard — Web Server with Password Auth.
Lightweight aiohttp server that serves the dashboard SPA and REST API.
Non-localhost access requires username/password authentication via a
session cookie obtained through ``POST /login``.
"""
from __future__ import annotations
import logging
import secrets
import time
from typing import Dict, Optional
from urllib.parse import urlparse
from aiohttp import web
from ayn_antivirus.config import Config
from ayn_antivirus.constants import QUARANTINE_ENCRYPTION_KEY_FILE
from ayn_antivirus.dashboard.api import setup_routes
from ayn_antivirus.dashboard.collector import MetricsCollector
from ayn_antivirus.dashboard.store import DashboardStore
from ayn_antivirus.dashboard.templates import get_dashboard_html
logger = logging.getLogger("ayn_antivirus.dashboard.server")
# ------------------------------------------------------------------
# JSON error handler — prevent aiohttp returning HTML on /api/* routes
# ------------------------------------------------------------------
@web.middleware
async def json_error_middleware(
request: web.Request,
handler,
) -> web.StreamResponse:
"""Catch unhandled exceptions and return JSON for API routes.
Without this, aiohttp's default error handler returns HTML error
pages, which break frontend ``fetch().json()`` calls.
"""
try:
return await handler(request)
except web.HTTPException as exc:
if request.path.startswith("/api/"):
return web.json_response(
{"error": exc.reason or "Request failed"},
status=exc.status,
)
raise
except Exception as exc:
logger.exception("Unhandled error on %s %s", request.method, request.path)
if request.path.startswith("/api/"):
return web.json_response(
{"error": f"Internal server error: {exc}"},
status=500,
)
return web.Response(
text="<h1>500 Internal Server Error</h1>",
status=500,
content_type="text/html",
)
# ------------------------------------------------------------------
# Rate limiting state
# ------------------------------------------------------------------
_action_timestamps: Dict[str, float] = {}
_RATE_LIMIT_SECONDS = 10
# ------------------------------------------------------------------
# Authentication middleware
# ------------------------------------------------------------------
@web.middleware
async def auth_middleware(
request: web.Request,
handler,
) -> web.StreamResponse:
"""Authenticate all requests.
* ``/login`` and ``/favicon.ico`` are always allowed.
* All other routes require a valid session cookie.
* Unauthenticated HTML routes serve the login page.
* Unauthenticated ``/api/*`` returns 401.
* POST ``/api/actions/*`` enforces CSRF and rate limiting.
"""
# Login route is always open.
if request.path in ("/login", "/favicon.ico"):
return await handler(request)
# All requests require auth (no localhost bypass — behind reverse proxy).
# Check session cookie.
session_token = request.app.get("_session_token", "")
cookie = request.cookies.get("ayn_session", "")
authenticated = (
cookie
and session_token
and secrets.compare_digest(cookie, session_token)
)
if not authenticated:
if request.path.startswith("/api/"):
return web.json_response(
{"error": "Unauthorized. Please login."}, status=401,
)
# Serve login page for HTML routes.
return web.Response(
text=request.app["_login_html"], content_type="text/html",
)
# CSRF + rate-limiting for POST action endpoints.
if request.method == "POST" and request.path.startswith("/api/actions/"):
origin = request.headers.get("Origin", "")
if origin:
parsed = urlparse(origin)
origin_host = parsed.hostname or ""
host = request.headers.get("Host", "")
expected = host.split(":")[0] if host else ""
allowed = {expected, "localhost", "127.0.0.1", "::1"}
allowed.discard("")
if origin_host not in allowed:
return web.json_response(
{"error": "CSRF: Origin mismatch"}, status=403,
)
now = time.time()
last = _action_timestamps.get(request.path, 0)
if now - last < _RATE_LIMIT_SECONDS:
return web.json_response(
{"error": "Rate limited. Try again in a few seconds."},
status=429,
)
_action_timestamps[request.path] = now
return await handler(request)
# ------------------------------------------------------------------
# Dashboard server
# ------------------------------------------------------------------
class DashboardServer:
"""AYN Antivirus dashboard with username/password authentication."""
def __init__(self, config: Optional[Config] = None) -> None:
self.config = config or Config()
self.store = DashboardStore(self.config.dashboard_db_path)
self.collector = MetricsCollector(self.store)
self.app = web.Application(middlewares=[json_error_middleware, auth_middleware])
self._session_token: str = secrets.token_urlsafe(32)
self._runner: Optional[web.AppRunner] = None
self._site: Optional[web.TCPSite] = None
self._setup()
# ------------------------------------------------------------------
# Setup
# ------------------------------------------------------------------
def _setup(self) -> None:
"""Configure the aiohttp application."""
self.app["_session_token"] = self._session_token
self.app["_login_html"] = self._build_login_page()
self.app["store"] = self.store
self.app["collector"] = self.collector
self.app["config"] = self.config
# Quarantine vault (best-effort).
try:
from ayn_antivirus.quarantine.vault import QuarantineVault
self.app["vault"] = QuarantineVault(
quarantine_dir=self.config.quarantine_path,
key_file_path=QUARANTINE_ENCRYPTION_KEY_FILE,
)
except Exception as exc:
logger.warning("Quarantine vault not available: %s", exc)
# API routes (``/api/*``).
setup_routes(self.app)
# HTML routes.
self.app.router.add_get("/", self._serve_dashboard)
self.app.router.add_get("/dashboard", self._serve_dashboard)
self.app.router.add_get("/login", self._serve_login)
self.app.router.add_post("/login", self._handle_login)
# Lifecycle hooks.
self.app.on_startup.append(self._on_startup)
self.app.on_shutdown.append(self._on_shutdown)
# ------------------------------------------------------------------
# Request handlers
# ------------------------------------------------------------------
async def _serve_login(self, request: web.Request) -> web.Response:
"""``GET /login`` — render the login page."""
return web.Response(
text=self.app["_login_html"], content_type="text/html",
)
async def _serve_dashboard(self, request: web.Request) -> web.Response:
"""``GET /`` or ``GET /dashboard`` — render the SPA.
The middleware already enforces auth for non-localhost, so if we
reach here the client is authenticated (or local).
"""
html = get_dashboard_html()
return web.Response(text=html, content_type="text/html")
async def _handle_login(self, request: web.Request) -> web.Response:
"""``POST /login`` — validate username/password, set session cookie."""
try:
body = await request.json()
username = body.get("username", "").strip()
password = body.get("password", "").strip()
except Exception:
return web.json_response({"error": "Invalid request"}, status=400)
if not username or not password:
return web.json_response(
{"error": "Username and password required"}, status=400,
)
valid_user = secrets.compare_digest(
username, self.config.dashboard_username,
)
valid_pass = secrets.compare_digest(
password, self.config.dashboard_password,
)
if not (valid_user and valid_pass):
self.store.log_activity(
f"Failed login attempt from {request.remote}: user={username}",
"WARNING",
"auth",
)
return web.json_response(
{"error": "Invalid username or password"}, status=401,
)
self.store.log_activity(
f"Successful login from {request.remote}: user={username}",
"INFO",
"auth",
)
response = web.json_response(
{"status": "ok", "message": "Welcome to AYN Antivirus"},
)
response.set_cookie(
"ayn_session",
self._session_token,
httponly=True,
max_age=86400,
samesite="Strict",
)
return response
# ------------------------------------------------------------------
# Login page
# ------------------------------------------------------------------
@staticmethod
def _build_login_page() -> str:
"""Return a polished HTML login form with username + password fields."""
return '''<!DOCTYPE html>
<html lang="en"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>AYN Antivirus \u2014 Login</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0a0e17;color:#e2e8f0;font-family:'Segoe UI',system-ui,-apple-system,sans-serif;
display:flex;justify-content:center;align-items:center;min-height:100vh;
background-image:radial-gradient(circle at 50% 50%,#111827 0%,#0a0e17 70%)}
.login-box{background:#111827;padding:2.5rem;border-radius:16px;border:1px solid #2a3444;
width:420px;max-width:90vw;box-shadow:0 25px 80px rgba(0,0,0,0.6)}
.logo{text-align:center;margin-bottom:2rem}
.logo .shield{font-size:3.5rem;display:block;margin-bottom:0.5rem}
.logo h1{font-size:1.8rem;background:linear-gradient(135deg,#3b82f6,#06b6d4);
-webkit-background-clip:text;-webkit-text-fill-color:transparent;font-weight:800}
.logo .subtitle{color:#6b7280;font-size:0.8rem;margin-top:0.3rem;letter-spacing:2px;text-transform:uppercase}
.field{margin-bottom:1.2rem}
.field label{display:block;font-size:0.75rem;color:#9ca3af;margin-bottom:0.4rem;
text-transform:uppercase;letter-spacing:1px;font-weight:600}
.field input{width:100%;padding:12px 16px;background:#0d1117;border:1px solid #2a3444;
color:#e2e8f0;border-radius:10px;font-size:15px;transition:all 0.2s;outline:none}
.field input:focus{border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,0.15)}
.field input::placeholder{color:#4b5563}
.btn{width:100%;padding:13px;background:linear-gradient(135deg,#3b82f6,#2563eb);color:white;
border:none;border-radius:10px;cursor:pointer;font-size:15px;font-weight:700;
transition:all 0.2s;margin-top:0.8rem;letter-spacing:0.5px}
.btn:hover{transform:translateY(-2px);box-shadow:0 8px 25px rgba(59,130,246,0.4)}
.btn:active{transform:translateY(0)}
.btn:disabled{opacity:0.5;cursor:not-allowed;transform:none}
.error{color:#fca5a5;font-size:0.85rem;text-align:center;margin-top:1rem;padding:10px 16px;
background:rgba(239,68,68,0.1);border-radius:8px;display:none;border:1px solid rgba(239,68,68,0.2)}
.footer{text-align:center;margin-top:2rem;padding-top:1.5rem;border-top:1px solid #1e293b}
.footer p{color:#4b5563;font-size:0.7rem;line-height:1.8}
.spinner{display:inline-block;width:16px;height:16px;border:2px solid #fff;
border-top-color:transparent;border-radius:50%;animation:spin 0.6s linear infinite;
vertical-align:middle;margin-right:8px}
@keyframes spin{to{transform:rotate(360deg)}}
</style></head>
<body>
<div class="login-box">
<div class="logo">
<span class="shield">\U0001f6e1\ufe0f</span>
<h1>AYN ANTIVIRUS</h1>
<div class="subtitle">Security Operations Dashboard</div>
</div>
<form id="loginForm" onsubmit="return doLogin()">
<div class="field">
<label>Username</label>
<input type="text" id="username" placeholder="Enter username" autocomplete="username" autofocus required>
</div>
<div class="field">
<label>Password</label>
<input type="password" id="password" placeholder="Enter password" autocomplete="current-password" required>
</div>
<button type="submit" class="btn" id="loginBtn">\U0001f510 Sign In</button>
</form>
<div class="error" id="errMsg">Invalid credentials</div>
<div class="footer">
<p>AYN Antivirus v1.0.0 \u2014 Server Protection Suite<br>
Secure Access Portal</p>
</div>
</div>
<script>
async function doLogin(){
var btn=document.getElementById('loginBtn');
var err=document.getElementById('errMsg');
var user=document.getElementById('username').value.trim();
var pass=document.getElementById('password').value;
if(!user||!pass)return false;
err.style.display='none';
btn.disabled=true;
btn.innerHTML='<span class="spinner"></span>Signing in...';
try{
var r=await fetch('/login',{method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({username:user,password:pass})});
if(r.ok){window.location.href='/dashboard';}
else{var d=await r.json();err.textContent=d.error||'Invalid credentials';err.style.display='block';}
}catch(e){err.textContent='Connection failed. Check server.';err.style.display='block';}
btn.disabled=false;btn.innerHTML='\\U0001f510 Sign In';
return false;
}
document.querySelectorAll('input').forEach(function(i){i.addEventListener('input',function(){
document.getElementById('errMsg').style.display='none';
});});
</script>
</body></html>'''
# ------------------------------------------------------------------
# Lifecycle hooks
# ------------------------------------------------------------------
async def _on_startup(self, app: web.Application) -> None:
await self.collector.start()
self.store.log_activity("Dashboard server started", "INFO", "server")
logger.info(
"Dashboard on http://%s:%d",
self.config.dashboard_host,
self.config.dashboard_port,
)
async def _on_shutdown(self, app: web.Application) -> None:
await self.collector.stop()
self.store.log_activity("Dashboard server stopped", "INFO", "server")
self.store.close()
# ------------------------------------------------------------------
# Blocking run
# ------------------------------------------------------------------
def run(self) -> None:
"""Run the dashboard server (blocking)."""
host = self.config.dashboard_host
port = self.config.dashboard_port
print(f"\n \U0001f6e1\ufe0f AYN Antivirus Dashboard")
print(f" \U0001f310 http://{host}:{port}")
print(f" \U0001f464 Username: {self.config.dashboard_username}")
print(f" \U0001f511 Password: {self.config.dashboard_password}")
print(f" Press Ctrl+C to stop\n")
web.run_app(self.app, host=host, port=port, print=None)
# ------------------------------------------------------------------
# Async start / stop (non-blocking)
# ------------------------------------------------------------------
async def start_async(self) -> None:
"""Start the server without blocking."""
self._runner = web.AppRunner(self.app)
await self._runner.setup()
self._site = web.TCPSite(
self._runner,
self.config.dashboard_host,
self.config.dashboard_port,
)
await self._site.start()
self.store.log_activity(
"Dashboard server started (async)", "INFO", "server",
)
async def stop_async(self) -> None:
"""Stop a server previously started with :meth:`start_async`."""
if self._site:
await self._site.stop()
if self._runner:
await self._runner.cleanup()
await self.collector.stop()
self.store.close()
# ------------------------------------------------------------------
# Convenience entry point
# ------------------------------------------------------------------
def run_dashboard(config: Optional[Config] = None) -> None:
"""Create a :class:`DashboardServer` and run it (blocking)."""
DashboardServer(config).run()

View File

@@ -0,0 +1,386 @@
"""Persistent storage for dashboard metrics, threat logs, and scan history."""
from __future__ import annotations
import json
import os
import sqlite3
import threading
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from ayn_antivirus.constants import (
DASHBOARD_MAX_THREATS_DISPLAY,
DASHBOARD_METRIC_RETENTION_HOURS,
DASHBOARD_SCAN_HISTORY_DAYS,
DEFAULT_DASHBOARD_DB_PATH,
)
class DashboardStore:
"""SQLite-backed store for all dashboard data.
Parameters
----------
db_path:
Path to the SQLite database file. Created automatically if it
does not exist.
"""
def __init__(self, db_path: str = DEFAULT_DASHBOARD_DB_PATH) -> None:
os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True)
self.db_path = db_path
self._lock = threading.RLock()
self.conn = sqlite3.connect(db_path, check_same_thread=False)
self.conn.row_factory = sqlite3.Row
self.conn.execute("PRAGMA journal_mode=WAL")
self.conn.execute("PRAGMA synchronous=NORMAL")
self._create_tables()
# ------------------------------------------------------------------
# Schema
# ------------------------------------------------------------------
def _create_tables(self) -> None:
self.conn.executescript("""
CREATE TABLE IF NOT EXISTS metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
cpu_percent REAL DEFAULT 0,
mem_percent REAL DEFAULT 0,
mem_used INTEGER DEFAULT 0,
mem_total INTEGER DEFAULT 0,
disk_usage_json TEXT DEFAULT '[]',
load_avg_json TEXT DEFAULT '[]',
net_connections INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS threat_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
file_path TEXT,
threat_name TEXT NOT NULL,
threat_type TEXT NOT NULL,
severity TEXT NOT NULL,
detector TEXT,
file_hash TEXT,
action_taken TEXT DEFAULT 'detected',
details TEXT
);
CREATE TABLE IF NOT EXISTS scan_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
scan_type TEXT NOT NULL,
scan_path TEXT,
files_scanned INTEGER DEFAULT 0,
files_skipped INTEGER DEFAULT 0,
threats_found INTEGER DEFAULT 0,
duration_seconds REAL DEFAULT 0,
status TEXT DEFAULT 'completed'
);
CREATE TABLE IF NOT EXISTS signature_updates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
feed_name TEXT NOT NULL,
hashes_added INTEGER DEFAULT 0,
ips_added INTEGER DEFAULT 0,
domains_added INTEGER DEFAULT 0,
urls_added INTEGER DEFAULT 0,
status TEXT DEFAULT 'success',
details TEXT
);
CREATE TABLE IF NOT EXISTS activity_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
level TEXT NOT NULL DEFAULT 'INFO',
source TEXT,
message TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_metrics_ts ON metrics(timestamp);
CREATE INDEX IF NOT EXISTS idx_threats_ts ON threat_log(timestamp);
CREATE INDEX IF NOT EXISTS idx_threats_severity ON threat_log(severity);
CREATE INDEX IF NOT EXISTS idx_scans_ts ON scan_history(timestamp);
CREATE INDEX IF NOT EXISTS idx_sigs_ts ON signature_updates(timestamp);
CREATE INDEX IF NOT EXISTS idx_activity_ts ON activity_log(timestamp);
""")
self.conn.commit()
# ------------------------------------------------------------------
# Metrics
# ------------------------------------------------------------------
def record_metric(
self,
cpu: float,
mem_pct: float,
mem_used: int,
mem_total: int,
disk_usage: list,
load_avg: list,
net_conns: int,
) -> None:
with self._lock:
self.conn.execute(
"INSERT INTO metrics "
"(cpu_percent, mem_percent, mem_used, mem_total, "
"disk_usage_json, load_avg_json, net_connections) "
"VALUES (?,?,?,?,?,?,?)",
(cpu, mem_pct, mem_used, mem_total,
json.dumps(disk_usage), json.dumps(load_avg), net_conns),
)
self.conn.commit()
def get_latest_metrics(self) -> Optional[Dict[str, Any]]:
with self._lock:
row = self.conn.execute(
"SELECT * FROM metrics ORDER BY id DESC LIMIT 1"
).fetchone()
if not row:
return None
d = dict(row)
d["disk_usage"] = json.loads(d.pop("disk_usage_json", "[]"))
d["load_avg"] = json.loads(d.pop("load_avg_json", "[]"))
return d
def get_metrics_history(self, hours: int = 1) -> List[Dict[str, Any]]:
cutoff = (datetime.utcnow() - timedelta(hours=hours)).strftime("%Y-%m-%d %H:%M:%S")
with self._lock:
rows = self.conn.execute(
"SELECT * FROM metrics WHERE timestamp >= ? ORDER BY timestamp",
(cutoff,),
).fetchall()
result: List[Dict[str, Any]] = []
for r in rows:
d = dict(r)
d["disk_usage"] = json.loads(d.pop("disk_usage_json", "[]"))
d["load_avg"] = json.loads(d.pop("load_avg_json", "[]"))
result.append(d)
return result
# ------------------------------------------------------------------
# Threats
# ------------------------------------------------------------------
def record_threat(
self,
file_path: str,
threat_name: str,
threat_type: str,
severity: str,
detector: str = "",
file_hash: str = "",
action: str = "detected",
details: str = "",
) -> None:
with self._lock:
self.conn.execute(
"INSERT INTO threat_log "
"(file_path, threat_name, threat_type, severity, "
"detector, file_hash, action_taken, details) "
"VALUES (?,?,?,?,?,?,?,?)",
(file_path, threat_name, threat_type, severity,
detector, file_hash, action, details),
)
self.conn.commit()
def get_recent_threats(
self, limit: int = DASHBOARD_MAX_THREATS_DISPLAY,
) -> List[Dict[str, Any]]:
with self._lock:
rows = self.conn.execute(
"SELECT * FROM threat_log ORDER BY id DESC LIMIT ?", (limit,)
).fetchall()
return [dict(r) for r in rows]
def get_threat_stats(self) -> Dict[str, Any]:
with self._lock:
total = self.conn.execute(
"SELECT COUNT(*) FROM threat_log"
).fetchone()[0]
by_severity: Dict[str, int] = {}
for row in self.conn.execute(
"SELECT severity, COUNT(*) as cnt FROM threat_log GROUP BY severity"
):
by_severity[row[0]] = row[1]
cutoff_24h = (datetime.utcnow() - timedelta(hours=24)).strftime("%Y-%m-%d %H:%M:%S")
cutoff_7d = (datetime.utcnow() - timedelta(days=7)).strftime("%Y-%m-%d %H:%M:%S")
last_24h = self.conn.execute(
"SELECT COUNT(*) FROM threat_log WHERE timestamp >= ?",
(cutoff_24h,),
).fetchone()[0]
last_7d = self.conn.execute(
"SELECT COUNT(*) FROM threat_log WHERE timestamp >= ?",
(cutoff_7d,),
).fetchone()[0]
return {
"total": total,
"by_severity": by_severity,
"last_24h": last_24h,
"last_7d": last_7d,
}
# ------------------------------------------------------------------
# Scans
# ------------------------------------------------------------------
def record_scan(
self,
scan_type: str,
scan_path: str,
files_scanned: int,
files_skipped: int,
threats_found: int,
duration: float,
status: str = "completed",
) -> None:
with self._lock:
self.conn.execute(
"INSERT INTO scan_history "
"(scan_type, scan_path, files_scanned, files_skipped, "
"threats_found, duration_seconds, status) "
"VALUES (?,?,?,?,?,?,?)",
(scan_type, scan_path, files_scanned, files_skipped,
threats_found, duration, status),
)
self.conn.commit()
def get_recent_scans(self, limit: int = 30) -> List[Dict[str, Any]]:
with self._lock:
rows = self.conn.execute(
"SELECT * FROM scan_history ORDER BY id DESC LIMIT ?", (limit,)
).fetchall()
return [dict(r) for r in rows]
def get_scan_chart_data(
self, days: int = DASHBOARD_SCAN_HISTORY_DAYS,
) -> List[Dict[str, Any]]:
cutoff = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
with self._lock:
rows = self.conn.execute(
"SELECT DATE(timestamp) as day, "
"COUNT(*) as scans, "
"SUM(threats_found) as threats, "
"SUM(files_scanned) as files "
"FROM scan_history WHERE timestamp >= ? "
"GROUP BY DATE(timestamp) ORDER BY day",
(cutoff,),
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# Signature Updates
# ------------------------------------------------------------------
def record_sig_update(
self,
feed_name: str,
hashes: int = 0,
ips: int = 0,
domains: int = 0,
urls: int = 0,
status: str = "success",
details: str = "",
) -> None:
with self._lock:
self.conn.execute(
"INSERT INTO signature_updates "
"(feed_name, hashes_added, ips_added, domains_added, "
"urls_added, status, details) "
"VALUES (?,?,?,?,?,?,?)",
(feed_name, hashes, ips, domains, urls, status, details),
)
self.conn.commit()
def get_recent_sig_updates(self, limit: int = 20) -> List[Dict[str, Any]]:
with self._lock:
rows = self.conn.execute(
"SELECT * FROM signature_updates ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(r) for r in rows]
def get_sig_stats(self) -> Dict[str, Any]:
"""Return signature stats from the actual signatures database."""
result = {
"total_hashes": 0,
"total_ips": 0,
"total_domains": 0,
"total_urls": 0,
"last_update": None,
}
# Try to read live counts from the signatures DB
sig_db_path = self.db_path.replace("dashboard.db", "signatures.db")
try:
import sqlite3 as _sql
sdb = _sql.connect(sig_db_path)
sdb.row_factory = _sql.Row
for tbl, key in [("threats", "total_hashes"), ("ioc_ips", "total_ips"),
("ioc_domains", "total_domains"), ("ioc_urls", "total_urls")]:
try:
result[key] = sdb.execute(f"SELECT COUNT(*) FROM {tbl}").fetchone()[0]
except Exception:
pass
try:
ts = sdb.execute("SELECT MAX(added_date) FROM threats").fetchone()[0]
result["last_update"] = ts
except Exception:
pass
sdb.close()
except Exception:
# Fallback to dashboard update log
with self._lock:
row = self.conn.execute(
"SELECT SUM(hashes_added), SUM(ips_added), "
"SUM(domains_added), SUM(urls_added) FROM signature_updates"
).fetchone()
result["total_hashes"] = row[0] or 0
result["total_ips"] = row[1] or 0
result["total_domains"] = row[2] or 0
result["total_urls"] = row[3] or 0
lu = self.conn.execute(
"SELECT MAX(timestamp) FROM signature_updates"
).fetchone()[0]
result["last_update"] = lu
return result
# ------------------------------------------------------------------
# Activity Log
# ------------------------------------------------------------------
def log_activity(
self,
message: str,
level: str = "INFO",
source: str = "system",
) -> None:
with self._lock:
self.conn.execute(
"INSERT INTO activity_log (level, source, message) VALUES (?,?,?)",
(level, source, message),
)
self.conn.commit()
def get_recent_logs(self, limit: int = 20) -> List[Dict[str, Any]]:
with self._lock:
rows = self.conn.execute(
"SELECT * FROM activity_log ORDER BY id DESC LIMIT ?", (limit,)
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# Cleanup
# ------------------------------------------------------------------
def cleanup_old_metrics(
self, hours: int = DASHBOARD_METRIC_RETENTION_HOURS,
) -> None:
cutoff = (datetime.utcnow() - timedelta(hours=hours)).strftime("%Y-%m-%d %H:%M:%S")
with self._lock:
self.conn.execute("DELETE FROM metrics WHERE timestamp < ?", (cutoff,))
self.conn.commit()
def close(self) -> None:
self.conn.close()

View File

@@ -0,0 +1,910 @@
"""AYN Antivirus Dashboard — HTML Template.
Single-page application with embedded CSS and JavaScript.
All data is fetched from the ``/api/*`` endpoints.
"""
from __future__ import annotations
def get_dashboard_html() -> str:
"""Return the complete HTML dashboard as a string."""
return _HTML
_HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>AYN Antivirus — Security Dashboard</title>
<style>
:root{--bg:#0a0e17;--surface:#111827;--surface2:#1a2332;--surface3:#1f2b3d;
--border:#2a3444;--text:#e2e8f0;--text-dim:#8892a4;--accent:#3b82f6;
--green:#10b981;--red:#ef4444;--orange:#f59e0b;--yellow:#eab308;
--purple:#8b5cf6;--cyan:#06b6d4;--radius:8px;--shadow:0 2px 8px rgba(0,0,0,.3)}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,-apple-system,sans-serif;font-size:14px;min-height:100vh}
a{color:var(--accent);text-decoration:none}
::-webkit-scrollbar{width:6px;height:6px}
::-webkit-scrollbar-track{background:var(--surface)}
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
/* ── Header ── */
.header{background:var(--surface);border-bottom:1px solid var(--border);padding:12px 24px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100}
.header-left{display:flex;align-items:center;gap:16px}
.logo{font-size:1.3rem;font-weight:800;letter-spacing:.05em}
.logo span{color:var(--accent)}
.header-meta{display:flex;gap:20px;font-size:.82rem;color:var(--text-dim)}
.header-meta b{color:var(--text);font-weight:600}
.pulse{display:inline-block;width:8px;height:8px;background:var(--green);border-radius:50%;margin-right:6px;animation:pulse 2s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
/* ── Navigation ── */
.nav{background:var(--surface);border-bottom:1px solid var(--border);padding:0 24px;display:flex;gap:0;overflow-x:auto}
.nav-tab{padding:12px 20px;cursor:pointer;color:var(--text-dim);font-weight:600;font-size:.85rem;border-bottom:2px solid transparent;transition:all .2s;white-space:nowrap;user-select:none}
.nav-tab:hover{color:var(--text);background:var(--surface2)}
.nav-tab.active{color:var(--accent);border-bottom-color:var(--accent)}
/* ── Layout ── */
.content{padding:20px 24px;max-width:1440px;margin:0 auto}
.tab-panel{display:none;animation:fadeIn .25s}
.tab-panel.active{display:block}
@keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
.grid{display:grid;gap:16px}
.g2{grid-template-columns:repeat(2,1fr)}
.g3{grid-template-columns:repeat(3,1fr)}
.g4{grid-template-columns:repeat(4,1fr)}
.g6{grid-template-columns:repeat(6,1fr)}
@media(max-width:900px){.g2,.g3,.g4,.g6{grid-template-columns:1fr}}
@media(min-width:901px) and (max-width:1200px){.g4{grid-template-columns:repeat(2,1fr)}.g6{grid-template-columns:repeat(3,1fr)}}
.section{margin-bottom:24px}
.section-title{font-size:1rem;font-weight:700;margin-bottom:12px;display:flex;align-items:center;gap:8px}
.section-title .icon{font-size:1.1rem}
/* ── Cards ── */
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px;transition:border-color .2s,box-shadow .2s}
.card:hover{border-color:var(--accent);box-shadow:0 0 12px rgba(59,130,246,.1)}
.card-label{font-size:.75rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px}
.card-value{font-size:1.6rem;font-weight:700}
.card-sub{font-size:.78rem;color:var(--text-dim);margin-top:4px}
.card-green .card-value{color:var(--green)}
.card-red .card-value{color:var(--red)}
.card-orange .card-value{color:var(--orange)}
.card-yellow .card-value{color:var(--yellow)}
.card-accent .card-value{color:var(--accent)}
.card-purple .card-value{color:var(--purple)}
/* ── Gauge (SVG circular) ── */
.gauge-wrap{display:flex;flex-direction:column;align-items:center;padding:12px}
.gauge{position:relative;width:110px;height:110px}
.gauge svg{transform:rotate(-90deg)}
.gauge-bg{fill:none;stroke:var(--border);stroke-width:10}
.gauge-fill{fill:none;stroke-width:10;stroke-linecap:round;transition:stroke-dashoffset .8s ease}
.gauge-text{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:1.3rem;font-weight:700}
.gauge-label{margin-top:8px;font-size:.8rem;color:var(--text-dim);font-weight:600}
/* ── Badges ── */
.badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.03em}
.badge-critical{background:rgba(239,68,68,.15);color:var(--red);border:1px solid var(--red)}
.badge-high{background:rgba(245,158,11,.15);color:var(--orange);border:1px solid var(--orange)}
.badge-medium{background:rgba(234,179,8,.15);color:var(--yellow);border:1px solid var(--yellow)}
.badge-low{background:rgba(16,185,129,.15);color:var(--green);border:1px solid var(--green)}
.badge-success{background:rgba(16,185,129,.15);color:var(--green);border:1px solid var(--green)}
.badge-error{background:rgba(239,68,68,.15);color:var(--red);border:1px solid var(--red)}
.badge-running{background:rgba(59,130,246,.15);color:var(--accent);border:1px solid var(--accent)}
.badge-info{background:rgba(59,130,246,.15);color:var(--accent);border:1px solid var(--accent)}
.badge-warning{background:rgba(245,158,11,.15);color:var(--orange);border:1px solid var(--orange)}
/* ── Tables ── */
.tbl-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface)}
table{width:100%;border-collapse:collapse}
th{background:var(--surface2);color:var(--text-dim);font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;padding:10px 12px;text-align:left;position:sticky;top:0}
td{padding:9px 12px;border-top:1px solid var(--border);font-size:.84rem;vertical-align:middle}
tr:hover td{background:rgba(59,130,246,.04)}
.mono{font-family:'Cascadia Code','Fira Code',monospace;font-size:.78rem}
.trunc{max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.empty-row td{text-align:center;padding:32px;color:var(--text-dim);font-size:.95rem}
/* ── Bar Chart (CSS) ── */
.bar-chart{display:flex;align-items:flex-end;gap:4px;height:120px;padding:8px 0}
.bar-col{display:flex;flex-direction:column;align-items:center;flex:1;min-width:0}
.bar{width:100%;min-height:2px;border-radius:3px 3px 0 0;transition:height .4s;position:relative;cursor:default}
.bar:hover::after{content:attr(data-tip);position:absolute;bottom:calc(100% + 4px);left:50%;transform:translateX(-50%);background:var(--surface3);border:1px solid var(--border);padding:3px 8px;border-radius:4px;font-size:.7rem;white-space:nowrap;z-index:5}
.bar-label{font-size:.6rem;color:var(--text-dim);margin-top:4px;writing-mode:vertical-lr;transform:rotate(180deg);max-height:40px;overflow:hidden}
.bar-threats{background:var(--red)}
.bar-scans{background:var(--accent)}
/* ── Disk bars ── */
.disk-row{display:flex;align-items:center;gap:12px;padding:6px 0}
.disk-mount{width:100px;font-size:.8rem;color:var(--text-dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.disk-bar-outer{flex:1;height:14px;background:var(--surface2);border-radius:7px;overflow:hidden}
.disk-bar-inner{height:100%;border-radius:7px;transition:width .6s}
.disk-pct{width:50px;text-align:right;font-size:.8rem;font-weight:600}
/* ── Buttons ── */
.btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:6px;border:1px solid var(--border);background:var(--surface2);color:var(--text);font-size:.82rem;font-weight:600;cursor:pointer;transition:all .2s;white-space:nowrap}
.btn:hover{border-color:var(--accent);background:var(--surface3)}
.btn-primary{background:var(--accent);border-color:var(--accent);color:#fff}
.btn-primary:hover{background:#2563eb}
.btn-sm{padding:5px 10px;font-size:.76rem}
.btn:disabled{opacity:.5;cursor:not-allowed}
.spinner{display:inline-block;width:14px;height:14px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.btn-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}
/* ── Filter bar ── */
.filters{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;align-items:center}
.filters select,.filters input{background:var(--surface2);border:1px solid var(--border);color:var(--text);padding:7px 10px;border-radius:6px;font-size:.82rem}
.filters select:focus,.filters input:focus{outline:none;border-color:var(--accent)}
.filters input{min-width:200px}
/* ── Sub-tabs ── */
.sub-tabs{display:flex;gap:0;margin-bottom:14px;border-bottom:1px solid var(--border)}
.sub-tab{padding:8px 16px;cursor:pointer;color:var(--text-dim);font-size:.82rem;font-weight:600;border-bottom:2px solid transparent;transition:all .2s}
.sub-tab:hover{color:var(--text)}
.sub-tab.active{color:var(--accent);border-bottom-color:var(--accent)}
/* ── Pagination ── */
.pager{display:flex;align-items:center;justify-content:center;gap:12px;padding:12px;font-size:.84rem;color:var(--text-dim)}
/* ── Logs ── */
.log-view{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:8px;max-height:500px;overflow-y:auto;font-family:'Cascadia Code','Fira Code',monospace;font-size:.78rem;line-height:1.7}
.log-line{padding:2px 4px;display:flex;gap:10px;border-bottom:1px solid rgba(42,52,68,.4)}
.log-ts{color:var(--text-dim);min-width:140px}
.log-src{color:var(--purple);min-width:80px}
.log-msg{flex:1;word-break:break-all}
/* ── Toast ── */
.toast-area{position:fixed;top:70px;right:20px;z-index:200;display:flex;flex-direction:column;gap:8px}
.toast{padding:10px 18px;border-radius:6px;font-size:.84rem;font-weight:600;animation:slideIn .3s;box-shadow:var(--shadow)}
.toast-success{background:#065f46;color:#a7f3d0;border:1px solid var(--green)}
.toast-error{background:#7f1d1d;color:#fca5a5;border:1px solid var(--red)}
.toast-info{background:#1e3a5f;color:#93c5fd;border:1px solid var(--accent)}
@keyframes slideIn{from{opacity:0;transform:translateX(30px)}to{opacity:1;transform:none}}
</style>
</head>
<body>
<!-- HEADER -->
<div class="header">
<div class="header-left">
<div class="logo">⚔️ <span>AYN</span> ANTIVIRUS</div>
<div style="font-size:.75rem;color:var(--text-dim)">Security Dashboard</div>
</div>
<div class="header-meta">
<div><span class="pulse"></span><b id="hd-host">—</b></div>
<div>Up <b id="hd-uptime">—</b></div>
<div id="hd-time">—</div>
</div>
</div>
<!-- NAV -->
<div class="nav" id="nav">
<div class="nav-tab active" data-tab="overview">📊 Overview</div>
<div class="nav-tab" data-tab="threats">🛡️ Threats</div>
<div class="nav-tab" data-tab="scans">🔍 Scans</div>
<div class="nav-tab" data-tab="definitions">📚 Definitions</div>
<div class="nav-tab" data-tab="containers">🐳 Containers</div>
<div class="nav-tab" data-tab="quarantine">🔒 Quarantine</div>
<div class="nav-tab" data-tab="logs">📋 Logs</div>
</div>
<!-- CONTENT -->
<div class="content">
<!-- ═══════ TAB: OVERVIEW ═══════ -->
<div class="tab-panel active" id="panel-overview">
<!-- Status cards -->
<div class="section">
<div class="grid g4" id="status-cards">
<div class="card card-green"><div class="card-label">Protection</div><div class="card-value" id="ov-prot">Active</div><div class="card-sub" id="ov-prot-sub">AI-powered analysis</div></div>
<div class="card card-accent"><div class="card-label">Last Scan</div><div class="card-value" id="ov-scan">—</div><div class="card-sub" id="ov-scan-sub">—</div></div>
<div class="card card-purple"><div class="card-label">Signatures</div><div class="card-value" id="ov-sigs">—</div><div class="card-sub" id="ov-sigs-sub">—</div></div>
<div class="card"><div class="card-label">Quarantine</div><div class="card-value" id="ov-quar">0</div><div class="card-sub">Isolated items</div></div>
</div>
</div>
<!-- CPU Per-Core -->
<div class="section">
<div class="section-title"><span class="icon">🧮</span> CPU Per Core <span id="cpu-summary" style="font-size:.8rem;color:var(--text-dim);margin-left:8px"></span></div>
<div class="card" style="padding:12px">
<canvas id="cpu-canvas" height="140" style="width:100%;display:block"></canvas>
</div>
</div>
<!-- Memory Breakdown -->
<div class="section">
<div class="section-title"><span class="icon">🧠</span> Memory Usage <span id="mem-summary" style="font-size:.8rem;color:var(--text-dim);margin-left:8px"></span></div>
<div class="grid g2">
<div class="card" style="padding:12px">
<canvas id="mem-canvas" height="160" style="width:100%;display:block"></canvas>
</div>
<div class="card" style="padding:12px">
<div class="card-label">Memory Breakdown</div>
<div id="mem-bars" style="margin-top:8px"></div>
<div style="margin-top:12px;border-top:1px solid var(--border);padding-top:8px">
<div class="card-label">Swap</div>
<div id="swap-bar"></div>
</div>
</div>
</div>
</div>
<!-- Load + Network + Processes -->
<div class="section">
<div class="grid g3">
<div class="card"><div class="card-label">Load Average</div><div class="card-value" id="ov-load" style="font-size:1.2rem">—</div><div class="card-sub">1 / 5 / 15 min</div></div>
<div class="card"><div class="card-label">Network Connections</div><div class="card-value" id="ov-netconn">—</div><div class="card-sub">Active inet sockets</div></div>
<div class="card"><div class="card-label">CPU Frequency</div><div class="card-value" id="ov-freq" style="font-size:1.2rem">—</div><div class="card-sub">Current MHz</div></div>
</div>
</div>
<!-- Top Processes -->
<div class="section">
<div class="section-title"><span class="icon">⚙️</span> Top Processes</div>
<div class="tbl-wrap">
<table><thead><tr><th>PID</th><th>Process</th><th>CPU %</th><th>RAM %</th></tr></thead><tbody id="proc-tbody"><tr class="empty-row"><td colspan="4">Loading…</td></tr></tbody></table>
</div>
</div>
<!-- Disk -->
<div class="section">
<div class="section-title"><span class="icon">💾</span> Disk Usage</div>
<div class="card" id="disk-area"><div style="color:var(--text-dim);padding:8px">Loading…</div></div>
</div>
<!-- Threat summary -->
<div class="section">
<div class="section-title"><span class="icon">⚠️</span> Threat Summary</div>
<div class="grid g4">
<div class="card card-red"><div class="card-label">Critical</div><div class="card-value" id="ov-tc">0</div></div>
<div class="card card-orange"><div class="card-label">High</div><div class="card-value" id="ov-th">0</div></div>
<div class="card card-yellow"><div class="card-label">Medium</div><div class="card-value" id="ov-tm">0</div></div>
<div class="card card-green"><div class="card-label">Low</div><div class="card-value" id="ov-tl">0</div></div>
</div>
</div>
<!-- Scan Activity Chart -->
<div class="section">
<div class="section-title"><span class="icon">📈</span> Scan Activity (14 days)</div>
<div class="card" style="padding:12px">
<canvas id="scan-canvas" height="160" style="width:100%;display:block"></canvas>
<div style="display:flex;gap:16px;justify-content:center;margin-top:8px;font-size:.75rem;color:var(--text-dim)">
<span>🔵 Scans</span><span>🔴 Threats Found</span>
</div>
</div>
</div>
</div>
<!-- ═══════ TAB: THREATS ═══════ -->
<div class="tab-panel" id="panel-threats">
<div class="filters">
<select id="f-severity"><option value="">All Severities</option><option value="CRITICAL">Critical</option><option value="HIGH">High</option><option value="MEDIUM">Medium</option><option value="LOW">Low</option></select>
<select id="f-type"><option value="">All Types</option><option value="MALWARE">Malware</option><option value="MINER">Miner</option><option value="SPYWARE">Spyware</option><option value="VIRUS">Virus</option><option value="ROOTKIT">Rootkit</option></select>
<input type="text" id="f-search" placeholder="Search threats…">
</div>
<div class="tbl-wrap">
<table><thead><tr><th>Time</th><th>File Path</th><th>Threat</th><th>Type</th><th>Severity</th><th>Detector</th><th>AI Verdict</th><th>Status</th><th>Actions</th></tr></thead><tbody id="threat-tbody"><tr class="empty-row"><td colspan="9">Loading…</td></tr></tbody></table>
</div>
<div class="pager"><button class="btn btn-sm" id="threat-prev">← Prev</button><span id="threat-page">Page 1</span><button class="btn btn-sm" id="threat-next">Next →</button></div>
</div>
<!-- ═══════ TAB: SCANS ═══════ -->
<div class="tab-panel" id="panel-scans">
<div class="btn-row">
<button class="btn btn-primary" id="btn-quick-scan" onclick="doAction('quick-scan',this)">⚡ Run Quick Scan</button>
<button class="btn" id="btn-full-scan" onclick="doAction('full-scan',this)">🔍 Run Full Scan</button>
</div>
<div class="section">
<div class="section-title"><span class="icon">📈</span> Scan History (30 days)</div>
<div class="card" style="padding:12px"><canvas id="scan-chart-canvas" height="160" style="width:100%;display:block"></canvas><div style="display:flex;gap:16px;justify-content:center;margin-top:8px;font-size:.75rem;color:var(--text-dim)"><span>🔵 Scans</span><span>🔴 Threats</span></div></div>
</div>
<div class="section">
<div class="section-title"><span class="icon">📋</span> Recent Scans</div>
<div class="tbl-wrap">
<table><thead><tr><th>Time</th><th>Type</th><th>Path</th><th>Files</th><th>Threats</th><th>Duration</th><th>Status</th></tr></thead><tbody id="scan-tbody"><tr class="empty-row"><td colspan="7">Loading…</td></tr></tbody></table>
</div>
</div>
</div>
<!-- ═══════ TAB: DEFINITIONS ═══════ -->
<div class="tab-panel" id="panel-definitions">
<div class="grid g4 section">
<div class="card card-purple"><div class="card-label">Hashes</div><div class="card-value" id="def-hashes">0</div></div>
<div class="card card-accent"><div class="card-label">Malicious IPs</div><div class="card-value" id="def-ips">0</div></div>
<div class="card card-orange"><div class="card-label">Domains</div><div class="card-value" id="def-domains">0</div></div>
<div class="card card-red"><div class="card-label">URLs</div><div class="card-value" id="def-urls">0</div></div>
</div>
<div class="btn-row">
<button class="btn btn-primary" onclick="doAction('update-sigs',this)">🔄 Update All Feeds</button>
<button class="btn btn-sm" onclick="doFeedUpdate('malwarebazaar',this)">MalwareBazaar</button>
<button class="btn btn-sm" onclick="doFeedUpdate('threatfox',this)">ThreatFox</button>
<button class="btn btn-sm" onclick="doFeedUpdate('urlhaus',this)">URLhaus</button>
<button class="btn btn-sm" onclick="doFeedUpdate('feodotracker',this)">FeodoTracker</button>
<button class="btn btn-sm" onclick="doFeedUpdate('emergingthreats',this)">EmergingThreats</button>
</div>
<div class="sub-tabs" id="def-subtabs">
<div class="sub-tab active" data-def="all">All</div>
<div class="sub-tab" data-def="hash">Hashes</div>
<div class="sub-tab" data-def="ip">IPs</div>
<div class="sub-tab" data-def="domain">Domains</div>
<div class="sub-tab" data-def="url">URLs</div>
</div>
<div class="filters"><input type="text" id="def-search" placeholder="Search definitions…" style="flex:1;max-width:400px"></div>
<div class="tbl-wrap"><table><thead id="def-thead"></thead><tbody id="def-tbody"><tr class="empty-row"><td colspan="6">Loading…</td></tr></tbody></table></div>
<div class="pager"><button class="btn btn-sm" id="def-prev" onclick="defPage(-1)">← Prev</button><span id="def-page-info">Page 1</span><button class="btn btn-sm" id="def-next" onclick="defPage(1)">Next →</button></div>
<div class="section" style="margin-top:20px">
<div class="section-title"><span class="icon">🔄</span> Recent Updates</div>
<div class="tbl-wrap"><table><thead><tr><th>Time</th><th>Feed</th><th>Hashes</th><th>IPs</th><th>Domains</th><th>URLs</th><th>Status</th></tr></thead><tbody id="sigup-tbody"></tbody></table></div>
</div>
</div>
<!-- ═══════ TAB: CONTAINERS ═══════ -->
<div class="tab-panel" id="panel-containers">
<div class="btn-row">
<button class="btn btn-primary" id="btn-scan-containers" onclick="doAction('scan-containers',this)">🐳 Scan All Containers</button>
</div>
<div class="grid g3 section">
<div class="card card-accent"><div class="card-label">Containers Found</div><div class="card-value" id="ct-count">0</div></div>
<div class="card card-green"><div class="card-label">Available Runtimes</div><div class="card-value" id="ct-runtimes" style="font-size:1rem">—</div></div>
<div class="card card-red"><div class="card-label">Container Threats</div><div class="card-value" id="ct-threats">0</div></div>
</div>
<div class="section">
<div class="section-title"><span class="icon">📦</span> Discovered Containers</div>
<div class="tbl-wrap">
<table><thead><tr><th>ID</th><th>Name</th><th>Image</th><th>Runtime</th><th>Status</th><th>IP</th><th>Ports</th><th>Action</th></tr></thead>
<tbody id="ct-tbody"><tr class="empty-row"><td colspan="8">Loading…</td></tr></tbody></table>
</div>
</div>
<div class="section">
<div class="section-title"><span class="icon">⚠️</span> Container Threats</div>
<div class="tbl-wrap">
<table><thead><tr><th>Time</th><th>Container</th><th>Threat</th><th>Type</th><th>Severity</th><th>Details</th></tr></thead>
<tbody id="ct-threat-tbody"><tr class="empty-row"><td colspan="6">No container threats ✅</td></tr></tbody></table>
</div>
</div>
</div>
<!-- ═══════ TAB: QUARANTINE ═══════ -->
<div class="tab-panel" id="panel-quarantine">
<div class="grid g2 section">
<div class="card"><div class="card-label">Total Quarantined</div><div class="card-value" id="q-count">0</div></div>
<div class="card"><div class="card-label">Vault Size</div><div class="card-value" id="q-size">0 B</div></div>
</div>
<div class="tbl-wrap"><table><thead><tr><th>ID</th><th>Original Path</th><th>Threat</th><th>Date</th><th>Size</th></tr></thead><tbody id="quar-tbody"><tr class="empty-row"><td colspan="5">Vault is empty ✅</td></tr></tbody></table></div>
</div>
<!-- ═══════ TAB: LOGS ═══════ -->
<div class="tab-panel" id="panel-logs">
<div class="btn-row"><button class="btn btn-sm" onclick="loadLogs()">🔄 Refresh</button></div>
<div class="log-view" id="log-view"><div style="color:var(--text-dim)">Loading…</div></div>
</div>
</div><!-- /content -->
<!-- Toast area -->
<div class="toast-area" id="toast-area"></div>
<script>
/* ── State ── */
let S={threats:[],threatPage:1,threatPerPage:25,defType:'all',defPage:1,defPerPage:50,defSearch:''};
const $=id=>document.getElementById(id);
const Q=(s,el)=>(el||document).querySelectorAll(s);
/* ── Helpers ── */
function fmt(n){return n==null?'':Number(n).toLocaleString()}
function fmtBytes(b){if(!b)return '0 B';const u=['B','KB','MB','GB','TB'];let i=0;let v=b;while(v>=1024&&i<u.length-1){v/=1024;i++;}return v.toFixed(i?1:0)+' '+u[i];}
function fmtDur(s){if(!s||s<0)return '0s';s=Math.round(s);if(s<60)return s+'s';if(s<3600)return Math.floor(s/60)+'m '+s%60+'s';return Math.floor(s/3600)+'h '+Math.floor(s%3600/60)+'m';}
function ago(ts){if(!ts)return '';const d=new Date(ts+'Z');const s=Math.floor((Date.now()-d)/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';}
function sevBadge(s){const c=esc((s||'').toUpperCase());const cl=c.toLowerCase().replace(/[^a-z]/g,'');return `<span class="badge badge-${cl}">${c}</span>`;}
function statusBadge(s){const m={completed:'success',success:'success',running:'running',failed:'error',error:'error'};const safe=esc(s||'');const cl=(m[s]||'info').replace(/[^a-z]/g,'');return `<span class="badge badge-${cl}">${safe}</span>`;}
function esc(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML;}
function trunc(s,n){s=s||'';return s.length>n?s.slice(0,n)+'':s;}
function setGauge(id,pct,color){const g=$(id);if(!g)return;const c=g.querySelector('.gauge-fill');const t=g.querySelector('.gauge-text');const off=314-(314*Math.min(pct,100)/100);c.style.strokeDashoffset=off;if(color)c.style.stroke=color;t.textContent=Math.round(pct)+'%';}
function gaugeColor(p){return p>90?'var(--red)':p>70?'var(--orange)':p>50?'var(--yellow)':'var(--green)';}
/* ── Toast ── */
function toast(msg,type='info'){const t=document.createElement('div');t.className='toast toast-'+type;t.textContent=msg;$('toast-area').appendChild(t);setTimeout(()=>t.remove(),4000);}
/* ── API ── */
async function api(path){try{const r=await fetch(path);if(!r.ok)throw new Error(r.statusText);return await r.json();}catch(e){console.error('API error:',path,e);return null;}}
/* ── Tab switching ── */
Q('.nav-tab').forEach(t=>t.addEventListener('click',()=>{
Q('.nav-tab').forEach(x=>x.classList.remove('active'));
Q('.tab-panel').forEach(x=>x.classList.remove('active'));
t.classList.add('active');
$('panel-'+t.dataset.tab).classList.add('active');
if(t.dataset.tab==='threats')loadThreats();
if(t.dataset.tab==='scans')loadScans();
if(t.dataset.tab==='definitions')loadDefs();
if(t.dataset.tab==='containers')loadContainers();
if(t.dataset.tab==='quarantine')loadQuarantine();
if(t.dataset.tab==='logs')loadLogs();
}));
/* ═══════ OVERVIEW ═══════ */
/* ── Canvas Chart Helpers ── */
let _cpuHistory=[];const _CPU_HIST_MAX=60;
let _memHistory=[];const _MEM_HIST_MAX=60;
function drawLineChart(canvasId,datasets,opts={}){
const cv=$(canvasId);if(!cv)return;
const dpr=window.devicePixelRatio||1;
const rect=cv.getBoundingClientRect();
cv.width=rect.width*dpr;cv.height=(opts.height||rect.height)*dpr;
const ctx=cv.getContext('2d');ctx.scale(dpr,dpr);
const W=rect.width,H=opts.height||rect.height;
const pad={t:10,r:10,b:24,l:42};
const cw=W-pad.l-pad.r,ch=H-pad.t-pad.b;
// Background
ctx.fillStyle='#0d1117';ctx.fillRect(0,0,W,H);
// Grid
const gridLines=opts.gridLines||5;
const maxVal=opts.maxVal||Math.max(...datasets.flatMap(d=>d.data),1);
ctx.strokeStyle='#1e293b';ctx.lineWidth=1;ctx.font='10px system-ui';ctx.fillStyle='#6b7280';
for(let i=0;i<=gridLines;i++){
const y=pad.t+ch-(ch*i/gridLines);
ctx.beginPath();ctx.moveTo(pad.l,y);ctx.lineTo(pad.l+cw,y);ctx.stroke();
const v=((maxVal*i/gridLines)).toFixed(opts.decimals||0);
ctx.fillText(v+(opts.unit||''),2,y+3);
}
// Data lines
datasets.forEach(ds=>{
if(!ds.data.length)return;
const n=ds.data.length;
ctx.beginPath();ctx.strokeStyle=ds.color;ctx.lineWidth=ds.lineWidth||2;
ds.data.forEach((v,i)=>{
const x=pad.l+(cw*i/(n-1||1));
const y=pad.t+ch-ch*(Math.min(v,maxVal)/maxVal);
if(i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y);
});
ctx.stroke();
// Fill
if(ds.fill){
const n2=ds.data.length;
ctx.lineTo(pad.l+cw,pad.t+ch);ctx.lineTo(pad.l,pad.t+ch);ctx.closePath();
ctx.fillStyle=ds.fill;ctx.fill();
}
});
// Labels
if(opts.labels&&opts.labels.length){
ctx.fillStyle='#6b7280';ctx.font='9px system-ui';ctx.textAlign='center';
const n=opts.labels.length;
opts.labels.forEach((l,i)=>{
if(i%Math.ceil(n/8)!==0&&i!==n-1)return;
const x=pad.l+(cw*i/(n-1||1));
ctx.fillText(l,x,H-2);
});
}
}
function drawBarChart(canvasId,data,opts={}){
const cv=$(canvasId);if(!cv||!data.length)return;
const dpr=window.devicePixelRatio||1;
const rect=cv.getBoundingClientRect();
cv.width=rect.width*dpr;cv.height=(opts.height||rect.height)*dpr;
const ctx=cv.getContext('2d');ctx.scale(dpr,dpr);
const W=rect.width,H=opts.height||rect.height;
const pad={t:10,r:10,b:28,l:42};
const cw=W-pad.l-pad.r,ch=H-pad.t-pad.b;
ctx.fillStyle='#0d1117';ctx.fillRect(0,0,W,H);
const maxVal=opts.maxVal||Math.max(...data.flatMap(d=>[(d.scans||0),(d.threats||0)]),1);
// Grid
ctx.strokeStyle='#1e293b';ctx.lineWidth=1;ctx.font='10px system-ui';ctx.fillStyle='#6b7280';
for(let i=0;i<=4;i++){
const y=pad.t+ch-(ch*i/4);
ctx.beginPath();ctx.moveTo(pad.l,y);ctx.lineTo(pad.l+cw,y);ctx.stroke();
ctx.fillText(Math.round(maxVal*i/4),2,y+3);
}
const n=data.length;const bw=Math.max((cw/n)*0.35,2);const gap=cw/n;
data.forEach((d,i)=>{
const x=pad.l+gap*i+gap*0.15;
const sh=ch*(d.scans||0)/maxVal;
const th=ch*(d.threats||0)/maxVal;
// Scans bar
ctx.fillStyle='#3b82f6';ctx.fillRect(x,pad.t+ch-sh,bw,sh);
// Threats bar
ctx.fillStyle='#ef4444';ctx.fillRect(x+bw+1,pad.t+ch-th,bw,th);
// Label
ctx.fillStyle='#6b7280';ctx.font='9px system-ui';ctx.textAlign='center';
const day=(d.day||'').slice(5);
ctx.fillText(day,x+bw,H-4);
});
}
function drawCoreChart(canvasId,cores){
const cv=$(canvasId);if(!cv||!cores.length)return;
const dpr=window.devicePixelRatio||1;
const rect=cv.getBoundingClientRect();
cv.width=rect.width*dpr;cv.height=140*dpr;
const ctx=cv.getContext('2d');ctx.scale(dpr,dpr);
const W=rect.width,H=140;
const pad={t:8,r:8,b:20,l:8};
const n=cores.length;const gap=4;
const bw=Math.min((W-pad.l-pad.r-(n-1)*gap)/n,60);
ctx.fillStyle='#0d1117';ctx.fillRect(0,0,W,H);
cores.forEach((pct,i)=>{
const x=pad.l+i*(bw+gap);
const barH=(H-pad.t-pad.b)*(pct/100);
const c=pct>90?'#ef4444':pct>70?'#f59e0b':pct>50?'#eab308':'#3b82f6';
// Background
ctx.fillStyle='#1e293b';ctx.fillRect(x,pad.t,bw,H-pad.t-pad.b);
// Bar
ctx.fillStyle=c;ctx.fillRect(x,H-pad.b-barH,bw,barH);
// Label
ctx.fillStyle='#e2e8f0';ctx.font='bold 10px system-ui';ctx.textAlign='center';
ctx.fillText(Math.round(pct)+'%',x+bw/2,H-pad.b-barH-4>pad.t?H-pad.b-barH-4:pad.t+12);
ctx.fillStyle='#6b7280';ctx.font='9px system-ui';
ctx.fillText('C'+i,x+bw/2,H-4);
});
}
function renderMemBars(h){
const total=h.mem_total||1;
const used=h.mem_used||0;
const cached=h.mem_cached||0;
const buffers=h.mem_buffers||0;
const avail=h.mem_available||0;
const app=used-cached-buffers;
const items=[
{label:'App/Used',val:Math.max(app,0),color:'var(--purple)'},
{label:'Cached',val:cached,color:'var(--cyan)'},
{label:'Buffers',val:buffers,color:'var(--accent)'},
{label:'Available',val:avail,color:'var(--green)'},
];
$('mem-bars').innerHTML=items.map(it=>{
const pct=(it.val/total*100).toFixed(1);
return `<div class="disk-row"><div class="disk-mount" style="width:70px"><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${it.color};margin-right:4px"></span>${it.label}</div><div class="disk-bar-outer"><div class="disk-bar-inner" style="width:${pct}%;background:${it.color}"></div></div><div class="disk-pct">${fmtBytes(it.val)}</div></div>`;
}).join('');
// Swap
const spct=h.swap_total?(h.swap_used/h.swap_total*100).toFixed(1):0;
$('swap-bar').innerHTML=h.swap_total?`<div class="disk-row"><div class="disk-mount" style="width:70px">${fmtBytes(h.swap_used)}/${fmtBytes(h.swap_total)}</div><div class="disk-bar-outer"><div class="disk-bar-inner" style="width:${spct}%;background:var(--orange)"></div></div><div class="disk-pct">${spct}%</div></div>`:'<div style="color:var(--text-dim);font-size:.8rem">No swap</div>';
}
async function loadOverview(){
const [st,h,ts,ch]=await Promise.all([api('/api/status'),api('/api/health'),api('/api/threat-stats'),api('/api/scan-chart?days=14')]);
if(st){
$('hd-host').textContent=st.hostname||'';
$('hd-uptime').textContent=fmtDur(st.uptime_seconds);
$('hd-time').textContent=st.server_time||'';
const ls=st.last_scan;
$('ov-scan').textContent=ls?ago(ls.timestamp):'Never';
$('ov-scan-sub').textContent=ls?`${fmt(ls.files_scanned)} files, ${ls.threats_found} threats`:'No scans yet';
const sig=st.signatures||{};
$('ov-sigs').textContent=fmt((sig.total_hashes||0)+(sig.total_ips||0)+(sig.total_domains||0)+(sig.total_urls||0));
$('ov-sigs-sub').textContent=sig.last_update?'Updated '+ago(sig.last_update):'Not updated';
$('ov-quar').textContent=fmt(st.quarantine_count);
}
if(h){
// CPU per-core chart
const cores=h.cpu_per_core||[];
if(cores.length){
drawCoreChart('cpu-canvas',cores);
$('cpu-summary').textContent=`${cores.length} cores @ ${h.cpu_freq_mhz||'?'} MHz — avg ${Math.round(h.cpu_percent)}%`;
}
// CPU history
_cpuHistory.push(h.cpu_percent||0);if(_cpuHistory.length>_CPU_HIST_MAX)_cpuHistory.shift();
// Memory
_memHistory.push(h.mem_percent||0);if(_memHistory.length>_MEM_HIST_MAX)_memHistory.shift();
drawLineChart('mem-canvas',[
{data:_memHistory,color:'#8b5cf6',fill:'rgba(139,92,246,0.1)',lineWidth:2},
],{maxVal:100,unit:'%',height:160,gridLines:4});
$('mem-summary').textContent=`${fmtBytes(h.mem_used)} / ${fmtBytes(h.mem_total)} (${h.mem_percent?.toFixed(1)}%)`;
renderMemBars(h);
// Load / Net / Freq
const la=h.load_avg||[0,0,0];
$('ov-load').textContent=la.map(v=>v.toFixed(2)).join(' / ');
$('ov-netconn').textContent=fmt(h.net_connections||0);
$('ov-freq').textContent=h.cpu_freq_mhz?h.cpu_freq_mhz+' MHz':'';
// Top processes
const procs=h.top_processes||[];
const ptb=$('proc-tbody');
if(procs.length){
ptb.innerHTML=procs.map(p=>{
const cpuC=p.cpu>50?'var(--red)':p.cpu>20?'var(--orange)':'var(--text)';
return `<tr><td class="mono">${p.pid}</td><td>${esc(p.name)}</td><td style="color:${cpuC};font-weight:600">${p.cpu}%</td><td>${p.mem}%</td></tr>`;
}).join('');
}else{ptb.innerHTML='<tr class="empty-row"><td colspan="4">No active processes</td></tr>';}
// Disks
const da=$('disk-area');
const disks=h.disk_usage||[];
if(disks.length){
da.innerHTML=disks.map(d=>{
const p=d.percent||0;const c=p>90?'var(--red)':p>70?'var(--orange)':'var(--accent)';
return `<div class="disk-row"><div class="disk-mount" title="${esc(d.mount)}">${esc(d.mount)}</div><div class="disk-bar-outer"><div class="disk-bar-inner" style="width:${p}%;background:${c}"></div></div><div class="disk-pct">${p.toFixed(1)}%</div><div style="font-size:.75rem;color:var(--text-dim);min-width:100px">${fmtBytes(d.used)} / ${fmtBytes(d.total)}</div></div>`;
}).join('');
}else{da.innerHTML='<div style="color:var(--text-dim);padding:8px">No disk info</div>';}
}
if(ts){
const bs=ts.by_severity||{};
$('ov-tc').textContent=fmt(bs.CRITICAL||0);
$('ov-th').textContent=fmt(bs.HIGH||0);
$('ov-tm').textContent=fmt(bs.MEDIUM||0);
$('ov-tl').textContent=fmt(bs.LOW||0);
}
if(ch&&ch.chart&&ch.chart.length){
drawBarChart('scan-canvas',ch.chart.slice(-14),{height:160});
}
}
/* ═══════ THREATS ═══════ */
async function loadThreats(){
const d=await api(`/api/threats?limit=200`);
if(!d)return;
S.threats=d.threats||[];
renderThreats();
}
function renderThreats(){
const sev=$('f-severity').value.toUpperCase();
const typ=$('f-type').value.toUpperCase();
const q=$('f-search').value.toLowerCase();
let f=S.threats;
if(sev)f=f.filter(t=>(t.severity||'').toUpperCase()===sev);
if(typ)f=f.filter(t=>(t.threat_type||'').toUpperCase()===typ);
if(q)f=f.filter(t=>(t.threat_name||'').toLowerCase().includes(q)||(t.file_path||'').toLowerCase().includes(q));
const total=f.length;const pages=Math.max(Math.ceil(total/S.threatPerPage),1);
S.threatPage=Math.min(S.threatPage,pages);
const start=(S.threatPage-1)*S.threatPerPage;
const slice=f.slice(start,start+S.threatPerPage);
const tb=$('threat-tbody');
if(!slice.length){tb.innerHTML='<tr class="empty-row"><td colspan="9">No threats detected ✅</td></tr>';
}else{tb.innerHTML=slice.map(t=>{
const act=t.action_taken||'detected';
const st=act==='detected'?'<span class="badge badge-warning">detected</span>':act==='quarantined'?'<span class="badge badge-info">quarantined</span>':statusBadge(act);
let btns='';
if(act==='detected'||act==='monitoring'){
btns=`<div style="display:flex;gap:4px;flex-wrap:wrap"><button class="btn btn-sm" style="background:var(--purple);color:#fff;border-color:var(--purple);font-size:.7rem;padding:3px 8px" onclick="aiAnalyze(${t.id},this)">🧠 AI Analyze</button><button class="btn btn-sm" style="background:var(--red);color:#fff;border-color:var(--red);font-size:.7rem;padding:3px 8px" onclick="threatAction('quarantine',${t.id},'${esc(t.file_path).replace(/'/g,"\\'")}','${esc(t.threat_name).replace(/'/g,"\\'")}',this)">🔒 Quarantine</button><button class="btn btn-sm" style="font-size:.7rem;padding:3px 8px" onclick="threatAction('delete-threat',${t.id},'${esc(t.file_path).replace(/'/g,"\\'")}','',this)">🗑️ Delete</button><button class="btn btn-sm" style="font-size:.7rem;padding:3px 8px" onclick="threatAction('whitelist',${t.id},'','',this)">✅ Ignore</button></div>`;
} else if(act==='quarantined'){
btns=`<div style="display:flex;gap:4px;flex-wrap:wrap"><button class="btn btn-sm" style="background:var(--purple);color:#fff;border-color:var(--purple);font-size:.7rem;padding:3px 8px" onclick="aiAnalyze(${t.id},this)">🧠 AI Analyze</button><button class="btn btn-sm" style="background:var(--green);color:#fff;border-color:var(--green);font-size:.7rem;padding:3px 8px" onclick="threatAction('restore',${t.id},'${esc(t.file_path).replace(/'/g,"\\'")}','',this)">♻️ Restore</button><button class="btn btn-sm" style="font-size:.7rem;padding:3px 8px" onclick="threatAction('delete-threat',${t.id},'${esc(t.file_path).replace(/'/g,"\\'")}','',this)">🗑️ Delete</button><button class="btn btn-sm" style="font-size:.7rem;padding:3px 8px" onclick="threatAction('whitelist',${t.id},'','',this)">✅ Ignore</button></div>`;
} else {
btns=`<span style="color:var(--text-dim);font-size:.75rem">${esc(act)}</span>`;
}
const det=t.details||'';
let aiCol='<span style="color:var(--text-dim);font-size:.75rem">—</span>';
const aiMatch=det.match(/\[AI:\s*(\w+)\s+(\d+)%\]\s*(.*)/);
if(aiMatch){const v=aiMatch[1],c=aiMatch[2],rsn=aiMatch[3];const vc=v==='safe'?'var(--green)':v==='threat'?'var(--red)':'var(--orange)';aiCol=`<div style="font-size:.75rem"><span style="color:${vc};font-weight:700">${v.toUpperCase()}</span> <span style="color:var(--text-dim)">${c}%</span><div style="color:var(--text-dim);max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(rsn)}">${esc(rsn)}</div></div>`;}
return `<tr><td>${ago(t.timestamp)}</td><td class="mono trunc" title="${esc(t.file_path)}">${esc(trunc(t.file_path,50))}</td><td>${esc(t.threat_name)}</td><td>${esc(t.threat_type)}</td><td>${sevBadge(t.severity)}</td><td>${esc(t.detector)}</td><td>${aiCol}</td><td>${st}</td><td>${btns}</td></tr>`;
}).join('');}
$('threat-page').textContent=`Page ${S.threatPage} of ${pages} (${total})`;
}
$('threat-prev').onclick=()=>{S.threatPage=Math.max(1,S.threatPage-1);renderThreats();};
$('threat-next').onclick=()=>{S.threatPage++;renderThreats();};
$('f-severity').onchange=$('f-type').onchange=()=>{S.threatPage=1;renderThreats();};
let _tTimer;$('f-search').oninput=()=>{clearTimeout(_tTimer);_tTimer=setTimeout(()=>{S.threatPage=1;renderThreats();},300);};
/* ═══════ SCANS ═══════ */
async function loadScans(){
const [sc,ch]=await Promise.all([api('/api/scans?limit=30'),api('/api/scan-chart?days=30')]);
if(sc){
const tb=$('scan-tbody');
const scans=sc.scans||[];
if(!scans.length){tb.innerHTML='<tr class="empty-row"><td colspan="7">No scans yet</td></tr>';
}else{tb.innerHTML=scans.map(s=>`<tr><td>${ago(s.timestamp)}</td><td>${esc(s.scan_type)}</td><td class="mono trunc" title="${esc(s.scan_path)}">${esc(trunc(s.scan_path,40))}</td><td>${fmt(s.files_scanned)}</td><td>${s.threats_found?'<span style="color:var(--red)">'+s.threats_found+'</span>':'0'}</td><td>${fmtDur(s.duration_seconds)}</td><td>${statusBadge(s.status)}</td></tr>`).join('');}
}
if(ch&&ch.chart&&ch.chart.length)drawBarChart('scan-chart-canvas',ch.chart,{height:160});
}
/* ═══════ DEFINITIONS ═══════ */
Q('#def-subtabs .sub-tab').forEach(t=>t.addEventListener('click',()=>{
Q('#def-subtabs .sub-tab').forEach(x=>x.classList.remove('active'));
t.classList.add('active');S.defType=t.dataset.def;S.defPage=1;loadDefs();
}));
let _dTimer;$('def-search').oninput=()=>{clearTimeout(_dTimer);_dTimer=setTimeout(()=>{S.defPage=1;S.defSearch=$('def-search').value;loadDefs();},400);};
function defPage(d){S.defPage=Math.max(1,S.defPage+d);loadDefs();}
async function loadDefs(){
const typ=S.defType==='all'?'':S.defType;
const q=encodeURIComponent(S.defSearch||'');
const [dd,su]=await Promise.all([
api(`/api/definitions?type=${typ}&page=${S.defPage}&per_page=${S.defPerPage}&search=${q}`),
api('/api/sig-updates?limit=10')
]);
if(dd){
$('def-hashes').textContent=fmt(dd.total_hashes);
$('def-ips').textContent=fmt(dd.total_ips);
$('def-domains').textContent=fmt(dd.total_domains);
$('def-urls').textContent=fmt(dd.total_urls);
renderDefTable(dd);
const total=dd.total_hashes+dd.total_ips+dd.total_domains+dd.total_urls;
const pages=Math.max(Math.ceil(total/S.defPerPage),1);
$('def-page-info').textContent=`Page ${S.defPage} of ${pages}`;
}
if(su){
const tb=$('sigup-tbody');
const ups=su.updates||[];
if(!ups.length){tb.innerHTML='<tr class="empty-row"><td colspan="7">No updates yet</td></tr>';
}else{tb.innerHTML=ups.map(u=>`<tr><td>${ago(u.timestamp)}</td><td>${esc(u.feed_name)}</td><td>${fmt(u.hashes_added)}</td><td>${fmt(u.ips_added)}</td><td>${fmt(u.domains_added)}</td><td>${fmt(u.urls_added)}</td><td>${statusBadge(u.status)}</td></tr>`).join('');}
}
}
function renderDefTable(dd){
const th=$('def-thead');const tb=$('def-tbody');
let rows=[];const t=S.defType;
if(t==='all'||t==='hash'){
th.innerHTML='<tr><th>Hash</th><th>Threat Name</th><th>Type</th><th>Severity</th><th>Source</th><th>Date</th></tr>';
rows=rows.concat((dd.hashes||[]).map(r=>`<tr><td class="mono">${esc(trunc(r.hash,16))}</td><td>${esc(r.threat_name)}</td><td>${esc(r.threat_type)}</td><td>${sevBadge(r.severity)}</td><td>${esc(r.source)}</td><td>${ago(r.added_date)}</td></tr>`));
}
if(t==='all'||t==='ip'){
if(t==='ip')th.innerHTML='<tr><th>IP Address</th><th>Threat Name</th><th>Type</th><th>Source</th><th>Date</th></tr>';
rows=rows.concat((dd.ips||[]).map(r=>`<tr><td class="mono">${esc(r.ip)}</td><td>${esc(r.threat_name)}</td><td>${esc(r.type)}</td><td>${esc(r.source)}</td><td>${ago(r.added_date)}</td></tr>`));
}
if(t==='all'||t==='domain'){
if(t==='domain')th.innerHTML='<tr><th>Domain</th><th>Threat Name</th><th>Type</th><th>Source</th><th>Date</th></tr>';
rows=rows.concat((dd.domains||[]).map(r=>`<tr><td class="mono">${esc(r.domain)}</td><td>${esc(r.threat_name)}</td><td>${esc(r.type)}</td><td>${esc(r.source)}</td><td>${ago(r.added_date)}</td></tr>`));
}
if(t==='all'||t==='url'){
if(t==='url')th.innerHTML='<tr><th>URL</th><th>Threat Name</th><th>Type</th><th>Source</th><th>Date</th></tr>';
rows=rows.concat((dd.urls||[]).map(r=>`<tr><td class="mono trunc" title="${esc(r.url)}">${esc(trunc(r.url,60))}</td><td>${esc(r.threat_name)}</td><td>${esc(r.type)}</td><td>${esc(r.source)}</td><td>${ago(r.added_date)}</td></tr>`));
}
if(t==='all'&&!rows.length&&!dd.hashes?.length)th.innerHTML='<tr><th>Hash</th><th>Threat Name</th><th>Type</th><th>Severity</th><th>Source</th><th>Date</th></tr>';
tb.innerHTML=rows.length?rows.join(''):'<tr class="empty-row"><td colspan="6">No definitions found. Run an update to fetch threat feeds.</td></tr>';
}
/* ═══════ CONTAINERS ═══════ */
async function loadContainers(){
const [cl,cs]=await Promise.all([api('/api/containers'),api('/api/container-scan')]);
if(cl){
$('ct-count').textContent=fmt(cl.count);
$('ct-runtimes').textContent=cl.runtimes.length?cl.runtimes.join(', '):'None detected';
const tb=$('ct-tbody');
const cc=cl.containers||[];
if(!cc.length){tb.innerHTML='<tr class="empty-row"><td colspan="8">No containers found. Install Docker, Podman, or LXC.</td></tr>';
}else{
tb.innerHTML=cc.map(c=>{
const st=c.status==='running'?'<span class="badge badge-success">running</span>':c.status==='stopped'?'<span class="badge badge-error">stopped</span>':'<span class="badge badge-warning">'+esc(c.status)+'</span>';
const ports=(c.ports||[]).slice(0,3).join(', ')||(c.status==='running'?'':'');
return `<tr><td class="mono">${esc(trunc(c.container_id,12))}</td><td>${esc(c.name)}</td><td class="mono trunc" title="${esc(c.image)}">${esc(trunc(c.image,30))}</td><td>${esc(c.runtime)}</td><td>${st}</td><td class="mono">${esc(c.ip_address||'')}</td><td class="mono" style="font-size:.72rem">${esc(ports)}</td><td><button class="btn btn-sm" onclick="scanSingleContainer('${esc(c.container_id)}',this)">Scan</button></td></tr>`;
}).join('');
}
}
if(cs){
const threats=cs.threats||[];
$('ct-threats').textContent=fmt(threats.length);
const tb=$('ct-threat-tbody');
if(!threats.length){tb.innerHTML='<tr class="empty-row"><td colspan="6">No container threats ✅</td></tr>';
}else{tb.innerHTML=threats.map(t=>`<tr><td>${ago(t.timestamp)}</td><td>${esc(trunc(t.file_path,30))}</td><td>${esc(t.threat_name)}</td><td>${esc(t.threat_type)}</td><td>${sevBadge(t.severity)}</td><td class="trunc" title="${esc(t.details)}">${esc(trunc(t.details,60))}</td></tr>`).join('');}
}
}
async function scanSingleContainer(id,btn){
const orig=btn.innerHTML;btn.innerHTML='<span class="spinner"></span>';btn.disabled=true;
toast(`Scanning container ${id.slice(0,12)}…`,'info');
try{
const r=await fetch('/api/actions/scan-container',{method:'POST',headers:{'Content-Type':'application/json','X-API-Key':window.AYN_API_KEY||''},body:JSON.stringify({container_id:id})});
const ct=r.headers.get('content-type')||'';
if(!ct.includes('application/json')){const t=await r.text();toast('Server error: '+t.slice(0,100),'error');btn.innerHTML=orig;btn.disabled=false;return;}
const d=await r.json();
if(d.status==='error'){toast(d.error||'Failed','error');}
else{toast(`Container scan: ${d.threats_found||0} threats found`,'success');}
loadContainers();
}catch(e){toast('Failed: '+e.message,'error');}
btn.innerHTML=orig;btn.disabled=false;
}
/* ═══════ QUARANTINE ═══════ */
async function loadQuarantine(){
const d=await api('/api/quarantine');
if(!d)return;
$('q-count').textContent=fmt(d.count);
$('q-size').textContent=fmtBytes(d.total_size);
const tb=$('quar-tbody');
const items=d.items||[];
if(!items.length){tb.innerHTML='<tr class="empty-row"><td colspan="5">Vault is empty ✅</td></tr>';
}else{tb.innerHTML=items.map(i=>`<tr><td class="mono">${esc(trunc(i.id,12))}</td><td class="mono trunc" title="${esc(i.original_path)}">${esc(trunc(i.original_path,50))}</td><td>${esc(i.threat_name)}</td><td>${ago(i.quarantine_date)}</td><td>${fmtBytes(i.size||i.file_size||0)}</td></tr>`).join('');}
}
/* ═══════ LOGS ═══════ */
async function loadLogs(){
const d=await api('/api/logs?limit=50');
if(!d)return;
const lv=$('log-view');
const logs=d.logs||[];
if(!logs.length){lv.innerHTML='<div style="color:var(--text-dim);padding:12px">No activity yet.</div>';return;}
lv.innerHTML=logs.map(l=>{
const lc=l.level==='ERROR'?'var(--red)':l.level==='WARNING'?'var(--orange)':'var(--accent)';
return `<div class="log-line"><span class="log-ts">${l.timestamp||''}</span><span style="color:${lc};font-weight:700;min-width:56px">${l.level}</span><span class="log-src">${esc(l.source)}</span><span class="log-msg">${esc(l.message)}</span></div>`;
}).join('');
lv.scrollTop=0;
}
/* ═══════ AI ANALYSIS ═══════ */
async function aiAnalyze(threatId,btn){
const orig=btn.innerHTML;btn.innerHTML='<span class="spinner"></span> Analyzing…';btn.disabled=true;
try{
const r=await fetch('/api/actions/ai-analyze',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({threat_id:threatId})});
const ct=r.headers.get('content-type')||'';
if(!ct.includes('application/json')){toast('Server error','error');btn.innerHTML=orig;btn.disabled=false;return;}
const d=await r.json();
if(d.status==='ok'){
const emoji=d.verdict==='safe'?'':d.verdict==='threat'?'🚨':'⚠️';
const color=d.verdict==='safe'?'success':d.verdict==='threat'?'error':'info';
toast(`${emoji} AI: ${d.verdict.toUpperCase()} (${d.confidence}%) — ${d.reason}`,color);
if(d.verdict==='safe'){toast(`Recommended: ${d.recommended_action}`,'info');}
loadThreats();
} else {toast(d.error||'AI analysis failed','error');}
}catch(e){toast('Failed: '+e.message,'error');}
btn.innerHTML=orig;btn.disabled=false;
}
/* ═══════ THREAT ACTIONS ═══════ */
async function threatAction(action,threatId,filePath,threatName,btn){
const labels={'quarantine':'Quarantine','delete-threat':'Delete','whitelist':'Whitelist'};
if(action==='delete-threat'&&!confirm('Permanently delete '+filePath+'?'))return;
const orig=btn.parentElement.innerHTML;btn.parentElement.innerHTML='<span class="spinner"></span>';
try{
const body={threat_id:threatId};
if(filePath)body.file_path=filePath;
if(threatName)body.threat_name=threatName;
const r=await fetch('/api/actions/'+action,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const ct=r.headers.get('content-type')||'';
if(!ct.includes('application/json')){toast('Server error','error');return;}
const d=await r.json();
if(d.status==='ok'){toast((labels[action]||action)+' done','success');loadThreats();}
else{toast(d.error||'Failed','error');}
}catch(e){toast('Failed: '+e.message,'error');}
}
/* ═══════ ACTIONS ═══════ */
async function doAction(action,btn){
const orig=btn.innerHTML;btn.innerHTML='<span class="spinner"></span> Running…';btn.disabled=true;
toast(`${action} started…`,'info');
try{
const r=await fetch(`/api/actions/${action}`,{method:'POST',headers:{'X-API-Key':window.AYN_API_KEY||''}});
const ct=r.headers.get('content-type')||'';
if(!ct.includes('application/json')){const t=await r.text();toast('Server error: '+(r.status>=400?r.status+' ':'')+t.slice(0,100),'error');btn.innerHTML=orig;btn.disabled=false;return;}
const d=await r.json();
if(d.status==='error'){toast(d.error||'Failed','error');}
else{toast(`${action} completed`,'success');}
refreshAll();
}catch(e){toast('Request failed: '+e.message,'error');}
btn.innerHTML=orig;btn.disabled=false;
}
async function doFeedUpdate(feed,btn){
const orig=btn.innerHTML;btn.innerHTML='<span class="spinner"></span>';btn.disabled=true;
toast(`Updating ${feed}…`,'info');
try{
const r=await fetch('/api/actions/update-feed',{method:'POST',headers:{'Content-Type':'application/json','X-API-Key':window.AYN_API_KEY||''},body:JSON.stringify({feed})});
const ct=r.headers.get('content-type')||'';
if(!ct.includes('application/json')){const t=await r.text();toast(`${feed}: Server error `+t.slice(0,100),'error');btn.innerHTML=orig;btn.disabled=false;return;}
const d=await r.json();
if(d.status==='error'){toast(`${feed}: ${d.error}`,'error');}
else{toast(`${feed} updated`,'success');}
loadDefs();
}catch(e){toast('Failed: '+e.message,'error');}
btn.innerHTML=orig;btn.disabled=false;
}
/* ═══════ REFRESH ═══════ */
async function refreshAll(){
await loadOverview();
const active=document.querySelector('.nav-tab.active');
if(active){
const tab=active.dataset.tab;
if(tab==='threats')loadThreats();
if(tab==='scans')loadScans();
if(tab==='definitions')loadDefs();
if(tab==='containers')loadContainers();
if(tab==='quarantine')loadQuarantine();
if(tab==='logs')loadLogs();
}
}
/* ── Boot ── */
refreshAll();
setInterval(refreshAll,30000);
</script>
</body>
</html>"""