Spaces:
Sleeping
Sleeping
feat(telemetry): add admin UI, server/client auth telemetry; grant admin to Ishita
Browse files- app.py +106 -0
- auth.py +2 -2
- static/index.html +2 -2
- static/js/modules/auth-manager.js +16 -0
- static/js/tree-track-app.js +75 -1
- static/map.html +2 -2
- static/sw.js +1 -1
- static/telemetry.html +179 -0
- supabase_database.py +13 -0
- version.json +1 -1
app.py
CHANGED
@@ -99,6 +99,21 @@ def require_auth(request: Request) -> Dict[str, Any]:
|
|
99 |
"""Dependency that requires authentication"""
|
100 |
user = get_current_user(request)
|
101 |
if not user:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
102 |
raise HTTPException(
|
103 |
status_code=status.HTTP_401_UNAUTHORIZED,
|
104 |
detail="Authentication required",
|
@@ -308,6 +323,22 @@ async def login(login_data: LoginRequest, response: Response):
|
|
308 |
"""Authenticate user and create session"""
|
309 |
result = auth_manager.authenticate(login_data.username, login_data.password)
|
310 |
if not result:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
311 |
raise HTTPException(
|
312 |
status_code=status.HTTP_401_UNAUTHORIZED,
|
313 |
detail="Invalid username or password"
|
@@ -323,6 +354,18 @@ async def login(login_data: LoginRequest, response: Response):
|
|
323 |
samesite="lax" # CSRF protection
|
324 |
)
|
325 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
326 |
return result
|
327 |
|
328 |
@app.get("/api/auth/validate", tags=["Authentication"])
|
@@ -345,6 +388,18 @@ async def logout(request: Request, response: Response):
|
|
345 |
token = request.cookies.get('auth_token')
|
346 |
|
347 |
if token:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
348 |
auth_manager.logout(token)
|
349 |
|
350 |
# Clear the authentication cookie (must match creation parameters)
|
@@ -795,6 +850,28 @@ async def get_tree_codes_api():
|
|
795 |
|
796 |
|
797 |
# Telemetry logging
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
798 |
class TelemetryEvent(BaseModel):
|
799 |
event_type: str = Field(..., description="Type of event, e.g., 'upload', 'ui', 'error'")
|
800 |
status: Optional[str] = Field(None, description="Status such as success/error")
|
@@ -845,6 +922,35 @@ async def telemetry(event: TelemetryEvent, request: Request, user: Dict[str, Any
|
|
845 |
raise HTTPException(status_code=500, detail="Failed to record telemetry")
|
846 |
|
847 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
848 |
# Version info
|
849 |
@app.get("/api/version", tags=["System"])
|
850 |
async def get_version():
|
|
|
99 |
"""Dependency that requires authentication"""
|
100 |
user = get_current_user(request)
|
101 |
if not user:
|
102 |
+
# Server-side auth telemetry for unauthorized access
|
103 |
+
try:
|
104 |
+
_record_server_telemetry(
|
105 |
+
request=request,
|
106 |
+
event_type='auth',
|
107 |
+
status='unauthorized',
|
108 |
+
metadata={
|
109 |
+
'path': str(request.url),
|
110 |
+
'method': request.method,
|
111 |
+
'has_auth_header': bool(request.headers.get('Authorization'))
|
112 |
+
},
|
113 |
+
user=None
|
114 |
+
)
|
115 |
+
except Exception:
|
116 |
+
pass
|
117 |
raise HTTPException(
|
118 |
status_code=status.HTTP_401_UNAUTHORIZED,
|
119 |
detail="Authentication required",
|
|
|
323 |
"""Authenticate user and create session"""
|
324 |
result = auth_manager.authenticate(login_data.username, login_data.password)
|
325 |
if not result:
|
326 |
+
# Telemetry: login failure
|
327 |
+
try:
|
328 |
+
# Construct a minimal request-like object for _record_server_telemetry if needed
|
329 |
+
from fastapi import Request as _Req
|
330 |
+
except Exception:
|
331 |
+
pass
|
332 |
+
# We have the real request inside FastAPI dependency; emulate via middleware not needed here
|
333 |
+
# Instead, log via logger for this path
|
334 |
+
# Note: We cannot access Request here directly, so we skip client context
|
335 |
+
# Use file-based fallback
|
336 |
+
_write_telemetry({
|
337 |
+
'event_type': 'auth',
|
338 |
+
'status': 'login_failed',
|
339 |
+
'metadata': {'username': login_data.username},
|
340 |
+
'timestamp': datetime.now().isoformat()
|
341 |
+
})
|
342 |
raise HTTPException(
|
343 |
status_code=status.HTTP_401_UNAUTHORIZED,
|
344 |
detail="Invalid username or password"
|
|
|
354 |
samesite="lax" # CSRF protection
|
355 |
)
|
356 |
|
357 |
+
# Telemetry: login success (server-side)
|
358 |
+
try:
|
359 |
+
# We cannot access Request here directly; emit without client metadata
|
360 |
+
_write_telemetry({
|
361 |
+
'event_type': 'auth',
|
362 |
+
'status': 'login_success',
|
363 |
+
'metadata': {'username': login_data.username},
|
364 |
+
'timestamp': datetime.now().isoformat(),
|
365 |
+
'user': {'username': result['user']['username'], 'role': result['user']['role']}
|
366 |
+
})
|
367 |
+
except Exception:
|
368 |
+
pass
|
369 |
return result
|
370 |
|
371 |
@app.get("/api/auth/validate", tags=["Authentication"])
|
|
|
388 |
token = request.cookies.get('auth_token')
|
389 |
|
390 |
if token:
|
391 |
+
# Telemetry: logout server-side
|
392 |
+
try:
|
393 |
+
session = auth_manager.validate_session(token)
|
394 |
+
_record_server_telemetry(
|
395 |
+
request=request,
|
396 |
+
event_type='auth',
|
397 |
+
status='logout',
|
398 |
+
metadata={'path': str(request.url)},
|
399 |
+
user=session or None
|
400 |
+
)
|
401 |
+
except Exception:
|
402 |
+
pass
|
403 |
auth_manager.logout(token)
|
404 |
|
405 |
# Clear the authentication cookie (must match creation parameters)
|
|
|
850 |
|
851 |
|
852 |
# Telemetry logging
|
853 |
+
# Internal helper to record server-side telemetry without requiring client call
|
854 |
+
|
855 |
+
def _record_server_telemetry(request: Request, event_type: str, status: str = None, metadata: Dict[str, Any] = None, user: Dict[str, Any] = None):
|
856 |
+
evt = {
|
857 |
+
'event_type': event_type,
|
858 |
+
'status': status,
|
859 |
+
'metadata': metadata or {},
|
860 |
+
'timestamp': datetime.now().isoformat(),
|
861 |
+
'user': None,
|
862 |
+
'client': {
|
863 |
+
'ip': request.client.host if request.client else None,
|
864 |
+
'user_agent': request.headers.get('user-agent')
|
865 |
+
}
|
866 |
+
}
|
867 |
+
if user:
|
868 |
+
evt['user'] = { 'username': user.get('username'), 'role': user.get('role') }
|
869 |
+
if getattr(db, 'connected', False):
|
870 |
+
if not db.log_telemetry(evt):
|
871 |
+
_write_telemetry(evt)
|
872 |
+
else:
|
873 |
+
_write_telemetry(evt)
|
874 |
+
|
875 |
class TelemetryEvent(BaseModel):
|
876 |
event_type: str = Field(..., description="Type of event, e.g., 'upload', 'ui', 'error'")
|
877 |
status: Optional[str] = Field(None, description="Status such as success/error")
|
|
|
922 |
raise HTTPException(status_code=500, detail="Failed to record telemetry")
|
923 |
|
924 |
|
925 |
+
# Telemetry query (admin-only)
|
926 |
+
@app.get("/api/telemetry", tags=["System"])
|
927 |
+
async def get_telemetry(limit: int = 100, user: Dict[str, Any] = Depends(require_permission("admin"))):
|
928 |
+
"""Return recent telemetry events. Uses Supabase if connected, else reads telemetry.log."""
|
929 |
+
limit = max(1, min(1000, limit))
|
930 |
+
try:
|
931 |
+
if getattr(db, 'connected', False):
|
932 |
+
events = db.get_recent_telemetry(limit)
|
933 |
+
return {"events": events, "source": "supabase", "count": len(events)}
|
934 |
+
# Fallback to file
|
935 |
+
events: List[Dict[str, Any]] = []
|
936 |
+
try:
|
937 |
+
with open("telemetry.log", "r", encoding="utf-8") as f:
|
938 |
+
lines = f.readlines()
|
939 |
+
for line in lines[-limit:]:
|
940 |
+
line = line.strip()
|
941 |
+
if not line:
|
942 |
+
continue
|
943 |
+
try:
|
944 |
+
events.append(json.loads(line))
|
945 |
+
except Exception:
|
946 |
+
continue
|
947 |
+
except FileNotFoundError:
|
948 |
+
events = []
|
949 |
+
return {"events": events, "source": "file", "count": len(events)}
|
950 |
+
except Exception as e:
|
951 |
+
logger.error(f"Get telemetry failed: {e}")
|
952 |
+
raise HTTPException(status_code=500, detail="Failed to fetch telemetry")
|
953 |
+
|
954 |
# Version info
|
955 |
@app.get("/api/version", tags=["System"])
|
956 |
async def get_version():
|
auth.py
CHANGED
@@ -55,9 +55,9 @@ class AuthManager:
|
|
55 |
# User accounts
|
56 |
"ishita": {
|
57 |
"password_hash": self._hash_password(ishita_password),
|
58 |
-
"role": "
|
59 |
"full_name": "Ishita",
|
60 |
-
"permissions": ["read", "write", "
|
61 |
},
|
62 |
|
63 |
"jeeb": {
|
|
|
55 |
# User accounts
|
56 |
"ishita": {
|
57 |
"password_hash": self._hash_password(ishita_password),
|
58 |
+
"role": "admin",
|
59 |
"full_name": "Ishita",
|
60 |
+
"permissions": ["read", "write", "delete", "admin"]
|
61 |
},
|
62 |
|
63 |
"jeeb": {
|
static/index.html
CHANGED
@@ -917,7 +917,7 @@
|
|
917 |
// Force refresh if we detect cached version
|
918 |
(function() {
|
919 |
const currentVersion = '5.1.1';
|
920 |
-
const timestamp = '
|
921 |
const lastVersion = sessionStorage.getItem('treetrack_version');
|
922 |
const lastTimestamp = sessionStorage.getItem('treetrack_timestamp');
|
923 |
|
@@ -1162,7 +1162,7 @@
|
|
1162 |
</div>
|
1163 |
</div>
|
1164 |
|
1165 |
-
<script type="module" src="/static/js/tree-track-app.js?v=5.1.1&t=
|
1166 |
|
1167 |
<script>
|
1168 |
// Initialize Granim background animation on page load
|
|
|
917 |
// Force refresh if we detect cached version
|
918 |
(function() {
|
919 |
const currentVersion = '5.1.1';
|
920 |
+
const timestamp = '1755113716'; // Cache-busting bump
|
921 |
const lastVersion = sessionStorage.getItem('treetrack_version');
|
922 |
const lastTimestamp = sessionStorage.getItem('treetrack_timestamp');
|
923 |
|
|
|
1162 |
</div>
|
1163 |
</div>
|
1164 |
|
1165 |
+
<script type="module" src="/static/js/tree-track-app.js?v=5.1.1&t=1755113716"></script>
|
1166 |
|
1167 |
<script>
|
1168 |
// Initialize Granim background animation on page load
|
static/js/modules/auth-manager.js
CHANGED
@@ -38,6 +38,22 @@ export class AuthManager {
|
|
38 |
|
39 |
async logout() {
|
40 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
41 |
await fetch('/api/auth/logout', {
|
42 |
method: 'POST',
|
43 |
headers: {
|
|
|
38 |
|
39 |
async logout() {
|
40 |
try {
|
41 |
+
// Telemetry: logout initiated (best-effort)
|
42 |
+
try {
|
43 |
+
await fetch('/api/telemetry', {
|
44 |
+
method: 'POST',
|
45 |
+
headers: {
|
46 |
+
'Content-Type': 'application/json',
|
47 |
+
'Authorization': `Bearer ${this.authToken}`
|
48 |
+
},
|
49 |
+
body: JSON.stringify({
|
50 |
+
event_type: 'auth',
|
51 |
+
status: 'logout_initiated',
|
52 |
+
metadata: { source: 'client' }
|
53 |
+
})
|
54 |
+
});
|
55 |
+
} catch (_) { /* ignore */ }
|
56 |
+
|
57 |
await fetch('/api/auth/logout', {
|
58 |
method: 'POST',
|
59 |
headers: {
|
static/js/tree-track-app.js
CHANGED
@@ -38,12 +38,25 @@ export class TreeTrackApp {
|
|
38 |
// Setup event listeners
|
39 |
this.setupEventListeners();
|
40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
41 |
// Load initial data
|
42 |
await this.loadInitialData();
|
43 |
|
44 |
} catch (error) {
|
45 |
console.error('Error initializing TreeTrack app:', error);
|
46 |
this.uiManager.showMessage('Error initializing application: ' + error.message, 'error');
|
|
|
|
|
|
|
|
|
|
|
|
|
47 |
}
|
48 |
}
|
49 |
|
@@ -155,16 +168,41 @@ export class TreeTrackApp {
|
|
155 |
// Save or update tree
|
156 |
let result;
|
157 |
if (this.formManager.isInEditMode()) {
|
158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
159 |
this.uiManager.showMessage(`Tree #${result.id} updated successfully!`, 'success');
|
|
|
|
|
|
|
|
|
|
|
|
|
160 |
// Exit edit mode silently after successful update
|
161 |
this.handleCancelEdit(false);
|
162 |
} else {
|
|
|
|
|
|
|
|
|
|
|
|
|
163 |
result = await this.apiClient.saveTree(treeData);
|
164 |
this.uiManager.showMessage(
|
165 |
`Tree successfully added! Tree ID: ${result.id}. The form has been cleared for your next entry.`,
|
166 |
'success'
|
167 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
168 |
this.formManager.resetForm(true);
|
169 |
}
|
170 |
|
@@ -176,6 +214,12 @@ export class TreeTrackApp {
|
|
176 |
console.error('Error submitting form:', error);
|
177 |
this.uiManager.showMessage('Error saving tree: ' + error.message, 'error');
|
178 |
this.uiManager.focusFirstError();
|
|
|
|
|
|
|
|
|
|
|
|
|
179 |
}
|
180 |
}
|
181 |
|
@@ -209,12 +253,30 @@ export class TreeTrackApp {
|
|
209 |
}
|
210 |
|
211 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
212 |
await this.apiClient.deleteTree(treeId);
|
213 |
this.uiManager.showMessage(`Tree #${treeId} deleted successfully.`, 'success');
|
|
|
|
|
|
|
|
|
|
|
|
|
214 |
await this.loadTrees();
|
215 |
} catch (error) {
|
216 |
console.error('Error deleting tree:', error);
|
217 |
this.uiManager.showMessage('Error deleting tree: ' + error.message, 'error');
|
|
|
|
|
|
|
|
|
|
|
|
|
218 |
}
|
219 |
}
|
220 |
|
@@ -232,9 +294,21 @@ export class TreeTrackApp {
|
|
232 |
this.uiManager.showLoadingState('treeList', 'Loading trees...');
|
233 |
const trees = await this.apiClient.loadTrees();
|
234 |
this.uiManager.renderTreeList(trees);
|
|
|
|
|
|
|
|
|
|
|
|
|
235 |
} catch (error) {
|
236 |
console.error('Error loading trees:', error);
|
237 |
this.uiManager.showErrorState('treeList', 'Error loading trees');
|
|
|
|
|
|
|
|
|
|
|
|
|
238 |
}
|
239 |
}
|
240 |
|
|
|
38 |
// Setup event listeners
|
39 |
this.setupEventListeners();
|
40 |
|
41 |
+
// Log UI load telemetry
|
42 |
+
this.apiClient.sendTelemetry({
|
43 |
+
event_type: 'ui',
|
44 |
+
status: 'view',
|
45 |
+
metadata: { page: 'index', action: 'load' }
|
46 |
+
}).catch(() => {});
|
47 |
+
|
48 |
// Load initial data
|
49 |
await this.loadInitialData();
|
50 |
|
51 |
} catch (error) {
|
52 |
console.error('Error initializing TreeTrack app:', error);
|
53 |
this.uiManager.showMessage('Error initializing application: ' + error.message, 'error');
|
54 |
+
// Telemetry for init error
|
55 |
+
this.apiClient?.sendTelemetry({
|
56 |
+
event_type: 'ui',
|
57 |
+
status: 'error',
|
58 |
+
metadata: { page: 'index', action: 'init', error: error?.message }
|
59 |
+
}).catch(() => {});
|
60 |
}
|
61 |
}
|
62 |
|
|
|
168 |
// Save or update tree
|
169 |
let result;
|
170 |
if (this.formManager.isInEditMode()) {
|
171 |
+
const id = this.formManager.getCurrentEditId();
|
172 |
+
// Telemetry: update start
|
173 |
+
this.apiClient.sendTelemetry({
|
174 |
+
event_type: 'tree_update',
|
175 |
+
status: 'start',
|
176 |
+
metadata: { tree_id: id }
|
177 |
+
}).catch(() => {});
|
178 |
+
result = await this.apiClient.updateTree(id, treeData);
|
179 |
this.uiManager.showMessage(`Tree #${result.id} updated successfully!`, 'success');
|
180 |
+
// Telemetry: update success
|
181 |
+
this.apiClient.sendTelemetry({
|
182 |
+
event_type: 'tree_update',
|
183 |
+
status: 'success',
|
184 |
+
metadata: { tree_id: result.id }
|
185 |
+
}).catch(() => {});
|
186 |
// Exit edit mode silently after successful update
|
187 |
this.handleCancelEdit(false);
|
188 |
} else {
|
189 |
+
// Telemetry: create start
|
190 |
+
this.apiClient.sendTelemetry({
|
191 |
+
event_type: 'tree_create',
|
192 |
+
status: 'start',
|
193 |
+
metadata: { has_location_name: !!treeData.location_name }
|
194 |
+
}).catch(() => {});
|
195 |
result = await this.apiClient.saveTree(treeData);
|
196 |
this.uiManager.showMessage(
|
197 |
`Tree successfully added! Tree ID: ${result.id}. The form has been cleared for your next entry.`,
|
198 |
'success'
|
199 |
);
|
200 |
+
// Telemetry: create success
|
201 |
+
this.apiClient.sendTelemetry({
|
202 |
+
event_type: 'tree_create',
|
203 |
+
status: 'success',
|
204 |
+
metadata: { tree_id: result.id }
|
205 |
+
}).catch(() => {});
|
206 |
this.formManager.resetForm(true);
|
207 |
}
|
208 |
|
|
|
214 |
console.error('Error submitting form:', error);
|
215 |
this.uiManager.showMessage('Error saving tree: ' + error.message, 'error');
|
216 |
this.uiManager.focusFirstError();
|
217 |
+
// Telemetry: save/update error
|
218 |
+
this.apiClient.sendTelemetry({
|
219 |
+
event_type: this.formManager.isInEditMode() ? 'tree_update' : 'tree_create',
|
220 |
+
status: 'error',
|
221 |
+
metadata: { error: error?.message }
|
222 |
+
}).catch(() => {});
|
223 |
}
|
224 |
}
|
225 |
|
|
|
253 |
}
|
254 |
|
255 |
try {
|
256 |
+
// Telemetry: delete start
|
257 |
+
this.apiClient.sendTelemetry({
|
258 |
+
event_type: 'tree_delete',
|
259 |
+
status: 'start',
|
260 |
+
metadata: { tree_id: treeId }
|
261 |
+
}).catch(() => {});
|
262 |
await this.apiClient.deleteTree(treeId);
|
263 |
this.uiManager.showMessage(`Tree #${treeId} deleted successfully.`, 'success');
|
264 |
+
// Telemetry: delete success
|
265 |
+
this.apiClient.sendTelemetry({
|
266 |
+
event_type: 'tree_delete',
|
267 |
+
status: 'success',
|
268 |
+
metadata: { tree_id: treeId }
|
269 |
+
}).catch(() => {});
|
270 |
await this.loadTrees();
|
271 |
} catch (error) {
|
272 |
console.error('Error deleting tree:', error);
|
273 |
this.uiManager.showMessage('Error deleting tree: ' + error.message, 'error');
|
274 |
+
// Telemetry: delete error
|
275 |
+
this.apiClient.sendTelemetry({
|
276 |
+
event_type: 'tree_delete',
|
277 |
+
status: 'error',
|
278 |
+
metadata: { tree_id: treeId, error: error?.message }
|
279 |
+
}).catch(() => {});
|
280 |
}
|
281 |
}
|
282 |
|
|
|
294 |
this.uiManager.showLoadingState('treeList', 'Loading trees...');
|
295 |
const trees = await this.apiClient.loadTrees();
|
296 |
this.uiManager.renderTreeList(trees);
|
297 |
+
// Telemetry: list loaded
|
298 |
+
this.apiClient.sendTelemetry({
|
299 |
+
event_type: 'tree_list',
|
300 |
+
status: 'success',
|
301 |
+
metadata: { count: Array.isArray(trees) ? trees.length : null }
|
302 |
+
}).catch(() => {});
|
303 |
} catch (error) {
|
304 |
console.error('Error loading trees:', error);
|
305 |
this.uiManager.showErrorState('treeList', 'Error loading trees');
|
306 |
+
// Telemetry: list error
|
307 |
+
this.apiClient.sendTelemetry({
|
308 |
+
event_type: 'tree_list',
|
309 |
+
status: 'error',
|
310 |
+
metadata: { error: error?.message }
|
311 |
+
}).catch(() => {});
|
312 |
}
|
313 |
}
|
314 |
|
static/map.html
CHANGED
@@ -813,7 +813,7 @@
|
|
813 |
// Force refresh if we detect cached version
|
814 |
(function() {
|
815 |
const currentVersion = '5.1.1';
|
816 |
-
const timestamp = '
|
817 |
const lastVersion = sessionStorage.getItem('treetrack_version');
|
818 |
const lastTimestamp = sessionStorage.getItem('treetrack_timestamp');
|
819 |
|
@@ -939,7 +939,7 @@ const timestamp = '1755112765'; // Current timestamp for cache busting
|
|
939 |
|
940 |
<!-- Leaflet JS -->
|
941 |
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
|
942 |
-
<script src="/static/map.js?v=5.1.1&t=
|
943 |
|
944 |
"default-state": {
|
945 |
gradients: [
|
|
|
813 |
// Force refresh if we detect cached version
|
814 |
(function() {
|
815 |
const currentVersion = '5.1.1';
|
816 |
+
const timestamp = '1755113716'; // Current timestamp for cache busting
|
817 |
const lastVersion = sessionStorage.getItem('treetrack_version');
|
818 |
const lastTimestamp = sessionStorage.getItem('treetrack_timestamp');
|
819 |
|
|
|
939 |
|
940 |
<!-- Leaflet JS -->
|
941 |
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
|
942 |
+
<script src="/static/map.js?v=5.1.1&t=1755113716">
|
943 |
|
944 |
"default-state": {
|
945 |
gradients: [
|
static/sw.js
CHANGED
@@ -1,5 +1,5 @@
|
|
1 |
// TreeTrack Service Worker - PWA and Offline Support
|
2 |
-
const VERSION =
|
3 |
const CACHE_NAME = `treetrack-v${VERSION}`;
|
4 |
const STATIC_CACHE = `static-v${VERSION}`;
|
5 |
const API_CACHE = `api-v${VERSION}`;
|
|
|
1 |
// TreeTrack Service Worker - PWA and Offline Support
|
2 |
+
const VERSION = 1755113716; // Cache busting bump - force clients to fetch new static assets and header image change
|
3 |
const CACHE_NAME = `treetrack-v${VERSION}`;
|
4 |
const STATIC_CACHE = `static-v${VERSION}`;
|
5 |
const API_CACHE = `api-v${VERSION}`;
|
static/telemetry.html
ADDED
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html lang="en">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8" />
|
5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
6 |
+
<title>Telemetry Viewer - Admin</title>
|
7 |
+
<link rel="stylesheet" href="/static/css/design-system.css" />
|
8 |
+
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
9 |
+
<meta http-equiv="Pragma" content="no-cache">
|
10 |
+
<meta http-equiv="Expires" content="0">
|
11 |
+
<style>
|
12 |
+
body { font-family: Inter, system-ui, -apple-system, Segoe UI, sans-serif; background: var(--gray-50); color: var(--gray-800); }
|
13 |
+
.container { max-width: 1200px; margin: 0 auto; padding: 1rem; }
|
14 |
+
.header { display: flex; justify-content: space-between; align-items: center; margin: 1rem 0; }
|
15 |
+
.title { font-size: 1.25rem; font-weight: 700; }
|
16 |
+
.controls { display: flex; gap: .5rem; align-items: center; }
|
17 |
+
.filter-input { padding: .5rem .75rem; border: 1px solid var(--gray-300); border-radius: .5rem; font-size: .9rem; }
|
18 |
+
.btn { padding: .5rem .75rem; border: 1px solid var(--gray-300); background: white; border-radius: .5rem; cursor: pointer; }
|
19 |
+
.btn-primary { background: var(--primary-600); color: white; border-color: var(--primary-600); }
|
20 |
+
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
21 |
+
.meta { color: var(--gray-500); font-size: .85rem; }
|
22 |
+
.table { width: 100%; border-collapse: collapse; background: white; border: 1px solid var(--gray-200); border-radius: .75rem; overflow: hidden; }
|
23 |
+
.table th, .table td { padding: .5rem .75rem; border-bottom: 1px solid var(--gray-100); text-align: left; vertical-align: top; font-size: .9rem; }
|
24 |
+
.table th { background: var(--gray-50); font-weight: 600; }
|
25 |
+
.pill { display: inline-block; padding: .15rem .4rem; border-radius: .5rem; font-size: .75rem; border: 1px solid var(--gray-300); }
|
26 |
+
.pill-success { background: var(--green-50); color: var(--green-700); border-color: var(--green-300); }
|
27 |
+
.pill-error { background: var(--red-50); color: var(--red-700); border-color: var(--red-300); }
|
28 |
+
.pill-start { background: var(--orange-50); color: var(--orange-700); border-color: var(--orange-300); }
|
29 |
+
.code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .8rem; background: var(--gray-50); padding: .35rem .5rem; border-radius: .35rem; border: 1px solid var(--gray-200); display: inline-block; max-width: 520px; overflow: auto; }
|
30 |
+
.row { transition: background .2s ease; }
|
31 |
+
.row:hover { background: var(--gray-50); }
|
32 |
+
.footer { display: flex; justify-content: space-between; align-items: center; margin-top: .75rem; }
|
33 |
+
</style>
|
34 |
+
</head>
|
35 |
+
<body>
|
36 |
+
<div class="container">
|
37 |
+
<div class="header">
|
38 |
+
<div>
|
39 |
+
<div class="title">Telemetry Viewer (Admin)</div>
|
40 |
+
<div class="meta">Inspect recent telemetry events to monitor app health and user actions.</div>
|
41 |
+
</div>
|
42 |
+
<div class="controls">
|
43 |
+
<input id="limit" class="filter-input" type="number" min="1" max="1000" value="200" title="Max events" />
|
44 |
+
<input id="search" class="filter-input" placeholder="Search text (type, status, user, etc.)" />
|
45 |
+
<button id="refresh" class="btn btn-primary">Refresh</button>
|
46 |
+
<a class="btn" href="/">Back</a>
|
47 |
+
</div>
|
48 |
+
</div>
|
49 |
+
|
50 |
+
<div id="meta" class="meta" style="margin-bottom: .5rem;"> </div>
|
51 |
+
|
52 |
+
<table class="table">
|
53 |
+
<thead>
|
54 |
+
<tr>
|
55 |
+
<th style="width: 10rem;">Timestamp</th>
|
56 |
+
<th style="width: 8rem;">Type</th>
|
57 |
+
<th style="width: 8rem;">Status</th>
|
58 |
+
<th style="width: 10rem;">User</th>
|
59 |
+
<th>Metadata</th>
|
60 |
+
</tr>
|
61 |
+
</thead>
|
62 |
+
<tbody id="tbody"></tbody>
|
63 |
+
</table>
|
64 |
+
|
65 |
+
<div class="footer">
|
66 |
+
<div id="source" class="meta"></div>
|
67 |
+
<div class="meta">Only accessible to admin users</div>
|
68 |
+
</div>
|
69 |
+
</div>
|
70 |
+
|
71 |
+
<script type="module">
|
72 |
+
async function fetchTelemetry(limit) {
|
73 |
+
const params = new URLSearchParams({ limit: String(limit) });
|
74 |
+
const res = await fetch(`/api/telemetry?${params.toString()}`, { headers: authHeaders() });
|
75 |
+
if (res.status === 401 || res.status === 403) {
|
76 |
+
alert('Unauthorized. You must be an admin to view telemetry.');
|
77 |
+
window.location.href = '/login';
|
78 |
+
return null;
|
79 |
+
}
|
80 |
+
if (!res.ok) {
|
81 |
+
throw new Error('Failed to fetch telemetry');
|
82 |
+
}
|
83 |
+
return res.json();
|
84 |
+
}
|
85 |
+
|
86 |
+
function authHeaders() {
|
87 |
+
const token = localStorage.getItem('auth_token');
|
88 |
+
return token ? { 'Authorization': `Bearer ${token}` } : {};
|
89 |
+
}
|
90 |
+
|
91 |
+
function pill(status) {
|
92 |
+
if (!status) return '';
|
93 |
+
const cls = status === 'success' ? 'pill-success' : status === 'error' ? 'pill-error' : status === 'start' ? 'pill-start' : '';
|
94 |
+
return `<span class="pill ${cls}">${status}</span>`;
|
95 |
+
}
|
96 |
+
|
97 |
+
function escapeHtml(s) { return s.replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c])); }
|
98 |
+
|
99 |
+
function renderRows(events) {
|
100 |
+
const tbody = document.getElementById('tbody');
|
101 |
+
const q = (document.getElementById('search').value || '').toLowerCase().trim();
|
102 |
+
tbody.innerHTML = '';
|
103 |
+
for (const evt of events) {
|
104 |
+
const ts = evt.timestamp || '';
|
105 |
+
const type = evt.event_type || '';
|
106 |
+
const status = evt.status || '';
|
107 |
+
const user = (evt.user && evt.user.username) ? `${evt.user.username} (${evt.user.role || ''})` : '';
|
108 |
+
const meta = evt.metadata ? escapeHtml(JSON.stringify(evt.metadata)) : '';
|
109 |
+
const line = `${ts} ${type} ${status} ${user} ${meta}`.toLowerCase();
|
110 |
+
if (q && !line.includes(q)) continue;
|
111 |
+
const tr = document.createElement('tr');
|
112 |
+
tr.className = 'row';
|
113 |
+
tr.innerHTML = `
|
114 |
+
<td>${escapeHtml(ts)}</td>
|
115 |
+
<td><span class="pill">${escapeHtml(type)}</span></td>
|
116 |
+
<td>${pill(status)}</td>
|
117 |
+
<td>${escapeHtml(user)}</td>
|
118 |
+
<td><span class="code">${meta}</span></td>
|
119 |
+
`;
|
120 |
+
tbody.appendChild(tr);
|
121 |
+
}
|
122 |
+
}
|
123 |
+
|
124 |
+
async function refresh() {
|
125 |
+
const limit = Math.max(1, Math.min(1000, parseInt(document.getElementById('limit').value || '200', 10)));
|
126 |
+
document.getElementById('refresh').disabled = true;
|
127 |
+
try {
|
128 |
+
const data = await fetchTelemetry(limit);
|
129 |
+
if (!data) return;
|
130 |
+
document.getElementById('meta').textContent = `Loaded ${data.count} events`;
|
131 |
+
document.getElementById('source').textContent = `Source: ${data.source}`;
|
132 |
+
renderRows(data.events || []);
|
133 |
+
} catch (e) {
|
134 |
+
alert(e.message || 'Failed to load telemetry');
|
135 |
+
} finally {
|
136 |
+
document.getElementById('refresh').disabled = false;
|
137 |
+
}
|
138 |
+
}
|
139 |
+
|
140 |
+
document.getElementById('refresh').addEventListener('click', refresh);
|
141 |
+
document.getElementById('search').addEventListener('input', () => {
|
142 |
+
// quick re-filter without fetching again
|
143 |
+
const meta = document.getElementById('meta').textContent || '';
|
144 |
+
// no-op; filtering reads from table content
|
145 |
+
const rows = Array.from(document.querySelectorAll('#tbody tr'));
|
146 |
+
const q = (document.getElementById('search').value || '').toLowerCase().trim();
|
147 |
+
rows.forEach(row => {
|
148 |
+
const text = row.textContent.toLowerCase();
|
149 |
+
row.style.display = q && !text.includes(q) ? 'none' : '';
|
150 |
+
});
|
151 |
+
});
|
152 |
+
|
153 |
+
// On load: validate auth and fetch
|
154 |
+
(async () => {
|
155 |
+
try {
|
156 |
+
const token = localStorage.getItem('auth_token');
|
157 |
+
if (!token) {
|
158 |
+
window.location.href = '/login';
|
159 |
+
return;
|
160 |
+
}
|
161 |
+
// Validate user and ensure admin
|
162 |
+
const res = await fetch('/api/auth/validate', { headers: authHeaders() });
|
163 |
+
if (!res.ok) { window.location.href = '/login'; return; }
|
164 |
+
const info = await res.json();
|
165 |
+
const perms = (info.user && info.user.permissions) || [];
|
166 |
+
if (!perms.includes('admin')) {
|
167 |
+
alert('Admin access required');
|
168 |
+
window.location.href = '/';
|
169 |
+
return;
|
170 |
+
}
|
171 |
+
await refresh();
|
172 |
+
} catch {
|
173 |
+
window.location.href = '/login';
|
174 |
+
}
|
175 |
+
})();
|
176 |
+
</script>
|
177 |
+
</body>
|
178 |
+
</html>
|
179 |
+
|
supabase_database.py
CHANGED
@@ -295,3 +295,16 @@ class SupabaseDatabase:
|
|
295 |
except Exception as e:
|
296 |
logger.error(f"Failed to log telemetry: {e}")
|
297 |
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
295 |
except Exception as e:
|
296 |
logger.error(f"Failed to log telemetry: {e}")
|
297 |
return False
|
298 |
+
|
299 |
+
def get_recent_telemetry(self, limit: int = 100) -> List[Dict[str, Any]]:
|
300 |
+
"""Fetch recent telemetry events from Supabase."""
|
301 |
+
try:
|
302 |
+
result = self.client.table('telemetry_events') \
|
303 |
+
.select('*') \
|
304 |
+
.order('timestamp', desc=True) \
|
305 |
+
.limit(limit) \
|
306 |
+
.execute()
|
307 |
+
return result.data or []
|
308 |
+
except Exception as e:
|
309 |
+
logger.error(f"Failed to fetch telemetry: {e}")
|
310 |
+
return []
|
version.json
CHANGED
@@ -1,4 +1,4 @@
|
|
1 |
{
|
2 |
"version": "5.1.1",
|
3 |
-
"timestamp":
|
4 |
}
|
|
|
1 |
{
|
2 |
"version": "5.1.1",
|
3 |
+
"timestamp": 1755113716
|
4 |
}
|