Skip to content
This repository was archived by the owner on May 12, 2026. It is now read-only.

Commit a511764

Browse files
committed
feat: implement file watcher and reviewer
1 parent defe9c5 commit a511764

11 files changed

Lines changed: 383 additions & 209 deletions

File tree

control_plane/main.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,20 @@
22
import os
33

44
# Import all pages to register their routes
5-
from pages import dashboard, clients, capture, agent, rerun_view, settings
5+
from pages import anomalies, clients, logs, agent, rerun_view, settings
66

77
# Initialize pages
8-
dashboard.create_page()
8+
anomalies.create_page()
99
clients.create_page()
10-
capture.create_page()
10+
logs.create_page()
1111
agent.create_page()
1212
rerun_view.create_page()
1313
settings.create_page()
1414

15+
@ui.page('/')
16+
def index():
17+
ui.navigate.to('/anomalies')
18+
1519
if __name__ in {"__main__", "__mp_main__"}:
1620
# Read port from config.json and start NiceGUI on config['port'] + 1 (default 8081).
1721
full_config = settings.load_config()

control_plane/pages/agent.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import subprocess
44
import os
55
import sys
6+
from pages.anomalies import fetch_files, get_anomaly_path
67

78
def launch_rerun():
89
ui.notify('Launching Rerun native viewer...', type='info')
@@ -29,7 +30,11 @@ def agent_page():
2930
with ui.row().classes('w-full gap-6'):
3031
with ui.column().classes('w-1/3'):
3132
ui.label('Investigate Issue').classes('text-xl font-bold mb-4')
32-
ui.select(['Latency > 500ms (Camera Rig A)', 'Connection Dropped (Mobile Client B)'], label='Target Anomaly').classes('w-full mb-4')
33+
34+
anomaly_files = fetch_files(get_anomaly_path())
35+
options = [f"{f['name']} ({f['mtime_str']})" for f in anomaly_files] if anomaly_files else ['No anomalies found']
36+
37+
ui.select(options, label='Target Anomaly', value=options[0] if options else None).classes('w-full mb-4')
3338
ui.textarea(label='Context / Prompt', value='Analyze the provided QUIC videos and MQTT logs to determine why the latency spiked over 500ms on Rig A.').classes('w-full h-32 mb-4')
3439
ui.button('Execute Investigation', icon='science', on_click=lambda: ui.notify('Agent investigation dispatched! Context sent to agentic frameworks.')).classes('w-full bg-blue-600')
3540

control_plane/pages/anomalies.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
from nicegui import app, ui
2+
import os
3+
import time
4+
from pathlib import Path
5+
from theme import menu
6+
from pages.settings import load_config
7+
8+
def get_anomaly_path():
9+
full_config = load_config()
10+
cp_config = full_config.get('control_plane', {})
11+
anomaly_path = cp_config.get('logPaths', {}).get('anomalyLogs', 'logs/anomalies/')
12+
return os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', anomaly_path))
13+
14+
def fetch_files(base_path):
15+
files_data = []
16+
if not os.path.exists(base_path):
17+
return files_data
18+
19+
for p in Path(base_path).rglob('*'):
20+
if p.is_file():
21+
stat = p.stat()
22+
files_data.append({
23+
'path': str(p),
24+
'rel_path': str(p.relative_to(base_path)),
25+
'name': p.name,
26+
'ext': p.suffix.lower(),
27+
'size': stat.st_size,
28+
'mtime': stat.st_mtime,
29+
'mtime_str': time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(stat.st_mtime))
30+
})
31+
return sorted(files_data, key=lambda x: x['mtime'], reverse=True)
32+
33+
def render_file_content(f):
34+
ext = f['ext']
35+
path = f['path']
36+
rel_path = f['rel_path']
37+
38+
# Text-based
39+
if ext in ['.txt', '.json', '.yaml', '.yml', '.csv', '.md', '.log']:
40+
try:
41+
with open(path, 'r', encoding='utf-8') as file:
42+
content = file.read()
43+
# Truncate if too huge
44+
if len(content) > 50000:
45+
content = content[:50000] + "\n...[truncated]"
46+
ui.textarea(value=content).props('readonly').classes('w-full h-64 font-mono text-sm bg-gray-50 border p-2')
47+
except Exception as e:
48+
ui.label(f"Error reading text file: {e}").classes('text-red-500')
49+
50+
# Media: Video
51+
elif ext in ['.mp4', '.webm', '.ogg']:
52+
# To serve this media, it needs an app.add_media_files route.
53+
# We can dynamically add a route for the anomaly dir if not already added.
54+
ui.video(f'/anomaly_media/{rel_path}').classes('w-full max-w-2xl bg-black rounded')
55+
56+
# Media: Image
57+
elif ext in ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp']:
58+
ui.image(f'/anomaly_media/{rel_path}').classes('w-full max-w-2xl rounded')
59+
60+
# Audio
61+
elif ext in ['.mp3', '.wav']:
62+
ui.audio(f'/anomaly_media/{rel_path}').classes('w-full')
63+
64+
else:
65+
ui.label(f"Binary or unsupported format. File size: {f['size']} bytes.").classes('text-gray-500 italic')
66+
ui.button('Download', on_click=lambda p=path: ui.download(p)).classes('mt-2')
67+
68+
def create_page():
69+
# Ensure media router is set up once
70+
base_path = get_anomaly_path()
71+
os.makedirs(base_path, exist_ok=True)
72+
app.add_media_files('/anomaly_media', base_path)
73+
74+
@ui.page('/anomalies')
75+
def anomalies_page():
76+
# Store state locally to this page connection
77+
page_state = {'last_hash': None}
78+
79+
with menu('System Anomalies'):
80+
ui.label('Detected Anomaly Files').classes('text-xl font-semibold mb-4 text-gray-800')
81+
82+
container = ui.column().classes('w-full gap-2')
83+
84+
def update_ui():
85+
current_files = fetch_files(base_path)
86+
87+
# Simple hash/check to avoid re-rendering if no changes
88+
state_hash = hash(str([(f['path'], f['mtime'], f['size']) for f in current_files]))
89+
if page_state['last_hash'] == state_hash:
90+
return
91+
page_state['last_hash'] = state_hash
92+
93+
container.clear()
94+
with container:
95+
if not current_files:
96+
ui.label(f'No anomalies detected. Watching: {base_path}').classes('text-gray-500 italic p-4')
97+
return
98+
99+
for f in current_files:
100+
with ui.expansion(f"{f['name']} ({f['mtime_str']})", icon='warning').classes('w-full bg-white border rounded shadow-sm'):
101+
ui.label(f"Path: {f['rel_path']} | Size: {f['size']/1024:.1f} KB").classes('text-xs text-gray-400 mb-2')
102+
render_file_content(f)
103+
104+
# Initial render
105+
update_ui()
106+
107+
# Poll every 3 seconds for file changes
108+
ui.timer(3.0, update_ui)

control_plane/pages/capture.py

Lines changed: 0 additions & 56 deletions
This file was deleted.

control_plane/pages/clients.py

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,44 @@
1-
from nicegui import ui
1+
from nicegui import app, ui
22
import aiohttp
3+
import asyncio
4+
import json
35
from theme import menu, get_local_ip
46
from pages.settings import load_config
57

68
def create_page():
9+
config = load_config()
10+
edge_port = config.get('mobile_client', {}).get('port', 8080)
11+
712
@ui.page('/clients')
813
def clients_page():
9-
config = load_config()
10-
edge_port = config.get('port', 8080)
11-
12-
with menu('Mobile Clients & Pairing'):
14+
with menu('Mobile Client Connections'):
1315
ip = get_local_ip()
1416
server_url = f"ws://{ip}:{edge_port}"
1517

1618
with ui.row().classes('w-full gap-8'):
1719
with ui.card().classes('items-center p-6 w-1/3'):
18-
ui.label('Server Pairing').classes('text-xl font-bold mb-4')
20+
ui.label('Server Pairing').classes('text-xl font-bold mb-4 text-gray-800')
1921
ui.label('Scan this QR code with the AllSpark app:').classes('text-sm text-gray-600 text-center mb-4')
2022

