| <!DOCTYPE html> |
| <html lang="ko"> |
| <head> |
| <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"> |
| <title>SiteAgent Admin — Intelligence Dashboard</title> |
| <style> |
| *{margin:0;padding:0;box-sizing:border-box;} |
| body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro','Pretendard','Helvetica Neue',sans-serif;background:#f5f5f7;color:#1d1d1f;font-size:12px;} |
| ::-webkit-scrollbar{width:5px;}::-webkit-scrollbar-thumb{background:rgba(88,86,214,.2);border-radius:4px;} |
| .hdr{background:linear-gradient(135deg,#5856D6,#4f46e5);padding:20px 24px;position:sticky;top:0;z-index:100;box-shadow:0 2px 20px rgba(88,86,214,.3);} |
| .hdr h1{font-size:18px;font-weight:800;color:#fff;display:flex;align-items:center;gap:8px;} |
| .hdr h1 span{color:#fff;} |
| .hdr p{font-size:10px;color:rgba(255,255,255,.7);margin-top:4px;} |
| .ct{max-width:1200px;margin:0 auto;padding:16px;} |
| .tabs{display:flex;gap:4px;margin-bottom:16px;flex-wrap:wrap;background:#fff;padding:6px;border-radius:12px;border:1px solid rgba(0,0,0,.06);box-shadow:0 1px 4px rgba(0,0,0,.03);} |
| .tab{padding:7px 14px;border-radius:8px;font-size:11px;font-weight:600;cursor:pointer;color:#8e8e93;transition:all .15s;border:none;background:none;} |
| .tab:hover{color:#1d1d1f;background:rgba(0,0,0,.03);} |
| .tab.on{background:#5856D6;color:#fff;box-shadow:0 2px 12px rgba(88,86,214,.25);} |
| .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:10px;margin-bottom:16px;} |
| .sc{background:#fff;border-radius:14px;padding:16px;border:1px solid rgba(0,0,0,.04);box-shadow:0 1px 6px rgba(0,0,0,.03);transition:all .2s;} |
| .sc:hover{border-color:rgba(88,86,214,.15);transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,.06);} |
| .sc .n{font-size:26px;font-weight:800;color:#5856D6;} |
| .sc .l{font-size:10px;color:#8e8e93;margin-top:2px;} |
| .sec{background:#fff;border-radius:14px;padding:16px;margin-bottom:12px;border:1px solid rgba(0,0,0,.04);box-shadow:0 1px 6px rgba(0,0,0,.03);} |
| .sec h2{font-size:13px;font-weight:700;margin-bottom:10px;padding-bottom:6px;border-bottom:1.5px solid rgba(88,86,214,.08);color:#5856D6;} |
| table{width:100%;border-collapse:collapse;font-size:11px;} |
| th{text-align:left;padding:6px 8px;color:#8e8e93;font-weight:600;font-size:9px;text-transform:uppercase;letter-spacing:.5px;border-bottom:1px solid rgba(0,0,0,.06);} |
| td{padding:6px 8px;border-bottom:1px solid rgba(0,0,0,.03);vertical-align:top;} |
| tr:hover{background:rgba(88,86,214,.02);} |
| .bg{display:inline-block;padding:2px 7px;border-radius:5px;font-size:9px;font-weight:600;} |
| .bg-a{background:rgba(88,86,214,.1);color:#5856D6;}.bg-u{background:rgba(0,0,0,.04);color:#666;} |
| .bg-f{background:rgba(13,148,136,.08);color:#0d9488;}.bg-s{background:rgba(234,179,8,.08);color:#a16207;} |
| .bg-d{background:rgba(239,68,68,.08);color:#dc2626;}.bg-g{background:rgba(34,197,94,.08);color:#16a34a;} |
| .db{padding:3px 8px;background:#ff3b30;color:#fff;border:none;border-radius:5px;font-size:9px;cursor:pointer;font-weight:600;transition:all .15s;} |
| .db:hover{background:#d63030;} |
| .det-btn{padding:3px 8px;background:#5856D6;color:#fff;border:none;border-radius:5px;font-size:9px;cursor:pointer;font-weight:600;transition:all .15s;} |
| .det-btn:hover{background:#4f46e5;} |
| .bar{height:6px;background:rgba(88,86,214,.06);border-radius:4px;margin-top:3px;} |
| .bf{height:100%;border-radius:4px;transition:width .3s;} |
| .bf-p{background:linear-gradient(90deg,#5856D6,#007AFF);} |
| .bf-t{background:linear-gradient(90deg,#0d9488,#059669);} |
| .bf-a{background:linear-gradient(90deg,#f59e0b,#ef4444);} |
| .emp{text-align:center;color:#c7c7cc;padding:20px;font-size:11px;} |
| .modal{position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:200;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(4px);} |
| .modal-body{background:#fff;border:1px solid rgba(0,0,0,.06);border-radius:16px;max-width:800px;width:95%;max-height:85vh;overflow-y:auto;padding:24px;box-shadow:0 20px 60px rgba(0,0,0,.15);} |
| .modal-close{float:right;background:none;border:none;font-size:20px;cursor:pointer;color:#8e8e93;} |
| .rfb{position:fixed;bottom:16px;right:16px;padding:10px 18px;background:#5856D6;color:#fff;border:none;border-radius:10px;font-size:11px;font-weight:700;cursor:pointer;box-shadow:0 4px 16px rgba(88,86,214,.3);z-index:50;} |
| .chip{display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:20px;font-size:10px;font-weight:600;margin:2px;} |
| .chip-i{background:rgba(88,86,214,.08);color:#5856D6;} |
| .chip-p{background:rgba(13,148,136,.08);color:#0d9488;} |
| .chip-k{background:rgba(234,179,8,.06);color:#a16207;} |
| .chip-c{background:rgba(59,130,246,.06);color:#2563eb;} |
| .cta-card{background:linear-gradient(135deg,rgba(88,86,214,.04),rgba(0,122,255,.03));border:1px solid rgba(88,86,214,.1);border-radius:10px;padding:10px 14px;margin:4px 0;display:flex;align-items:center;justify-content:space-between;} |
| .cta-card .cta-label{font-weight:700;font-size:12px;color:#5856D6;} |
| .cta-card .cta-reason{font-size:9px;color:#8e8e93;} |
| .cta-card .cta-prio{background:#5856D6;color:#fff;padding:2px 8px;border-radius:10px;font-size:9px;font-weight:800;} |
| .hour-bar{display:inline-block;width:12px;margin:0 1px;background:rgba(88,86,214,.08);border-radius:2px 2px 0 0;vertical-align:bottom;transition:height .3s;} |
| .hour-active{background:linear-gradient(180deg,#5856D6,#007AFF);} |
| .seg-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px;} |
| .seg-card{background:#fff;border:1px solid rgba(0,0,0,.04);border-radius:12px;padding:14px;text-align:center;box-shadow:0 1px 6px rgba(0,0,0,.03);} |
| .seg-card .seg-n{font-size:32px;font-weight:800;color:#5856D6;} |
| .seg-card .seg-l{font-size:10px;color:#8e8e93;margin-top:4px;} |
| .pane{display:none;}.pane.active{display:block;} |
| </style> |
| </head> |
| <body> |
| <div class="hdr"><h1>🔑 <span>SiteAgent</span> Intelligence Dashboard</h1><p id="updateTime"></p><p style="font-size:9px;color:rgba(255,255,255,.5);margin-top:2px;">admin: <span id="adminDisplay"></span></p></div> |
| <div class="ct"> |
| <div class="tabs" id="tabBar"> |
| <div class="tab on" data-t="overview">📊 개요</div> |
| <div class="tab" data-t="insights">🔥 인사이트</div> |
| <div class="tab" data-t="analytics">📈 분석</div> |
| <div class="tab" data-t="users">👥 사용자</div> |
| <div class="tab" data-t="segments">🎯 세그먼트</div> |
| <div class="tab" data-t="visits">🌐 방문</div> |
| <div class="tab" data-t="inputs">📝 입력</div> |
| <div class="tab" data-t="features">⚡ 기능</div> |
| <div class="tab" data-t="waitlist">📋 웨이팅</div> |
| </div> |
| <div id="overview" class="pane active"></div> |
| <div id="insights" class="pane"></div> |
| <div id="analytics" class="pane"></div> |
| <div id="users" class="pane"></div> |
| <div id="segments" class="pane"></div> |
| <div id="visits" class="pane"></div> |
| <div id="inputs" class="pane"></div> |
| <div id="features" class="pane"></div> |
| <div id="waitlist" class="pane"></div> |
| </div> |
| <div id="modalWrap"></div> |
| <button class="rfb" onclick="loadAll()">🔄 새로고침</button> |
| <script> |
| var API=location.origin; |
| var ADMIN=new URLSearchParams(location.search).get('key')||''; |
| var D={}; |
| |
| /* ── 탭 전환 ── */ |
| document.getElementById('tabBar').addEventListener('click',function(e){ |
| var tab=e.target.closest('.tab');if(!tab)return; |
| document.querySelectorAll('.tab').forEach(function(t){t.classList.remove('on');}); |
| tab.classList.add('on'); |
| document.querySelectorAll('.pane').forEach(function(p){p.classList.remove('active');}); |
| var t=tab.getAttribute('data-t'); |
| document.getElementById(t).classList.add('active'); |
| }); |
| |
| function ts(v){return v?new Date(v*1000).toLocaleString('ko-KR',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}):'—';} |
| function esc(s){var d=document.createElement('div');d.textContent=s||'';return d.innerHTML;} |
| function card(icon,num,label){return '<div class="sc"><div class="n">'+icon+' '+num+'</div><div class="l">'+label+'</div></div>';} |
| function barHTML(items,maxVal,cls){ |
| var h='';if(!maxVal)maxVal=items[0]?.cnt||1; |
| items.forEach(function(v){ |
| h+='<div style="margin-bottom:5px;"><div style="display:flex;justify-content:space-between;font-size:10px;"><span>'+esc(v.label||v.domain||v.feature||v.event||v.name||'')+'</span><b style="color:#5856D6;">'+v.cnt+'</b></div><div class="bar"><div class="bf '+(cls||'bf-p')+'" style="width:'+Math.round(v.cnt/maxVal*100)+'%"></div></div></div>'; |
| }); |
| return h||'<div class="emp">데이터 없음</div>'; |
| } |
| |
| async function api(path){ |
| var url=API+path+(path.includes('?')?'&':'?')+'admin='+encodeURIComponent(ADMIN); |
| console.log('[SA-Admin] fetch:',url); |
| var r=await fetch(url); |
| if(!r.ok){ |
| var t=await r.text().catch(function(){return '';}); |
| console.error('[SA-Admin] API error:',r.status,path,t); |
| throw new Error(r.status+' '+path); |
| } |
| var d=await r.json(); |
| console.log('[SA-Admin] data:',path,JSON.stringify(d).substring(0,200)); |
| return d; |
| } |
| |
| async function loadAll(){ |
| if(!ADMIN){ |
| document.getElementById('overview').innerHTML='<div class="emp">❌ 관리자 키가 없습니다.<br>URL에 <b>?key=관리자이메일</b>을 추가하세요.</div>'; |
| return; |
| } |
| async function safeApi(path){ |
| try{ |
| var url=API+path+(path.includes('?')?'&':'?')+'admin='+encodeURIComponent(ADMIN); |
| var r=await fetch(url); |
| if(!r.ok) return null; |
| return await r.json(); |
| }catch(e){return null;} |
| } |
| D.dash=await safeApi('/api/admin/dashboard')||{total_users:0,total_visits:0,total_inputs:0,top_domains:[],top_features:[],recent_users:[]}; |
| D.inputs=await safeApi('/api/admin/inputs')||{inputs:[]}; |
| D.allVisits=await safeApi('/api/admin/all-visits')||{visits:[]}; |
| D.waitlist=await safeApi('/api/admin/waitlist')||{waitlist:[]}; |
| D.analytics=await safeApi('/api/admin/analytics')||{}; |
| D.segments=await safeApi('/api/admin/segments')||{}; |
| D.insights={}; |
| render(); |
| document.getElementById('updateTime').textContent='Last: '+new Date().toLocaleTimeString('ko-KR'); |
| document.getElementById('insights').innerHTML='<div class="emp"><div class="sd" style="display:inline-flex;margin-right:8px;"><i></i><i style="animation-delay:.15s"></i><i style="animation-delay:.3s"></i></div>🔥 인사이트 분석 중... (사용자가 많으면 10~30초 소요)</div>'; |
| safeApi('/api/admin/insights').then(function(d){ |
| D.insights=d||{}; |
| renderInsights(); |
| }); |
| } |
| |
| function render(){ |
| renderOverview(); |
| renderInsights(); |
| renderAnalytics(); |
| renderUsers(); |
| renderSegments(); |
| renderVisits(); |
| renderInputs(); |
| renderFeatures(); |
| renderWaitlist(); |
| } |
| |
| function renderOverview(){ |
| var d=D.dash||{};var a=D.analytics||{}; |
| var h='<div class="stats">'+card('👥',d.total_users||0,'총 사용자')+card('🌐',d.total_visits||0,'총 방문')+card('📝',d.total_inputs||0,'총 입력')+card('📈',a.today_events||0,'오늘 이벤트')+card('📊',a.week_events||0,'이번주 이벤트')+'</div>'; |
| /* AI 성능 */ |
| var perf=a.ai_performance||{}; |
| if(perf.cnt){ |
| h+='<div class="stats">'+card('⚡',Math.round(perf.avg_ms||0)+'ms','평균 AI 응답')+card('✅',perf.cnt||0,'AI 호출 수')+card('❌',perf.errors||0,'AI 에러')+'</div>'; |
| } |
| h+='<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">'; |
| h+='<div class="sec"><h2>🌐 TOP 도메인</h2>'; |
| var td=d.top_domains||[];if(td.length){var mx=td[0].cnt;h+=barHTML(td.map(function(v){return{label:v.domain,cnt:v.cnt};}),mx,'bf-p');}else h+='<div class="emp">없음</div>'; |
| h+='</div>'; |
| h+='<div class="sec"><h2>⚡ 기능 사용</h2>'; |
| var tf=d.top_features||[];if(tf.length){var mx=tf[0].cnt;h+=barHTML(tf.map(function(v){return{label:v.feature,cnt:v.cnt};}),mx,'bf-t');}else h+='<div class="emp">없음</div>'; |
| h+='</div></div>'; |
| document.getElementById('overview').innerHTML=h; |
| } |
| |
| function renderInsights(){ |
| var ins=D.insights||{}; |
| if(!ins.total_analyzed){document.getElementById('insights').innerHTML='<div class="emp">아직 분석할 사용자 데이터가 없습니다. 사용자가 활동하면 자동으로 채워집니다.</div>';return;} |
| var h='<div class="stats">'+card('👥',ins.total_analyzed,'분석된 사용자')+card('💯',ins.avg_engagement,'평균 참여도')+card('🔥',ins.total_action_signals,'구매/전환 신호')+'</div>'; |
| h+='<div class="sec"><h2>🎯 CTA 추천 랭킹 (전체 사용자 통합)</h2>'; |
| var ctas=ins.cta_ranking||[]; |
| if(ctas.length){ |
| ctas.forEach(function(c){ |
| h+='<div class="cta-card"><div style="flex:1;"><div class="cta-label">'+esc(c.label)+'</div><div class="cta-reason">'+esc(c.reason)+' <span class="bg bg-s">'+esc(c.source||'')+'</span></div></div><div style="display:flex;align-items:center;gap:8px;"><span style="font-size:11px;font-weight:700;color:#1d1d1f;">👥 '+c.user_count+'명</span><div class="cta-prio">P'+c.priority+'</div></div></div>'; |
| }); |
| }else h+='<div class="emp">CTA 추천 없음</div>'; |
| h+='</div>'; |
| h+='<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">'; |
| h+='<div class="sec"><h2>🎯 입력 의도 분류 (전체)</h2>'; |
| var ir=ins.intent_ranking||[]; |
| if(ir.length){var mx=ir[0].score;ir.forEach(function(i){h+='<div style="margin-bottom:5px;"><div style="display:flex;justify-content:space-between;font-size:10px;"><span>'+esc(i.intent)+'</span><b style="color:#5856D6;">'+i.score+'</b></div><div class="bar"><div class="bf bf-p" style="width:'+Math.round(i.score/mx*100)+'%"></div></div></div>';});} |
| else h+='<div class="emp">없음</div>'; |
| h+='</div>'; |
| h+='<div class="sec"><h2>📂 관심 주제 클러스터 (전체)</h2>'; |
| var tc=ins.topic_ranking||[]; |
| if(tc.length){var mx=tc[0].count;tc.forEach(function(t){h+='<div style="margin-bottom:5px;"><div style="display:flex;justify-content:space-between;font-size:10px;"><span>'+esc(t.topic)+'</span><b style="color:#0d9488;">'+t.count+'</b></div><div class="bar"><div class="bf bf-t" style="width:'+Math.round(t.count/mx*100)+'%"></div></div></div>';});} |
| else h+='<div class="emp">없음</div>'; |
| h+='</div></div>'; |
| h+='<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">'; |
| h+='<div class="sec"><h2>❤️ 관심 카테고리 (도메인 기반)</h2>'; |
| var ic=ins.interest_ranking||[]; |
| if(ic.length){var mx=ic[0].score;ic.forEach(function(i){h+='<div style="margin-bottom:5px;"><div style="display:flex;justify-content:space-between;font-size:10px;"><span>'+esc(i.category)+'</span><b style="color:#5856D6;">'+i.score+'</b></div><div class="bar"><div class="bf bf-p" style="width:'+Math.round(i.score/mx*100)+'%"></div></div></div>';});} |
| else h+='<div class="emp">없음</div>'; |
| h+='</div>'; |
| h+='<div class="sec"><h2>🧬 사용자 유형 (기능 기반)</h2>'; |
| var pr=ins.persona_ranking||[]; |
| if(pr.length){var mx=pr[0].score;pr.forEach(function(p){h+='<div style="margin-bottom:5px;"><div style="display:flex;justify-content:space-between;font-size:10px;"><span>'+esc(p.type)+'</span><b style="color:#0d9488;">'+p.score+'</b></div><div class="bar"><div class="bf bf-t" style="width:'+Math.round(p.score/mx*100)+'%"></div></div></div>';});} |
| else h+='<div class="emp">없음</div>'; |
| h+='</div></div>'; |
| h+='<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">'; |
| h+='<div class="sec"><h2>📊 질문 복잡도 (전체)</h2>'; |
| var cx=ins.complexity||{}; |
| var cxT=(cx.simple||0)+(cx.medium||0)+(cx.complex||0)||1; |
| h+='<div style="display:flex;height:28px;border-radius:8px;overflow:hidden;margin-bottom:8px;">'; |
| if(cx.simple)h+='<div style="width:'+(cx.simple/cxT*100)+'%;background:#22c55e;display:flex;align-items:center;justify-content:center;font-size:10px;color:#fff;font-weight:700;">단순 '+cx.simple+'</div>'; |
| if(cx.medium)h+='<div style="width:'+(cx.medium/cxT*100)+'%;background:#f59e0b;display:flex;align-items:center;justify-content:center;font-size:10px;color:#fff;font-weight:700;">보통 '+cx.medium+'</div>'; |
| if(cx.complex)h+='<div style="width:'+(cx.complex/cxT*100)+'%;background:#8b5cf6;display:flex;align-items:center;justify-content:center;font-size:10px;color:#fff;font-weight:700;">복잡 '+cx.complex+'</div>'; |
| h+='</div></div>'; |
| h+='<div class="sec"><h2>🔑 핵심 키워드 (전체 입력)</h2>'; |
| var kw=ins.keyword_ranking||[]; |
| if(kw.length){kw.forEach(function(k){h+='<span class="chip chip-k">'+esc(k.word)+' ×'+k.count+'</span>';});} |
| else h+='<div class="emp">없음</div>'; |
| h+='</div></div>'; |
| h+='<div class="sec"><h2>👤 사용자별 요약</h2>'; |
| var up=ins.user_profiles||[]; |
| if(up.length){ |
| h+='<table><tr><th>이메일</th><th>참여도</th><th>TOP CTA</th><th>TOP 의도</th><th>전환신호</th><th>입력수</th><th>프로파일</th></tr>'; |
| up.forEach(function(u){ |
| var engColor=u.engagement>=70?'#22c55e':u.engagement>=40?'#f59e0b':'#8e8e93'; |
| h+='<tr><td style="font-size:10px;">'+esc(u.email)+'</td>'; |
| h+='<td><b style="color:'+engColor+';">'+u.engagement+'</b>/100</td>'; |
| h+='<td style="font-size:9px;">'+esc(u.top_cta)+'</td>'; |
| h+='<td><span class="bg bg-f">'+esc(u.top_intent)+'</span></td>'; |
| h+='<td>'+(u.action_signals?'<span class="bg bg-d">🔥 '+u.action_signals+'</span>':'—')+'</td>'; |
| h+='<td>'+u.input_count+'</td>'; |
| h+='<td><button class="det-btn" onclick="showFullProfile(\''+esc(u.email)+'\')">상세</button></td></tr>'; |
| }); |
| h+='</table>'; |
| }else h+='<div class="emp">없음</div>'; |
| h+='</div>'; |
| document.getElementById('insights').innerHTML=h; |
| } |
| function renderAnalytics(){ |
| var a=D.analytics||{}; |
| var h='<div class="stats">'+card('📈',a.today_events||0,'오늘')+card('📊',a.week_events||0,'이번주')+'</div>'; |
| h+='<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">'; |
| /* 이벤트 유형 */ |
| h+='<div class="sec"><h2>📋 이벤트 유형 (7일)</h2>'; |
| var ev=a.events_by_type||[];if(ev.length){var mx=ev[0].cnt;h+=barHTML(ev,mx,'bf-p');}else h+='<div class="emp">없음</div>'; |
| h+='</div>'; |
| /* 기능별 */ |
| h+='<div class="sec"><h2>⚡ 기능별 사용 (7일)</h2>'; |
| var fw=a.features_week||[];if(fw.length){var mx=fw[0].cnt;h+=barHTML(fw,mx,'bf-t');}else h+='<div class="emp">없음</div>'; |
| h+='</div></div>'; |
| h+='<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">'; |
| /* 클라이언트 */ |
| h+='<div class="sec"><h2>📱 클라이언트</h2>'; |
| var cl=a.clients||[];if(cl.length){h+=barHTML(cl.map(function(v){return{label:v.client,cnt:v.cnt};}),cl[0]?.cnt,'bf-p');}else h+='<div class="emp">없음</div>'; |
| h+='</div>'; |
| /* OS */ |
| h+='<div class="sec"><h2>💻 OS</h2>'; |
| var os=a.os_stats||[];if(os.length){h+=barHTML(os.map(function(v){return{label:v.os,cnt:v.cnt};}),os[0]?.cnt,'bf-t');}else h+='<div class="emp">없음</div>'; |
| h+='</div>'; |
| /* 브라우저 */ |
| h+='<div class="sec"><h2>🌐 브라우저</h2>'; |
| var br=a.browser_stats||[];if(br.length){h+=barHTML(br.map(function(v){return{label:v.browser,cnt:v.cnt};}),br[0]?.cnt,'bf-a');}else h+='<div class="emp">없음</div>'; |
| h+='</div></div>'; |
| /* 프로바이더 + MARL 엔진 */ |
| h+='<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">'; |
| h+='<div class="sec"><h2>🔌 프로바이더</h2>'; |
| var pv=a.providers||[];if(pv.length){h+=barHTML(pv.map(function(v){return{label:v.provider,cnt:v.cnt};}),pv[0]?.cnt,'bf-p');}else h+='<div class="emp">없음</div>'; |
| h+='</div>'; |
| h+='<div class="sec"><h2>🧠 MARL 엔진</h2>'; |
| var en=a.engine_stats||[];if(en.length){h+=barHTML(en.map(function(v){return{label:v.engine,cnt:v.cnt};}),en[0]?.cnt,'bf-t');}else h+='<div class="emp">없음</div>'; |
| h+='</div></div>'; |
| document.getElementById('analytics').innerHTML=h; |
| } |
| |
| function renderUsers(){ |
| var d=D.dash||{}; |
| var h='<div class="sec"><h2>👥 전체 사용자 ('+((d.recent_users||[]).length)+'명)</h2>'; |
| if(d.recent_users&&d.recent_users.length){ |
| h+='<table><tr><th>이메일</th><th>닉네임</th><th>역할</th><th>방문</th><th>최근접속</th><th>프로파일</th><th>관리</th></tr>'; |
| d.recent_users.forEach(function(u){ |
| h+='<tr><td>'+esc(u.email)+'</td><td>'+esc(u.nickname)+'</td><td><span class="bg '+(u.role==='admin'?'bg-a':'bg-u')+'">'+u.role+'</span></td><td>'+u.visit_count+'</td><td style="font-size:9px;">'+ts(u.last_seen_at)+'</td>'; |
| h+='<td><button class="det-btn" onclick="showFullProfile(\''+esc(u.email)+'\')">🎯 분석</button></td>'; |
| h+='<td>'+(u.role!=='admin'?'<button class="db" onclick="delUser(\''+esc(u.email)+'\')">삭제</button>':'—')+'</td></tr>'; |
| }); |
| h+='</table>'; |
| }else h+='<div class="emp">사용자 없음</div>'; |
| h+='</div>'; |
| document.getElementById('users').innerHTML=h; |
| } |
| |
| function renderSegments(){ |
| var s=D.segments||{}; |
| var h='<div class="seg-grid">'; |
| h+='<div class="seg-card"><div class="seg-n">'+(s.power_users_count||0)+'</div><div class="seg-l">파워 유저 (10+방문 or 20+AI)</div></div>'; |
| h+='<div class="seg-card"><div class="seg-n">'+(s.casual_users_count||0)+'</div><div class="seg-l">일반 유저 (3~10방문)</div></div>'; |
| h+='<div class="seg-card"><div class="seg-n">'+(s.new_users_count||0)+'</div><div class="seg-l">신규 유저 (1~3방문)</div></div>'; |
| h+='</div>'; |
| h+='<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px;">'; |
| /* 클라이언트별 */ |
| h+='<div class="sec"><h2>📱 클라이언트별 분포</h2>'; |
| var cl=s.by_client||{};var clArr=Object.entries(cl).map(function(e){return{label:e[0],cnt:e[1]};}).sort(function(a,b){return b.cnt-a.cnt;}); |
| h+=barHTML(clArr,clArr[0]?.cnt,'bf-p'); |
| h+='</div>'; |
| /* 프로바이더별 */ |
| h+='<div class="sec"><h2>🔌 프로바이더별 분포</h2>'; |
| var pv=s.by_provider||{};var pvArr=Object.entries(pv).map(function(e){return{label:e[0],cnt:e[1]};}).sort(function(a,b){return b.cnt-a.cnt;}); |
| h+=barHTML(pvArr,pvArr[0]?.cnt,'bf-t'); |
| h+='</div></div>'; |
| h+='<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">'; |
| /* OS별 */ |
| h+='<div class="sec"><h2>💻 OS 분포</h2>'; |
| var os=s.by_os||{};var osArr=Object.entries(os).map(function(e){return{label:e[0],cnt:e[1]};}).sort(function(a,b){return b.cnt-a.cnt;}); |
| h+=barHTML(osArr,osArr[0]?.cnt,'bf-a'); |
| h+='</div>'; |
| /* 브라우저별 */ |
| h+='<div class="sec"><h2>🌐 브라우저 분포</h2>'; |
| var br=s.by_browser||{};var brArr=Object.entries(br).map(function(e){return{label:e[0],cnt:e[1]};}).sort(function(a,b){return b.cnt-a.cnt;}); |
| h+=barHTML(brArr,brArr[0]?.cnt,'bf-p'); |
| h+='</div></div>'; |
| document.getElementById('segments').innerHTML=h; |
| } |
| |
| function renderVisits(){ |
| var visits=(D.allVisits||{}).visits||[]; |
| var h='<div class="sec"><h2>🌐 최근 방문 기록</h2>'; |
| if(visits.length){ |
| h+='<table><tr><th>이메일</th><th>도메인</th><th>URL</th><th>제목</th><th>횟수</th><th>최근</th><th></th></tr>'; |
| visits.forEach(function(v){ |
| h+='<tr><td style="font-size:9px;">'+esc(v.email)+'</td><td><b>'+esc(v.domain)+'</b></td><td style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:9px;">'+esc(v.url)+'</td><td style="max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:9px;">'+esc(v.title)+'</td><td>'+v.visit_count+'</td><td style="font-size:9px;">'+ts(v.last_visited_at)+'</td>'; |
| h+='<td><button class="db" onclick="delRecord(\'page_visits\','+v.id+')">삭제</button></td></tr>'; |
| }); |
| h+='</table>'; |
| }else h+='<div class="emp">없음</div>'; |
| h+='</div>'; |
| document.getElementById('visits').innerHTML=h; |
| } |
| |
| function renderInputs(){ |
| var inp=(D.inputs||{}).inputs||[]; |
| var h='<div class="sec"><h2>📝 최근 입력 로그</h2>'; |
| if(inp.length){ |
| h+='<table><tr><th>이메일</th><th>기능</th><th>URL</th><th>입력</th><th>시간</th><th></th></tr>'; |
| inp.forEach(function(v){ |
| h+='<tr><td style="font-size:9px;">'+esc(v.email)+'</td><td><span class="bg bg-f">'+esc(v.feature)+'</span></td><td style="max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:9px;">'+esc(v.url||'')+'</td><td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">'+esc(v.input_text)+'</td><td style="font-size:9px;">'+ts(v.created_at)+'</td>'; |
| h+='<td><button class="db" onclick="delRecord(\'user_inputs\','+v.id+')">삭제</button></td></tr>'; |
| }); |
| h+='</table>'; |
| }else h+='<div class="emp">없음</div>'; |
| h+='</div>'; |
| document.getElementById('inputs').innerHTML=h; |
| } |
| |
| function renderFeatures(){ |
| var d=D.dash||{}; |
| var h='<div class="sec"><h2>⚡ 기능 사용 현황</h2>'; |
| var tf=d.top_features||[]; |
| if(tf.length){ |
| h+='<table><tr><th>기능</th><th>횟수</th><th>비율</th></tr>'; |
| var total=tf.reduce(function(s,v){return s+v.cnt;},0); |
| tf.forEach(function(v){ |
| var pct=total?Math.round(v.cnt/total*100):0; |
| h+='<tr><td><b>'+esc(v.feature)+'</b></td><td>'+v.cnt+'회</td><td><div class="bar" style="width:100px;display:inline-block;vertical-align:middle;"><div class="bf bf-t" style="width:'+pct+'%"></div></div> '+pct+'%</td></tr>'; |
| }); |
| h+='</table>'; |
| }else h+='<div class="emp">없음</div>'; |
| h+='</div>'; |
| document.getElementById('features').innerHTML=h; |
| } |
| |
| function renderWaitlist(){ |
| var wl=(D.waitlist||{}).waitlist||[]; |
| var h='<div class="sec"><h2>📋 얼리 액세스 ('+wl.length+'명)</h2>'; |
| if(wl.length){ |
| h+='<table><tr><th>#</th><th>이메일</th><th>이름</th><th>관심</th><th>출처</th><th>등록일</th></tr>'; |
| wl.forEach(function(w,i){ |
| h+='<tr><td>'+(i+1)+'</td><td><b>'+esc(w.email)+'</b></td><td>'+esc(w.name||'-')+'</td><td>'+esc(w.interest||'-')+'</td><td>'+esc(w.source||'-')+'</td><td style="font-size:9px;">'+ts(w.created_at)+'</td></tr>'; |
| }); |
| h+='</table>'; |
| }else h+='<div class="emp">없음</div>'; |
| h+='</div>'; |
| document.getElementById('waitlist').innerHTML=h; |
| } |
| |
| /* ── 사용자 종합 프로파일 모달 ── */ |
| async function showFullProfile(email){ |
| try{ |
| var p=await api('/api/admin/user-profile/'+encodeURIComponent(email)); |
| var u=p.user||{};var pr=p.profile||{}; |
| var h='<button class="modal-close" onclick="closeModal()">✕</button>'; |
| h+='<h2 style="margin-bottom:4px;color:#1d1d1f;font-size:16px;">🎯 '+esc(u.email)+'</h2>'; |
| h+='<p style="font-size:11px;color:#8e8e93;margin-bottom:16px;">'+esc(u.nickname||'')+' · '+esc(u.role||'user')+' · 방문 '+u.visit_count+'회 · 참여도 <b style="color:#5856D6;">'+pr.engagement_score+'/100</b></p>'; |
| |
| /* ── CTA 추천 ── */ |
| h+='<div class="sec"><h2>🎯 맞춤 CTA 추천 (광고 타겟팅)</h2>'; |
| var ctas=pr.cta_recommendations||[]; |
| if(ctas.length){ |
| ctas.forEach(function(c){ |
| h+='<div class="cta-card"><div><div class="cta-label">'+esc(c.label)+'</div><div class="cta-reason">'+esc(c.reason)+' <span class="bg bg-s">'+esc(c.source||'')+'</span></div></div><div class="cta-prio">P'+c.priority+'</div></div>'; |
| }); |
| }else h+='<div class="emp">데이터 부족 — 더 많은 활동 후 추론 가능</div>'; |
| h+='</div>'; |
| |
| /* ── 입력 행동 분석 (NEW) ── */ |
| var ib=pr.input_behavior||{}; |
| if(ib.total_inputs>0){ |
| /* 행동 요약 */ |
| h+='<div class="sec"><h2>🧠 행동 분석 요약</h2>'; |
| var bs=ib.behavior_summary||[]; |
| if(bs.length){bs.forEach(function(s){h+='<div style="padding:4px 0;font-size:11px;border-bottom:1px solid rgba(0,0,0,.04);">→ '+esc(s)+'</div>';});} |
| h+='<div style="margin-top:8px;display:flex;gap:12px;font-size:10px;color:#8e8e93;">분석된 입력 <b style="color:#5856D6;">'+ib.analyzed+'</b>건 / 전체 <b>'+ib.total_inputs+'</b>건</div>'; |
| h+='</div>'; |
| |
| /* 의도 분석 */ |
| h+='<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">'; |
| h+='<div class="sec"><h2>🎯 입력 의도 분류</h2>'; |
| var ir=ib.intent_ranking||[]; |
| if(ir.length){ |
| var mxI=ir[0].score; |
| ir.forEach(function(i){ |
| h+='<div style="margin-bottom:5px;"><div style="display:flex;justify-content:space-between;font-size:10px;"><span>'+esc(i.emoji)+' '+esc(i.intent)+'</span><b style="color:#5856D6;">'+i.score+'</b></div><div class="bar"><div class="bf bf-p" style="width:'+Math.round(i.score/mxI*100)+'%"></div></div></div>'; |
| }); |
| }else h+='<div class="emp">미확인</div>'; |
| h+='</div>'; |
| |
| /* 토픽 클러스터 */ |
| h+='<div class="sec"><h2>📂 관심 주제 클러스터</h2>'; |
| var tc=ib.topic_clusters||[]; |
| if(tc.length){ |
| tc.forEach(function(t){ |
| h+='<div style="padding:5px 0;border-bottom:1px solid rgba(0,0,0,.04);"><div style="display:flex;justify-content:space-between;font-size:11px;"><b>'+esc(t.topic)+'</b><span class="bg bg-a">'+t.count+'건</span></div><div style="font-size:9px;color:#8e8e93;margin-top:2px;">최근: '+esc(t.recent)+'</div></div>'; |
| }); |
| }else h+='<div class="emp">미확인</div>'; |
| h+='</div></div>'; |
| |
| /* 질문 복잡도 + 구매 신호 */ |
| h+='<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">'; |
| h+='<div class="sec"><h2>📊 질문 복잡도</h2>'; |
| var cx=ib.complexity||{}; |
| var cxTotal=(cx.simple||0)+(cx.medium||0)+(cx.complex||0)||1; |
| h+='<div style="display:flex;height:24px;border-radius:6px;overflow:hidden;margin-bottom:8px;">'; |
| if(cx.simple)h+='<div style="width:'+(cx.simple/cxTotal*100)+'%;background:#4ade80;" title="단순 '+cx.simple+'건"></div>'; |
| if(cx.medium)h+='<div style="width:'+(cx.medium/cxTotal*100)+'%;background:#fbbf24;" title="보통 '+cx.medium+'건"></div>'; |
| if(cx.complex)h+='<div style="width:'+(cx.complex/cxTotal*100)+'%;background:#a78bfa;" title="복잡 '+cx.complex+'건"></div>'; |
| h+='</div>'; |
| h+='<div style="display:flex;gap:12px;font-size:10px;"><span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:#4ade80;margin-right:3px;"></span>단순 '+cx.simple+'</span>'; |
| h+='<span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:#fbbf24;margin-right:3px;"></span>보통 '+cx.medium+'</span>'; |
| h+='<span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:#a78bfa;margin-right:3px;"></span>복잡 '+cx.complex+'</span></div>'; |
| h+='</div>'; |
| |
| /* 구매/전환 신호 */ |
| h+='<div class="sec"><h2>🔥 구매/전환 신호</h2>'; |
| var as2=ib.action_signals||[]; |
| if(as2.length){ |
| as2.forEach(function(a){ |
| h+='<div style="padding:4px 0;border-bottom:1px solid rgba(0,0,0,.04);font-size:10px;"><span class="bg bg-d" style="margin-right:4px;">'+esc(a.intent)+'</span>'+esc(a.text)+'</div>'; |
| }); |
| }else h+='<div style="font-size:10px;color:#c7c7cc;">전환 신호 미감지 — 아직 탐색 단계</div>'; |
| h+='</div></div>'; |
| |
| /* 최근 입력 상세 분석 */ |
| h+='<div class="sec"><h2>📝 최근 입력 분석 (의도 분류 포함)</h2>'; |
| var ra=ib.recent_analysis||[]; |
| if(ra.length){ |
| h+='<table><tr><th>의도</th><th>복잡도</th><th>기능</th><th>입력</th><th>도메인</th></tr>'; |
| ra.slice(0,15).forEach(function(r){ |
| var cxCls=r.complexity==='complex'?'bg-a':r.complexity==='medium'?'bg-s':'bg-g'; |
| h+='<tr><td><span class="bg bg-f">'+esc(r.intent_emoji)+' '+esc(r.intent)+'</span></td>'; |
| h+='<td><span class="bg '+cxCls+'">'+esc(r.complexity)+'</span></td>'; |
| h+='<td style="font-size:9px;">'+esc(r.feature)+'</td>'; |
| h+='<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">'+esc(r.text)+'</td>'; |
| h+='<td style="font-size:9px;">'+esc(r.url_domain)+'</td></tr>'; |
| }); |
| h+='</table>'; |
| }else h+='<div class="emp">없음</div>'; |
| h+='</div>'; |
| } |
| |
| /* ── 관심사 ── */ |
| h+='<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">'; |
| h+='<div class="sec"><h2>❤️ 관심 카테고리</h2>'; |
| var ints=pr.interests||[]; |
| if(ints.length){ints.forEach(function(i){h+='<span class="chip chip-i">'+esc(i.category)+' ('+i.score+')</span>';});}else h+='<span style="color:#c7c7cc;font-size:10px;">미확인</span>'; |
| h+='</div>'; |
| |
| /* ── 사용자 유형 ── */ |
| h+='<div class="sec"><h2>🧬 사용 성향</h2>'; |
| var pers=pr.personas||[]; |
| if(pers.length){pers.forEach(function(p){h+='<span class="chip chip-p">'+esc(p.type)+' ('+p.score+')</span>';});}else h+='<span style="color:#c7c7cc;font-size:10px;">미확인</span>'; |
| h+='<div style="margin-top:8px;"><span class="chip chip-c">⏰ '+esc(pr.time_persona||'unknown')+'</span></div>'; |
| h+='</div></div>'; |
| |
| /* ── 키워드 ── */ |
| h+='<div class="sec"><h2>🔑 주요 키워드</h2>'; |
| var kws=pr.keywords||[]; |
| if(kws.length){kws.forEach(function(k){h+='<span class="chip chip-k">'+esc(k.word)+' ×'+k.count+'</span>';});}else h+='<span style="color:#c7c7cc;font-size:10px;">미확인</span>'; |
| h+='</div>'; |
| |
| /* ── 시간대 분포 ── */ |
| h+='<div class="sec"><h2>🕐 활동 시간대 (KST)</h2>'; |
| var hd=pr.hour_distribution||[]; |
| var maxH=Math.max.apply(null,hd)||1; |
| h+='<div style="display:flex;align-items:flex-end;height:60px;gap:1px;">'; |
| for(var i=0;i<24;i++){ |
| var barH=hd[i]?Math.max(2,Math.round(hd[i]/maxH*50)):1; |
| var isActive=hd[i]>maxH*0.5; |
| h+='<div class="hour-bar'+(isActive?' hour-active':'')+'" style="height:'+barH+'px;" title="'+i+'시: '+hd[i]+'건"></div>'; |
| } |
| h+='</div><div style="display:flex;justify-content:space-between;font-size:8px;color:#c7c7cc;margin-top:2px;"><span>0시</span><span>6</span><span>12</span><span>18</span><span>23시</span></div>'; |
| h+='</div>'; |
| |
| /* ── TOP 도메인 ── */ |
| h+='<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">'; |
| h+='<div class="sec"><h2>🌐 자주 방문</h2>'; |
| var doms=p.domains||[]; |
| if(doms.length){doms.slice(0,10).forEach(function(d){h+='<div style="display:flex;justify-content:space-between;padding:3px 0;font-size:10px;border-bottom:1px solid rgba(0,0,0,.04);"><span>'+esc(d.domain)+'</span><b style="color:#5856D6;">'+d.cnt+'</b></div>';});} |
| else h+='<div class="emp">없음</div>'; |
| h+='</div>'; |
| /* 기능 사용 */ |
| h+='<div class="sec"><h2>⚡ 기능 사용</h2>'; |
| var feats=p.features||[]; |
| if(feats.length){feats.forEach(function(f){h+='<div style="display:flex;justify-content:space-between;padding:3px 0;font-size:10px;border-bottom:1px solid rgba(0,0,0,.04);"><span>'+esc(f.feature)+'</span><b style="color:#0d9488;">'+f.cnt+'</b></div>';});} |
| else h+='<div class="emp">없음</div>'; |
| h+='</div></div>'; |
| |
| /* ── 최근 입력 ── */ |
| h+='<div class="sec"><h2>📝 최근 입력</h2>'; |
| var ri=p.recent_inputs||[]; |
| if(ri.length){ri.slice(0,8).forEach(function(i){h+='<div style="padding:4px 0;border-bottom:1px solid rgba(0,0,0,.04);font-size:10px;"><span class="bg bg-f" style="margin-right:5px;">'+esc(i.feature)+'</span>'+esc((i.input_text||'').substring(0,80))+'<span style="margin-left:6px;color:#c7c7cc;font-size:8px;">'+ts(i.created_at)+'</span></div>';});} |
| else h+='<div class="emp">없음</div>'; |
| h+='</div>'; |
| |
| /* ── 디바이스 ── */ |
| h+='<div class="sec"><h2>📱 환경 정보</h2><div style="display:flex;flex-wrap:wrap;gap:6px;">'; |
| if(u.last_browser) h+='<span class="chip chip-c">🌐 '+esc(u.last_browser)+'</span>'; |
| if(u.last_os) h+='<span class="chip chip-c">💻 '+esc(u.last_os)+'</span>'; |
| if(u.last_screen) h+='<span class="chip chip-c">📐 '+esc(u.last_screen)+'</span>'; |
| if(u.last_language) h+='<span class="chip chip-c">🗣 '+esc(u.last_language)+'</span>'; |
| if(u.last_timezone) h+='<span class="chip chip-c">🕐 '+esc(u.last_timezone)+'</span>'; |
| if(u.last_provider) h+='<span class="chip chip-c">🔌 '+esc(u.last_provider)+'</span>'; |
| if(u.last_client) h+='<span class="chip chip-c">📱 '+esc(u.last_client)+'</span>'; |
| h+='</div></div>'; |
| |
| document.getElementById('modalWrap').innerHTML='<div class="modal" onclick="if(event.target===this)closeModal()"><div class="modal-body">'+h+'</div></div>'; |
| }catch(e){alert('로드 실패: '+e.message);} |
| } |
| |
| function closeModal(){document.getElementById('modalWrap').innerHTML='';} |
| async function delUser(email){ |
| if(!confirm(email+' 삭제?'))return; |
| await fetch(API+'/api/admin/user/'+encodeURIComponent(email),{method:'DELETE',headers:{'X-Admin-Email':ADMIN}}); |
| loadAll(); |
| } |
| async function delRecord(table,id){ |
| if(!confirm('#'+id+' 삭제?'))return; |
| await fetch(API+'/api/admin/record/'+table+'/'+id,{method:'DELETE',headers:{'X-Admin-Email':ADMIN}}); |
| loadAll(); |
| } |
| loadAll(); |
| document.getElementById('adminDisplay').textContent=ADMIN||'(없음)'; |
| setInterval(loadAll,30000); |
| </script> |
| </body> |
| </html> |
|
|