mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Add statistics endpoint
This commit is contained in:
@@ -278,6 +278,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| PATCH | `/api/settings` | Update app settings |
|
||||
| POST | `/api/settings/favorites/toggle` | Toggle favorite status |
|
||||
| POST | `/api/settings/migrate` | One-time migration from frontend localStorage |
|
||||
| GET | `/api/statistics` | Aggregated mesh network statistics |
|
||||
| WS | `/api/ws` | Real-time updates |
|
||||
|
||||
## Key Concepts
|
||||
|
||||
@@ -40,6 +40,7 @@ app/
|
||||
├── packets.py
|
||||
├── read_state.py
|
||||
├── settings.py
|
||||
├── statistics.py
|
||||
└── ws.py
|
||||
```
|
||||
|
||||
@@ -138,6 +139,9 @@ app/
|
||||
- `POST /settings/favorites/toggle`
|
||||
- `POST /settings/migrate`
|
||||
|
||||
### Statistics
|
||||
- `GET /statistics` — aggregated mesh network stats (entity counts, message/packet splits, activity windows, busiest channels)
|
||||
|
||||
### WebSocket
|
||||
- `WS /ws`
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ from app.routers import (
|
||||
radio,
|
||||
read_state,
|
||||
settings,
|
||||
statistics,
|
||||
ws,
|
||||
)
|
||||
|
||||
@@ -83,6 +84,7 @@ app.include_router(messages.router, prefix="/api")
|
||||
app.include_router(packets.router, prefix="/api")
|
||||
app.include_router(read_state.router, prefix="/api")
|
||||
app.include_router(settings.router, prefix="/api")
|
||||
app.include_router(statistics.router, prefix="/api")
|
||||
app.include_router(ws.router, prefix="/api")
|
||||
|
||||
# Serve frontend static files in production
|
||||
|
||||
@@ -296,3 +296,30 @@ class AppSettings(BaseModel):
|
||||
default_factory=list,
|
||||
description="List of bot configurations",
|
||||
)
|
||||
|
||||
|
||||
class BusyChannel(BaseModel):
|
||||
channel_key: str
|
||||
channel_name: str
|
||||
message_count: int
|
||||
|
||||
|
||||
class ContactActivityCounts(BaseModel):
|
||||
last_hour: int
|
||||
last_24_hours: int
|
||||
last_week: int
|
||||
|
||||
|
||||
class StatisticsResponse(BaseModel):
|
||||
busiest_channels_24h: list[BusyChannel]
|
||||
contact_count: int
|
||||
repeater_count: int
|
||||
channel_count: int
|
||||
total_packets: int
|
||||
decrypted_packets: int
|
||||
undecrypted_packets: int
|
||||
total_dms: int
|
||||
total_channel_messages: int
|
||||
total_outgoing: int
|
||||
contacts_heard: ContactActivityCounts
|
||||
repeaters_heard: ContactActivityCounts
|
||||
|
||||
@@ -20,6 +20,11 @@ from app.models import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SECONDS_1H = 3600
|
||||
SECONDS_24H = 86400
|
||||
SECONDS_7D = 604800
|
||||
|
||||
|
||||
class AmbiguousPublicKeyPrefixError(ValueError):
|
||||
"""Raised when a public key prefix matches multiple contacts."""
|
||||
|
||||
@@ -1013,3 +1018,123 @@ class AppSettingsRepository:
|
||||
)
|
||||
|
||||
return settings, True
|
||||
|
||||
|
||||
class StatisticsRepository:
|
||||
@staticmethod
|
||||
async def _activity_counts(type_condition: str) -> dict[str, int]:
|
||||
"""Get time-windowed counts for contacts/repeaters heard."""
|
||||
now = int(time.time())
|
||||
cursor = await db.conn.execute(
|
||||
f"""
|
||||
SELECT
|
||||
SUM(CASE WHEN last_seen >= ? THEN 1 ELSE 0 END) AS last_hour,
|
||||
SUM(CASE WHEN last_seen >= ? THEN 1 ELSE 0 END) AS last_24_hours,
|
||||
SUM(CASE WHEN last_seen >= ? THEN 1 ELSE 0 END) AS last_week
|
||||
FROM contacts
|
||||
WHERE {type_condition} AND last_seen IS NOT NULL
|
||||
""",
|
||||
(now - SECONDS_1H, now - SECONDS_24H, now - SECONDS_7D),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None # Aggregate query always returns a row
|
||||
return {
|
||||
"last_hour": row["last_hour"] or 0,
|
||||
"last_24_hours": row["last_24_hours"] or 0,
|
||||
"last_week": row["last_week"] or 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def get_all() -> dict:
|
||||
"""Aggregate all statistics from existing tables."""
|
||||
now = int(time.time())
|
||||
|
||||
# Top 5 busiest channels in last 24h
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT m.conversation_key, COALESCE(c.name, m.conversation_key) AS channel_name,
|
||||
COUNT(*) AS message_count
|
||||
FROM messages m
|
||||
LEFT JOIN channels c ON m.conversation_key = c.key
|
||||
WHERE m.type = 'CHAN' AND m.received_at >= ?
|
||||
GROUP BY m.conversation_key
|
||||
ORDER BY COUNT(*) DESC
|
||||
LIMIT 5
|
||||
""",
|
||||
(now - SECONDS_24H,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
busiest_channels_24h = [
|
||||
{
|
||||
"channel_key": row["conversation_key"],
|
||||
"channel_name": row["channel_name"],
|
||||
"message_count": row["message_count"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
# Entity counts
|
||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM contacts WHERE type != 2")
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
contact_count: int = row["cnt"]
|
||||
|
||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM contacts WHERE type = 2")
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
repeater_count: int = row["cnt"]
|
||||
|
||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM channels")
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
channel_count: int = row["cnt"]
|
||||
|
||||
# Packet split
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN message_id IS NOT NULL THEN 1 ELSE 0 END) AS decrypted
|
||||
FROM raw_packets
|
||||
"""
|
||||
)
|
||||
pkt_row = await cursor.fetchone()
|
||||
assert pkt_row is not None
|
||||
total_packets = pkt_row["total"] or 0
|
||||
decrypted_packets = pkt_row["decrypted"] or 0
|
||||
undecrypted_packets = total_packets - decrypted_packets
|
||||
|
||||
# Message type counts
|
||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE type = 'PRIV'")
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
total_dms: int = row["cnt"]
|
||||
|
||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE type = 'CHAN'")
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
total_channel_messages: int = row["cnt"]
|
||||
|
||||
# Outgoing count
|
||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE outgoing = 1")
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
total_outgoing: int = row["cnt"]
|
||||
|
||||
# Activity windows
|
||||
contacts_heard = await StatisticsRepository._activity_counts("type != 2")
|
||||
repeaters_heard = await StatisticsRepository._activity_counts("type = 2")
|
||||
|
||||
return {
|
||||
"busiest_channels_24h": busiest_channels_24h,
|
||||
"contact_count": contact_count,
|
||||
"repeater_count": repeater_count,
|
||||
"channel_count": channel_count,
|
||||
"total_packets": total_packets,
|
||||
"decrypted_packets": decrypted_packets,
|
||||
"undecrypted_packets": undecrypted_packets,
|
||||
"total_dms": total_dms,
|
||||
"total_channel_messages": total_channel_messages,
|
||||
"total_outgoing": total_outgoing,
|
||||
"contacts_heard": contacts_heard,
|
||||
"repeaters_heard": repeaters_heard,
|
||||
}
|
||||
|
||||
12
app/routers/statistics.py
Normal file
12
app/routers/statistics.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.models import StatisticsResponse
|
||||
from app.repository import StatisticsRepository
|
||||
|
||||
router = APIRouter(prefix="/statistics", tags=["statistics"])
|
||||
|
||||
|
||||
@router.get("", response_model=StatisticsResponse)
|
||||
async def get_statistics() -> StatisticsResponse:
|
||||
data = await StatisticsRepository.get_all()
|
||||
return StatisticsResponse(**data)
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
MigratePreferencesResponse,
|
||||
RadioConfig,
|
||||
RadioConfigUpdate,
|
||||
StatisticsResponse,
|
||||
TelemetryResponse,
|
||||
TraceResponse,
|
||||
UnreadCounts,
|
||||
@@ -216,4 +217,7 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
}),
|
||||
|
||||
// Statistics
|
||||
getStatistics: () => fetchJson<StatisticsResponse>('/statistics'),
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
HealthStatus,
|
||||
RadioConfig,
|
||||
RadioConfigUpdate,
|
||||
StatisticsResponse,
|
||||
} from '../types';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
@@ -109,6 +110,7 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
connectivity: false,
|
||||
database: false,
|
||||
bot: false,
|
||||
statistics: false,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -185,6 +187,10 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
const [editingNameId, setEditingNameId] = useState<string | null>(null);
|
||||
const [editingNameValue, setEditingNameValue] = useState('');
|
||||
|
||||
// Statistics state
|
||||
const [stats, setStats] = useState<StatisticsResponse | null>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setName(config.name);
|
||||
@@ -239,6 +245,31 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
setSectionError(null);
|
||||
}, [externalSidebarNav, desktopSection]);
|
||||
|
||||
// Fetch statistics when the section becomes visible
|
||||
const statisticsVisible = externalSidebarNav
|
||||
? desktopSection === 'statistics'
|
||||
: expandedSections.statistics;
|
||||
|
||||
useEffect(() => {
|
||||
if (!statisticsVisible) return;
|
||||
let cancelled = false;
|
||||
setStatsLoading(true);
|
||||
api.getStatistics().then(
|
||||
(data) => {
|
||||
if (!cancelled) {
|
||||
setStats(data);
|
||||
setStatsLoading(false);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (!cancelled) setStatsLoading(false);
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [statisticsVisible]);
|
||||
|
||||
// Detect current preset from form values
|
||||
const currentPreset = useMemo(() => {
|
||||
const freqNum = parseFloat(freq);
|
||||
@@ -1207,6 +1238,141 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldRenderSection('statistics') && (
|
||||
<div className={sectionWrapperClass}>
|
||||
{renderSectionHeader('statistics')}
|
||||
{isSectionVisible('statistics') && (
|
||||
<div className={sectionContentClass}>
|
||||
{statsLoading && !stats ? (
|
||||
<div className="py-8 text-center text-muted-foreground">Loading statistics...</div>
|
||||
) : stats ? (
|
||||
<div className="space-y-6">
|
||||
{/* Network */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Network</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="text-center p-3 bg-muted/50 rounded-md">
|
||||
<div className="text-2xl font-bold">{stats.contact_count}</div>
|
||||
<div className="text-xs text-muted-foreground">Contacts</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-muted/50 rounded-md">
|
||||
<div className="text-2xl font-bold">{stats.repeater_count}</div>
|
||||
<div className="text-xs text-muted-foreground">Repeaters</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-muted/50 rounded-md">
|
||||
<div className="text-2xl font-bold">{stats.channel_count}</div>
|
||||
<div className="text-xs text-muted-foreground">Channels</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Messages */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Messages</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="text-center p-3 bg-muted/50 rounded-md">
|
||||
<div className="text-2xl font-bold">{stats.total_dms}</div>
|
||||
<div className="text-xs text-muted-foreground">Direct Messages</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-muted/50 rounded-md">
|
||||
<div className="text-2xl font-bold">{stats.total_channel_messages}</div>
|
||||
<div className="text-xs text-muted-foreground">Channel Messages</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-muted/50 rounded-md">
|
||||
<div className="text-2xl font-bold">{stats.total_outgoing}</div>
|
||||
<div className="text-xs text-muted-foreground">Sent (Outgoing)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Packets */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Packets</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Total stored</span>
|
||||
<span className="font-medium">{stats.total_packets}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-green-500">Decrypted</span>
|
||||
<span className="font-medium text-green-500">
|
||||
{stats.decrypted_packets}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-yellow-500">Undecrypted</span>
|
||||
<span className="font-medium text-yellow-500">
|
||||
{stats.undecrypted_packets}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Activity */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Activity</h4>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground">
|
||||
<th className="text-left font-normal pb-1"></th>
|
||||
<th className="text-right font-normal pb-1">1h</th>
|
||||
<th className="text-right font-normal pb-1">24h</th>
|
||||
<th className="text-right font-normal pb-1">7d</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="py-1">Contacts heard</td>
|
||||
<td className="text-right py-1">{stats.contacts_heard.last_hour}</td>
|
||||
<td className="text-right py-1">{stats.contacts_heard.last_24_hours}</td>
|
||||
<td className="text-right py-1">{stats.contacts_heard.last_week}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-1">Repeaters heard</td>
|
||||
<td className="text-right py-1">{stats.repeaters_heard.last_hour}</td>
|
||||
<td className="text-right py-1">{stats.repeaters_heard.last_24_hours}</td>
|
||||
<td className="text-right py-1">{stats.repeaters_heard.last_week}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Busiest Channels */}
|
||||
{stats.busiest_channels_24h.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Busiest Channels (24h)</h4>
|
||||
<div className="space-y-1">
|
||||
{stats.busiest_channels_24h.map((ch, i) => (
|
||||
<div
|
||||
key={ch.channel_key}
|
||||
className="flex justify-between items-center text-sm"
|
||||
>
|
||||
<span>
|
||||
<span className="text-muted-foreground mr-2">{i + 1}.</span>
|
||||
{ch.channel_name}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{ch.message_count} msgs</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
export type SettingsSection = 'radio' | 'identity' | 'connectivity' | 'database' | 'bot';
|
||||
export type SettingsSection =
|
||||
| 'radio'
|
||||
| 'identity'
|
||||
| 'connectivity'
|
||||
| 'database'
|
||||
| 'bot'
|
||||
| 'statistics';
|
||||
|
||||
export const SETTINGS_SECTION_ORDER: SettingsSection[] = [
|
||||
'radio',
|
||||
@@ -6,6 +12,7 @@ export const SETTINGS_SECTION_ORDER: SettingsSection[] = [
|
||||
'connectivity',
|
||||
'database',
|
||||
'bot',
|
||||
'statistics',
|
||||
];
|
||||
|
||||
export const SETTINGS_SECTION_LABELS: Record<SettingsSection, string> = {
|
||||
@@ -14,4 +21,5 @@ export const SETTINGS_SECTION_LABELS: Record<SettingsSection, string> = {
|
||||
connectivity: '📡 Connectivity',
|
||||
database: '🗄️ Database',
|
||||
bot: '🤖 Bot',
|
||||
statistics: '📊 Statistics',
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
HealthStatus,
|
||||
RadioConfig,
|
||||
RadioConfigUpdate,
|
||||
StatisticsResponse,
|
||||
} from '../types';
|
||||
import type { SettingsSection } from '../components/SettingsModal';
|
||||
|
||||
@@ -289,4 +290,55 @@ describe('SettingsModal', () => {
|
||||
});
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders statistics section with fetched data', async () => {
|
||||
const mockStats: StatisticsResponse = {
|
||||
busiest_channels_24h: [
|
||||
{ channel_key: 'AA'.repeat(16), channel_name: 'general', message_count: 42 },
|
||||
],
|
||||
contact_count: 10,
|
||||
repeater_count: 3,
|
||||
channel_count: 5,
|
||||
total_packets: 200,
|
||||
decrypted_packets: 150,
|
||||
undecrypted_packets: 50,
|
||||
total_dms: 25,
|
||||
total_channel_messages: 80,
|
||||
total_outgoing: 30,
|
||||
contacts_heard: { last_hour: 2, last_24_hours: 7, last_week: 10 },
|
||||
repeaters_heard: { last_hour: 1, last_24_hours: 3, last_week: 3 },
|
||||
};
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(mockStats), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
);
|
||||
|
||||
renderModal({
|
||||
externalSidebarNav: true,
|
||||
desktopSection: 'statistics',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Network')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify key labels are present
|
||||
expect(screen.getByText('Contacts')).toBeInTheDocument();
|
||||
expect(screen.getByText('Repeaters')).toBeInTheDocument();
|
||||
expect(screen.getByText('Direct Messages')).toBeInTheDocument();
|
||||
expect(screen.getByText('Channel Messages')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sent (Outgoing)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Total stored')).toBeInTheDocument();
|
||||
expect(screen.getByText('Decrypted')).toBeInTheDocument();
|
||||
expect(screen.getByText('Undecrypted')).toBeInTheDocument();
|
||||
expect(screen.getByText('Contacts heard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Repeaters heard')).toBeInTheDocument();
|
||||
|
||||
// Busiest channels
|
||||
expect(screen.getByText('general')).toBeInTheDocument();
|
||||
expect(screen.getByText('42 msgs')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -211,3 +211,30 @@ export interface UnreadCounts {
|
||||
mentions: Record<string, boolean>;
|
||||
last_message_times: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface BusyChannel {
|
||||
channel_key: string;
|
||||
channel_name: string;
|
||||
message_count: number;
|
||||
}
|
||||
|
||||
export interface ContactActivityCounts {
|
||||
last_hour: number;
|
||||
last_24_hours: number;
|
||||
last_week: number;
|
||||
}
|
||||
|
||||
export interface StatisticsResponse {
|
||||
busiest_channels_24h: BusyChannel[];
|
||||
contact_count: number;
|
||||
repeater_count: number;
|
||||
channel_count: number;
|
||||
total_packets: number;
|
||||
decrypted_packets: number;
|
||||
undecrypted_packets: number;
|
||||
total_dms: number;
|
||||
total_channel_messages: number;
|
||||
total_outgoing: number;
|
||||
contacts_heard: ContactActivityCounts;
|
||||
repeaters_heard: ContactActivityCounts;
|
||||
}
|
||||
|
||||
267
tests/test_statistics.py
Normal file
267
tests/test_statistics.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""Tests for the statistics repository and endpoint."""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from app.database import Database
|
||||
from app.repository import StatisticsRepository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_db():
|
||||
"""Create an in-memory test database with the module-level db swapped in."""
|
||||
import app.repository as repo_module
|
||||
|
||||
db = Database(":memory:")
|
||||
await db.connect()
|
||||
|
||||
original_db = repo_module.db
|
||||
repo_module.db = db
|
||||
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
repo_module.db = original_db
|
||||
await db.disconnect()
|
||||
|
||||
|
||||
class TestStatisticsEmpty:
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_database(self, test_db):
|
||||
"""All counts should be zero on an empty database."""
|
||||
result = await StatisticsRepository.get_all()
|
||||
|
||||
assert result["contact_count"] == 0
|
||||
assert result["repeater_count"] == 0
|
||||
assert result["channel_count"] == 0
|
||||
assert result["total_packets"] == 0
|
||||
assert result["decrypted_packets"] == 0
|
||||
assert result["undecrypted_packets"] == 0
|
||||
assert result["total_dms"] == 0
|
||||
assert result["total_channel_messages"] == 0
|
||||
assert result["total_outgoing"] == 0
|
||||
assert result["busiest_channels_24h"] == []
|
||||
assert result["contacts_heard"]["last_hour"] == 0
|
||||
assert result["contacts_heard"]["last_24_hours"] == 0
|
||||
assert result["contacts_heard"]["last_week"] == 0
|
||||
assert result["repeaters_heard"]["last_hour"] == 0
|
||||
assert result["repeaters_heard"]["last_24_hours"] == 0
|
||||
assert result["repeaters_heard"]["last_week"] == 0
|
||||
|
||||
|
||||
class TestStatisticsCounts:
|
||||
@pytest.mark.asyncio
|
||||
async def test_counts_contacts_and_repeaters(self, test_db):
|
||||
"""Contacts and repeaters are counted separately by type."""
|
||||
now = int(time.time())
|
||||
conn = test_db.conn
|
||||
# type=1 is client, type=2 is repeater
|
||||
await conn.execute(
|
||||
"INSERT INTO contacts (public_key, type, last_seen) VALUES (?, ?, ?)",
|
||||
("aa" * 32, 1, now),
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO contacts (public_key, type, last_seen) VALUES (?, ?, ?)",
|
||||
("bb" * 32, 1, now),
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO contacts (public_key, type, last_seen) VALUES (?, ?, ?)",
|
||||
("cc" * 32, 2, now),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
result = await StatisticsRepository.get_all()
|
||||
|
||||
assert result["contact_count"] == 2
|
||||
assert result["repeater_count"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_count(self, test_db):
|
||||
conn = test_db.conn
|
||||
await conn.execute(
|
||||
"INSERT INTO channels (key, name) VALUES (?, ?)",
|
||||
("AA" * 16, "test-chan"),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
result = await StatisticsRepository.get_all()
|
||||
assert result["channel_count"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_type_counts(self, test_db):
|
||||
"""DM, channel, and outgoing messages are counted correctly."""
|
||||
now = int(time.time())
|
||||
conn = test_db.conn
|
||||
# 2 DMs, 3 channel messages, 1 outgoing
|
||||
await conn.execute(
|
||||
"INSERT INTO messages (type, conversation_key, text, received_at, outgoing) VALUES (?, ?, ?, ?, ?)",
|
||||
("PRIV", "aa" * 32, "dm1", now, 0),
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO messages (type, conversation_key, text, received_at, outgoing) VALUES (?, ?, ?, ?, ?)",
|
||||
("PRIV", "bb" * 32, "dm2", now, 0),
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO messages (type, conversation_key, text, received_at, outgoing) VALUES (?, ?, ?, ?, ?)",
|
||||
("CHAN", "CC" * 16, "ch1", now, 0),
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO messages (type, conversation_key, text, received_at, outgoing) VALUES (?, ?, ?, ?, ?)",
|
||||
("CHAN", "CC" * 16, "ch2", now, 0),
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO messages (type, conversation_key, text, received_at, outgoing) VALUES (?, ?, ?, ?, ?)",
|
||||
("CHAN", "DD" * 16, "ch3", now, 1),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
result = await StatisticsRepository.get_all()
|
||||
|
||||
assert result["total_dms"] == 2
|
||||
assert result["total_channel_messages"] == 3
|
||||
assert result["total_outgoing"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_packet_split(self, test_db):
|
||||
"""Packets are split into decrypted and undecrypted."""
|
||||
now = int(time.time())
|
||||
conn = test_db.conn
|
||||
# Insert a message to link to
|
||||
await conn.execute(
|
||||
"INSERT INTO messages (type, conversation_key, text, received_at) VALUES (?, ?, ?, ?)",
|
||||
("CHAN", "AA" * 16, "msg", now),
|
||||
)
|
||||
msg_id = (await (await conn.execute("SELECT last_insert_rowid() AS id")).fetchone())["id"]
|
||||
|
||||
# 2 decrypted packets (linked to message), 1 undecrypted
|
||||
await conn.execute(
|
||||
"INSERT INTO raw_packets (timestamp, data, message_id, payload_hash) VALUES (?, ?, ?, ?)",
|
||||
(now, b"\x01", msg_id, "hash1"),
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO raw_packets (timestamp, data, message_id, payload_hash) VALUES (?, ?, ?, ?)",
|
||||
(now, b"\x02", msg_id, "hash2"),
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)",
|
||||
(now, b"\x03", "hash3"),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
result = await StatisticsRepository.get_all()
|
||||
|
||||
assert result["total_packets"] == 3
|
||||
assert result["decrypted_packets"] == 2
|
||||
assert result["undecrypted_packets"] == 1
|
||||
|
||||
|
||||
class TestBusiestChannels:
|
||||
@pytest.mark.asyncio
|
||||
async def test_busiest_channels_returns_top_5(self, test_db):
|
||||
"""Only the top 5 channels are returned, ordered by message count."""
|
||||
now = int(time.time())
|
||||
conn = test_db.conn
|
||||
|
||||
# Create 6 channels with varying message counts
|
||||
for i in range(6):
|
||||
key = f"{i:02X}" * 16
|
||||
await conn.execute(
|
||||
"INSERT INTO channels (key, name) VALUES (?, ?)",
|
||||
(key, f"chan-{i}"),
|
||||
)
|
||||
for j in range(i + 1):
|
||||
await conn.execute(
|
||||
"INSERT INTO messages (type, conversation_key, text, received_at) VALUES (?, ?, ?, ?)",
|
||||
("CHAN", key, f"msg-{j}", now),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
result = await StatisticsRepository.get_all()
|
||||
|
||||
assert len(result["busiest_channels_24h"]) == 5
|
||||
# Most messages first
|
||||
counts = [ch["message_count"] for ch in result["busiest_channels_24h"]]
|
||||
assert counts == sorted(counts, reverse=True)
|
||||
assert counts[0] == 6 # channel 5 has 6 messages
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_busiest_channels_excludes_old_messages(self, test_db):
|
||||
"""Messages older than 24h are not counted."""
|
||||
now = int(time.time())
|
||||
old = now - 90000 # older than 24h
|
||||
conn = test_db.conn
|
||||
|
||||
key = "AA" * 16
|
||||
await conn.execute("INSERT INTO channels (key, name) VALUES (?, ?)", (key, "old-chan"))
|
||||
await conn.execute(
|
||||
"INSERT INTO messages (type, conversation_key, text, received_at) VALUES (?, ?, ?, ?)",
|
||||
("CHAN", key, "old-msg", old),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
result = await StatisticsRepository.get_all()
|
||||
assert result["busiest_channels_24h"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_busiest_channels_shows_key_when_no_channel_name(self, test_db):
|
||||
"""When channel has no name in channels table, conversation_key is used."""
|
||||
now = int(time.time())
|
||||
conn = test_db.conn
|
||||
|
||||
key = "FF" * 16
|
||||
# Don't insert into channels table
|
||||
await conn.execute(
|
||||
"INSERT INTO messages (type, conversation_key, text, received_at) VALUES (?, ?, ?, ?)",
|
||||
("CHAN", key, "msg", now),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
result = await StatisticsRepository.get_all()
|
||||
assert len(result["busiest_channels_24h"]) == 1
|
||||
assert result["busiest_channels_24h"][0]["channel_name"] == key
|
||||
|
||||
|
||||
class TestActivityWindows:
|
||||
@pytest.mark.asyncio
|
||||
async def test_activity_windows(self, test_db):
|
||||
"""Contacts are bucketed into time windows based on last_seen."""
|
||||
now = int(time.time())
|
||||
conn = test_db.conn
|
||||
|
||||
# Contact seen 30 min ago (within 1h, 24h, 7d)
|
||||
await conn.execute(
|
||||
"INSERT INTO contacts (public_key, type, last_seen) VALUES (?, ?, ?)",
|
||||
("aa" * 32, 1, now - 1800),
|
||||
)
|
||||
# Contact seen 12h ago (within 24h, 7d but not 1h)
|
||||
await conn.execute(
|
||||
"INSERT INTO contacts (public_key, type, last_seen) VALUES (?, ?, ?)",
|
||||
("bb" * 32, 1, now - 43200),
|
||||
)
|
||||
# Contact seen 3 days ago (within 7d but not 1h or 24h)
|
||||
await conn.execute(
|
||||
"INSERT INTO contacts (public_key, type, last_seen) VALUES (?, ?, ?)",
|
||||
("cc" * 32, 1, now - 259200),
|
||||
)
|
||||
# Contact seen 10 days ago (outside all windows)
|
||||
await conn.execute(
|
||||
"INSERT INTO contacts (public_key, type, last_seen) VALUES (?, ?, ?)",
|
||||
("dd" * 32, 1, now - 864000),
|
||||
)
|
||||
# Repeater seen 30 min ago
|
||||
await conn.execute(
|
||||
"INSERT INTO contacts (public_key, type, last_seen) VALUES (?, ?, ?)",
|
||||
("ee" * 32, 2, now - 1800),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
result = await StatisticsRepository.get_all()
|
||||
|
||||
assert result["contacts_heard"]["last_hour"] == 1
|
||||
assert result["contacts_heard"]["last_24_hours"] == 2
|
||||
assert result["contacts_heard"]["last_week"] == 3
|
||||
|
||||
assert result["repeaters_heard"]["last_hour"] == 1
|
||||
assert result["repeaters_heard"]["last_24_hours"] == 1
|
||||
assert result["repeaters_heard"]["last_week"] == 1
|
||||
Reference in New Issue
Block a user