Spaces:
Runtime error
Runtime error
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/leaflet@1.9.4/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/leaflet@1.9.4/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 |
}
|