refactor!: complete migration from Python to Go backend with distroless containerization and multi-arch CI

This commit is contained in:
andreicscs
2026-04-07 00:15:53 +02:00
parent 903938b121
commit f50388bf2e
25 changed files with 50 additions and 1765 deletions
-20
View File
@@ -1,20 +0,0 @@
# STAGE 1: Builder
FROM golang:1.25 AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o honeywire-hub ./cmd/hub/main.go
# STAGE 2: Distroless
# The "static" image contains literally nothing except SSL certificates and a non-root user.
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=builder /build/honeywire-hub /app/
# Expose port and run
EXPOSE 8080
CMD ["/app/honeywire-hub"]
-31
View File
@@ -1,31 +0,0 @@
version: '3.8'
services:
# 1. THE FIXER: Runs as root, fixes permissions, and dies instantly.
permission-fixer:
image: alpine:latest
# 65532 is the standard UID/GID for the Distroless nonroot user
command: sh -c "chown -R 65532:65532 /data"
volumes:
- ./honeywire_data:/data
# 2. THE HUB: pure Go distroless backend.
honeywire-hub:
build:
context: .
dockerfile: Dockerfile
container_name: honeywire-hub
restart: unless-stopped
ports:
- "${HW_PORT:-8080}:${HW_PORT:-8080}"
volumes:
- ./honeywire_data:/data
depends_on:
# This ensures the Hub waits until the fixer is completely finished
permission-fixer:
condition: service_completed_successfully
environment:
- HW_PORT=8080
- HW_DB_PATH=/data/honeywire.db
- HW_HUB_KEY=change_this_to_a_secure_random_string
- HW_DASHBOARD_PASSWORD=admin
-143
View File
@@ -1,143 +0,0 @@
"""Generate demo sensors + dynamic events for HoneyWire UI showcase."""
import os
import requests
import random
import socket
import struct
import time
from datetime import datetime
HUB_URL = "http://localhost:8080"
API_SECRET = "change_this_to_a_secure_random_string"
HEADERS = {"x-api-key": API_SECRET, "Content-Type": "application/json"}
# Determine version for contract validation
def get_project_version():
version = os.getenv("HONEYWIRE_VERSION")
if version: return version
try:
with open(os.path.join(os.path.dirname(__file__), "..", "VERSION"), "r") as f:
return f.read().strip()
except FileNotFoundError:
return "1.0.0"
VERSION = get_project_version()
# --- Random Data Generators ---
def generate_random_ip():
"""Generates a random public-looking IPv4 address."""
return socket.inet_ntoa(struct.pack('>I', random.randint(0x01000000, 0xDF000000)))
def generate_fleet(num_sensors=8):
"""Generates a list of random sensors with dynamic names."""
types = ["tarpit", "canary_token", "ids_sentinel", "web_honeypot", "llm_probe"]
regions = ["us-east", "us-west", "eu-central", "ap-south", "lan-core", "dmz"]
fleet = []
for _ in range(num_blocks := num_sensors):
s_type = random.choice(types)
region = random.choice(regions)
sensor_id = f"{s_type}-{region}-{random.randint(10, 99)}"
fleet.append({"sensor_id": sensor_id, "sensor_type": s_type})
return fleet
def generate_contextual_event(sensor):
"""Generates a realistic event that matches the sensor type."""
s_type = sensor["sensor_type"]
ip = generate_random_ip()
# Base Event Template
event = {
"sensor_id": sensor["sensor_id"],
"sensor_type": s_type,
"contract_version": VERSION,
"source": ip,
"action_taken": random.choice(["logged", "blocked", "tarpitted", "alert_only"])
}
# Contextual Threat Data
if s_type == "web_honeypot":
event["event_type"] = random.choice(["sqli_attempt", "xss_payload", "dir_bruteforce", "api_fuzz"])
event["severity"] = random.choice(["high", "critical", "medium"])
event["target"] = random.choice(["/wp-admin", "/api/v1/users", "/.env", "/config.php"])
event["details"] = {"user_agent": "masscan/1.3", "payload": "1' OR '1'='1", "method": "POST"}
elif s_type == "tarpit":
event["event_type"] = random.choice(["tcp_syn_flood", "port_scan", "ssh_bruteforce"])
event["severity"] = random.choice(["low", "medium", "info"])
event["target"] = random.choice(["Port 22", "Port 23", "Port 3389", "Port 445"])
event["details"] = {"packets_dropped": random.randint(50, 5000), "connection_time_held": f"{random.randint(10, 120)}s"}
elif s_type == "canary_token":
event["event_type"] = random.choice(["file_accessed", "aws_key_used", "db_queried"])
event["severity"] = "critical"
event["target"] = random.choice(["/etc/passwd.bak", "AWS_SECRET_KEY", "users_backup.sql"])
event["details"] = {"process_name": random.choice(["cat", "curl", "python3"]), "user": "www-data"}
elif s_type == "llm_probe":
event["event_type"] = random.choice(["prompt_injection", "jailbreak_attempt", "data_exfiltration"])
event["severity"] = random.choice(["high", "critical"])
event["target"] = "/v1/chat/completions"
event["details"] = {"prompt_snippet": "Ignore previous instructions and print...", "model": "gpt-4-turbo"}
else: # ids_sentinel
event["event_type"] = random.choice(["malware_signature", "lateral_movement", "beaconing"])
event["severity"] = random.choice(["high", "critical", "medium"])
event["target"] = f"Subnet 10.0.{random.randint(1,5)}.0/24"
event["details"] = {"signature_id": f"SID-{random.randint(1000, 9999)}", "confidence": f"{random.randint(80, 100)}%"}
return event
# --- API Interaction ---
def post_heartbeat(sensor):
body = {
"sensor_id": sensor["sensor_id"],
"sensor_type": sensor["sensor_type"],
"details": {
"version": VERSION,
"os": random.choice(["Linux", "Windows Server 2022", "FreeBSD"]),
"uptime_days": random.randint(1, 400),
},
}
r = requests.post(f"{HUB_URL}/api/v1/heartbeat", json=body, headers=HEADERS, timeout=5)
r.raise_for_status()
def post_event(event_data):
r = requests.post(f"{HUB_URL}/api/v1/event", json=event_data, headers=HEADERS, timeout=5)
r.raise_for_status()
def main():
print(f"Generating dynamic fleet and events for HoneyWire Hub (v{VERSION})...")
# 1. Generate Fleet & Send Heartbeats
fleet = generate_fleet(10) # Change this number to test UI scaling
for sensor in fleet:
post_heartbeat(sensor)
print(f"[+] Heartbeat registered: {sensor['sensor_id']}")
time.sleep(0.1)
print("\nSimulating threat activity...")
# 2. Fire 40 random, contextual events
for _ in range(40):
sensor = random.choice(fleet)
# Occasionally simulate an offline sensor by NOT sending a heartbeat,
# but 90% of the time, refresh the heartbeat with the event.
if random.random() > 0.1:
post_heartbeat(sensor)
event_data = generate_contextual_event(sensor)
post_event(event_data)
# Print a tiny log to the terminal so it looks cool running
print(f"{event_data['severity'].upper().ljust(8)} | {event_data['sensor_id']} | {event_data['event_type']}")
time.sleep(random.uniform(0.05, 0.2))
print("\n✅ Done. Refresh http://localhost:8080 to view the SOC dashboard.")
if __name__ == "__main__":
main()
-24
View File
@@ -1,24 +0,0 @@
# ==========================================
# HONEYWIRE HUB CONFIGURATION
# ==========================================
HW_HUB_PORT=8080
# The secret key your sensors use to talk to the Hub. Make this long and random!
HW_HUB_KEY=change_this_to_a_secure_random_string
# The password to log into the Web Dashboard
HW_DASHBOARD_PASSWORD=admin
# Where should the SQLite database be stored? (Inside the container)
HW_DB_PATH=/data/honeywire.db
# ==========================================
# NOTIFICATIONS (Optional)
# ==========================================
# Push alerts directly to your phone via Ntfy (e.g., https://ntfy.sh/my_secret_topic)
HW_NTFY_URL=
# Push alerts to a self-hosted Gotify instance
HW_GOTIFY_URL=
HW_GOTIFY_TOKEN=
+17 -35
View File
@@ -1,38 +1,20 @@
# --- Stage 1: Builder ---
FROM python:3.11-slim AS builder
# STAGE 1: Builder
FROM golang:1.25 AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o honeywire-hub ./cmd/hub/main.go
# STAGE 2: Distroless
# The "static" image contains literally nothing except SSL certificates and a non-root user.
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=builder /build/honeywire-hub /app/
# 1. Patch the OS (This is why your Debian vulnerabilities are now 0!)
RUN apt-get update && apt-get upgrade -y && \
rm -rf /var/lib/apt/lists/*
# 2. Create the virtual environment
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# 3. CRITICAL: Upgrade core tools to fix the 'jaraco' and 'wheel' vulnerabilities
# This pulls the fixed versions (6.1.0+ and 0.46.2+) identified in your scan
RUN pip install --no-cache-dir --upgrade pip setuptools wheel
# 4. Install your pinned requirements
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 5. Prepare data directory for Distroless
RUN mkdir /data && chown 65532:65532 /data
# --- Stage 2: Distroless & Rootless ---
FROM gcr.io/distroless/python3-debian12
WORKDIR /app
COPY --from=builder /opt/venv /opt/venv
ENV PYTHONPATH="/opt/venv/lib/python3.11/site-packages"
ENV PATH="/opt/venv/bin:$PATH"
COPY --from=builder --chown=65532:65532 /data /data
COPY --chown=65532:65532 main.py /app/main.py
COPY --chown=65532:65532 templates/ /app/templates/
USER nonroot:nonroot
# Expose port and run
EXPOSE 8080
CMD ["/app/main.py"]
CMD ["/app/honeywire-hub"]
@@ -18,7 +18,7 @@ func main() {
cfg := config.Load()
dbStore, err := store.NewStore("test_honeywire.db")
dbStore, err := store.NewStore(cfg.DBPath)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
+26 -9
View File
@@ -1,14 +1,31 @@
version: '3.8'
services:
hub:
build: .
# 1. THE FIXER: Runs as root, fixes permissions, and dies instantly.
permission-fixer:
image: alpine:latest
# 65532 is the standard UID/GID for the Distroless nonroot user
command: sh -c "chown -R 65532:65532 /data"
volumes:
- ./honeywire_data:/data
# 2. THE HUB: pure Go distroless backend.
honeywire-hub:
build:
context: .
dockerfile: Dockerfile
container_name: honeywire-hub
restart: unless-stopped
ports:
- "${HW_HUB_PORT}:8080"
- "${HW_PORT:-8080}:${HW_PORT:-8080}"
volumes:
- honeywire_data:/data
env_file:
- .env
volumes:
honeywire_data:
- ./honeywire_data:/data
depends_on:
# This ensures the Hub waits until the fixer is completely finished
permission-fixer:
condition: service_completed_successfully
environment:
- HW_PORT=8080
- HW_DB_PATH=/data/honeywire.db
- HW_HUB_KEY=change_this_to_a_secure_random_string
- HW_DASHBOARD_PASSWORD=admin
View File
View File
-737
View File
@@ -1,737 +0,0 @@
"""
HoneyWire Hub — main.py
Universal sensor hub supporting arbitrary sensor types via the HoneyWire Event Standard.
Architecture notes:
- DB schema is versioned via PRAGMA user_version; add new migrations to MIGRATIONS list.
- Notifications are dispatched through notify(); add new backends to NOTIFICATION_BACKENDS.
- Auth is centralised in verify_ui_auth(); cookie logic lives in one place.
- The Event model is intentionally sensor-agnostic: source, target, details cover every
conceivable sensor (network tarpit, file canary, LLM probe, email honeypot, …).
"""
import secrets
import sqlite3
import os
import json
import hashlib
import logging
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from typing import Any, Callable
import requests
from fastapi import Depends, FastAPI, Header, HTTPException, Request, BackgroundTasks
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, field_validator
from datetime import datetime, timedelta, timezone
from fastapi import FastAPI, Request, HTTPException, Depends, Header, BackgroundTasks, Query
from fastapi import Query
from datetime import timedelta
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger("honeywire")
# ---------------------------------------------------------------------------
# Configuration (all values come from HW_ environment variables)
# ---------------------------------------------------------------------------
# The API Key sensors use to authenticate to the Hub
API_SECRET = os.getenv("HW_HUB_KEY", "change_this_to_a_secure_random_string")
# Dashboard UI Password
DASHBOARD_PASSWORD = os.getenv("HW_DASHBOARD_PASSWORD", "admin")
# Notification Endpoints
NTFY_URL = os.getenv("HW_NTFY_URL", "")
GOTIFY_URL = os.getenv("HW_GOTIFY_URL", "")
GOTIFY_TOKEN = os.getenv("HW_GOTIFY_TOKEN", "")
# Database Location
DB_PATH = os.getenv("HW_DB_PATH", "/data/honeywire.db")
# Versioning: one source of truth stored in root file + env override.
DEFAULT_VERSION = "1.0.0"
VERSION_FILE_PATH = os.path.join(os.path.dirname(__file__), "..", "VERSION")
try:
with open(os.path.abspath(VERSION_FILE_PATH), "r") as f:
FILE_VERSION = f.read().strip()
except FileNotFoundError:
FILE_VERSION = DEFAULT_VERSION
# Use HW_VERSION to allow users to override it if strictly necessary
HONEYWIRE_VERSION = os.getenv("HW_VERSION", FILE_VERSION)
AUTH_COOKIE_NAME = "hw_auth"
# Store active sessions in memory to prevent Pass-the-Hash vulnerabilities
# Format: { "session_token": expiration_datetime }
ACTIVE_SESSIONS: dict[str, datetime] = {}
# ---------------------------------------------------------------------------
# Database — versioned migrations
# ---------------------------------------------------------------------------
# We use one clean initialization string for new deployments
INIT_SCHEMA = """
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
contract_version TEXT NOT NULL DEFAULT '1.0.0',
sensor_id TEXT NOT NULL,
sensor_type TEXT NOT NULL DEFAULT 'generic',
event_type TEXT NOT NULL DEFAULT 'alert',
severity TEXT NOT NULL DEFAULT 'medium',
source TEXT NOT NULL DEFAULT 'Unknown',
target TEXT NOT NULL DEFAULT 'Unknown',
action_taken TEXT NOT NULL DEFAULT 'logged',
details TEXT NOT NULL DEFAULT '{}',
is_read INTEGER NOT NULL DEFAULT 0,
is_archived INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS sensors (
sensor_id TEXT PRIMARY KEY,
first_seen TEXT,
last_seen TEXT NOT NULL,
sensor_type TEXT NOT NULL DEFAULT 'generic',
metadata TEXT NOT NULL DEFAULT '{}',
is_silenced INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
INSERT OR IGNORE INTO config (key, value) VALUES ('is_armed', 'true');
CREATE TABLE IF NOT EXISTS sensor_heartbeats (
sensor_id TEXT NOT NULL,
time_bucket TEXT NOT NULL,
PRIMARY KEY (sensor_id, time_bucket)
);
CREATE INDEX IF NOT EXISTS idx_heartbeats_time ON sensor_heartbeats(time_bucket);
"""
def _get_db_version(conn: sqlite3.Connection) -> int:
return conn.execute("PRAGMA user_version").fetchone()[0]
def _set_db_version(conn: sqlite3.Connection, version: int) -> None:
conn.execute(f"PRAGMA user_version = {version}")
def init_db():
"""Run exactly once on startup to ensure tables exist."""
conn = sqlite3.connect(DB_PATH)
try:
conn.executescript(INIT_SCHEMA)
conn.commit()
log.info("Database initialized successfully.")
except Exception as e:
log.error(f"Database initialization failed: {e}")
finally:
conn.close()
@contextmanager
def get_db():
"""Context manager that yields a sqlite3 connection and closes it on exit."""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
init_db()
# ---------------------------------------------------------------------------
# Notification dispatcher
# ---------------------------------------------------------------------------
def _notify_ntfy(title: str, message: str, severity: str) -> None:
if not NTFY_URL:
return
# Ntfy scale: 1 (min) to 5 (max)
priorities = {"info": 1, "low": 2, "medium": 3, "high": 4, "critical": 5}
priority = priorities.get(severity.lower(), 3) # Default to medium
headers = {"Title": title, "Priority": str(priority), "Tags": "rotating_light"}
NTFY_TOKEN = os.getenv("NTFY_TOKEN", "")
if NTFY_TOKEN:
headers["Authorization"] = f"Bearer {NTFY_TOKEN}"
try:
response = requests.post(NTFY_URL, data=message.encode('utf-8'), headers=headers, timeout=5)
response.raise_for_status()
except requests.exceptions.HTTPError as exc:
log.warning("Ntfy rejected the request: %s | Details: %s", exc, response.text)
except Exception as exc:
log.warning("Ntfy failed to connect entirely: %s", exc)
def _notify_gotify(title: str, message: str, severity: str) -> None:
if not (GOTIFY_URL and GOTIFY_TOKEN):
return
# Gotify scale: 0-3 (min/silent), 4-7 (default/sound), 8-10 (high/interrupt)
priorities = {"info": 1, "low": 3, "medium": 5, "high": 8, "critical": 10}
priority = priorities.get(severity.lower(), 5) # Default to medium
try:
response = requests.post(
GOTIFY_URL,
headers={"X-Gotify-App-Token": GOTIFY_TOKEN},
json={"title": title, "message": message, "priority": priority},
timeout=5,
)
response.raise_for_status()
except requests.exceptions.HTTPError as exc:
log.warning("Gotify rejected the request: %s | Details: %s", exc, response.text)
except Exception as exc:
log.warning("Gotify failed to connect entirely: %s", exc)
NOTIFICATION_BACKENDS: list[Callable[[str, str, str], None]] = [
_notify_ntfy,
_notify_gotify,
]
def notify(title: str, message: str, severity: str) -> None:
"""Dispatch a notification to all configured backends.
NOTE: This should always be called via BackgroundTasks to prevent blocking."""
for backend in NOTIFICATION_BACKENDS:
backend(title, message, severity)
# ---------------------------------------------------------------------------
# Pydantic models
# ---------------------------------------------------------------------------
class Event(BaseModel):
contract_version: str
sensor_id: str
sensor_type: str = "generic"
event_type: str = "alert"
severity: str = "medium"
source: str = "Unknown"
target: str = "Unknown"
action_taken: str = "logged"
metadata: dict = {}
@field_validator("severity")
@classmethod
def validate_severity(cls, v: str) -> str:
allowed = {"info", "low", "medium", "high", "critical"}
if v.lower() not in allowed:
raise ValueError(f"severity must be one of {allowed}")
return v.lower()
class Heartbeat(BaseModel):
sensor_id: str
sensor_type: str = "generic"
metadata: Any = {}
class SystemState(BaseModel):
is_armed: bool
class LoginRequest(BaseModel):
password: str
# ---------------------------------------------------------------------------
# FastAPI app
# ---------------------------------------------------------------------------
app = FastAPI(title="HoneyWire Hub")
templates = Jinja2Templates(directory="templates")
# ---------------------------------------------------------------------------
# Auth — Centralized Security Logic
# ---------------------------------------------------------------------------
def verify_ui_auth(request: Request) -> None:
"""Validates the presence of an active, unexpired session token."""
if DASHBOARD_PASSWORD:
cookie_val = request.cookies.get(AUTH_COOKIE_NAME)
if not cookie_val or cookie_val not in ACTIVE_SESSIONS:
raise HTTPException(status_code=401, detail="Unauthorized")
if datetime.now() > ACTIVE_SESSIONS[cookie_val]:
del ACTIVE_SESSIONS[cookie_val]
raise HTTPException(status_code=401, detail="Session Expired")
def verify_agent_auth(x_api_key: str = Header(None), authorization: str = Header(None)):
"""Verifies the secret key sent by the sensor (Supports X-Api-Key and Bearer)."""
token = x_api_key
if not token and authorization and authorization.startswith("Bearer "):
token = authorization.split(" ", 1)[1].strip()
if not token or not secrets.compare_digest(token, API_SECRET):
log.warning("Unauthorized access attempt detected.")
raise HTTPException(status_code=401, detail="Unauthorized")
# ---------------------------------------------------------------------------
# Frontend API
# ---------------------------------------------------------------------------
@app.get("/api/v1/system/state", dependencies=[Depends(verify_ui_auth)])
async def get_system_state():
with get_db() as conn:
row = conn.execute("SELECT value FROM config WHERE key='is_armed'").fetchone()
return {"is_armed": row["value"] == "true"}
@app.patch("/api/v1/system/state", dependencies=[Depends(verify_ui_auth)])
async def set_system_state(state: SystemState):
with get_db() as conn:
conn.execute(
"UPDATE config SET value=? WHERE key='is_armed'",
("true" if state.is_armed else "false",),
)
return {"status": "success", "is_armed": state.is_armed}
@app.get("/api/v1/version")
async def get_version():
return {"version": HONEYWIRE_VERSION}
@app.get("/api/v1/sensors", dependencies=[Depends(verify_ui_auth)])
async def get_sensors():
with get_db() as conn:
# FIX 1: Add is_silenced to the SQL SELECT statement
rows = conn.execute(
"SELECT sensor_id, sensor_type, last_seen, metadata, is_silenced FROM sensors ORDER BY sensor_id"
).fetchall()
now = datetime.now()
fleet = []
for r in rows:
last_seen_dt = datetime.strptime(r["last_seen"], "%Y-%m-%d %H:%M:%S")
fleet.append({
"sensor_id": r["sensor_id"],
"sensor_type": r["sensor_type"],
"last_seen": r["last_seen"],
"metadata": json.loads(r["metadata"]),
"status": "online" if (now - last_seen_dt) < timedelta(seconds=90) else "offline",
# FIX 2: Pass the boolean value to the frontend
"is_silenced": bool(r["is_silenced"])
})
return fleet
@app.get("/api/v1/events", dependencies=[Depends(verify_ui_auth)])
async def get_events(archived: bool = Query(False)):
with get_db() as conn:
rows = conn.execute(
"SELECT * FROM events WHERE is_archived = ? ORDER BY id DESC",
(1 if archived else 0,)
).fetchall()
return [
{
"contract_version": r["contract_version"],
"id": r["id"],
"timestamp": r["timestamp"],
"sensor_id": r["sensor_id"],
"sensor_type": r["sensor_type"],
"event_type": r["event_type"],
"severity": r["severity"],
"source": r["source"],
"target": r["target"],
"action_taken": r["action_taken"],
"details": json.loads(r["details"]) if r["details"] else {},
"is_read": bool(r["is_read"]),
"is_archived": bool(r["is_archived"]),
}
for r in rows
]
@app.patch("/api/v1/events/read", dependencies=[Depends(verify_ui_auth)])
async def mark_events_read():
with get_db() as conn:
conn.execute("UPDATE events SET is_read = 1 WHERE is_read = 0")
return {"status": "success"}
@app.patch("/api/v1/events/{event_id}/read", dependencies=[Depends(verify_ui_auth)])
async def mark_single_event_read(event_id: int):
with get_db() as conn:
conn.execute("UPDATE events SET is_read = 1 WHERE id = ?", (event_id,))
return {"status": "success"}
@app.delete("/api/v1/events", dependencies=[Depends(verify_ui_auth)])
async def clear_events():
with get_db() as conn:
conn.execute("DELETE FROM events")
return {"status": "success"}
@app.get("/api/v1/uptime", dependencies=[Depends(verify_ui_auth)])
async def get_uptime_data(timeframe: str = Query("24H")):
now = datetime.now(timezone.utc)
if timeframe == "1H":
num_blocks = 60
delta = timedelta(minutes=1)
fmt = "%Y-%m-%d %H:%M"
expected_pings = 1
elif timeframe == "24H":
num_blocks = 24
delta = timedelta(hours=1)
fmt = "%Y-%m-%d %H"
expected_pings = 60
elif timeframe == "7D":
num_blocks = 7
delta = timedelta(days=1)
fmt = "%Y-%m-%d"
expected_pings = 1440
else: # 30D
num_blocks = 30
delta = timedelta(days=1)
fmt = "%Y-%m-%d"
expected_pings = 1440
cutoff = now - (delta * num_blocks)
cutoff_str = cutoff.strftime("%Y-%m-%d %H:%M:%S")
with get_db() as conn:
sensors = conn.execute("SELECT sensor_id, last_seen, first_seen FROM sensors ORDER BY sensor_id").fetchall()
rows = conn.execute(
"SELECT sensor_id, time_bucket FROM sensor_heartbeats WHERE time_bucket >= ?",
(cutoff_str,)
).fetchall()
history = {s["sensor_id"]: {} for s in sensors}
for r in rows:
bucket_dt = datetime.strptime(r["time_bucket"], "%Y-%m-%d %H:%M:00")
time_key = bucket_dt.strftime(fmt)
history[r["sensor_id"]][time_key] = history[r["sensor_id"]].get(time_key, 0) + 1
result = []
for s in sensors:
sensor_id = s["sensor_id"]
blocks = []
# Safely parse first_seen (fallback to 'now' if missing)
first_seen_str = s["first_seen"] or now.strftime("%Y-%m-%d %H:%M:%S")
first_seen_dt = datetime.strptime(first_seen_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
# CRITICAL FIX: Convert first_seen to the exact same bucket format as time_key
first_seen_key = first_seen_dt.strftime(fmt)
for i in range(num_blocks - 1, -1, -1):
block_time = now - (delta * i)
time_key = block_time.strftime(fmt)
time_label = "Current" if i == 0 else f"{i} " + ("mins ago" if timeframe == "1H" else "hours ago" if timeframe == "24H" else "days ago")
# 1. NO DATA CHECK: Elegant string comparison
if time_key < first_seen_key:
status, label = "nodata", "No Data (Not Deployed Yet)"
else:
# 2. DEGRADED / UP / DOWN CHECK
ping_count = history[sensor_id].get(time_key, 0)
if ping_count == 0:
status, label = "down", "Offline"
elif ping_count < (expected_pings * 0.85):
status, label = "degraded", f"Degraded ({ping_count}/{expected_pings} pings)"
else:
status, label = "up", "Online"
blocks.append({
"status": status,
"timeLabel": time_label,
"label": label
})
# Override the very last block to strictly match live last_seen status
last_seen_dt = datetime.strptime(s["last_seen"], "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
is_live = (now - last_seen_dt) < timedelta(seconds=90)
blocks[-1] = {
"status": "up" if is_live else "down",
"timeLabel": "Current",
"label": "Online (Live)" if is_live else "Offline (Live)"
}
result.append({
"id": sensor_id,
"name": sensor_id,
"isOnline": is_live,
"blocks": blocks
})
return result
@app.patch("/api/v1/events/{event_id}/archive", dependencies=[Depends(verify_ui_auth)])
async def archive_single_event(event_id: int):
with get_db() as conn:
conn.execute("UPDATE events SET is_archived = 1, is_read = 1 WHERE id = ?", (event_id,))
return {"status": "success"}
@app.patch("/api/v1/events/archive-all", dependencies=[Depends(verify_ui_auth)])
async def archive_all_events():
with get_db() as conn:
# Only archive currently active events
conn.execute("UPDATE events SET is_archived = 1, is_read = 1 WHERE is_archived = 0")
return {"status": "success"}
class SilenceRequest(BaseModel):
is_silenced: bool
@app.patch("/api/v1/sensors/{sensor_id}/silence", dependencies=[Depends(verify_ui_auth)])
async def toggle_sensor_silence(sensor_id: str, req: SilenceRequest):
with get_db() as conn:
conn.execute(
"UPDATE sensors SET is_silenced = ? WHERE sensor_id = ?",
(1 if req.is_silenced else 0, sensor_id)
)
return {"status": "success", "sensor_id": sensor_id, "is_silenced": req.is_silenced}
# ---------------------------------------------------------------------------
# Sensor API
# ---------------------------------------------------------------------------
@app.post("/api/v1/heartbeat", dependencies=[Depends(verify_agent_auth)])
async def receive_heartbeat(hb: Heartbeat):
now = datetime.now(timezone.utc)
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
# Round down to the current minute (e.g., "2026-04-05 21:05:00")
minute_bucket = now.strftime("%Y-%m-%d %H:%M:00")
metadata_json = json.dumps(hb.metadata) if isinstance(hb.metadata, (dict, list)) else "{}"
with get_db() as conn:
# 1. Update current live status & explicitly set first_seen if new
conn.execute(
"""INSERT INTO sensors (sensor_id, first_seen, last_seen, sensor_type, metadata)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(sensor_id) DO UPDATE SET last_seen=?, sensor_type=?, metadata=?""",
(hb.sensor_id, now_str, now_str, hb.sensor_type, metadata_json,
now_str, hb.sensor_type, metadata_json),
)
# 2. Log historical bucket (Ignores duplicates within the same minute)
conn.execute(
"INSERT OR IGNORE INTO sensor_heartbeats (sensor_id, time_bucket) VALUES (?, ?)",
(hb.sensor_id, minute_bucket)
)
return {"status": "alive"}
@app.post("/api/v1/event", dependencies=[Depends(verify_agent_auth)])
async def receive_event(event: Event, bg_tasks: BackgroundTasks):
# Optional but recommended: Semantic versioning check
hub_major_version = HONEYWIRE_VERSION.split('.')[0]
agent_major_version = event.contract_version.split('.')[0]
if hub_major_version != agent_major_version:
log.warning(f"Version mismatch rejected: Hub is {HONEYWIRE_VERSION}, Agent sent {event.contract_version}")
raise HTTPException(
status_code=426,
detail=f"Upgrade Required: Hub is on v{HONEYWIRE_VERSION}, but agent uses v{event.contract_version}"
)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
details_payload = event.metadata if hasattr(event, 'metadata') else {}
details_json = json.dumps(details_payload) if isinstance(details_payload, (dict, list)) else str(details_payload)
with get_db() as conn:
conn.execute(
"""INSERT INTO events
(timestamp, contract_version, sensor_id, sensor_type, event_type, severity,
source, target, action_taken, details, is_read)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)""",
(timestamp, event.contract_version, event.sensor_id, event.sensor_type, event.event_type,
event.severity, event.source, event.target, event.action_taken, details_json),
)
# 1. Check Global Arm State
is_armed = conn.execute(
"SELECT value FROM config WHERE key='is_armed'"
).fetchone()["value"] == "true"
# 2. Check Specific Sensor Silence State
sensor_row = conn.execute(
"SELECT is_silenced FROM sensors WHERE sensor_id = ?",
(event.sensor_id,)
).fetchone()
is_silenced = bool(sensor_row["is_silenced"]) if sensor_row else False
msg = (
f"[{event.sensor_id}] {event.event_type.upper()}"
f"{event.source}{event.target} | action: {event.action_taken}"
)
if is_armed and not is_silenced:
bg_tasks.add_task(notify, title=f"HoneyWire Alert ({event.severity.upper()})", message=msg, severity=event.severity)
log.info("Event received (v%s): %s", event.contract_version, msg if is_armed else f"{event.sensor_id}/{event.event_type}")
return {"status": "success"}
# ---------------------------------------------------------------------------
# Web UI & Vault Door (Uncodixified)
# ---------------------------------------------------------------------------
LOGIN_HTML = """<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HoneyWire Sentinel | Authentication</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<script>
tailwind.config = { darkMode: 'class' }
if (localStorage.getItem('theme') === 'light' || (!('theme' in localStorage) && !window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.remove('dark')
} else {
document.documentElement.classList.add('dark')
}
</script>
<style>
body { font-family: 'Inter', sans-serif; }
.mono { font-family: 'JetBrains Mono', monospace; font-weight: 500; }
.bg-grid {
background-size: 40px 40px;
background-image: linear-gradient(to right, rgba(148, 163, 184, 0.08) 1px, transparent 1px),
linear-gradient(to bottom, rgba(148, 163, 184, 0.08) 1px, transparent 1px);
}
.dark .bg-grid {
background-image: linear-gradient(to right, rgba(255, 255, 255, 0.02) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
}
</style>
</head>
<body class="bg-slate-100 dark:bg-[#0a0a0c] text-slate-700 dark:text-zinc-200 h-screen flex flex-col items-center justify-center bg-grid transition-colors duration-200 p-6">
<div class="w-full max-w-[400px] space-y-8">
<div class="flex flex-col items-center">
<div class="w-12 h-12 flex items-center justify-center rounded-lg bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 shadow-sm text-2xl mb-4">🕸️</div>
<h1 class="text-xl font-bold text-slate-900 dark:text-white tracking-tight">HoneyWire Sentinel</h1>
<p class="text-sm text-slate-500 dark:text-zinc-500 mt-1">Authorized personnel only</p>
</div>
<div class="bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-lg shadow-sm p-8 transition-colors duration-200">
<form onsubmit="doLogin(event)" class="space-y-6">
<div class="space-y-2">
<label for="pwd" class="text-xs font-semibold text-slate-700 dark:text-zinc-400">Authentication Key</label>
<input type="password" id="pwd" placeholder="••••••••••••"
class="w-full px-3 py-2 rounded-md bg-slate-50 dark:bg-zinc-950 border border-slate-300 dark:border-zinc-800 text-sm mono text-slate-900 dark:text-zinc-200 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:focus:ring-zinc-600 focus:border-transparent transition-all placeholder-slate-300 dark:placeholder-zinc-700" required>
</div>
<button type="submit"
class="w-full py-2 rounded-md bg-slate-900 dark:bg-zinc-100 text-white dark:text-zinc-900 hover:bg-slate-800 dark:hover:bg-white text-sm font-semibold transition-all shadow-sm">
Sign in
</button>
</form>
<div id="err" class="hidden mt-6 p-3 rounded-md bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-800/30 text-center">
<p class="text-xs font-medium text-rose-700 dark:text-rose-400">Access Denied: Invalid Key</p>
</div>
</div>
<div class="flex justify-center">
<button onclick="toggleTheme()" class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-200 dark:bg-zinc-800 border border-slate-300 dark:border-zinc-700 text-slate-600 dark:text-zinc-400 hover:text-slate-900 dark:hover:text-white text-xs font-medium transition-colors">
<svg id="moon-icon" class="w-3.5 h-3.5 hidden dark:block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path></svg>
<svg id="sun-icon" class="w-3.5 h-3.5 block dark:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
<span>Switch Theme</span>
</button>
</div>
</div>
<script>
function toggleTheme() {
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
}
}
async function doLogin(e) {
e.preventDefault();
const btn = e.target.querySelector('button[type="submit"]');
const originalText = btn.innerText;
btn.innerText = "Authenticating...";
btn.disabled = true;
btn.classList.add('opacity-50');
try {
const res = await fetch('/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({password: document.getElementById('pwd').value})
});
if (res.ok) {
btn.innerText = "Access Granted";
btn.classList.replace('bg-slate-900', 'bg-emerald-600');
btn.classList.replace('dark:bg-zinc-100', 'dark:bg-emerald-600');
btn.classList.add('text-white');
setTimeout(() => window.location.reload(), 400);
} else {
document.getElementById('err').classList.remove('hidden');
btn.innerText = originalText;
btn.disabled = false;
btn.classList.remove('opacity-50');
document.getElementById('pwd').value = '';
document.getElementById('pwd').focus();
}
} catch (err) {
btn.innerText = originalText;
btn.disabled = false;
btn.classList.remove('opacity-50');
}
}
</script>
</body>
</html>"""
@app.post("/login")
async def login(req: LoginRequest):
if DASHBOARD_PASSWORD and secrets.compare_digest(req.password, DASHBOARD_PASSWORD):
session_token = secrets.token_hex(32)
ACTIVE_SESSIONS[session_token] = datetime.now() + timedelta(days=30)
response = JSONResponse(content={"status": "ok"})
response.set_cookie(
key=AUTH_COOKIE_NAME, value=session_token,
max_age=2_592_000, httponly=True, samesite="strict",
)
return response
raise HTTPException(status_code=401, detail="Invalid Password")
@app.get("/logout")
async def logout():
response = HTMLResponse(content="<script>window.location.href='/';</script>")
response.delete_cookie(AUTH_COOKIE_NAME)
return response
@app.get("/", response_class=HTMLResponse)
async def serve_dashboard(request: Request):
if DASHBOARD_PASSWORD:
cookie_val = request.cookies.get(AUTH_COOKIE_NAME)
if not cookie_val or cookie_val not in ACTIVE_SESSIONS or datetime.now() > ACTIVE_SESSIONS[cookie_val]:
return HTMLResponse(content=LOGIN_HTML)
return templates.TemplateResponse(request=request, name="index.html")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8080)
+6 -6
View File
@@ -62,31 +62,31 @@ def generate_contextual_event(sensor):
event["event_type"] = random.choice(["sqli_attempt", "xss_payload", "dir_bruteforce", "api_fuzz"])
event["severity"] = random.choice(["high", "critical", "medium"])
event["target"] = random.choice(["/wp-admin", "/api/v1/users", "/.env", "/config.php"])
event["metadata"] = {"user_agent": "masscan/1.3", "payload": "1' OR '1'='1", "method": "POST"}
event["details"] = {"user_agent": "masscan/1.3", "payload": "1' OR '1'='1", "method": "POST"}
elif s_type == "tarpit":
event["event_type"] = random.choice(["tcp_syn_flood", "port_scan", "ssh_bruteforce"])
event["severity"] = random.choice(["low", "medium", "info"])
event["target"] = random.choice(["Port 22", "Port 23", "Port 3389", "Port 445"])
event["metadata"] = {"packets_dropped": random.randint(50, 5000), "connection_time_held": f"{random.randint(10, 120)}s"}
event["details"] = {"packets_dropped": random.randint(50, 5000), "connection_time_held": f"{random.randint(10, 120)}s"}
elif s_type == "canary_token":
event["event_type"] = random.choice(["file_accessed", "aws_key_used", "db_queried"])
event["severity"] = "critical"
event["target"] = random.choice(["/etc/passwd.bak", "AWS_SECRET_KEY", "users_backup.sql"])
event["metadata"] = {"process_name": random.choice(["cat", "curl", "python3"]), "user": "www-data"}
event["details"] = {"process_name": random.choice(["cat", "curl", "python3"]), "user": "www-data"}
elif s_type == "llm_probe":
event["event_type"] = random.choice(["prompt_injection", "jailbreak_attempt", "data_exfiltration"])
event["severity"] = random.choice(["high", "critical"])
event["target"] = "/v1/chat/completions"
event["metadata"] = {"prompt_snippet": "Ignore previous instructions and print...", "model": "gpt-4-turbo"}
event["details"] = {"prompt_snippet": "Ignore previous instructions and print...", "model": "gpt-4-turbo"}
else: # ids_sentinel
event["event_type"] = random.choice(["malware_signature", "lateral_movement", "beaconing"])
event["severity"] = random.choice(["high", "critical", "medium"])
event["target"] = f"Subnet 10.0.{random.randint(1,5)}.0/24"
event["metadata"] = {"signature_id": f"SID-{random.randint(1000, 9999)}", "confidence": f"{random.randint(80, 100)}%"}
event["details"] = {"signature_id": f"SID-{random.randint(1000, 9999)}", "confidence": f"{random.randint(80, 100)}%"}
return event
@@ -96,7 +96,7 @@ def post_heartbeat(sensor):
body = {
"sensor_id": sensor["sensor_id"],
"sensor_type": sensor["sensor_type"],
"metadata": {
"details": {
"version": VERSION,
"os": random.choice(["Linux", "Windows Server 2022", "FreeBSD"]),
"uptime_days": random.randint(1, 400),
-15
View File
@@ -1,15 +0,0 @@
# Web Framework (Fixed 2024/2025 DoS vulnerabilities)
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
# HTML Templating
jinja2>=3.1.4
# Data Validation (Pydantic V2.10 is the 2026 stable standard)
pydantic>=2.10.0
# Security Fixes for multipart parsing (Fixes CVE-2024-53981)
python-multipart>=0.0.22
# Networking
requests>=2.32.3
-8
View File
@@ -1,8 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./templates/**/*.html"],
theme: {
extend: {},
},
plugins: [],
}
-736
View File
@@ -1,736 +0,0 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HoneyWire Sentinel</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
tailwind.config = { darkMode: 'class' }
if (localStorage.getItem('theme') === 'light' || (!('theme' in localStorage) && !window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.remove('dark')
} else {
document.documentElement.classList.add('dark')
}
</script>
<style>
[x-cloak] { display: none !important; }
body { font-family: 'Inter', sans-serif; }
.mono { font-family: 'JetBrains Mono', monospace; }
/* Preserved Theme: Severity Bleeds */
.bleed-critical { border-left-color: #f43f5e !important; background: linear-gradient(90deg, rgba(244, 63, 94, 0.08) 0%, transparent 100%); }
.bleed-high { border-left-color: #fb923c !important; background: linear-gradient(90deg, rgba(251, 146, 60, 0.06) 0%, transparent 100%); }
.bleed-medium { border-left-color: #facc15 !important; background: linear-gradient(90deg, rgba(250, 204, 21, 0.05) 0%, transparent 100%); }
.bleed-low { border-left-color: #3b82f6 !important; background: linear-gradient(90deg, rgba(59, 130, 246, 0.05) 0%, transparent 100%); }
.bleed-info { border-left-color: #64748b !important; background: linear-gradient(90deg, rgba(100, 116, 139, 0.05) 0%, transparent 100%); }
/* Preserved Theme: Functional Badges */
.severity-critical { color: #e11d48; border-color: rgba(225, 29, 72, 0.3); }
.dark .severity-critical { color: #f43f5e; border-color: rgba(244, 63, 94, 0.3); }
.severity-high { color: #ea580c; border-color: rgba(234, 88, 12, 0.3); }
.dark .severity-high { color: #fb923c; border-color: rgba(251, 146, 60, 0.3); }
.severity-medium { color: #ca8a04; border-color: rgba(202, 138, 4, 0.3); }
.dark .severity-medium { color: #facc15; border-color: rgba(250, 204, 21, 0.3); }
.severity-low { color: #2563eb; border-color: rgba(37, 99, 235, 0.3); }
.dark .severity-low { color: #3b82f6; border-color: rgba(59, 130, 246, 0.3); }
.severity-info { color: #64748b; border-color: rgba(100, 116, 139, 0.3); }
.dark .severity-info { color: #a1a1aa; border-color: rgba(161, 161, 170, 0.3); }
/* Standardized Scrollbar - Invisible "Fat" Hitbox */
.custom-scroll::-webkit-scrollbar { height: 14px; width: 14px; }
.custom-scroll::-webkit-scrollbar-track { background: transparent; }
.custom-scroll::-webkit-scrollbar-thumb {
background-color: #cbd5e1;
border-radius: 10px;
border: 4px solid transparent;
background-clip: padding-box;
}
.dark .custom-scroll::-webkit-scrollbar-thumb {
background-color: #3f3f46;
border: 4px solid transparent;
background-clip: padding-box;
}
.custom-scroll::-webkit-scrollbar-thumb:hover { background-color: #94a3b8; }
.dark .custom-scroll::-webkit-scrollbar-thumb:hover { background-color: #52525b; }
.custom-scroll::-webkit-scrollbar-corner { display: none; background: transparent; }
/* Hide scrollbar completely only when strictly needed */
.hide-scrollbar::-webkit-scrollbar { display: none; }
.hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
/* Tactical Grid Background */
.bg-grid {
background-size: 40px 40px;
background-image: linear-gradient(to right, rgba(148, 163, 184, 0.08) 1px, transparent 1px),
linear-gradient(to bottom, rgba(148, 163, 184, 0.08) 1px, transparent 1px);
}
.dark .bg-grid {
background-image: linear-gradient(to right, rgba(255, 255, 255, 0.02) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
}
</style>
</head>
<body class="flex h-screen overflow-hidden bg-slate-100 dark:bg-[#0a0a0c] text-slate-700 dark:text-zinc-200 transition-colors duration-200" x-data="sentinelApp()">
<aside class="flex flex-col bg-slate-50 dark:bg-zinc-950 border-r border-slate-200 dark:border-zinc-800 z-10 transition-all duration-300 ease-in-out shrink-0"
:class="sidebarOpen ? 'w-[250px]' : 'w-[68px]'">
<div class="h-14 flex items-center px-4 border-b border-slate-200 dark:border-zinc-800 shrink-0 gap-3 overflow-hidden">
<span class="text-xl shrink-0">🕸️</span>
<div class="flex flex-col whitespace-nowrap opacity-100 transition-opacity duration-200" :class="sidebarOpen ? 'opacity-100' : 'opacity-0'">
<span class="text-sm font-bold text-slate-800 dark:text-white leading-tight">HoneyWire</span>
<span class="text-[11px] text-slate-500 dark:text-zinc-500 font-medium leading-tight mono" x-text="'v' + version"></span>
</div>
</div>
<nav class="flex-1 py-4 space-y-1 overflow-y-auto custom-scroll overflow-x-hidden px-3">
<div x-show="sidebarOpen" class="px-3 py-2 text-xs font-semibold text-slate-400 dark:text-zinc-500 mb-1 transition-opacity">Menu</div>
<button @click="currentView = 'dashboard'"
class="w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors"
:class="currentView === 'dashboard' ? 'bg-slate-200 dark:bg-zinc-800 text-slate-900 dark:text-zinc-100' : 'text-slate-600 dark:text-zinc-400 hover:bg-slate-200/50 dark:hover:bg-zinc-800/50'"
:title="!sidebarOpen ? 'Dashboard' : ''">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path></svg>
<span x-show="sidebarOpen" class="whitespace-nowrap">Dashboard</span>
</button>
<button @click="currentView = 'store'"
class="w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors"
:class="currentView === 'store' ? 'bg-slate-200 dark:bg-zinc-800 text-slate-900 dark:text-zinc-100' : 'text-slate-600 dark:text-zinc-400 hover:bg-slate-200/50 dark:hover:bg-zinc-800/50'"
:title="!sidebarOpen ? 'Sensor Store' : ''">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path></svg>
<span x-show="sidebarOpen" class="whitespace-nowrap">Sensor Store</span>
</button>
<button @click="currentView = 'settings'"
class="w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors"
:class="currentView === 'settings' ? 'bg-slate-200 dark:bg-zinc-800 text-slate-900 dark:text-zinc-100' : 'text-slate-600 dark:text-zinc-400 hover:bg-slate-200/50 dark:hover:bg-zinc-800/50'"
:title="!sidebarOpen ? 'Settings' : ''">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
<span x-show="sidebarOpen" class="whitespace-nowrap">Settings</span>
</button>
</nav>
<div class="p-3 border-t border-slate-200 dark:border-zinc-800 shrink-0 space-y-2">
<button @click="viewingArchive = !viewingArchive; update()"
class="w-full flex justify-center items-center py-1.5 px-3 rounded-md text-xs font-semibold transition-colors border"
:class="viewingArchive ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 border-amber-200 dark:border-amber-800/50 shadow-sm' : 'text-slate-600 dark:text-zinc-400 bg-slate-100 dark:bg-zinc-800 hover:bg-slate-200 dark:hover:bg-zinc-700 border-slate-300 dark:border-zinc-700'"
:title="!sidebarOpen ? (viewingArchive ? 'Active Events' : 'Event Archive') : ''">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path></svg>
<div class="overflow-hidden transition-all duration-300 ease-in-out whitespace-nowrap flex items-center"
:class="sidebarOpen ? 'max-w-[120px] ml-2 opacity-100' : 'max-w-0 ml-0 opacity-0'">
<span x-text="viewingArchive ? 'Active Events' : 'Event Archive'"></span>
</div>
</button>
<button @click="clearLogs()"
class="w-full flex justify-center items-center py-1.5 px-3 rounded-md text-xs font-semibold text-rose-600 bg-rose-50 hover:bg-rose-100 dark:text-rose-400 dark:bg-rose-900/20 dark:hover:bg-rose-900/40 border border-rose-200 dark:border-rose-800/30 transition-colors"
:title="!sidebarOpen ? 'Purge Logs' : ''">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
<div class="overflow-hidden transition-all duration-300 ease-in-out whitespace-nowrap flex items-center"
:class="sidebarOpen ? 'max-w-[120px] ml-2 opacity-100' : 'max-w-0 ml-0 opacity-0'">
<span>Purge System Logs</span>
</div>
</button>
</div>
</aside>
<main class="flex-1 flex flex-col min-w-0 bg-grid">
<header class="h-14 bg-slate-50/90 dark:bg-zinc-950/90 border-b border-slate-200 dark:border-zinc-800 flex items-center justify-between px-4 sm:px-6 shrink-0 backdrop-blur-sm">
<div class="flex items-center gap-4">
<button @click="sidebarOpen = !sidebarOpen" class="text-slate-400 hover:text-slate-700 dark:text-zinc-500 dark:hover:text-zinc-200 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
</button>
<h2 class="text-sm font-semibold text-slate-800 dark:text-white capitalize hidden sm:block" x-text="currentView.replace('-', ' ')"></h2>
</div>
<div class="flex items-center gap-3">
<div class="hidden sm:flex items-center gap-2 pr-1">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse dark:shadow-[0_0_8px_rgba(16,185,129,0.8)]"></span>
<span class="text-[11px] font-bold uppercase tracking-widest text-slate-500 dark:text-zinc-400">Live</span>
</div>
<button x-show="unreadCount > 0" @click="markAllRead()" x-cloak
class="hidden sm:flex items-center gap-2 px-2.5 py-1 rounded-md bg-rose-100 dark:bg-rose-900/30 text-rose-700 dark:text-rose-400 text-xs font-semibold mr-1 border border-rose-200 dark:border-rose-800/30">
<span class="w-1.5 h-1.5 rounded-full bg-rose-500"></span>
<span x-text="unreadCount + ' Unread'"></span>
</button>
<button @click="toggleArmed()"
class="px-3 py-1.5 rounded-md text-xs font-semibold transition-colors border"
:class="isArmed ? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400 border-emerald-200 dark:border-emerald-800/50' : 'bg-slate-200 dark:bg-zinc-800 text-slate-600 dark:text-zinc-400 border-slate-300 dark:border-zinc-700'">
<span x-text="isArmed ? 'Armed' : 'Passive'"></span>
</button>
<button @click="toggleTheme()" class="p-1.5 rounded-md bg-slate-200 dark:bg-zinc-800 border border-slate-300 dark:border-zinc-700 text-slate-600 dark:text-zinc-300 hover:bg-slate-300 dark:hover:bg-zinc-700 transition-colors">
<svg class="w-4 h-4 hidden dark:block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path></svg>
<svg class="w-4 h-4 block dark:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
</button>
<div class="w-px h-4 bg-slate-300 dark:bg-zinc-700 mx-1"></div>
<a href="/logout" class="text-xs font-semibold text-slate-500 hover:text-slate-800 dark:text-zinc-400 dark:hover:text-white transition-colors">Exit</a>
</div>
</header>
<div class="flex-1 overflow-auto custom-scroll p-4 sm:p-6">
<template x-if="currentView === 'dashboard'">
<div class="max-w-[1600px] mx-auto space-y-6">
<div class="flex justify-between items-center gap-4">
<div class="flex items-center w-full gap-2" x-data="{ canScrollRight: true }">
<div class="flex-1 relative overflow-hidden">
<div class="flex overflow-x-auto whitespace-nowrap gap-2 items-center custom-scroll pb-3 pr-2 relative"
@scroll.passive="canScrollRight = $el.scrollLeft + $el.clientWidth < $el.scrollWidth - 5"
x-init="$watch('fleet', () => $nextTick(() => canScrollRight = $el.scrollLeft + $el.clientWidth < $el.scrollWidth - 5))">
<div class="sticky left-0 z-20 pr-2 bg-slate-100 dark:bg-[#0a0a0c] flex items-center border-r border-slate-200 dark:border-zinc-800 transition-all duration-200">
<button id="pill-all" @click="selectedSensor = null"
class="shrink-0 px-3 py-1.5 rounded-md border text-sm font-medium transition-colors"
:class="!selectedSensor ? 'bg-slate-800 text-slate-50 dark:bg-zinc-200 dark:text-black border-transparent' : 'bg-slate-50 dark:bg-zinc-900 border-slate-300 dark:border-zinc-700 text-slate-600 dark:text-zinc-300 hover:bg-slate-100 dark:hover:bg-zinc-800'">
All Traffic
</button>
</div>
<template x-for="s in fleet" :key="s.sensor_id">
<button :id="'pill-' + s.sensor_id" @click="selectedSensor = s.sensor_id"
class="shrink-0 flex items-center gap-2 px-3 py-1.5 rounded-md border text-sm font-medium transition-colors group"
:class="selectedSensor === s.sensor_id ? 'bg-slate-800 text-slate-50 dark:bg-zinc-200 dark:text-black border-transparent' : 'bg-slate-50 dark:bg-zinc-900 border-slate-300 dark:border-zinc-700 text-slate-600 dark:text-zinc-300 hover:bg-slate-100 dark:hover:bg-zinc-800'"
:title="s.status === 'online' ? 'Active' : 'Last seen: ' + s.last_seen">
<span class="w-1.5 h-1.5 rounded-full" :class="s.status === 'online' ? 'bg-emerald-500' : 'bg-rose-500'"></span>
<span class="mono text-xs pointer-events-none" x-text="s.sensor_id"></span>
</button>
</template>
</div>
<div class="absolute right-0 top-0 bottom-3 w-16 bg-gradient-to-l from-slate-100 dark:from-[#0a0a0c] to-transparent pointer-events-none"></div>
</div>
<div class="w-6 h-8 shrink-0 flex items-center justify-center">
<span x-show="canScrollRight && fleet.some(s => s.status !== 'online')" x-transition.opacity class="w-1.5 h-1.5 rounded-full bg-rose-500 animate-pulse"></span>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
<div class="lg:col-span-4 bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-lg p-5 flex flex-col backdrop-blur-sm">
<h3 class="text-sm font-semibold mb-4 text-slate-800 dark:text-zinc-200">Severity Distribution</h3>
<div class="flex-1 relative min-h-[220px]" x-init="$nextTick(() => initChart())">
<canvas id="severityChart"></canvas>
<div class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none mt-2">
<span class="text-3xl font-bold text-slate-900 dark:text-zinc-100" x-text="filteredEvents.length"></span>
<span class="text-xs font-medium text-slate-500 dark:text-zinc-500">Events</span>
</div>
</div>
</div>
<div class="lg:col-span-8 bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-lg p-5 flex flex-col backdrop-blur-sm">
<div class="flex justify-between items-start mb-1">
<div>
<h3 class="text-sm font-semibold text-slate-800 dark:text-zinc-200">Fleet Uptime</h3>
<p class="text-xs text-slate-500 dark:text-zinc-400 mt-1">
Fleet Overall Uptime:
<span class="font-semibold transition-colors"
:class="parseFloat(overallUptime) >= 95 ? 'text-emerald-600 dark:text-emerald-400' : (parseFloat(overallUptime) >= 85 ? 'text-amber-600 dark:text-amber-400' : 'text-rose-600 dark:text-rose-400')"
x-text="overallUptime"></span>
</p>
</div>
<div class="flex bg-slate-200 dark:bg-zinc-800 p-0.5 rounded-md text-[11px] font-medium text-slate-600 dark:text-zinc-400">
<template x-for="time in ['1H', '24H', '7D', '30D']" :key="time">
<button @click="activeTimeframe = time; update()"
class="px-2.5 py-1 rounded transition-colors"
:class="activeTimeframe === time ? 'bg-slate-50 dark:bg-zinc-700 text-slate-900 dark:text-zinc-100 shadow-sm' : 'hover:text-slate-800 dark:hover:text-zinc-200'"
x-text="time"></button>
</template>
</div>
</div>
<div class="relative mt-4 flex-1" x-data="{ canScrollDown: true }">
<div x-ref="uptimeScrollArea"
@scroll.passive="canScrollDown = $el.scrollTop + $el.clientHeight < $el.scrollHeight - 5"
x-init="$watch('uptimeData', () => $nextTick(() => canScrollDown = $el.scrollTop + $el.clientHeight < $el.scrollHeight - 5))"
class="max-h-[240px] overflow-y-auto custom-scroll pr-2 space-y-3">
<div x-show="uptimeData.length === 0" class="text-xs text-slate-400 dark:text-zinc-500 py-4 text-center">
No fleet data available.
</div>
<template x-for="sensor in uptimeData" :key="sensor.id">
<div class="flex items-center w-full">
<div class="w-50 flex items-center gap-1.5 shrink-0">
<span class="w-1.5 h-1.5 rounded-full shrink-0"
:class="sensor.isOnline ? 'bg-emerald-500' : 'bg-rose-500'"></span>
<button @click="selectedSensor = selectedSensor === sensor.id ? null : sensor.id; $nextTick(() => { document.getElementById(selectedSensor ? 'pill-' + sensor.id : 'pill-all')?.scrollIntoView({behavior: 'smooth', inline: 'center', block: 'nearest'}) })"
class="text-[11px] mono text-left truncate transition-colors cursor-pointer px-2 py-0.5 rounded-md flex items-center gap-1 flex-1 min-w-0"
:class="selectedSensor === sensor.id ? 'bg-slate-200 text-slate-900 dark:bg-zinc-700 dark:text-white font-bold' : 'text-slate-600 dark:text-zinc-400 font-medium hover:text-slate-900 dark:hover:text-zinc-200'"
:title="'Filter by ' + sensor.name + (fleet.find(f => f.sensor_id === sensor.id)?.is_silenced ? ' (Silenced)' : '')">
<span class="truncate flex-1" x-text="sensor.name"></span>
<svg x-show="fleet.find(f => f.sensor_id === sensor.id)?.is_silenced" class="w-3 h-3 shrink-0 text-slate-400 dark:text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.73 21a2 2 0 01-3.46 0m-3.9-3.9a2.032 2.032 0 01-2.37.5L4 17h12.59l3.12 3.12M3 3l18 18M18 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341c-.5.186-.967.447-1.385.772"></path></svg>
</button>
</div>
<div class="flex-1 flex justify-end gap-[2px] overflow-hidden flex-nowrap pl-2">
<template x-for="(block, i) in sensor.blocks" :key="i">
<div class="flex-1 max-w-[8px] min-w-[2px] h-5 rounded-[1px] transition-opacity hover:opacity-70 cursor-pointer"
:class="{
'bg-emerald-500': block.status === 'up',
'bg-rose-500': block.status === 'down',
'bg-amber-500': block.status === 'degraded',
'bg-slate-200 dark:bg-zinc-700': block.status === 'nodata'
}"
:title="block.timeLabel + ' - ' + block.label">
</div>
</template>
</div>
</div>
</template>
</div>
<div x-show="canScrollDown && uptimeData.some(s => s.blocks.some(b => b.status === 'down'))" x-cloak
@click="$refs.uptimeScrollArea.scrollTo({ top: $refs.uptimeScrollArea.scrollHeight, behavior: 'smooth' })"
class="absolute bottom-1 left-1/2 -translate-x-1/2 bg-white/80 dark:bg-zinc-800/80 border border-slate-200 dark:border-zinc-700 text-slate-500 dark:text-zinc-400 text-[10px] font-medium px-2.5 py-1 rounded-md shadow-sm backdrop-blur-md flex items-center gap-2 cursor-pointer transition-colors hover:bg-slate-50 dark:hover:bg-zinc-700 z-10">
<div class="w-1.5 sm:w-2 h-4 rounded-[1px] bg-rose-500 animate-pulse"></div>
<span>↓ Offline Events Below</span>
</div>
</div>
</div>
</div>
<div class="bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-lg overflow-hidden flex flex-col backdrop-blur-sm">
<div class="px-5 py-3 border-b border-slate-200 dark:border-zinc-800 flex justify-between items-center bg-slate-100/50 dark:bg-zinc-950/50">
<h3 class="text-sm font-semibold text-slate-800 dark:text-zinc-200" x-text="viewingArchive ? 'Archived Events' : 'Active Threat Queue'"></h3>
<!-- FIX 2: Archive All Button - Fixed condition to show when not viewing archive AND events exist -->
<button x-show="!viewingArchive && filteredEvents.length > 0" @click="archiveAll()" x-cloak
class="px-2.5 py-1 rounded-md text-xs font-semibold text-slate-600 dark:text-zinc-400 bg-white dark:bg-zinc-800 hover:bg-slate-100 dark:hover:bg-zinc-700 transition-colors border border-slate-300 dark:border-zinc-700 shadow-sm">
Archive All
</button>
</div>
<div class="overflow-x-auto custom-scroll">
<table class="w-full text-left border-collapse">
<thead class="text-xs font-semibold text-slate-500 dark:text-zinc-400 border-b border-slate-200 dark:border-zinc-800">
<tr>
<th class="px-5 py-3">Threat</th>
<th class="px-4 py-3">Event Trigger</th>
<th class="px-4 py-3">Source</th>
<th class="px-4 py-3">Target</th>
<th class="px-4 py-3 text-right min-w-[180px]">Node</th>
<th class="px-5 py-3 text-right">Time</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 dark:divide-zinc-800/50">
<tr x-show="filteredEvents.length === 0" x-cloak>
<td colspan="6" class="px-5 py-8 text-center text-slate-500 dark:text-zinc-500 text-sm">
No events detected matching criteria.
</td>
</tr>
<template x-for="event in filteredEvents" :key="event.id">
<tr class="hover:bg-slate-100/50 dark:hover:bg-zinc-800/30 cursor-pointer border-l-[3px] border-transparent transition-colors"
:class="'bleed-' + event.severity"
@click="openEvent(event)">
<td class="px-5 py-3 flex items-center gap-3">
<div x-show="!event.is_read" class="w-1.5 h-1.5 rounded-full bg-rose-500"></div>
<span class="px-2 py-0.5 rounded border text-[11px] font-semibold uppercase tracking-wider bg-slate-50 dark:bg-transparent"
:class="'severity-' + event.severity" x-text="event.severity"></span>
</td>
<td class="px-4 py-3 text-sm text-slate-900 dark:text-zinc-100 capitalize" x-text="event.event_type.replace(/_/g, ' ')"></td>
<td class="px-4 py-3 text-sm text-slate-600 dark:text-zinc-400 mono" x-text="event.source"></td>
<td class="px-4 py-3 text-sm text-slate-600 dark:text-zinc-400 mono" x-text="event.target"></td>
<td class="px-4 py-3 text-sm text-right text-slate-500 dark:text-zinc-500 mono min-w-[180px] max-w-[200px] truncate" x-text="event.sensor_id" :title="event.sensor_id"></td>
<td class="px-5 py-3 text-sm text-right text-slate-500 dark:text-zinc-500 mono whitespace-nowrap" x-text="event.timestamp.split(' ')[1]"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</template>
<template x-if="currentView === 'store'">
<div class="max-w-7xl mx-auto flex items-center justify-center h-full">
<div class="text-center text-slate-500 dark:text-zinc-500">
<h3 class="text-lg font-semibold text-slate-800 dark:text-zinc-200 mb-2">Sensor Store</h3>
<p class="text-sm">Storefront coming in next architectural phase.</p>
</div>
</div>
</template>
<template x-if="currentView === 'settings'">
<div class="max-w-7xl mx-auto flex items-center justify-center h-full">
<div class="text-center text-slate-500 dark:text-zinc-500">
<h3 class="text-lg font-semibold text-slate-800 dark:text-zinc-200 mb-2">Settings</h3>
<p class="text-sm">Configuration interface coming in next architectural phase.</p>
</div>
</div>
</template>
</div>
</main>
<div x-show="modal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6">
<div class="absolute inset-0 bg-slate-900/40 dark:bg-black/60" @click="modal = false"></div>
<div class="relative bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 w-full max-w-2xl rounded-lg shadow-xl flex flex-col max-h-[90vh]"
x-transition:enter="transition ease-out duration-150" x-transition:enter-start="scale-95 opacity-0"
x-transition:enter-end="scale-100 opacity-100">
<div class="p-5 sm:p-6 border-b border-slate-200 dark:border-zinc-800 flex justify-between items-start shrink-0">
<div>
<h2 class="text-xl font-bold text-slate-900 dark:text-white capitalize" x-text="activeEvent?.event_type.replace(/_/g, ' ')"></h2>
<p class="text-xs text-slate-500 dark:text-zinc-500 mono mt-1" x-text="'Trace: ' + activeEvent?.id"></p>
</div>
<span class="px-2 py-0.5 rounded border text-xs font-semibold uppercase tracking-wider bg-slate-100 dark:bg-transparent"
:class="'severity-' + activeEvent?.severity" x-text="activeEvent?.severity"></span>
</div>
<div class="p-5 sm:p-6 overflow-y-auto custom-scroll flex-1 space-y-5">
<div class="grid grid-cols-3 gap-4">
<div class="bg-slate-100/50 dark:bg-zinc-950/50 p-3 rounded-md border border-slate-200 dark:border-zinc-800/50">
<div class="text-xs font-medium text-slate-500 dark:text-zinc-500 mb-1">Sensor Node</div>
<div class="text-sm font-semibold text-slate-900 dark:text-zinc-100 mono truncate" x-text="activeEvent?.sensor_id" :title="activeEvent?.sensor_id"></div>
</div>
<div class="bg-slate-100/50 dark:bg-zinc-950/50 p-3 rounded-md border border-slate-200 dark:border-zinc-800/50">
<div class="text-xs font-medium text-slate-500 dark:text-zinc-500 mb-1">Source</div>
<div class="text-sm font-semibold text-slate-900 dark:text-zinc-100 mono" x-text="activeEvent?.source"></div>
</div>
<div class="bg-slate-100/50 dark:bg-zinc-950/50 p-3 rounded-md border border-slate-200 dark:border-zinc-800/50">
<div class="text-xs font-medium text-slate-500 dark:text-zinc-500 mb-1">Target</div>
<div class="text-sm font-semibold text-slate-900 dark:text-zinc-100 mono" x-text="activeEvent?.target"></div>
</div>
</div>
<div class="space-y-4">
<template x-for="(val, key) in activeEvent?.details" :key="key">
<div>
<div class="text-xs font-semibold text-slate-700 dark:text-zinc-300 capitalize mb-2" x-text="key.replace(/_/g, ' ')"></div>
<template x-if="Array.isArray(val)">
<div class="space-y-2">
<template x-for="(item, index) in val.slice(0, 50)" :key="index">
<pre class="bg-slate-100 dark:bg-[#0c0c0e] border border-slate-200 dark:border-[#1f1f23] rounded p-2.5 text-sm text-emerald-700 dark:text-emerald-400 mono overflow-x-auto custom-scroll"
x-text="typeof item === 'object' ? JSON.stringify(item, null, 2) : item"></pre>
</template>
<div x-show="val.length > 50" class="text-xs text-slate-500 dark:text-zinc-500 font-medium mt-2">
<span x-text="'+ ' + (val.length - 50) + ' more packets truncated'"></span>
</div>
</div>
</template>
<template x-if="!Array.isArray(val)">
<div class="text-sm text-slate-800 dark:text-zinc-200 mono break-all bg-slate-100 dark:bg-[#0c0c0e] border border-slate-200 dark:border-[#1f1f23] p-2.5 rounded whitespace-pre-wrap"
x-text="typeof val === 'object' ? JSON.stringify(val, null, 2) : val"></div>
</template>
</div>
</template>
</div>
<!-- FIX 3: Event Modal Actions - Improved button styling and state management -->
<div class="p-5 sm:p-6 border-t border-slate-200 dark:border-zinc-800 shrink-0 flex items-center justify-between gap-3">
<div class="flex items-center gap-2">
<button @click.stop="toggleSilence(activeEvent?.sensor_id)"
class="flex items-center justify-center gap-2 py-2 px-3 border transition-colors rounded-md text-sm font-medium w-[155px] shrink-0"
:class="isActiveSensorSilenced ? 'bg-slate-100 dark:bg-zinc-800 text-slate-700 dark:text-zinc-300 border-slate-300 dark:border-zinc-700 hover:bg-slate-200 dark:hover:bg-zinc-700' : 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 border-blue-200 dark:border-blue-800/30 hover:bg-blue-100 dark:hover:bg-blue-900/30'">
<svg x-show="!isActiveSensorSilenced" class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path>
</svg>
<svg x-show="isActiveSensorSilenced" x-cloak class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.73 21a2 2 0 01-3.46 0m-3.9-3.9a2.032 2.032 0 01-2.37.5L4 17h12.59l3.12 3.12M3 3l18 18M18 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341c-.5.186-.967.447-1.385.772"></path>
</svg>
<span class="whitespace-nowrap" x-text="isActiveSensorSilenced ? 'Unsilence Sensor' : 'Silence Sensor'"></span>
</button>
<button x-show="!viewingArchive" @click="archiveEvent(activeEvent?.id); modal = false"
class="flex items-center gap-2 py-2 px-3 border transition-colors rounded-md text-sm font-medium bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-400 border-amber-200 dark:border-amber-800/30 hover:bg-amber-100 dark:hover:bg-amber-900/30">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path></svg>
Archive Event
</button>
</div>
<button @click="modal = false" class="py-2 px-4 bg-slate-800 hover:bg-slate-900 dark:bg-zinc-100 dark:hover:bg-white text-white dark:text-slate-900 text-sm font-medium rounded-md transition-colors">
Close
</button>
</div>
</div>
</div>
<script>
// Chart.js Neon Plugin
const neonGlowPlugin = {
id: 'neonGlow',
beforeDatasetsDraw(chart) {
if (!document.documentElement.classList.contains('dark')) return;
const ctx = chart.ctx;
const meta = chart.getDatasetMeta(0);
ctx.save();
meta.data.forEach(arc => {
ctx.shadowColor = arc.options.backgroundColor;
ctx.shadowBlur = 8;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
arc.draw(ctx);
});
ctx.restore();
}
};
Chart.register(neonGlowPlugin);
let severityChartInstance = null;
function sentinelApp() {
return {
// UI State
currentView: 'dashboard',
sidebarOpen: true,
modal: false,
activeEvent: null,
selectedSensor: null,
activeTimeframe: '30D',
viewingArchive: false,
// Data State
events: [],
fleet: [],
uptimeData: [],
isArmed: true,
version: '1.0.0',
// Computed Properties
get overallUptime() {
if (!this.uptimeData || this.uptimeData.length === 0) return '0.0%';
let validBlocks = 0;
let upBlocks = 0;
this.uptimeData.forEach(sensor => {
sensor.blocks.forEach(block => {
if (block.status !== 'nodata') {
validBlocks++;
if (block.status === 'up') upBlocks += 1;
else if (block.status === 'degraded') upBlocks += 0.8; // 80% credit for degraded
}
});
});
if (validBlocks === 0) return '100.0%';
return ((upBlocks / validBlocks) * 100).toFixed(1) + '%';
},
get unreadCount() {
return this.events.filter(e => !e.is_read).length;
},
get filteredEvents() {
return this.selectedSensor ? this.events.filter(e => e.sensor_id === this.selectedSensor) : this.events;
},
init() {
this.update();
setInterval(() => this.update(), 5000);
// Re-calculate Heatmap blocks when timeframe changes
this.$watch('activeTimeframe', () => this.generateUptimeData());
this.$watch('selectedSensor', () => this.refreshChart());
const observer = new MutationObserver(() => this.refreshChart());
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
},
async update() {
try {
const [ev, sn, st, ver, uptimeResult] = await Promise.all([
fetch(`/api/v1/events?archived=${this.viewingArchive}`).then(r => r.json()).catch(() => []),
fetch('/api/v1/sensors').then(r => r.json()).catch(() => []),
fetch('/api/v1/system/state').then(r => r.json()).catch(() => ({is_armed: this.isArmed})),
fetch('/api/v1/version').then(r => r.json()).catch(() => ({version: this.version})),
fetch(`/api/v1/uptime?timeframe=${this.activeTimeframe}`).then(r => r.json()).catch(() => [])
]);
if (ev.length || sn.length) {
this.events = ev;
this.fleet = sn.map(newSensor => {
// Force strict boolean based on the NOW CORRECT backend data
const normalizedSensor = { ...newSensor, is_silenced: !!newSensor.is_silenced };
const current = this.fleet.find(f => f.sensor_id === normalizedSensor.sensor_id);
// Respect the manual UI lock so it doesn't flicker while updating
if (current && current._lockedUntil && Date.now() < current._lockedUntil) {
return { ...normalizedSensor, is_silenced: current.is_silenced, _lockedUntil: current._lockedUntil };
}
return normalizedSensor;
});
}
if (uptimeResult && uptimeResult.length) {
this.uptimeData = uptimeResult;
}
this.isArmed = st.is_armed !== undefined ? st.is_armed : this.isArmed;
this.version = ver.version || this.version;
if (this.currentView === 'dashboard') { this.refreshChart(); }
} catch(e) { console.error('Polling failed', e); }
},
initChart() {
const ctx = document.getElementById('severityChart');
if (!ctx) return;
if (severityChartInstance) severityChartInstance.destroy();
severityChartInstance = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['critical', 'high', 'medium', 'low', 'info'],
datasets: [{
data: [0,0,0,0,0],
backgroundColor: ['#f43f5e', '#fb923c', '#eab308', '#3b82f6', '#64748b'],
borderWidth: 0, spacing: 4, borderRadius: 2
}]
},
options: {
cutout: '82%',
responsive: true,
maintainAspectRatio: false,
animation: true,
plugins: { legend: { display: false } }
}
});
this.refreshChart();
},
refreshChart() {
if (!severityChartInstance) return;
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
this.filteredEvents.forEach(e => {
const s = e.severity ? e.severity.toLowerCase() : 'info';
if (counts.hasOwnProperty(s)) counts[s]++;
});
const newData = ['critical', 'high', 'medium', 'low', 'info'].map(k => counts[k]);
if (JSON.stringify(severityChartInstance.data.datasets[0].data) !== JSON.stringify(newData)) {
severityChartInstance.data.datasets[0].data = newData;
}
severityChartInstance.update();
},
async openEvent(e) {
this.activeEvent = e;
this.modal = true;
if (!e.is_read) {
e.is_read = 1;
try { await fetch(`/api/v1/events/${e.id}/read`, {method: 'PATCH'}); } catch(err) {}
}
},
async markAllRead() {
try {
await fetch('/api/v1/events/read', {method: 'PATCH'});
this.events.forEach(e => e.is_read = 1);
} catch(err) {}
},
async clearLogs() {
if (confirm("Confirm Database Purge? This will permanently delete all event logs.")) {
try {
await fetch('/api/v1/events', {method: 'DELETE'});
this.update();
} catch(err) {}
}
},
toggleTheme() {
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
}
},
async toggleArmed() {
const next = !this.isArmed;
try {
await fetch('/api/v1/system/state', {
method: 'PATCH', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({is_armed: next})
});
this.isArmed = next;
} catch(err) {}
},
async archiveEvent(id) {
try {
await fetch(`/api/v1/events/${id}/archive`, {method: 'PATCH'});
// Immediately remove it from the active view for a snappy UI
this.events = this.events.filter(e => e.id !== id);
this.refreshChart();
} catch(err) { console.error(err); }
},
async archiveAll() {
if (confirm("Archive all currently active events?")) {
try {
await fetch('/api/v1/events/archive-all', {method: 'PATCH'});
this.update(); // Refresh to pull the now-empty queue
} catch(err) { console.error(err); }
}
},
async toggleSilence(sensorId) {
if (!sensorId) return;
const sensor = this.fleet.find(s => s.sensor_id === sensorId);
if (!sensor) return;
const nextStateBool = !sensor.is_silenced;
const nextStateInt = nextStateBool ? 1 : 0;
// Optimistic UI Update + 5 Second Lock
this.fleet = this.fleet.map(s =>
s.sensor_id === sensorId ? { ...s, is_silenced: nextStateBool, _lockedUntil: Date.now() + 5000 } : s
);
try {
const response = await fetch(`/api/v1/sensors/${sensorId}/silence`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ is_silenced: nextStateInt })
});
if (!response.ok) throw new Error('Server rejected update');
// Let the background polling seamlessly pick up the state once the lock expires
} catch(err) {
console.error("Silence failed:", err);
// Break lock and revert on error
this.fleet = this.fleet.map(s => s.sensor_id === sensorId ? { ...s, _lockedUntil: 0 } : s);
this.update();
}
},
get isActiveSensorSilenced() {
if (!this.activeEvent) return false;
return this.fleet.find(s => s.sensor_id === this.activeEvent.sensor_id)?.is_silenced || false;
}
}
}
</script>
</body>
</html>