feat: implement advanced live logs with KQL search, histogram, and live/historical modes

This commit is contained in:
2026-04-08 21:01:05 -04:00
parent fe6b349e5e
commit ec503b9ec6
61 changed files with 1083 additions and 40 deletions

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/web/sqlite_repository.py
# hypothesis_version: 6.151.11
[' AND ', ' WHERE ', ':', '[^a-zA-Z0-9_]', 'active_deckies', 'attacker', 'attacker-ip', 'attacker_ip', 'bucket_time', 'count', 'decky', 'decnet.db', 'deployed_deckies', 'event', 'event_type', 'fields', 'id > ?', 'max_id', 'msg', 'must_change_password', 'password_hash', 'raw_line', 'role', 'service', 'time', 'timestamp', 'timestamp <= ?', 'timestamp >= ?', 'total', 'total_logs', 'unique_attackers', 'username', 'uuid']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/logging/file_handler.py
# hypothesis_version: 6.151.11
[1024, '%(message)s', 'DECNET_LOG_FILE', 'decnet.syslog', 'utf-8']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/services/tftp.py
# hypothesis_version: 6.151.11
['LOG_TARGET', 'NODE_NAME', 'build', 'container_name', 'context', 'environment', 'restart', 'templates', 'tftp', 'unless-stopped']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/web/ingester.py
# hypothesis_version: 6.151.11
['.json', 'decnet.web.ingester', 'r', 'replace', 'utf-8']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/logging/forwarder.py
# hypothesis_version: 6.151.11
[2.0, ':']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/services/mongodb.py
# hypothesis_version: 6.151.11
[27017, 'LOG_TARGET', 'NODE_NAME', 'build', 'container_name', 'context', 'environment', 'mongodb', 'restart', 'templates', 'unless-stopped']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/services/snmp.py
# hypothesis_version: 6.151.11
[161, 'LOG_TARGET', 'NODE_NAME', 'build', 'container_name', 'context', 'environment', 'restart', 'snmp', 'templates', 'unless-stopped']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/services/sip.py
# hypothesis_version: 6.151.11
[5060, 'LOG_TARGET', 'NODE_NAME', 'build', 'container_name', 'context', 'environment', 'restart', 'sip', 'templates', 'unless-stopped']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/web/auth.py
# hypothesis_version: 6.151.11
[1440, 'HS256', 'exp', 'iat', 'utf-8']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/services/mssql.py
# hypothesis_version: 6.151.11
[1433, 'LOG_TARGET', 'NODE_NAME', 'build', 'container_name', 'context', 'environment', 'mssql', 'restart', 'templates', 'unless-stopped']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/composer.py
# hypothesis_version: 6.151.11
['/var/log/decnet', '3.8', 'BASE_IMAGE', 'DECNET_LOG_FILE', 'HOSTNAME', 'NET_ADMIN', 'args', 'bridge', 'build', 'cap_add', 'command', 'container_name', 'decnet_logs', 'depends_on', 'driver', 'environment', 'external', 'hostname', 'image', 'infinity', 'internal', 'ipv4_address', 'network_mode', 'networks', 'restart', 'services', 'sleep', 'sysctls', 'unless-stopped', 'version', 'volumes']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/services/elasticsearch.py
# hypothesis_version: 6.151.11
[9200, 'LOG_TARGET', 'NODE_NAME', 'build', 'container_name', 'context', 'elasticsearch', 'environment', 'restart', 'templates', 'unless-stopped']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/services/telnet.py
# hypothesis_version: 6.151.11
[':', 'COWRIE_SSH_ENABLED', 'NET_BIND_SERVICE', 'cap_add', 'container_name', 'cowrie/cowrie', 'environment', 'false', 'image', 'restart', 'telnet', 'true', 'unless-stopped']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/web/sqlite_repository.py
# hypothesis_version: 6.151.11
[' AND ', ' WHERE ', ':', '[^a-zA-Z0-9_]', 'active_deckies', 'attacker', 'attacker-ip', 'attacker_ip', 'bucket_time', 'count', 'decky', 'decnet.db', 'deployed_deckies', 'event', 'event_type', 'fields', 'id > ?', 'max_id', 'msg', 'must_change_password', 'password_hash', 'raw_line', 'role', 'service', 'time', 'timestamp', 'timestamp <= ?', 'timestamp >= ?', 'total', 'total_logs', 'unique_attackers', 'username', 'uuid']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/ini_loader.py
# hypothesis_version: 6.151.11
[100, 512, 1024, ',', '.', '1', '[', ']', 'amount', 'archetype', 'binary', 'custom-', 'exceeds maximum', 'exec', 'general', 'gw', 'interface', 'ip', 'log-target', 'log_target', 'mutate-interval', 'mutate_interval', 'net', 'nmap-os', 'nmap_os', 'ports', 'services']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/archetypes.py
# hypothesis_version: 6.151.11
[', ', 'Database Server', 'DevOps Host', 'Domain Controller', 'File Server', 'IoT Device', 'Linux Server', 'Mail Server', 'Monitoring Node', 'Network Printer', 'VoIP Server', 'Web Server', 'Windows Server', 'Windows Workstation', 'alpine', 'conpot', 'database-server', 'deaddeck', 'debian', 'devops-host', 'docker_api', 'domain-controller', 'embedded', 'fedora', 'file-server', 'ftp', 'http', 'imap', 'industrial-control', 'iot-device', 'k8s', 'ldap', 'linux', 'linux-server', 'llmnr', 'mail-server', 'monitoring-node', 'mqtt', 'mysql', 'pop3', 'postgres', 'printer', 'rdp', 'real_ssh', 'redis', 'rocky9', 'sip', 'smb', 'smtp', 'snmp', 'ssh', 'telnet', 'ubuntu20', 'ubuntu22', 'voip-server', 'web-server', 'windows', 'windows-server', 'windows-workstation']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/env.py
# hypothesis_version: 6.151.11
['.env', '.env.local', '0.0.0.0', '8000', '8080', 'DECNET_ADMIN_USER', 'DECNET_API_HOST', 'DECNET_API_PORT', 'DECNET_JWT_SECRET', 'DECNET_WEB_HOST', 'DECNET_WEB_PORT', 'admin']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/cli.py
# hypothesis_version: 6.151.11
[8000, ',', ', ', '--all', '--api', '--api-port', '--archetype', '--config', '--deckies', '--decky', '--distro', '--dry-run', '--emit-syslog', '--host', '--id', '--interface', '--ip-start', '--ipvlan', '--log-file', '--log-target', '--min-deckies', '--mode', '--mutate-interval', '--no-cache', '--output', '--port', '--randomize-distros', '--randomize-services', '--services', '--subnet', '--watch', '--web-port', '-a', '-c', '-d', '-f', '-i', '-m', '-n', '-o', '-w', '/index.html', '0.0.0.0', 'Available Services', 'Default Services', 'Description', 'Display Name', 'Docker Image', 'Image', 'Machine Archetypes', 'Name', 'Ports', 'Slug', 'archetypes', 'bold cyan', 'correlate', 'decnet', 'decnet.cli', 'decnet.log', 'decnet.web.api:app', 'decnet_web', 'dim', 'dist', 'distros', 'green', 'json', 'linux', 'mutate', 'services', 'swarm', 'syslog', 'table', 'unihost', 'uvicorn', 'web']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/services/http.py
# hypothesis_version: 6.151.11
[443, '/opt/html_files', 'CUSTOM_BODY', 'EXTRA_HEADERS', 'FAKE_APP', 'FILES_DIR', 'LOG_TARGET', 'NODE_NAME', 'RESPONSE_CODE', 'SERVER_HEADER', 'build', 'container_name', 'context', 'custom_body', 'environment', 'extra_headers', 'fake_app', 'files', 'http', 'response_code', 'restart', 'server_header', 'templates', 'unless-stopped', 'volumes']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/services/ftp.py
# hypothesis_version: 6.151.11
['LOG_TARGET', 'NODE_NAME', 'build', 'container_name', 'context', 'environment', 'ftp', 'restart', 'templates', 'unless-stopped']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/correlation/engine.py
# hypothesis_version: 6.151.11
[3600, ',', 'Attacker IP', 'Deckies', 'Duration', 'Events', 'First Seen', 'Traversal Path', 'bold red', 'correlator', 'cyan', 'decnet-correlator', 'dim', 'events_indexed', 'lines_parsed', 'right', 'stats', 'traversal_detected', 'traversals', 'unique_ips', 'yellow']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/services/vnc.py
# hypothesis_version: 6.151.11
[5900, 'LOG_TARGET', 'NODE_NAME', 'build', 'container_name', 'context', 'environment', 'restart', 'templates', 'unless-stopped', 'vnc']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/correlation/parser.py
# hypothesis_version: 6.151.11
['"', '-', '\\', '\\"', '\\\\', '\\]', ']', 'client_ip', 'ip', 'remote_ip', 'src', 'src_ip']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/config.py
# hypothesis_version: 6.151.11
[0.0, ':', 'compose_path', 'config', 'debian', 'debian:bookworm-slim', 'decnet-state.json', 'linux', 'log_target', 'services', 'swarm', 'unihost']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/services/llmnr.py
# hypothesis_version: 6.151.11
[5353, 5355, 'LOG_TARGET', 'NODE_NAME', 'build', 'container_name', 'context', 'environment', 'llmnr', 'restart', 'templates', 'unless-stopped']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/services/registry.py
# hypothesis_version: 6.151.11
['base', 'decnet.services.', 'registry']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/correlation/__init__.py
# hypothesis_version: 6.151.11
['AttackerTraversal', 'CorrelationEngine', 'LogEvent', 'TraversalHop', 'parse_line']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/services/ldap.py
# hypothesis_version: 6.151.11
[389, 636, 'LOG_TARGET', 'NET_BIND_SERVICE', 'NODE_NAME', 'build', 'cap_add', 'container_name', 'context', 'environment', 'ldap', 'restart', 'templates', 'unless-stopped']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/web/api.py
# hypothesis_version: 6.151.11
[400, 404, 500, 512, 1000, 1024, '*', '/api/v1/auth/login', '/api/v1/deckies', '/api/v1/logs', '/api/v1/stats', '/api/v1/stream', '1.0.0', 'Authorization', 'Bearer', 'Bearer ', 'Decky not found', 'No active deployment', 'WWW-Authenticate', 'access_token', 'admin', 'bearer', 'data', 'decnet.web.api', 'histogram', 'id', 'lastEventId', 'limit', 'logs', 'message', 'must_change_password', 'offset', 'password_hash', 'role', 'stats', 'text/event-stream', 'token', 'token_type', 'total', 'type', 'unihost', 'username', 'uuid']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/services/mqtt.py
# hypothesis_version: 6.151.11
[1883, 'LOG_TARGET', 'NODE_NAME', 'build', 'container_name', 'context', 'environment', 'mqtt', 'restart', 'templates', 'unless-stopped']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/custom_service.py
# hypothesis_version: 6.151.11
['-', 'LOG_TARGET', 'NODE_NAME', '_', 'command', 'container_name', 'environment', 'image', 'restart', 'unless-stopped']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/services/docker_api.py
# hypothesis_version: 6.151.11
[2375, 2376, 'LOG_TARGET', 'NODE_NAME', 'build', 'container_name', 'context', 'docker_api', 'environment', 'restart', 'templates', 'unless-stopped']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/services/real_ssh.py
# hypothesis_version: 6.151.11
['NET_BIND_SERVICE', 'SSH_HOSTNAME', 'SSH_ROOT_PASSWORD', 'admin', 'build', 'cap_add', 'container_name', 'context', 'environment', 'hostname', 'password', 'real_ssh', 'restart', 'templates', 'unless-stopped']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/logging/syslog_formatter.py
# hypothesis_version: 6.151.11
[255, '"', '-', '1', '\\', '\\"', '\\\\', '\\]', ']', 'decnet@55555']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/web/repository.py
# hypothesis_version: 6.151.11
[]

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/services/ssh.py
# hypothesis_version: 6.151.11
[2222, ':', 'COWRIE_HOSTNAME', 'COWRIE_SSH_VERSION', 'NET_BIND_SERVICE', 'NODE_NAME', 'build', 'cap_add', 'container_name', 'context', 'cowrie', 'environment', 'hardware_platform', 'kernel_build_string', 'kernel_version', 'restart', 'ssh', 'ssh_banner', 'templates', 'true', 'unless-stopped', 'users']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/services/smb.py
# hypothesis_version: 6.151.11
[139, 445, 'LOG_TARGET', 'NET_BIND_SERVICE', 'NODE_NAME', 'build', 'cap_add', 'container_name', 'context', 'environment', 'restart', 'smb', 'templates', 'unless-stopped']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/services/base.py
# hypothesis_version: 6.151.11
[]

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/services/mysql.py
# hypothesis_version: 6.151.11
[3306, 'LOG_TARGET', 'MYSQL_VERSION', 'NODE_NAME', 'build', 'container_name', 'context', 'environment', 'mysql', 'restart', 'templates', 'unless-stopped', 'version']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/services/smtp.py
# hypothesis_version: 6.151.11
[587, 'LOG_TARGET', 'NET_BIND_SERVICE', 'NODE_NAME', 'SMTP_BANNER', 'SMTP_MTA', 'banner', 'build', 'cap_add', 'container_name', 'context', 'environment', 'mta', 'restart', 'smtp', 'templates', 'unless-stopped']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/services/postgres.py
# hypothesis_version: 6.151.11
[5432, 'LOG_TARGET', 'NODE_NAME', 'build', 'container_name', 'context', 'environment', 'postgres', 'restart', 'templates', 'unless-stopped']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/services/pop3.py
# hypothesis_version: 6.151.11
[110, 995, 'LOG_TARGET', 'NODE_NAME', 'build', 'container_name', 'context', 'environment', 'pop3', 'restart', 'templates', 'unless-stopped']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/os_fingerprint.py
# hypothesis_version: 6.151.11
['128', '2', '255', '3', '6', '64', 'bsd', 'cisco', 'embedded', 'linux', 'windows']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/services/k8s.py
# hypothesis_version: 6.151.11
[6443, 8080, 'LOG_TARGET', 'NODE_NAME', 'build', 'container_name', 'context', 'environment', 'k8s', 'restart', 'templates', 'unless-stopped']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/services/redis.py
# hypothesis_version: 6.151.11
[6379, 'LOG_TARGET', 'NODE_NAME', 'REDIS_OS', 'REDIS_VERSION', 'build', 'container_name', 'context', 'environment', 'os_string', 'redis', 'restart', 'templates', 'unless-stopped', 'version']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/correlation/graph.py
# hypothesis_version: 6.151.11
[' → ', 'attacker_ip', 'deckies', 'decky', 'decky_count', 'duration_seconds', 'event_type', 'first_seen', 'hop_count', 'hops', 'last_seen', 'path', 'service', 'timestamp']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/services/imap.py
# hypothesis_version: 6.151.11
[143, 993, 'LOG_TARGET', 'NODE_NAME', 'build', 'container_name', 'context', 'environment', 'imap', 'restart', 'templates', 'unless-stopped']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/network.py
# hypothesis_version: 6.151.11
['/', 'add', 'addr', 'bridge', 'decnet_ipvlan0', 'decnet_lan', 'decnet_macvlan0', 'default', 'del', 'dev', 'inet ', 'inet6', 'ip', 'ipvlan', 'ipvlan_mode', 'l2', 'link', 'macvlan', 'mode', 'parent', 'route', 'set', 'show', 'type', 'up', 'via']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/distros.py
# hypothesis_version: 6.151.11
['Alpine Linux 3.19', 'Arch Linux', 'CentOS 7', 'Debian 12 (Bookworm)', 'Fedora 39', 'Kali Linux (Rolling)', 'Rocky Linux 9', 'alpha', 'alpine', 'alpine:3.19', 'arch', 'archlinux:latest', 'backup', 'bravo', 'centos7', 'centos:7', 'charlie', 'db', 'debian', 'debian:bookworm-slim', 'delta', 'dev', 'echo', 'fedora', 'fedora:39', 'files', 'foxtrot', 'generic', 'golf', 'hotel', 'india', 'juliet', 'kali', 'kilo', 'lima', 'mail', 'mike', 'minimal', 'monitor', 'nova', 'oscar', 'prod', 'proxy', 'rhel', 'rocky9', 'rockylinux:9-minimal', 'rolling', 'stage', 'ubuntu20', 'ubuntu22', 'ubuntu:20.04', 'ubuntu:22.04', 'web']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/.local/bin/pytest
# hypothesis_version: 6.151.11
['__main__']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/__init__.py
# hypothesis_version: 6.151.11
[]

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/services/rdp.py
# hypothesis_version: 6.151.11
[3389, 'LOG_TARGET', 'NODE_NAME', 'build', 'container_name', 'context', 'environment', 'rdp', 'restart', 'templates', 'unless-stopped']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/deployer.py
# hypothesis_version: 6.151.11
[5.0, ', ', '--build', '--no-cache', '--watch', '-d', '-f', 'DECNET Deckies', 'Decky', 'Deployed Deckies', 'Hostname', 'IP', 'IPvlan', 'IPvlan L2', 'MACVLAN', 'Services', 'Status', '[green]up[/]', '[red]degraded[/]', 'absent', 'bold', 'build', 'cmdline', 'compose', 'decnet-compose.yml', 'decnet.cli', 'decnet.web.api:app', 'docker', 'down', 'green', 'manifest for', 'manifest unknown', 'mutate', 'name', 'not found', 'pid', 'pull access denied', 'red', 'rm', 'running', 'stop', 'up', 'uvicorn']

