remove infra.md.example, infra.md is the source of truth
This commit is contained in:
7
ayn-antivirus/ayn_antivirus/dashboard/__init__.py
Normal file
7
ayn-antivirus/ayn_antivirus/dashboard/__init__.py
Normal 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"]
|
||||
1159
ayn-antivirus/ayn_antivirus/dashboard/api.py
Normal file
1159
ayn-antivirus/ayn_antivirus/dashboard/api.py
Normal file
File diff suppressed because it is too large
Load Diff
181
ayn-antivirus/ayn_antivirus/dashboard/collector.py
Normal file
181
ayn-antivirus/ayn_antivirus/dashboard/collector.py
Normal 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(),
|
||||
}
|
||||
427
ayn-antivirus/ayn_antivirus/dashboard/server.py
Normal file
427
ayn-antivirus/ayn_antivirus/dashboard/server.py
Normal 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()
|
||||
386
ayn-antivirus/ayn_antivirus/dashboard/store.py
Normal file
386
ayn-antivirus/ayn_antivirus/dashboard/store.py
Normal 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()
|
||||
910
ayn-antivirus/ayn_antivirus/dashboard/templates.py
Normal file
910
ayn-antivirus/ayn_antivirus/dashboard/templates.py
Normal 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>"""
|
||||
Reference in New Issue
Block a user