From eb4be44c9ae92e0d4b23a462010f6b4056318087 Mon Sep 17 00:00:00 2001 From: anti Date: Tue, 7 Apr 2026 23:15:20 -0400 Subject: [PATCH] feat: add dedicated Decoy Fleet inventory page and API --- decnet/web/api.py | 6 + decnet/web/repository.py | 5 + decnet/web/sqlite_repository.py | 23 +++- decnet_web/src/App.tsx | 2 + decnet_web/src/components/Dashboard.tsx | 5 +- decnet_web/src/components/DeckyFleet.tsx | 134 +++++++++++++++++++++++ decnet_web/src/components/Layout.tsx | 3 +- tests/test_fleet_api.py | 87 +++++++++++++++ 8 files changed, 261 insertions(+), 4 deletions(-) create mode 100644 decnet_web/src/components/DeckyFleet.tsx create mode 100644 tests/test_fleet_api.py diff --git a/decnet/web/api.py b/decnet/web/api.py index 625990b..77b3f70 100644 --- a/decnet/web/api.py +++ b/decnet/web/api.py @@ -166,8 +166,14 @@ class StatsResponse(BaseModel): total_logs: int unique_attackers: int active_deckies: int + deployed_deckies: int @app.get("/api/v1/stats", response_model=StatsResponse) async def get_stats(current_user: str = Depends(get_current_user)) -> dict[str, Any]: return await repo.get_stats_summary() + + +@app.get("/api/v1/deckies") +async def get_deckies(current_user: str = Depends(get_current_user)) -> list[dict[str, Any]]: + return await repo.get_deckies() diff --git a/decnet/web/repository.py b/decnet/web/repository.py index ec07f53..cb4f958 100644 --- a/decnet/web/repository.py +++ b/decnet/web/repository.py @@ -35,6 +35,11 @@ class BaseRepository(ABC): """Retrieve high-level dashboard metrics.""" pass + @abstractmethod + async def get_deckies(self) -> list[dict[str, Any]]: + """Retrieve the list of currently deployed deckies.""" + pass + @abstractmethod async def get_user_by_username(self, username: str) -> Optional[dict[str, Any]]: """Retrieve a user by their username.""" diff --git a/decnet/web/sqlite_repository.py b/decnet/web/sqlite_repository.py index 7fdfe88..25e69b9 100644 --- a/decnet/web/sqlite_repository.py +++ b/decnet/web/sqlite_repository.py @@ -1,6 +1,7 @@ import aiosqlite from typing import Any, Optional from decnet.web.repository import BaseRepository +from decnet.config import load_state class SQLiteRepository(BaseRepository): @@ -128,16 +129,36 @@ class SQLiteRepository(BaseRepository): _row = await _cursor.fetchone() _unique_attackers: int = _row["unique_attackers"] if _row else 0 + # Active deckies are those that HAVE interaction logs async with _db.execute("SELECT COUNT(DISTINCT decky) as active_deckies FROM logs") as _cursor: _row = await _cursor.fetchone() _active_deckies: int = _row["active_deckies"] if _row else 0 + # Deployed deckies are all those in the state file + _state = load_state() + _deployed_deckies: int = 0 + if _state: + _deployed_deckies = len(_state[0].deckies) + return { "total_logs": _total_logs, "unique_attackers": _unique_attackers, - "active_deckies": _active_deckies + "active_deckies": _active_deckies, + "deployed_deckies": _deployed_deckies } + async def get_deckies(self) -> list[dict[str, Any]]: + _state = load_state() + if not _state: + return [] + + # We can also enrich this with interaction counts/last seen from DB + _deckies: list[dict[str, Any]] = [] + for _d in _state[0].deckies: + _deckies.append(_d.model_dump()) + + return _deckies + 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 diff --git a/decnet_web/src/App.tsx b/decnet_web/src/App.tsx index e816a3c..80f761e 100644 --- a/decnet_web/src/App.tsx +++ b/decnet_web/src/App.tsx @@ -3,6 +3,7 @@ import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-d import Login from './components/Login'; import Layout from './components/Layout'; import Dashboard from './components/Dashboard'; +import DeckyFleet from './components/DeckyFleet'; import LiveLogs from './components/LiveLogs'; import Attackers from './components/Attackers'; import Config from './components/Config'; @@ -40,6 +41,7 @@ function App() { } /> + } /> } /> } /> } /> diff --git a/decnet_web/src/components/Dashboard.tsx b/decnet_web/src/components/Dashboard.tsx index bb1ea13..740360c 100644 --- a/decnet_web/src/components/Dashboard.tsx +++ b/decnet_web/src/components/Dashboard.tsx @@ -7,6 +7,7 @@ interface Stats { total_logs: number; unique_attackers: number; active_deckies: number; + deployed_deckies: number; } interface LogEntry { @@ -69,7 +70,7 @@ const Dashboard: React.FC = ({ searchQuery }) => { } label="ACTIVE DECKIES" - value={stats?.active_deckies || 0} + value={`${stats?.active_deckies || 0} / ${stats?.deployed_deckies || 0}`} /> @@ -146,7 +147,7 @@ const Dashboard: React.FC = ({ searchQuery }) => { interface StatCardProps { icon: React.ReactNode; label: string; - value: number; + value: string | number; } const StatCard: React.FC = ({ icon, label, value }) => ( diff --git a/decnet_web/src/components/DeckyFleet.tsx b/decnet_web/src/components/DeckyFleet.tsx new file mode 100644 index 0000000..f3cf38d --- /dev/null +++ b/decnet_web/src/components/DeckyFleet.tsx @@ -0,0 +1,134 @@ +import React, { useEffect, useState } from 'react'; +import api from '../utils/api'; +import './Dashboard.css'; // Re-use common dashboard styles +import { Server, Cpu, Globe, Database } from 'lucide-react'; + +interface Decky { + name: string; + ip: string; + services: string[]; + distro: string; + hostname: string; + archetype: string | null; + service_config: Record>; +} + +const DeckyFleet: React.FC = () => { + const [deckies, setDeckies] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchDeckies = async () => { + try { + const _res = await api.get('/deckies'); + setDeckies(_res.data); + } catch (err) { + console.error('Failed to fetch decky fleet', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchDeckies(); + const _interval = setInterval(fetchDeckies, 10000); // Fleet state updates less frequently than logs + return () => clearInterval(_interval); + }, []); + + if (loading) return
SCANNING NETWORK FOR DECOYS...
; + + return ( +
+
+ +

DECOY FLEET ASSET INVENTORY

+
+ +
+ {deckies.length > 0 ? deckies.map(decky => ( +
+
+ {decky.name} + {decky.ip} +
+ +
+
+ + HOSTNAME: {decky.hostname} +
+
+ + DISTRO: {decky.distro} +
+ {decky.archetype && ( +
+ + ARCHETYPE: {decky.archetype} +
+ )} +
+ +
+
EXPOSED SERVICES:
+
+ {decky.services.map(svc => { + const _config = decky.service_config[svc]; + return ( +
+ + {svc} + + {_config && Object.keys(_config).length > 0 && ( +
+ {Object.entries(_config).map(([k, v]) => ( +
+ {k}: + {String(v)} +
+ ))} +
+ )} +
+ ); + })} +
+
+
+ )) : ( +
+ NO DECOYS CURRENTLY DEPLOYED IN THIS SECTOR +
+ )} +
+ +