Spaces:
Sleeping
Sleeping
| <html lang="en" data-theme="light"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>TrackIQ Logs</title> | |
| <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"> | |
| <style> | |
| :root{--bg:#f0f4f8;--surface:#fff;--surface2:#f6f8fb;--border:rgba(0,0,0,0.07);--shadow:0 2px 16px rgba(0,0,0,0.09);--shadow-lg:0 8px 36px rgba(0,0,0,0.13);--text:#0f1923;--text2:#5a6a7a;--text3:#9aaab8;--accent:#880000;--accent2:#660000;--accent-light:rgba(136,0,0,0.07);--sidebar-w:60px;--radius:14px;--radius-sm:9px;} | |
| [data-theme="dark"]{--bg:#0c1117;--surface:#151c25;--surface2:#1c2531;--border:rgba(255,255,255,0.07);--shadow:0 2px 16px rgba(0,0,0,0.45);--shadow-lg:0 8px 36px rgba(0,0,0,0.55);--text:#e8edf2;--text2:#7a8fa0;--text3:#3a4a5a;} | |
| *{box-sizing:border-box;margin:0;padding:0}::-webkit-scrollbar{width:0;height:0} | |
| body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--text);font-size:13px;display:flex;min-height:100vh} | |
| .sidebar{width:var(--sidebar-w);background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;align-items:center;padding:16px 0;gap:4px;position:fixed;top:0;left:0;bottom:0;z-index:200} | |
| .sb-logo{width:34px;height:34px;background:var(--accent);border-radius:8px;display:flex;align-items:center;justify-content:center;color:#fff;font-size:14px;margin-bottom:10px;text-decoration:none} | |
| .sb-sep{width:28px;height:1px;background:var(--border);margin:4px 0} | |
| .sb-btn{width:38px;height:38px;border-radius:9px;display:flex;align-items:center;justify-content:center;color:var(--text3);font-size:15px;cursor:pointer;transition:all .2s;text-decoration:none;border:none;background:none;position:relative} | |
| .sb-btn:hover,.sb-btn.active{background:var(--accent-light);color:var(--accent)} | |
| .sb-btn .tip{position:absolute;left:46px;top:50%;transform:translateY(-50%);background:var(--text);color:var(--surface);font-size:11px;padding:3px 8px;border-radius:5px;white-space:nowrap;opacity:0;pointer-events:none}.sb-btn:hover .tip{opacity:1} | |
| .sb-spacer{flex:1}.page-wrap{margin-left:var(--sidebar-w);flex:1;display:flex;flex-direction:column} | |
| .topbar{height:52px;background:var(--surface);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;padding:0 22px} | |
| .topbar-l,.topbar-r{display:flex;align-items:center;gap:10px} | |
| .brand-name{font-size:15px;font-weight:600}.brand-name b{color:var(--accent)}.sep{color:var(--border);font-size:18px;margin:0 2px} | |
| .lang-group{display:flex;background:var(--surface2);border-radius:6px;padding:2px;border:1px solid var(--border)} | |
| .lang-btn{font-size:11px;font-weight:500;padding:3px 8px;border:none;background:none;cursor:pointer;border-radius:4px;color:var(--text2);font-family:'DM Sans',sans-serif}.lang-btn.active{background:var(--accent);color:white} | |
| .icon-btn{width:30px;height:30px;border-radius:6px;border:1px solid var(--border);background:var(--surface);cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--text2);font-size:12px} | |
| main{flex:1;padding:16px 18px;max-width:1440px;width:100%;margin:0 auto;display:flex;flex-direction:column;gap:12px} | |
| .sum-row{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px} | |
| .sc,.card,.tcard{background:var(--surface);border-radius:var(--radius);border:1px solid var(--border);box-shadow:var(--shadow)} | |
| .sc{padding:13px 15px}.sc-l{font-size:11px;color:var(--text2);margin-bottom:5px}.sc-v{font-size:22px;font-weight:300}.sc-s{font-size:10px;color:var(--text3);margin-top:3px} | |
| .card{padding:14px 16px}.filter-row{display:flex;align-items:flex-end;gap:9px;flex-wrap:wrap}.fg{display:flex;flex-direction:column;gap:4px}.fg label{font-size:11px;color:var(--text2)} | |
| .fin{padding:8px 10px;border:1px solid var(--border);border-radius:7px;font-size:12px;background:var(--surface2);color:var(--text);font-family:'DM Sans',sans-serif;outline:none;min-width:160px}.fin:focus{border-color:var(--accent)} | |
| .f-sp{flex:1} | |
| .btn{display:flex;align-items:center;gap:5px;border:none;border-radius:7px;padding:8px 13px;font-size:12px;cursor:pointer;font-family:'DM Sans',sans-serif} | |
| .btn-exp{background:var(--accent);color:white}.btn-exp:hover{background:var(--accent2)} | |
| .save-opt{display:flex;align-items:center;gap:10px;width:100%;padding:9px 14px;border:none;background:none;cursor:pointer;font-size:12px;color:var(--text);font-family:'DM Sans',sans-serif;text-align:left}.save-opt:hover{background:var(--surface2)} | |
| .btn-clr{background:var(--surface2);color:var(--text2);border:1px solid var(--border)} | |
| .tcard{overflow:hidden}.twrap{overflow:auto} | |
| table{width:100%;border-collapse:collapse} | |
| thead th{padding:10px 12px;text-align:left;font-size:11px;font-weight:500;color:var(--text2);background:var(--surface2);border-bottom:1px solid var(--border);white-space:nowrap} | |
| tbody tr{border-bottom:1px solid var(--border)} tbody tr:last-child{border-bottom:none} | |
| tbody td{padding:10px 12px;font-size:12px;vertical-align:middle} | |
| .mono{font-family:'DM Mono',monospace} | |
| .pill{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);background:var(--surface2);border-radius:999px;padding:3px 8px} | |
| .dot{width:8px;height:8px;border-radius:50%} | |
| .empty{padding:38px;text-align:center;color:var(--text3)} | |
| @media(max-width:1100px){.sum-row{grid-template-columns:repeat(2,minmax(0,1fr))}} | |
| @media(max-width:760px){.sum-row{grid-template-columns:1fr}} | |
| </style> | |
| </head> | |
| <body> | |
| <nav class="sidebar"> | |
| <a href="/" class="sb-logo"><i class="fas fa-satellite-dish"></i></a> | |
| <div class="sb-sep"></div> | |
| <a href="/home" class="sb-btn"><i class="fas fa-play-circle"></i><span class="tip">Analyze</span></a> | |
| <a href="/dashboard" class="sb-btn"><i class="fas fa-chart-line"></i><span class="tip">Dashboard</span></a> | |
| <a href="/logs" class="sb-btn active"><i class="fas fa-list-ul"></i><span class="tip">Logs</span></a> | |
| <div class="sb-spacer"></div> | |
| <button class="sb-btn" onclick="toggleTheme()"><i class="fas fa-moon" id="themeIcon"></i><span class="tip">Theme</span></button> | |
| </nav> | |
| <div class="page-wrap"> | |
| <header class="topbar"> | |
| <div class="topbar-l"> | |
| <span class="brand-name">Track<b>IQ</b></span> | |
| <span class="sep">|</span> | |
| <span style="font-size:13px;font-weight:500;" id="t-title">Detection Logs</span> | |
| </div> | |
| <div class="topbar-r"> | |
| <div class="lang-group"> | |
| <button class="lang-btn active" onclick="setLang('en',this)">EN</button> | |
| <button class="lang-btn" onclick="setLang('fr',this)">FR</button> | |
| <button class="lang-btn" onclick="setLang('wo',this)">WO</button> | |
| </div> | |
| <button class="icon-btn" onclick="toggleTheme()"><i class="fas fa-moon" id="themeIcon2"></i></button> | |
| </div> | |
| </header> | |
| <main> | |
| <div class="sum-row"> | |
| <div class="sc"><div class="sc-l" id="t-s1">Detections</div><div class="sc-v" id="sumDetections">0</div><div class="sc-s" id="sumDetectionsSub">Real rows from generated videos</div></div> | |
| <div class="sc"><div class="sc-l" id="t-s2">Scenes</div><div class="sc-v" id="sumScenes">0</div><div class="sc-s" id="sumScenesSub">Processed analyses</div></div> | |
| <div class="sc"><div class="sc-l" id="t-s3">Classes</div><div class="sc-v" id="sumClasses">0</div><div class="sc-s" id="sumClassesSub">Distinct detected labels</div></div> | |
| <div class="sc"><div class="sc-l" id="t-s4">Latest video</div><div class="sc-v" id="sumLatest">-</div><div class="sc-s" id="sumLatestSub">Most recent analyzed clip</div></div> | |
| </div> | |
| <div class="card"> | |
| <div class="filter-row"> | |
| <div class="fg"><label id="t-search">Search video or scene</label><input id="searchIn" class="fin" oninput="applyFilters()"></div> | |
| <div class="fg"><label id="t-scene">Scene</label><select id="sceneF" class="fin" onchange="applyFilters()"><option value="">All</option></select></div> | |
| <div class="fg"><label id="t-type">Class</label><select id="classF" class="fin" onchange="applyFilters()"><option value="">All</option></select></div> | |
| <div class="fg"><label id="t-direction">Direction</label><select id="dirF" class="fin" onchange="applyFilters()"><option value="">All</option><option>up</option><option>down</option><option>left</option><option>right</option></select></div> | |
| <div class="f-sp"></div> | |
| <button class="btn btn-clr" onclick="clearFilters()"><i class="fas fa-rotate-left"></i> <span id="t-clear">Clear</span></button> | |
| <button class="btn btn-clr" onclick="clearAllLogs()" style="color:#ef4444;border-color:rgba(239,68,68,0.4);" title="Delete all logs"><i class="fas fa-trash"></i></button> | |
| <div style="position:relative"> | |
| <button class="btn btn-exp" onclick="toggleSaveMenu(event)"><i class="fas fa-cloud-arrow-up"></i> <span id="t-export">Save Logs</span> <i class="fas fa-chevron-down" style="font-size:9px;margin-left:2px"></i></button> | |
| <div id="saveMenu" style="display:none;position:absolute;right:0;top:calc(100% + 6px);background:var(--surface);border:1px solid var(--border);border-radius:10px;box-shadow:var(--shadow-lg);min-width:240px;z-index:999;overflow:hidden"> | |
| <div style="padding:10px 14px 6px;font-size:11px;color:var(--text2);font-weight:500;border-bottom:1px solid var(--border)"> | |
| <div style="display:flex;align-items:center;gap:6px;margin-bottom:6px"> | |
| <label style="font-size:11px;color:var(--text2)">Group name:</label> | |
| <input id="groupNameIn" value="trackiq" style="flex:1;padding:4px 7px;border:1px solid var(--border);border-radius:5px;font-size:11px;background:var(--surface2);color:var(--text);outline:none" placeholder="e.g. session1"> | |
| </div> | |
| <span style="color:var(--text3);font-size:10px">File: <span id="csvPreview">trackiq_all.csv</span></span> | |
| </div> | |
| <button class="save-opt" onmousedown="event.stopPropagation();saveLocal()"><i class="fas fa-download" style="color:#3b82f6"></i> Download locally</button> | |
| <button class="save-opt" onmousedown="event.stopPropagation();showGithubPanel()"><i class="fab fa-github" style="color:#e2e8f0"></i> Push to GitHub</button> | |
| <button class="save-opt" onmousedown="event.stopPropagation();saveDrive()"><i class="fab fa-google-drive" style="color:#34a853"></i> Save to Google Drive</button> | |
| </div> | |
| </div> | |
| <!-- GitHub panel --> | |
| <div id="githubPanel" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:1000;display:none;align-items:center;justify-content:center"> | |
| <div style="background:var(--surface);border-radius:14px;padding:22px 24px;width:420px;box-shadow:var(--shadow-lg)"> | |
| <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px"> | |
| <span style="font-weight:600;font-size:14px"><i class="fab fa-github"></i> Push to GitHub</span> | |
| <button onclick="closeGithubPanel()" style="background:none;border:none;color:var(--text2);cursor:pointer;font-size:16px"><i class="fas fa-times"></i></button> | |
| </div> | |
| <div style="display:flex;flex-direction:column;gap:9px"> | |
| <div class="fg"><label>Personal Access Token (PAT)</label><input id="ghToken" class="fin" type="password" placeholder="ghp_xxxxxxxxxxxx"></div> | |
| <div class="fg"><label>Repository (owner/repo)</label><input id="ghRepo" class="fin" placeholder="username/trackiq-logs"></div> | |
| <div class="fg"><label>Branch</label><input id="ghBranch" class="fin" value="main" placeholder="main"></div> | |
| <div class="fg"><label>Path in repo</label><input id="ghPath" class="fin" placeholder="logs/" value="logs/"></div> | |
| </div> | |
| <div id="ghStatus" style="margin-top:10px;font-size:12px;color:var(--text2);min-height:18px"></div> | |
| <div style="display:flex;gap:8px;margin-top:14px;justify-content:flex-end"> | |
| <button class="btn btn-clr" onclick="closeGithubPanel()">Cancel</button> | |
| <button class="btn btn-exp" onclick="pushToGithub()"><i class="fab fa-github"></i> Push CSV</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="tcard"> | |
| <div class="twrap"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th id="th-video">Video</th> | |
| <th id="th-scene">Scene</th> | |
| <th id="th-time">Time</th> | |
| <th id="th-frame">Frame</th> | |
| <th id="th-track">Track</th> | |
| <th id="th-class">Class</th> | |
| <th id="th-confidence">Confidence</th> | |
| <th id="th-direction">Direction</th> | |
| <th id="th-bbox">Bounding Box</th> | |
| <th id="th-position">Center</th> | |
| </tr> | |
| </thead> | |
| <tbody id="tbody"> | |
| <tr><td colspan="9" class="empty">Loading real logs...</td></tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </main> | |
| </div> | |
| <script> | |
| let allRows = []; | |
| let filteredRows = []; | |
| function toggleTheme(){ | |
| const d=document.documentElement; | |
| const dark=d.getAttribute('data-theme')==='dark'; | |
| d.setAttribute('data-theme',dark?'light':'dark'); | |
| ['themeIcon','themeIcon2'].forEach(id=>{const el=document.getElementById(id);if(el)el.className=dark?'fas fa-moon':'fas fa-sun';}); | |
| try{localStorage.setItem('tiq_theme',dark?'light':'dark');}catch(e){} | |
| } | |
| const TL = { | |
| en:{title:'Detection Logs',s1:'Detections',s2:'Scenes',s3:'Classes',s4:'Latest video',search:'Search video or scene',scene:'Scene',type:'Class',direction:'Direction',clear:'Clear',export:'Save Logs',video:'Video',time:'Time',frame:'Frame',track:'Track',class:'Class',confidence:'Confidence',bbox:'Bounding Box',position:'Center'}, | |
| fr:{title:'Logs de détection',s1:'Détections',s2:'Scènes',s3:'Classes',s4:'Dernière vidéo',search:'Recherche vidéo ou scène',scene:'Scène',type:'Classe',direction:'Direction',clear:'Effacer',export:'Sauvegarder',video:'Vidéo',time:'Temps',frame:'Frame',track:'Track',class:'Classe',confidence:'Confiance',bbox:'Boîte englobante',position:'Centre'}, | |
| wo:{title:'Logs yi',s1:'Réy',s2:'Scène yi',s3:'Classe yi',s4:'Video mujj',search:'Seet video walla scène',scene:'Scène',type:'Classe',direction:'Direction',clear:'Baň',export:'Sauvegarder',video:'Video',time:'Waxtu',frame:'Frame',track:'Track',class:'Classe',confidence:'Confiance',bbox:'Bounding Box',position:'Centre'} | |
| }; | |
| function setLang(l, btn){ | |
| document.querySelectorAll('.lang-btn').forEach(b=>b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| const t = TL[l]; | |
| const ids = {title:'t-title',s1:'t-s1',s2:'t-s2',s3:'t-s3',s4:'t-s4',search:'t-search',scene:'t-scene',type:'t-type',direction:'t-direction',clear:'t-clear',export:'t-export',video:'th-video',time:'th-time',frame:'th-frame',track:'th-track',class:'th-class',confidence:'th-confidence',bbox:'th-bbox',position:'th-position'}; | |
| Object.entries(ids).forEach(([k,id])=>{const el=document.getElementById(id);if(el)el.textContent=t[k];}); | |
| document.getElementById('th-scene').textContent = t.scene; | |
| document.getElementById('th-direction').textContent = t.direction; | |
| try{localStorage.setItem('tiq_lang',l);}catch(e){} | |
| } | |
| async function loadLogs(){ | |
| try{ | |
| // /api/logs/rows peut ne pas exister sur certaines versions — fallback sur /api/logs | |
| let allRowsData = []; | |
| const rowsRes = await fetch('/api/logs/rows'); | |
| if(rowsRes.ok){ | |
| allRowsData = await rowsRes.json(); | |
| } | |
| // Si pas de rows détaillées, construire depuis /api/logs (résumé par scène) | |
| const scenesRes = await fetch('/api/logs'); | |
| const scenes = await scenesRes.json(); | |
| if(!allRowsData.length && scenes.length){ | |
| // Fallback : une ligne par classe par scène | |
| scenes.forEach(s=>{ | |
| Object.entries(s.unique_counts||{}).forEach(([cls,cnt])=>{ | |
| allRowsData.push({ | |
| scene_id: s.scene_id, scene_name: s.scene_name||s.scene_id, | |
| video_name: s.video_name||'', class_name: cls, | |
| confidence: 0, frame_id: 0, timestamp_s: 0, | |
| track_id: 0, x1:0, y1:0, x2:0, y2:0, cx:0, cy:0, | |
| frame_width:0, frame_height:0, | |
| direction:'', speed_px_s:0, crossed_line:false | |
| }); | |
| }); | |
| }); | |
| } | |
| allRows = allRowsData; | |
| fillFilters(scenes); | |
| fillSummary(scenes, allRows); | |
| applyFilters(); | |
| }catch(err){ | |
| const tb=document.getElementById('tbody'); | |
| tb.innerHTML = '<tr><td colspan="10" class="empty">Unable to load logs.</td></tr>'; | |
| console.warn('[TrackIQ] logs:', err); | |
| } | |
| } | |
| function fillFilters(scenes){ | |
| const sceneF = document.getElementById('sceneF'); | |
| const classF = document.getElementById('classF'); | |
| sceneF.innerHTML = '<option value="">All</option>' + scenes.map(s=>`<option value="${s.scene_id}">${s.video_name || s.scene_id}</option>`).join(''); | |
| const classes = [...new Set(allRows.map(r=>r.class_name).filter(Boolean))].sort(); | |
| classF.innerHTML = '<option value="">All</option>' + classes.map(c=>`<option value="${c}">${c}</option>`).join(''); | |
| } | |
| function fillSummary(scenes, rows){ | |
| document.getElementById('sumDetections').textContent = rows.length.toLocaleString(); | |
| document.getElementById('sumScenes').textContent = scenes.length.toLocaleString(); | |
| document.getElementById('sumClasses').textContent = new Set(rows.map(r=>r.class_name).filter(Boolean)).size.toLocaleString(); | |
| document.getElementById('sumLatest').textContent = scenes[scenes.length-1]?.video_name || '-'; | |
| } | |
| function applyFilters(){ | |
| const search = document.getElementById('searchIn').value.toLowerCase(); | |
| const scene = document.getElementById('sceneF').value; | |
| const cls = document.getElementById('classF').value; | |
| const dir = document.getElementById('dirF').value; | |
| filteredRows = allRows.filter(r => | |
| (!search || (r.video_name || '').toLowerCase().includes(search) || (r.scene_id || '').toLowerCase().includes(search)) && | |
| (!scene || r.scene_id === scene) && | |
| (!cls || r.class_name === cls) && | |
| (!dir || r.direction === dir) | |
| ); | |
| renderRows(); | |
| } | |
| async function clearAllLogs(){ | |
| if(!confirm('Delete all logs? This cannot be undone.')) return; | |
| try{ | |
| // Essaie d'abord /api/logs/clear, sinon DELETE par scène | |
| const r = await fetch('/api/logs/clear',{method:'DELETE'}); | |
| if(!r.ok) throw new Error('clear failed'); | |
| location.reload(); | |
| }catch(e){ | |
| try{ | |
| const scenes = await fetch('/api/logs').then(r=>r.json()); | |
| await Promise.all(scenes.map(s=>fetch(`/api/logs/${s.scene_id}`,{method:'DELETE'}))); | |
| location.reload(); | |
| }catch(e2){ alert('Error: '+e2.message); } | |
| } | |
| } | |
| function clearFilters(){ | |
| ['searchIn','sceneF','classF','dirF'].forEach(id=>{document.getElementById(id).value='';}); | |
| applyFilters(); | |
| } | |
| function renderRows(){ | |
| const tb = document.getElementById('tbody'); | |
| if(!filteredRows.length){ | |
| tb.innerHTML = '<tr><td colspan="10" class="empty">No real detections found for this filter.</td></tr>'; | |
| return; | |
| } | |
| const colors = {Vehicle:'#3b82f6',Motorcycle:'#10b981',Truck:'#f59e0b',Bus:'#8b5cf6',Human:'#ec4899',Bicycle:'#06b6d4'}; | |
| tb.innerHTML = ''; | |
| filteredRows.forEach(r=>{ | |
| const tr = document.createElement('tr'); | |
| tr.innerHTML = ` | |
| <td title="${r.video_name || ''}">${(r.video_name || '-').slice(0,28)}</td> | |
| <td class="mono">${r.scene_id}</td> | |
| <td class="mono">${Number(r.timestamp_s || 0).toFixed(2)}s</td> | |
| <td class="mono">${r.frame_id}</td> | |
| <td class="mono">#${r.track_id}</td> | |
| <td><span class="pill"><span class="dot" style="background:${colors[r.class_name] || '#888'}"></span>${r.class_name || '-'}</span></td> | |
| <td class="mono">${Math.round((r.confidence || 0) * 100)}%</td> | |
| <td>${r.direction || ''}</td> | |
| <td class="mono" style="font-size:11px">(${r.x1},${r.y1})→(${r.x2},${r.y2})</td> | |
| <td class="mono">(${r.cx}, ${r.cy})</td> | |
| `; | |
| tb.appendChild(tr); | |
| }); | |
| } | |
| /* ── Helpers CSV ─────────────────────────────────────────────── */ | |
| function getGroupName(){ return (document.getElementById('groupNameIn')?.value||'trackiq').trim().replace(/\s+/g,'_')||'trackiq'; } | |
| function getCsvFilename(){ | |
| const g=getGroupName(); | |
| const scenes=[...new Set(filteredRows.map(r=>r.scene_id))]; | |
| return `${g}_${scenes.length===1?scenes[0]:'all'}.csv`; | |
| } | |
| function buildCsvContent(){ | |
| // Same schema as SCHEMA_EXAMPLE.csv. | |
| const hdr = 'frame,timestamp_sec,scene_name,group_id,video_name,track_id,class_name,confidence,bbox_x1,bbox_y1,bbox_x2,bbox_y2,cx,cy,frame_width,frame_height,crossed_line,direction,speed_px_s\n'; | |
| const g = getGroupName(); | |
| const rows = filteredRows.map(r => [ | |
| r.frame??r.frame_id??'', r.timestamp_sec??r.timestamp_s??'', | |
| r.scene_name||r.scene_id||'', | |
| r.group_id||g, | |
| `"${(r.video_name||'').replace(/"/g,'""')}"`, | |
| r.track_id??'', r.class_name||'', r.confidence??'', | |
| r.bbox_x1??r.x1??'', r.bbox_y1??r.y1??'', r.bbox_x2??r.x2??'', r.bbox_y2??r.y2??'', | |
| r.cx??'', r.cy??'', | |
| r.frame_width??'', r.frame_height??'', | |
| r.crossed_line??false, | |
| r.direction||'', | |
| r.speed_px_s??0 | |
| ].join(',')).join('\n'); | |
| return hdr + rows; | |
| } | |
| function updateCsvPreview(){ | |
| const el = document.getElementById('csvPreview'); | |
| if(el) el.textContent = getCsvFilename(); | |
| } | |
| /* ── Save menu ───────────────────────────────────────────────── */ | |
| function toggleSaveMenu(e){ | |
| e.stopPropagation(); | |
| const m = document.getElementById('saveMenu'); | |
| m.style.display = m.style.display==='none'?'block':'none'; | |
| updateCsvPreview(); | |
| } | |
| document.addEventListener('click', (e)=>{ | |
| const m=document.getElementById('saveMenu'); | |
| if(m && !e.target.closest('#saveMenu') && !e.target.closest('.btn-exp')) | |
| m.style.display='none'; | |
| }); | |
| /* ── 1. Download local ───────────────────────────────────────── */ | |
| function saveLocal(){ | |
| const a = document.createElement('a'); | |
| a.href = URL.createObjectURL(new Blob([buildCsvContent()], {type:'text/csv'})); | |
| a.download = getCsvFilename(); | |
| a.click(); | |
| document.getElementById('saveMenu').style.display='none'; | |
| } | |
| /* ── 2. GitHub push ──────────────────────────────────────────── */ | |
| function showGithubPanel(){ | |
| document.getElementById('saveMenu').style.display='none'; | |
| const p = document.getElementById('githubPanel'); | |
| p.style.display='flex'; | |
| updateCsvPreview(); | |
| } | |
| function closeGithubPanel(){ document.getElementById('githubPanel').style.display='none'; } | |
| async function pushToGithub(){ | |
| const token = document.getElementById('ghToken').value.trim(); | |
| const repo = document.getElementById('ghRepo').value.trim(); | |
| const branch = document.getElementById('ghBranch').value.trim() || 'main'; | |
| const path = (document.getElementById('ghPath').value.trim().replace(/\/+$/,'')||'logs') + '/' + getCsvFilename(); | |
| const status = document.getElementById('ghStatus'); | |
| if(!token||!repo){ status.textContent='⚠ Token and repo are required.'; return; } | |
| status.textContent='Pushing…'; | |
| const content = btoa(unescape(encodeURIComponent(buildCsvContent()))); | |
| // Check if file exists (to get SHA for update) | |
| let sha = null; | |
| try{ | |
| const check = await fetch(`https://api.github.com/repos/${repo}/contents/${path}`,{headers:{Authorization:`token ${token}`}}); | |
| if(check.ok){ const d=await check.json(); sha=d.sha; } | |
| }catch(e){} | |
| try{ | |
| const body = {message:`TrackIQ logs export — ${getCsvFilename()}`, content, branch}; | |
| if(sha) body.sha = sha; | |
| const res = await fetch(`https://api.github.com/repos/${repo}/contents/${path}`,{ | |
| method:'PUT', headers:{Authorization:`token ${token}`,'Content-Type':'application/json'}, | |
| body: JSON.stringify(body) | |
| }); | |
| if(res.ok){ status.innerHTML='<span style="color:#10b981">✓ Pushed successfully!</span>'; } | |
| else{ const e=await res.json(); status.innerHTML=`<span style="color:#ef4444">Error: ${e.message}</span>`; } | |
| }catch(err){ status.innerHTML=`<span style="color:#ef4444">Network error: ${err.message}</span>`; } | |
| } | |
| /* ── 3. Google Drive ─────────────────────────────────────────── */ | |
| async function saveDrive(){ | |
| document.getElementById('saveMenu').style.display='none'; | |
| // Uses Google Drive File Picker API via OAuth implicit flow | |
| const CLIENT_ID = ''; // User must fill their own GCP OAuth client ID | |
| if(!CLIENT_ID){ | |
| const ok = confirm('To save to Google Drive you need a Google OAuth Client ID.\n\nWould you like instructions on how to set it up?\n\n(For now the file will be downloaded locally as a fallback)'); | |
| if(ok){ window.open('https://developers.google.com/drive/api/guides/about-auth','_blank'); } | |
| saveLocal(); | |
| return; | |
| } | |
| // Full Drive upload with OAuth token | |
| const scope = 'https://www.googleapis.com/auth/drive.file'; | |
| const redirect = location.origin + location.pathname; | |
| const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${CLIENT_ID}&redirect_uri=${encodeURIComponent(redirect)}&response_type=token&scope=${encodeURIComponent(scope)}`; | |
| const popup = window.open(authUrl,'_blank','width=500,height=600'); | |
| // Listen for token in URL hash after redirect | |
| const interval = setInterval(()=>{ | |
| try{ | |
| if(popup.closed){ clearInterval(interval); return; } | |
| const hash = popup.location.hash; | |
| if(hash && hash.includes('access_token')){ | |
| clearInterval(interval); popup.close(); | |
| const token = new URLSearchParams(hash.replace('#','')).get('access_token'); | |
| uploadToDrive(token); | |
| } | |
| }catch(e){} | |
| },500); | |
| } | |
| async function uploadToDrive(token){ | |
| const csv = buildCsvContent(); | |
| const fname = getCsvFilename(); | |
| const meta = JSON.stringify({name:fname,mimeType:'text/csv'}); | |
| const form = new FormData(); | |
| form.append('metadata', new Blob([meta],{type:'application/json'})); | |
| form.append('file', new Blob([csv], {type:'text/csv'})); | |
| try{ | |
| const res = await fetch('https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart',{ | |
| method:'POST', headers:{Authorization:`Bearer ${token}`}, body:form | |
| }); | |
| if(res.ok){ alert(`✓ "${fname}" saved to Google Drive!`); } | |
| else{ const e=await res.json(); alert('Drive error: '+e.error?.message); } | |
| }catch(err){ alert('Drive upload failed: '+err.message); } | |
| } | |
| function exportFiltered(){ saveLocal(); } // legacy alias | |
| (function(){ | |
| try{ | |
| const th=localStorage.getItem('tiq_theme'); | |
| if(th){ | |
| document.documentElement.setAttribute('data-theme',th); | |
| if(th==='dark'){['themeIcon','themeIcon2'].forEach(id=>{const el=document.getElementById(id);if(el)el.className='fas fa-sun';});} | |
| } | |
| const lg=localStorage.getItem('tiq_lang'); | |
| if(lg&&lg!=='en'){ | |
| const btn=document.querySelector(`.lang-btn[onclick*="'${lg}'"]`); | |
| if(btn)setLang(lg,btn); | |
| } | |
| }catch(e){} | |
| loadLogs(); | |
| // Update CSV filename preview when group name changes | |
| document.addEventListener('input', e=>{ if(e.target.id==='groupNameIn') updateCsvPreview(); }); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |