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

Commit 1f5df65

Browse files
committed
feat: restore /logs filter/preview
1 parent 7d98836 commit 1f5df65

3 files changed

Lines changed: 221 additions & 1 deletion

File tree

python/control_plane/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22
import os
33

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

77
# Initialize pages
88
clients.create_page()
99
agent.create_page()
1010
rerun_view.create_page()
1111
settings.create_page()
1212
debug.create_page()
13+
logs.create_page()
1314

1415
@ui.page('/')
1516
def index():

python/control_plane/pages/logs.py

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
from nicegui import ui, app
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_log_paths():
9+
full_config = load_config()
10+
cp_config = full_config.get('control_plane', {})
11+
mc_config = full_config.get('mobile_client', {})
12+
13+
upload_path = mc_config.get('uploadPath', 'uploads/')
14+
client_uploads_path = mc_config.get('clientUploadsPath', 'uploads/mobile_clients/')
15+
agent_response_path = mc_config.get('agentResponsePath', 'uploads/agent_responses/')
16+
anomaly_path = cp_config.get('logPaths', {}).get('anomalyLogs', 'logs/anomalies/')
17+
rig_logs_path = cp_config.get('logPaths', {}).get('rigLogs', 'logs/data/datacapture-rig/')
18+
19+
base_dir = os.path.dirname(__file__)
20+
paths = [
21+
os.path.abspath(os.path.join(base_dir, '..', '..', '..', upload_path)),
22+
os.path.abspath(os.path.join(base_dir, '..', '..', '..', client_uploads_path)),
23+
os.path.abspath(os.path.join(base_dir, '..', '..', '..', agent_response_path)),
24+
os.path.abspath(os.path.join(base_dir, '..', '..', '..', anomaly_path)),
25+
os.path.abspath(os.path.join(base_dir, '..', '..', '..', rig_logs_path))
26+
]
27+
return list(set(paths))
28+
29+
def fetch_log_files(paths):
30+
files_data = []
31+
32+
for base_path in paths:
33+
if not os.path.exists(base_path):
34+
continue
35+
36+
for p in Path(base_path).rglob('*'):
37+
if p.is_file():
38+
try:
39+
stat = p.stat()
40+
# Relative to the edge_server root for cleaner UI
41+
# e.g. logs/data/datacapture-rig/foo.mp4
42+
try:
43+
edge_server_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
44+
rel_path = str(p.relative_to(edge_server_root))
45+
except ValueError:
46+
rel_path = str(p)
47+
48+
files_data.append({
49+
'path': rel_path,
50+
'abs_path': str(p), # Used for local references if needed
51+
'ext': p.suffix.lower() if p.suffix else 'unknown',
52+
'size': round(stat.st_size / 1024, 2), # KB
53+
'mtime': stat.st_mtime,
54+
'date': time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(stat.st_mtime))
55+
})
56+
except Exception:
57+
pass
58+
return files_data
59+
60+
def create_page():
61+
@ui.page('/logs')
62+
def logs_page():
63+
paths = get_log_paths()
64+
page_state = {'last_hash': None, 'search_val': ''}
65+
66+
# We must add media folder routes to serve the files
67+
for p in paths:
68+
os.makedirs(p, exist_ok=True)
69+
# Use safe unique names for routes
70+
safe_name = os.path.basename(p.strip('/')) or 'logs'
71+
app.add_media_files(f'/log_media_{safe_name}', p)
72+
73+
with menu('Logs & Data Capture'):
74+
with ui.row().classes('w-full justify-between items-center mb-4'):
75+
ui.label('Aggregate System Records').classes('text-xl font-bold text-gray-800')
76+
search = ui.input('Search Path/Type').classes('w-64').props('dense outlined clearable')
77+
78+
with ui.row().classes('w-full gap-4 items-center mb-4'):
79+
ui.label('Filter Source:')
80+
filter_rig = ui.checkbox('DataCapture', value=True)
81+
filter_mobile = ui.checkbox('Mobile Client', value=True)
82+
filter_anomalies = ui.checkbox('Anomalies', value=True)
83+
filter_agent = ui.checkbox('Agent', value=True)
84+
filter_uploads = ui.checkbox('Uploads', value=True)
85+
86+
ui.label('Type:').classes('ml-4')
87+
filter_video = ui.checkbox('Video', value=True)
88+
filter_image = ui.checkbox('Image', value=True)
89+
filter_text = ui.checkbox('Text', value=True)
90+
filter_hidden = ui.checkbox('Hide Hidden (.*)', value=True).classes('ml-4')
91+
92+
columns = [
93+
{'name': 'action', 'label': 'Review', 'field': 'action', 'align': 'center'},
94+
{'name': 'path', 'label': 'File Path', 'field': 'path', 'sortable': True, 'align': 'left', 'style': 'max-width: 40vw; white-space: normal; word-break: break-all;'},
95+
{'name': 'ext', 'label': 'Type', 'field': 'ext', 'sortable': True, 'align': 'left'},
96+
{'name': 'size', 'label': 'Size (KB)', 'field': 'size', 'sortable': True},
97+
{'name': 'date', 'label': 'Date Modified', 'field': 'date', 'sortable': True, 'align': 'left'}
98+
]
99+
100+
# Create table with pagination
101+
table = ui.table(columns=columns, rows=[], row_key='path', pagination=10).classes('w-full')
102+
103+
# Add action button slot
104+
table.add_slot('body-cell-action', '''
105+
<q-td :props="props">
106+
<q-btn icon="visibility" @click="$parent.$emit('review', props.row)" flat dense color="primary" />
107+
</q-td>
108+
''')
109+
110+
def open_review_dialog(row):
111+
ext = row.get('ext', '')
112+
abs_path = row.get('abs_path', '')
113+
114+
with ui.dialog() as dialog, ui.card().classes('w-full max-w-5xl h-[85vh] flex-col overflow-hidden'):
115+
with ui.row().classes('w-full justify-between items-center mb-2 shrink-0'):
116+
ui.label(os.path.basename(row['path'])).classes('font-bold text-lg')
117+
ui.button(icon='close', on_click=dialog.close).props('flat round dense')
118+
119+
if ext in ['.mp4', '.webm']:
120+
# Calculate routed media URL
121+
for p in paths:
122+
if abs_path.startswith(p):
123+
rel_to_media = os.path.relpath(abs_path, p)
124+
safe_name = os.path.basename(p.strip('/')) or 'logs'
125+
ui.video(f'/log_media_{safe_name}/{rel_to_media}').classes('w-full bg-black').style('max-height: 75vh; object-fit: contain;')
126+
break
127+
elif ext in ['.jpg', '.png', '.jpeg']:
128+
for p in paths:
129+
if abs_path.startswith(p):
130+
rel_to_media = os.path.relpath(abs_path, p)
131+
safe_name = os.path.basename(p.strip('/')) or 'logs'
132+
ui.image(f'/log_media_{safe_name}/{rel_to_media}').classes('w-full bg-black').style('max-height: 75vh; object-fit: contain;')
133+
break
134+
elif ext in ['.json', '.txt', '.yaml', '.csv', '.log']:
135+
try:
136+
with open(abs_path, 'r') as f:
137+
content = f.read()
138+
if len(content) > 250000:
139+
content = content[:250000] + "\n...[truncated]"
140+
with ui.scroll_area().classes('w-full flex-1 border rounded p-2'):
141+
ui.label(content).classes('font-mono text-xs whitespace-pre-wrap')
142+
except Exception as e:
143+
ui.label(f'Error reading file: {e}')
144+
else:
145+
ui.label('Preview not available for this file type. Please download.').classes('italic text-gray-500')
146+
147+
dialog.open()
148+
149+
table.on('review', lambda e: open_review_dialog(e.args))
150+
151+
def update_ui(force=False):
152+
current_files = fetch_log_files(paths)
153+
154+
# Simple hash/check to avoid updating DOM if no changes
155+
state_hash = hash(str([(f['path'], f['mtime'], f['size']) for f in current_files]) + search.value)
156+
if not force and page_state['last_hash'] == state_hash:
157+
return
158+
page_state['last_hash'] = state_hash
159+
160+
# Sort reversed by modified time dynamically on updates
161+
sorted_files = sorted(current_files, key=lambda x: x['mtime'], reverse=True)
162+
163+
# Keep original filter logic intact
164+
165+
valid_exts = set()
166+
if filter_video.value: valid_exts.update(['.mp4', '.mkv', '.avi', '.webm', '.ts', '.mov', '.quic'])
167+
if filter_image.value: valid_exts.update(['.jpg', '.jpeg', '.png', '.gif', '.svg'])
168+
if filter_text.value: valid_exts.update(['.txt', '.json', '.yaml', '.csv', '.log', '.md'])
169+
170+
filtered_files = []
171+
for f in sorted_files:
172+
# Hidden filter
173+
if filter_hidden.value and any(part.startswith('.') for part in Path(f['path']).parts):
174+
continue
175+
176+
# Source filter
177+
path_lower = f['path'].lower()
178+
if not filter_rig.value and 'datacapture' in path_lower: continue
179+
if not filter_mobile.value and 'mobile_clients' in path_lower: continue
180+
if not filter_anomalies.value and 'anomalies' in path_lower: continue
181+
if not filter_agent.value and 'agent_responses' in path_lower: continue
182+
if not filter_uploads.value and 'uploads' in path_lower and 'mobile_clients' not in path_lower and 'agent_responses' not in path_lower: continue
183+
184+
# Ext filter
185+
if valid_exts and f['ext'] not in valid_exts and f['ext'] != 'unknown':
186+
# Allow unknown to sneak through? Let's just strict filter if ANY checkbox is checked
187+
if filter_video.value or filter_image.value or filter_text.value:
188+
continue
189+
190+
# Text filter
191+
filter_text_search = search.value.lower() if search.value else ''
192+
if filter_text_search:
193+
if filter_text_search not in f['path'].lower() and filter_text_search not in f['ext'].lower():
194+
continue
195+
196+
filtered_files.append(f)
197+
198+
table.rows = filtered_files
199+
200+
# Bind events
201+
search.on_value_change(lambda _: update_ui(force=True))
202+
filter_video.on_value_change(lambda _: update_ui(force=True))
203+
filter_image.on_value_change(lambda _: update_ui(force=True))
204+
filter_text.on_value_change(lambda _: update_ui(force=True))
205+
filter_hidden.on_value_change(lambda _: update_ui(force=True))
206+
filter_rig.on_value_change(lambda _: update_ui(force=True))
207+
filter_mobile.on_value_change(lambda _: update_ui(force=True))
208+
filter_anomalies.on_value_change(lambda _: update_ui(force=True))
209+
filter_agent.on_value_change(lambda _: update_ui(force=True))
210+
filter_uploads.on_value_change(lambda _: update_ui(force=True))
211+
212+
# Ensure directories exist
213+
for p in paths:
214+
os.makedirs(p, exist_ok=True)
215+
216+
# Render initially and poll
217+
update_ui()
218+
ui.timer(3.0, update_ui)

python/control_plane/theme.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ async def fetch_status():
112112
nav_items = [
113113
('Agent', '/agent', 'Agent'),
114114
('Clients', '/clients', 'Client'),
115+
('Logs', '/logs', 'Log'),
115116
('Rerun', '/rerun', 'Rerun'),
116117
('Settings', '/settings', 'Setting'),
117118
('Debug', '/debug', 'Debug')

0 commit comments

Comments
 (0)