View File

@@ -0,0 +1,4 @@
# file: /home/anti/Tools/DECNET/decnet/services/conpot.py
# hypothesis_version: 6.151.11
[161, 502, 'CONPOT_TEMPLATE', 'conpot', 'container_name', 'default', 'environment', 'honeynet/conpot', 'image', 'restart', 'unless-stopped']

Binary file not shown.

Binary file not shown.

View File

@@ -226,6 +226,8 @@ async def stream_events(
request: Request,
last_event_id: int = Query(0, alias="lastEventId"),
search: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
current_user: str = Depends(get_current_user)
) -> StreamingResponse:
import json
@@ -245,7 +247,7 @@ async def stream_events(
break
# Poll for new logs
new_logs = await repo.get_logs_after_id(last_id, limit=50, search=search)
new_logs = await repo.get_logs_after_id(last_id, limit=50, search=search, start_time=start_time, end_time=end_time)
if new_logs:
# Update last_id to the max id in the fetched batch
last_id = max(log["id"] for log in new_logs)
@@ -260,6 +262,12 @@ async def stream_events(
stats = await repo.get_stats_summary()
payload = json.dumps({"type": "stats", "data": stats})
yield f"event: message\ndata: {payload}\n\n"
# Also yield histogram
histogram = await repo.get_log_histogram(search=search, start_time=start_time, end_time=end_time, interval_minutes=15)
hist_payload = json.dumps({"type": "histogram", "data": histogram})
yield f"event: message\ndata: {hist_payload}\n\n"
loops_since_stats = 0
loops_since_stats += 1

View File

@@ -12,6 +12,7 @@ class SQLiteRepository(BaseRepository):
async def initialize(self) -> None:
async with aiosqlite.connect(self.db_path) as _db:
await _db.execute("PRAGMA journal_mode=WAL")
# Logs table
await _db.execute("""
CREATE TABLE IF NOT EXISTS logs (
@@ -82,20 +83,76 @@ class SQLiteRepository(BaseRepository):
)
await _db.commit()
def _build_where_clause(
self,
search: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
base_where: Optional[str] = None,
base_params: Optional[list[Any]] = None
) -> tuple[str, list[Any]]:
import shlex
import re
where_clauses = []
params = []
if base_where:
where_clauses.append(base_where)
if base_params:
params.extend(base_params)
if start_time:
where_clauses.append("timestamp >= ?")
params.append(start_time)
if end_time:
where_clauses.append("timestamp <= ?")
params.append(end_time)
if search:
try:
tokens = shlex.split(search)
except ValueError:
tokens = search.split(" ")
core_fields = {
"decky": "decky",
"service": "service",
"event": "event_type",
"attacker": "attacker_ip",
"attacker-ip": "attacker_ip",
"attacker_ip": "attacker_ip"
}
for token in tokens:
if ":" in token:
key, val = token.split(":", 1)
if key in core_fields:
where_clauses.append(f"{core_fields[key]} = ?")
params.append(val)
else:
key_safe = re.sub(r'[^a-zA-Z0-9_]', '', key)
where_clauses.append(f"json_extract(fields, '$.{key_safe}') = ?")
params.append(val)
else:
where_clauses.append("(raw_line LIKE ? OR decky LIKE ? OR service LIKE ? OR attacker_ip LIKE ?)")
like_val = f"%{token}%"
params.extend([like_val, like_val, like_val, like_val])
if where_clauses:
return " WHERE " + " AND ".join(where_clauses), params
return "", []
async def get_logs(
self,
limit: int = 50,
offset: int = 0,
search: Optional[str] = None
search: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None
) -> list[dict[str, Any]]:
_query: str = "SELECT * FROM logs"
_params: list[Any] = []
if search:
_query += " WHERE raw_line LIKE ? OR decky LIKE ? OR service LIKE ? OR attacker_ip LIKE ?"
_like_val: str = f"%{search}%"
_params.extend([_like_val, _like_val, _like_val, _like_val])
_query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?"
_where, _params = self._build_where_clause(search, start_time, end_time)
_query = f"SELECT * FROM logs{_where} ORDER BY timestamp DESC LIMIT ? OFFSET ?"
_params.extend([limit, offset])
async with aiosqlite.connect(self.db_path) as _db:
@@ -112,16 +169,16 @@ class SQLiteRepository(BaseRepository):
_row: aiosqlite.Row | None = await _cursor.fetchone()
return _row["max_id"] if _row and _row["max_id"] is not None else 0
async def get_logs_after_id(self, last_id: int, limit: int = 50, search: Optional[str] = None) -> list[dict[str, Any]]:
_query: str = "SELECT * FROM logs WHERE id > ?"
_params: list[Any] = [last_id]
if search:
_query += " AND (raw_line LIKE ? OR decky LIKE ? OR service LIKE ? OR attacker_ip LIKE ?)"
_like_val: str = f"%{search}%"
_params.extend([_like_val, _like_val, _like_val, _like_val])
_query += " ORDER BY id ASC LIMIT ?"
async def get_logs_after_id(
self,
last_id: int,
limit: int = 50,
search: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None
) -> list[dict[str, Any]]:
_where, _params = self._build_where_clause(search, start_time, end_time, base_where="id > ?", base_params=[last_id])
_query = f"SELECT * FROM logs{_where} ORDER BY id ASC LIMIT ?"
_params.append(limit)
async with aiosqlite.connect(self.db_path) as _db:
@@ -130,13 +187,14 @@ class SQLiteRepository(BaseRepository):
_rows: list[aiosqlite.Row] = await _cursor.fetchall()
return [dict(_row) for _row in _rows]
async def get_total_logs(self, search: Optional[str] = None) -> int:
_query: str = "SELECT COUNT(*) as total FROM logs"
_params: list[Any] = []
if search:
_query += " WHERE raw_line LIKE ? OR decky LIKE ? OR service LIKE ? OR attacker_ip LIKE ?"
_like_val: str = f"%{search}%"
_params.extend([_like_val, _like_val, _like_val, _like_val])
async def get_total_logs(
self,
search: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None
) -> int:
_where, _params = self._build_where_clause(search, start_time, end_time)
_query = f"SELECT COUNT(*) as total FROM logs{_where}"
async with aiosqlite.connect(self.db_path) as _db:
_db.row_factory = aiosqlite.Row
@@ -144,6 +202,36 @@ class SQLiteRepository(BaseRepository):
_row: Optional[aiosqlite.Row] = await _cursor.fetchone()
return _row["total"] if _row else 0
async def get_log_histogram(
self,
search: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
interval_minutes: int = 15
) -> list[dict[str, Any]]:
# Map interval to sqlite strftime modifiers
# Since SQLite doesn't have an easy "bucket by X minutes" natively,
# we can do it by grouping by (strftime('%s', timestamp) / (interval_minutes * 60))
# and then multiplying back to get the bucket start time.
_where, _params = self._build_where_clause(search, start_time, end_time)
_query = f"""
SELECT
datetime((strftime('%s', timestamp) / {interval_minutes * 60}) * {interval_minutes * 60}, 'unixepoch') as bucket_time,
COUNT(*) as count
FROM logs
{_where}
GROUP BY bucket_time
ORDER BY bucket_time ASC
"""
async with aiosqlite.connect(self.db_path) as _db:
_db.row_factory = aiosqlite.Row
async with _db.execute(_query, _params) as _cursor:
_rows: list[aiosqlite.Row] = await _cursor.fetchall()
return [{"time": _row["bucket_time"], "count": _row["count"]} for _row in _rows]
async def get_stats_summary(self) -> dict[str, Any]:
async with aiosqlite.connect(self.db_path) as _db:
_db.row_factory = aiosqlite.Row

View File

@@ -12,7 +12,8 @@
"lucide-react": "^1.7.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.0"
"react-router-dom": "^7.14.0",
"recharts": "^3.8.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
@@ -591,6 +592,42 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.13.tgz",
@@ -855,6 +892,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -866,6 +915,69 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -894,7 +1006,7 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -910,6 +1022,12 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.58.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz",
@@ -1437,6 +1555,15 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1515,9 +1642,130 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1536,6 +1784,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -1628,6 +1882,16 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.45.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -1835,6 +2099,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2146,6 +2416,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -2173,6 +2453,15 @@
"node": ">=0.8.19"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -2862,6 +3151,36 @@
"react": "^19.2.4"
}
},
"node_modules/react-is": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-router": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz",
@@ -2900,6 +3219,57 @@
"react-dom": ">=18"
}
},
"node_modules/recharts": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3032,6 +3402,12 @@
"node": ">=8"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -3169,6 +3545,37 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz",

View File

@@ -14,7 +14,8 @@
"lucide-react": "^1.7.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.0"
"react-router-dom": "^7.14.0",
"recharts": "^3.8.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",

View File

@@ -1,17 +1,340 @@
import React from 'react';
import { Terminal } from 'lucide-react';
import React, { useEffect, useState, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Terminal, Search, Activity,
ChevronLeft, ChevronRight, Play, Pause
} from 'lucide-react';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell
} from 'recharts';
import api from '../utils/api';
import './Dashboard.css';
interface LogEntry {
id: number;
timestamp: string;
decky: string;
service: string;
event_type: string;
attacker_ip: string;
raw_line: string;
fields: string;
msg: string;
}
interface HistogramData {
time: string;
count: number;
}
const LiveLogs: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
// URL-synced state
const query = searchParams.get('q') || '';
const timeRange = searchParams.get('time') || '1h';
const isLive = searchParams.get('live') !== 'false';
const page = parseInt(searchParams.get('page') || '1');
// Local state
const [logs, setLogs] = useState<LogEntry[]>([]);
const [histogram, setHistogram] = useState<HistogramData[]>([]);
const [totalLogs, setTotalLogs] = useState(0);
const [loading, setLoading] = useState(true);
const [streaming, setStreaming] = useState(isLive);
const [searchInput, setSearchInput] = useState(query);
const eventSourceRef = useRef<EventSource | null>(null);
const limit = 50;
// Sync search input if URL changes (e.g. back button)
useEffect(() => {
setSearchInput(query);
}, [query]);
const fetchData = async () => {
if (streaming) return; // Don't fetch historical if streaming
setLoading(true);
try {
const offset = (page - 1) * limit;
let url = `/logs?limit=${limit}&offset=${offset}&search=${encodeURIComponent(query)}`;
// Calculate time bounds for historical fetch
const now = new Date();
let startTime: string | null = null;
if (timeRange !== 'all') {
const minutes = timeRange === '15m' ? 15 : timeRange === '1h' ? 60 : timeRange === '24h' ? 1440 : 0;
if (minutes > 0) {
startTime = new Date(now.getTime() - minutes * 60000).toISOString();
url += `&start_time=${startTime}`;
}
}
const res = await api.get(url);
setLogs(res.data.data);
setTotalLogs(res.data.total);
// Fetch histogram for historical view
const histUrl = `/logs/histogram?search=${encodeURIComponent(query)}` + (startTime ? `&start_time=${startTime}` : '');
const histRes = await api.get(histUrl);
setHistogram(histRes.data);
} catch (err) {
console.error('Failed to fetch historical logs', err);
} finally {
setLoading(false);
}
};
const setupSSE = () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
const token = localStorage.getItem('token');
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1';
let url = `${baseUrl}/stream?token=${token}&search=${encodeURIComponent(query)}`;
if (timeRange !== 'all') {
const minutes = timeRange === '15m' ? 15 : timeRange === '1h' ? 60 : timeRange === '24h' ? 1440 : 0;
if (minutes > 0) {
const startTime = new Date(Date.now() - minutes * 60000).toISOString();
url += `&start_time=${startTime}`;
}
}
const es = new EventSource(url);
eventSourceRef.current = es;
es.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
if (payload.type === 'logs') {
setLogs(prev => [...payload.data, ...prev].slice(0, 500));
} else if (payload.type === 'histogram') {
setHistogram(payload.data);
} else if (payload.type === 'stats') {
setTotalLogs(payload.data.total_logs);
}
} catch (err) {
console.error('Failed to parse SSE payload', err);
}
};
es.onerror = () => {
console.error('SSE connection lost, reconnecting...');
};
};
useEffect(() => {
if (streaming) {
setupSSE();
setLoading(false);
} else {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
fetchData();
}
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
};
}, [query, timeRange, streaming, page]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearchParams({ q: searchInput, time: timeRange, live: streaming.toString(), page: '1' });
};
const handleToggleLive = () => {
const newStreaming = !streaming;
setStreaming(newStreaming);
setSearchParams({ q: query, time: timeRange, live: newStreaming.toString(), page: '1' });
};
const handleTimeChange = (newTime: string) => {
setSearchParams({ q: query, time: newTime, live: streaming.toString(), page: '1' });
};
const changePage = (newPage: number) => {
setSearchParams({ q: query, time: timeRange, live: 'false', page: newPage.toString() });
};
return (
<div className="logs-section">
<div className="section-header">
<Terminal size={20} />
<h2>FULL LIVE LOG STREAM</h2>
<div className="dashboard">
{/* Control Bar */}
<div className="logs-section" style={{ border: 'none', background: 'transparent', padding: 0 }}>
<form onSubmit={handleSearch} style={{ display: 'flex', gap: '16px', marginBottom: '24px' }}>
<div className="search-container" style={{ flexGrow: 1, maxWidth: 'none' }}>
<Search className="search-icon" size={18} />
<input
type="text"
placeholder="Query logs (e.g. decky:decky-01 service:http attacker:192.168.1.5 status:failed)"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
</div>
<select
value={timeRange}
onChange={(e) => handleTimeChange(e.target.value)}
className="search-container"
style={{ width: 'auto', color: 'var(--text-color)', cursor: 'pointer' }}
>
<option value="15m">LAST 15 MIN</option>
<option value="1h">LAST 1 HOUR</option>
<option value="24h">LAST 24 HOURS</option>
<option value="all">ALL TIME</option>
</select>
<button
type="button"
onClick={handleToggleLive}
style={{
display: 'flex', alignItems: 'center', gap: '8px',
border: `1px solid ${streaming ? 'var(--text-color)' : 'var(--border-color)'}`,
color: streaming ? 'var(--text-color)' : 'var(--dim-color)',
minWidth: '120px', justifyContent: 'center'
}}
>
{streaming ? <><Play size={14} className="neon-blink" /> LIVE</> : <><Pause size={14} /> PAUSED</>}
</button>
</form>
</div>
{/* Histogram Chart */}
<div className="logs-section" style={{ height: '200px', padding: '20px', marginBottom: '24px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '10px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.7rem', color: 'var(--dim-color)' }}>
<Activity size={12} /> ATTACK VOLUME OVER TIME
</div>
<div style={{ fontSize: '0.7rem', color: 'var(--text-color)' }}>
MATCHES: {totalLogs.toLocaleString()}
</div>
</div>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={histogram}>
<CartesianGrid strokeDasharray="3 3" stroke="#30363d" vertical={false} />
<XAxis
dataKey="time"
hide
/>
<YAxis
stroke="#30363d"
fontSize={10}
tickFormatter={(val) => Math.floor(val).toString()}
/>
<Tooltip
contentStyle={{ backgroundColor: '#0d1117', border: '1px solid #30363d', fontSize: '0.8rem' }}
itemStyle={{ color: 'var(--text-color)' }}
labelStyle={{ color: 'var(--dim-color)', marginBottom: '4px' }}
cursor={{ fill: 'rgba(0, 255, 65, 0.05)' }}
/>
<Bar dataKey="count" fill="var(--text-color)" radius={[2, 2, 0, 0]}>
{histogram.map((entry, index) => (
<Cell key={`cell-${index}`} fillOpacity={0.6 + (entry.count / (Math.max(...histogram.map(h => h.count)) || 1)) * 0.4} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
{/* Logs Table */}
<div className="logs-section">
<div className="section-header" style={{ display: 'flex', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Terminal size={20} />
<h2>LOG EXPLORER</h2>
</div>
{!streaming && (
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<span className="dim" style={{ fontSize: '0.8rem' }}>
Page {page} of {Math.ceil(totalLogs / limit)}
</span>
<div style={{ display: 'flex', gap: '8px' }}>
<button
disabled={page === 1}
onClick={() => changePage(page - 1)}
style={{ padding: '4px', border: '1px solid var(--border-color)', opacity: page === 1 ? 0.3 : 1 }}
>
<ChevronLeft size={16} />
</button>
<button
disabled={page >= Math.ceil(totalLogs / limit)}
onClick={() => changePage(page + 1)}
style={{ padding: '4px', border: '1px solid var(--border-color)', opacity: page >= Math.ceil(totalLogs / limit) ? 0.3 : 1 }}
>
<ChevronRight size={16} />
</button>
</div>
</div>
)}
</div>
<div className="logs-table-container">
<table className="logs-table">
<thead>
<tr>
<th>TIMESTAMP</th>
<th>DECKY</th>
<th>SERVICE</th>
<th>ATTACKER</th>
<th>EVENT</th>
</tr>
</thead>
<tbody>
{logs.length > 0 ? logs.map(log => {
let parsedFields: Record<string, any> = {};
if (log.fields) {
try {
parsedFields = JSON.parse(log.fields);
} catch (e) {}
}
return (
<tr key={log.id}>
<td className="dim" style={{ fontSize: '0.75rem', whiteSpace: 'nowrap' }}>{new Date(log.timestamp).toLocaleString()}</td>
<td className="violet-accent">{log.decky}</td>
<td className="matrix-text">{log.service}</td>
<td>{log.attacker_ip}</td>
<td>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ fontWeight: 'bold', color: 'var(--text-color)', fontSize: '0.9rem' }}>
{log.event_type} {log.msg && log.msg !== '-' && <span style={{ fontWeight: 'normal', opacity: 0.8 }}> {log.msg}</span>}
</div>
{Object.keys(parsedFields).length > 0 && (
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{Object.entries(parsedFields).map(([k, v]) => (
<span key={k} style={{
fontSize: '0.7rem',
backgroundColor: 'rgba(0, 255, 65, 0.1)',
padding: '2px 8px',
borderRadius: '4px',
border: '1px solid rgba(0, 255, 65, 0.3)',
wordBreak: 'break-all'
}}>
<span style={{ opacity: 0.6 }}>{k}:</span> {typeof v === 'object' ? JSON.stringify(v) : v}
</span>
))}
</div>
)}
</div>
</td>
</tr>
);
}) : (
<tr>
<td colSpan={5} style={{ textAlign: 'center', padding: '40px', opacity: 0.5 }}>
{loading ? 'RETRIEVING DATA...' : 'NO LOGS MATCHING CRITERIA'}
</td>
</tr>
)}
</tbody>
</table>
</div>
<div style={{ padding: '40px', textAlign: 'center', opacity: 0.5 }}>
<p>STREAM ESTABLISHED. WAITING FOR INCOMING DATA...</p>
<p style={{ marginTop: '10px', fontSize: '0.8rem' }}>(Dedicated Live Logs view placeholder)</p>
</div>
</div>
);