feat: implement Logs endpoints for web dashboard

This commit is contained in:
2026-04-07 14:56:25 -04:00
parent 5b990743db
commit b46934db46
4 changed files with 242 additions and 1 deletions

View File

@@ -3,12 +3,16 @@ from contextlib import asynccontextmanager
from datetime import timedelta
from typing import Any, AsyncGenerator
from fastapi import FastAPI, HTTPException, status
import jwt
from fastapi import Depends, FastAPI, HTTPException, Query, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import OAuth2PasswordBearer
from pydantic import BaseModel
from decnet.web.auth import (
ACCESS_TOKEN_EXPIRE_MINUTES,
ALGORITHM,
SECRET_KEY,
create_access_token,
get_password_hash,
verify_password,
@@ -50,6 +54,25 @@ app.add_middleware(
)
oauth2_scheme: OAuth2PasswordBearer = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def get_current_user(token: str = Depends(oauth2_scheme)) -> str:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload: dict[str, Any] = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_uuid: str | None = payload.get("uuid")
if user_uuid is None:
raise credentials_exception
except jwt.PyJWTError:
raise credentials_exception
return user_uuid
class Token(BaseModel):
access_token: str
token_type: str
@@ -60,6 +83,13 @@ class LoginRequest(BaseModel):
password: str
class LogsResponse(BaseModel):
total: int
limit: int
offset: int
data: list[dict[str, Any]]
@app.post("/api/v1/auth/login", response_model=Token)
async def login(request: LoginRequest) -> dict[str, str]:
user: dict[str, Any] | None = await repo.get_user_by_username(request.username)
@@ -76,3 +106,20 @@ async def login(request: LoginRequest) -> dict[str, str]:
data={"uuid": user["uuid"]}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
@app.get("/api/v1/logs", response_model=LogsResponse)
async def get_logs(
limit: int = Query(50, ge=1, le=1000),
offset: int = Query(0, ge=0),
search: str | None = None,
current_user: str = Depends(get_current_user)
) -> dict[str, Any]:
logs: list[dict[str, Any]] = await repo.get_logs(limit=limit, offset=offset, search=search)
total: int = await repo.get_total_logs(search=search)
return {
"total": total,
"limit": limit,
"offset": offset,
"data": logs
}

46
decnet/web/repository.py Normal file
View File

@@ -0,0 +1,46 @@
from abc import ABC, abstractmethod
from typing import Any, Optional
class BaseRepository(ABC):
"""Abstract base class for DECNET web dashboard data storage."""
@abstractmethod
async def initialize(self) -> None:
"""Initialize the database schema."""
pass
@abstractmethod
async def add_log(self, log_data: dict[str, Any]) -> None:
"""Add a new log entry to the database."""
pass
@abstractmethod
async def get_logs(
self,
limit: int = 50,
offset: int = 0,
search: Optional[str] = None
) -> list[dict[str, Any]]:
"""Retrieve paginated log entries."""
pass
@abstractmethod
async def get_total_logs(self, search: Optional[str] = None) -> int:
"""Retrieve the total count of logs, optionally filtered by search."""
pass
@abstractmethod
async def get_stats_summary(self) -> dict[str, Any]:
"""Retrieve high-level dashboard metrics."""
pass
@abstractmethod
async def get_user_by_username(self, username: str) -> Optional[dict[str, Any]]:
"""Retrieve a user by their username."""
pass
@abstractmethod
async def create_user(self, user_data: dict[str, Any]) -> None:
"""Create a new dashboard user."""
pass

View File

@@ -0,0 +1,121 @@
import aiosqlite
from typing import Any, Optional
from decnet.web.repository import BaseRepository
class SQLiteRepository(BaseRepository):
"""SQLite implementation of the DECNET web repository."""
def __init__(self, db_path: str = "decnet.db") -> None:
self.db_path: str = db_path
async def initialize(self) -> None:
async with aiosqlite.connect(self.db_path) as db:
# Logs table
await db.execute("""
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
decky TEXT,
service TEXT,
event_type TEXT,
attacker_ip TEXT,
raw_line TEXT
)
""")
# Users table (internal RBAC)
await db.execute("""
CREATE TABLE IF NOT EXISTS users (
uuid TEXT PRIMARY KEY,
username TEXT UNIQUE,
password_hash TEXT,
role TEXT DEFAULT 'viewer'
)
""")
await db.commit()
async def add_log(self, log_data: dict[str, Any]) -> None:
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"INSERT INTO logs (decky, service, event_type, attacker_ip, raw_line) VALUES (?, ?, ?, ?, ?)",
(
log_data.get("decky"),
log_data.get("service"),
log_data.get("event_type"),
log_data.get("attacker_ip"),
log_data.get("raw_line")
)
)
await db.commit()
async def get_logs(
self,
limit: int = 50,
offset: int = 0,
search: 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 = f"%{search}%"
params.extend([like_val, like_val, like_val, like_val])
query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(query, params) as cursor:
rows = 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 = f"%{search}%"
params.extend([like_val, like_val, like_val, like_val])
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(query, params) as cursor:
row = await cursor.fetchone()
return row["total"] if row else 0
async def get_stats_summary(self) -> dict[str, Any]:
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute("SELECT COUNT(*) as total_logs FROM logs") as cursor:
row = await cursor.fetchone()
total_logs: int = row["total_logs"] if row else 0
async with db.execute("SELECT COUNT(DISTINCT attacker_ip) as unique_attackers FROM logs") as cursor:
row = await cursor.fetchone()
unique_attackers: int = row["unique_attackers"] if row else 0
return {
"total_logs": total_logs,
"unique_attackers": unique_attackers
}
async def get_user_by_username(self, username: str) -> Optional[dict[str, Any]]:
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute("SELECT * FROM users WHERE username = ?", (username,)) as cursor:
row = await cursor.fetchone()
return dict(row) if row else None
async def create_user(self, user_data: dict[str, Any]) -> None:
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"INSERT INTO users (uuid, username, password_hash, role) VALUES (?, ?, ?, ?)",
(
user_data["uuid"],
user_data["username"],
user_data["password_hash"],
user_data["role"]
)
)
await db.commit()