2123
# Generate a QR code using a public API for the prototype
2224
ui.image(f'https://api.qrserver.com/v1/create-qr-code/?size=150x150&data={server_url}').classes('w-32 h-32')
2325
ui.label(server_url).classes('mt-4 font-mono font-bold bg-gray-100 p-2 rounded max-w-full break-all text-center')
2426

25-
with ui.column().classes('w-2/3'):
26-
ui.label('Active Connections').classes('text-xl font-bold mb-4')
27+
with ui.column().classes('w-2/3 gap-4'):
28+
ui.label('Active Connections').classes('text-xl font-bold text-gray-800')
2729

28-
cards_container = ui.column().classes('w-full')
30+
cards_container = ui.column().classes('w-full gap-2')
2931

3032
async def fetch_and_render_clients():
3133
try:
3234
async with aiohttp.ClientSession() as session:
3335
async with session.get(f'http://127.0.0.1:{edge_port}/api/status', timeout=2) as resp:
3436
if resp.status == 200:
3537
data = await resp.json()
36-
clients_list = data.get('clients', [])
37-
# Also handle `connectedClients` just in case
38-
if not clients_list and 'connectedClients' in data:
38+
clients_list = data.get('connections', [])
39+
if not clients_list and 'clients' in data:
40+
clients_list = data['clients']
41+
elif not clients_list and 'connectedClients' in data:
3942
clients_list = data['connectedClients']
4043
render_clients(clients_list)
4144
else:
@@ -49,7 +52,7 @@ async def request_upload(client_id):
4952
payload = {"command": "upload"}
5053
async with session.post(f'http://127.0.0.1:{edge_port}/api/command/{client_id}', json=payload) as resp:
5154
if resp.status == 200:
52-
ui.notify(f'Upload command sent to {client_id}!', type='positive')
55+
ui.notify(f'Manual upload command sent to {client_id}!', type='positive')
5356
else:
5457
ui.notify(f'Failed to send command: HTTP {resp.status}', type='negative')
5558
except Exception as e:
@@ -59,7 +62,7 @@ def render_clients(clients_data):
5962
cards_container.clear()
6063
with cards_container:
6164
if clients_data is None:
62-
ui.label(f'Edge server offline or /api/status not reachable on port {edge_port}.').classes('text-red-500 italic p-4')
65+
ui.label(f'Edge server offline or api/status unreachable on port {edge_port}.').classes('text-red-500 italic p-4')
6366
return
6467
if not clients_data:
6568
ui.label('Waiting for mobile rig connections...').classes('text-gray-500 italic p-4')
@@ -68,17 +71,18 @@ def render_clients(clients_data):
6871
for c in clients_data:
6972
client_id = c.get('id', 'Unknown')
7073
c_type = c.get('type', 'Rig')
71-
with ui.card().classes('w-full mb-2'):
74+
with ui.card().classes('w-full border shadow-sm'):
7275
with ui.row().classes('w-full justify-between items-center'):
7376
ui.label(f'{c_type} (ID: {client_id})').classes('font-bold')
7477
ui.badge('Online', color='green')
75-
# Depending on how edge API shapes IP/Stats, display it safely
7678
ui.label(f"Stats: {c.get('details', 'No details available')}").classes('text-sm text-gray-600 mt-1')
79+
80+
if c.get("lastFilename"):
81+
ui.label(f"Latest File: {c['lastFilename']}").classes('text-sm text-green-700 mt-1')
82+
7783
with ui.row().classes('mt-4 items-center gap-2'):
78-
ui.input('Start Time', value='2026-03-25T09:00:00').props('type=datetime-local borderless dense').classes('w-48')
79-
ui.input('End Time', value='2026-03-25T10:00:00').props('type=datetime-local borderless dense').classes('w-48')
80-
ui.button('Request Upload', on_click=lambda ci=client_id: request_upload(ci)).classes('bg-blue-600 text-white')
84+
ui.button('Force Sync Uploads', on_click=lambda ci=client_id: request_upload(ci)).classes('bg-blue-600 text-white')
8185

82-
# Poll the API
83-
ui.timer(5.0, fetch_and_render_clients)
86+
# Poll the API for client list
87+
ui.timer(2.0, fetch_and_render_clients)
8488
ui.timer(0.1, fetch_and_render_clients, once=True)

0 commit comments

Comments
 (0)