/* Integrations / data wiring manager ================================ Lives in the Admin Panel. Connections feed the dashboard through one of three paths — MCP gateway, direct platform API, or manual import — plus the MySQL persistence store. All actions hit the real backend. =================================================================== */ const KIND_META = { mcp: { label:'MCP gateway', tag:'MCP', blurb:'JSON-RPC to an MCP server that streams platform data.' }, api: { label:'Direct · API', tag:'API', blurb:'Authenticated REST pull from the platform (or a proxy).' }, manual: { label:'Manual import', tag:'MANUAL', blurb:'Upload CSV / spreadsheet exports — no live connection.' }, db: { label:'Data store', tag:'SQL', blurb:'The persistence layer (users, audit, saved views).' }, }; /* Templates for the "New connection" menu. Zernio (Late) is the unified MCP connector — one connection feeds Meta, TikTok, X and LinkedIn. It replaces the old Pipeboard MCP path for TikTok + X. NOTE: Google is intentionally NOT on Zernio — Zernio's unified tree returns 0 for Google, so Google Ads stays on its own native Google Ads MCP connection. The direct per-platform API presets remain as fallbacks. */ const CONN_PRESETS = [ { id:'zernio', name:'Zernio — unified ads (MCP)', kind:'mcp', provider:'zernio', tone:'#8351ff', platforms:['meta','tiktok','x','linkedin'], endpoint:'mcp://api.zernio.co/v1/stream', scopes:['ads.read','insights.read'], config:{ sync_tool:'get_ads_bundle' } }, { id:'google', name:'Google Ads (MCP)', kind:'mcp', provider:'google', tone:'#34A853', platforms:['google'], endpoint:'mcp://ads.google.com/mcp/v1', scopes:['adwords'], config:{ sync_tool:'get_google_ads_bundle' } }, { id:'meta', name:'Meta Ads', kind:'api', provider:'meta', tone:'#2f7bff', platforms:['meta','facebook','instagram'], endpoint:'https://graph.facebook.com/v19.0', scopes:['ads_read'] }, { id:'tiktok', name:'TikTok Ads', kind:'api', provider:'tiktok', tone:'#06b6d4', platforms:['tiktok'], endpoint:'https://business-api.tiktok.com/open_api/v1.3', scopes:['ad.read'] }, { id:'snapchat', name:'Snapchat Ads', kind:'api', provider:'snapchat', tone:'#eab308', platforms:['snapchat'], endpoint:'https://adsapi.snapchat.com/v1', scopes:['snapchat-marketing-api'] }, { id:'x', name:'X / Twitter Ads', kind:'api', provider:'x', tone:'#ec4899', platforms:['x'], endpoint:'https://ads-api.x.com/12', scopes:['ads.read'] }, { id:'linkedin', name:'LinkedIn Ads', kind:'api', provider:'linkedin', tone:'#0A66C2', platforms:['linkedin'], endpoint:'https://api.linkedin.com/rest/adAccounts', scopes:['r_ads_reporting'] }, { id:'custom', name:'Custom MCP endpoint',kind:'mcp',provider:'custom', tone:'#5e5e55', platforms:[], endpoint:'', scopes:[] }, { id:'manual', name:'Manual import', kind:'manual', provider:'manual', tone:'#10b981', platforms:[], endpoint:'', scopes:[] }, { id:'mysql', name:'MySQL — Hostinger', kind:'db', provider:'mysql', tone:'#10b981', platforms:[], endpoint:'mysql://localhost:3306', scopes:[] }, ]; function healthMeta(i){ if (i.status !== 'connected') return { tone:'#8b8b80', label:'Disconnected', dot:'#8b8b80' }; if (i.health === 'degraded') return { tone:'#f59e0b', label:'Degraded', dot:'#f59e0b' }; return { tone:'#10b981', label:'Healthy', dot:'#10b981' }; } function maskToken(t){ if (!t) return '—'; if (t.length <= 8) return '••••••••'; return t.slice(0,4) + '••••••••' + t.slice(-4); } function agoStr(ts){ if (!ts) return 'never'; const s = Math.floor((Date.now()-ts)/1000); if (s<60) return s+'s ago'; if (s<3600) return Math.floor(s/60)+'m '+(s%60)+'s ago'; return Math.floor(s/3600)+'h ago'; } const CSV_TEMPLATE = 'platform,campaign_id,campaign_name,status,budget,date,spend,impressions,clicks,conversions,revenue\n' + 'meta,1001,Prospecting — Advantage+,active,500,2026-06-01,420,58000,1180,42,1680\n' + 'google,2001,Brand Search — Exact,active,300,2026-06-01,280,12000,640,55,2100\n'; /* ---------- Connection detail drawer ------------------------------- */ function ConnectionDrawer({ open, integration, onClose, onUpdate, onDelete, readOnly }){ const [draft, setDraft] = useState(integration||{}); const [showTok, setShowTok] = useState(false); const [log, setLog] = useState([]); const [busy, setBusy] = useState(false); const [csv, setCsv] = useState(''); const [importAcc, setImportAcc] = useState(''); const [importMsg, setImportMsg] = useState(''); const logRef = useRef(null); const fileRef = useRef(null); useEffect(()=>{ if(open){ setDraft({...integration}); setShowTok(false); setLog([]); setBusy(false); setCsv(''); setImportMsg(''); const accs = integration && integration.accounts; const first = (accs==='*'||(Array.isArray(accs)&&accs.includes('*'))) ? (ACCOUNTS[0]&&ACCOUNTS[0].id) : (Array.isArray(accs)&&accs[0]) || (ACCOUNTS[0]&&ACCOUNTS[0].id); setImportAcc(first||''); } }, [open, integration]); useEffect(()=>{ if(logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; }, [log]); if (!integration) return null; const set = (k,v)=>setDraft(d=>({...d,[k]:v})); const setCfg = (k,v)=>setDraft(d=>({...d, config:{...(d.config||{}), [k]:v}})); const h = healthMeta(draft); const kind = draft.kind; const isDb = kind==='db', isMcp = kind==='mcp', isApi = kind==='api', isManual = kind==='manual'; const cfg = draft.config || {}; /* Animate a returned log array line-by-line for the terminal feel. */ const playLog = (lines) => new Promise(res=>{ setLog([]); let i=0; const tick = ()=>{ if(i>=lines.length){ res(); return; } setLog(l=>[...l, lines[i]]); i++; setTimeout(tick, 220+Math.random()*180); }; tick(); }); const runTest = async () => { if (busy) return; setBusy(true); try { const lines = await RelayAPI.integrations.test(draft.id); await playLog(lines); } catch(e){ setLog([{line:'✗ '+(e.message||'test failed'), tone:'err'}]); } setBusy(false); }; const toggleConnect = async () => { if (busy) return; setBusy(true); try { if (draft.status==='connected'){ const saved = await onUpdate(draft.id, { status:'disconnected', health:'offline', lastSync:null }); setDraft(d=>({...d, ...saved})); setLog([{line:'→ closing connection…',tone:''},{line:'✓ disconnected',tone:'ok'}]); } else { // Persist current config first, then connect, then (mcp/api) sync. const saved = await onUpdate(draft.id, { ...draft, status:'connected', health:'healthy', lastSync:Date.now() }); setDraft(d=>({...d, ...saved})); if (isMcp || isApi){ await playLog([{line:'→ establishing '+(isMcp?'MCP stream':'API session')+'…',tone:''},{line:'✓ connected',tone:'ok'},{line:'→ running first sync…',tone:''}]); try { const r = await RelayAPI.integrations.sync(draft.id); if (r && r.note) setLog(l=>[...l,{line:'· '+r.note,tone:''}]); else setLog(l=>[...l,{line:`✓ sync complete · ${r.campaigns||0} campaigns · ${r.metrics||0} metric rows`,tone:'ok'}]); } catch(e){ setLog(l=>[...l,{line:'✗ sync error: '+(e.message||''),tone:'err'}]); } } else { setLog([{line:'✓ connected',tone:'ok'}]); } } } catch(e){ setLog([{line:'✗ '+(e.message||'failed'),tone:'err'}]); } setBusy(false); }; const save = async () => { if (busy) return; setBusy(true); /* status/health/lastSync are managed by Connect/Disconnect — don't resend on a plain save. */ const { status, health, lastSync, ...rest } = draft; try { const saved = await onUpdate(draft.id, rest); setDraft(d=>({...d,...saved})); onClose(); } catch(e){ setLog([{line:'✗ '+(e.message||'save failed'),tone:'err'}]); } setBusy(false); }; const onFile = (file) => { if(!file) return; const r=new FileReader(); r.onload=e=>setCsv(String(e.target.result||'')); r.readAsText(file); }; const runImport = async () => { if (busy || !csv.trim()) return; setBusy(true); setImportMsg(''); try { const r = await RelayAPI.ingest({ account: importAcc, integration: draft.id, csv }); const c = r.imported||{}; setImportMsg(`Imported ${c.campaigns||0} campaigns · ${c.metrics||0} metric rows.`); await onUpdate(draft.id, { status:'connected', health:'healthy', lastSync:Date.now() }); } catch(e){ setImportMsg('✗ '+(e.message||'import failed')); } setBusy(false); }; const downloadTemplate = () => { const a=document.createElement('a'); a.href='data:text/csv;charset=utf-8,'+encodeURIComponent(CSV_TEMPLATE); a.download='relay-import-template.csv'; a.click(); }; const importAccounts = (draft.accounts==='*'||(Array.isArray(draft.accounts)&&draft.accounts.includes('*'))) ? ACCOUNTS : ACCOUNTS.filter(a=>(draft.accounts||[]).includes(a.id)); return ( {draft.name}} footer={ <> {h.label} {!readOnly && { if(confirm('Remove this connection?')){ onDelete(draft.id); onClose(); } }}>Remove} Close {!readOnly && Save connection} }>
{/* status row */}
{KIND_META[kind]?.label}
Last sync · {agoStr(draft.lastSync)}
{!readOnly && !isManual && ( {busy ? 'Working…' : draft.status==='connected' ? 'Disconnect' : 'Connect'} )}
set('name',e.target.value)} className={inputCls}/> {!isManual && ( set('endpoint',e.target.value)} placeholder={isMcp?'mcp://your-server.com/v1/stream':isDb?'mysql://host:3306':'https://…'} className={inputCls+' font-mono text-[12px]'}/> )} {isDb && (
set('dbName',e.target.value)} className={inputCls+' font-mono text-[12px]'}/> set('dbUser',e.target.value)} className={inputCls+' font-mono text-[12px]'}/>
)} {/* token (not for manual) */} {!isManual && (
set('token',e.target.value)} placeholder="paste token" className={inputCls+' pr-11 font-mono text-[12px]'}/>
)} {/* MCP-specific: tool that returns the data bundle */} {isMcp && ( setCfg('sync_tool', e.target.value)} placeholder="e.g. get_insights" className={inputCls+' font-mono text-[12px]'}/> )} {/* API-specific: insights URL returning the normalized bundle */} {isApi && ( setCfg('insights_url', e.target.value)} placeholder="https://…/insights?account={account}" className={inputCls+' font-mono text-[12px]'}/> )} {/* platforms routed (mcp / api) */} {(isMcp || isApi) && (
{PLATFORMS.filter(p=>p.id!=='overview').map(p=>{ const on = (draft.platforms||[]).includes(p.id); return ( ); })}
)} {/* account mapping */} {}:(v=>set('accounts',v))}/> {/* sync frequency (mcp/api) */} {(isMcp || isApi) && (
{[15,30,60,120,300].map(f=>( ))}
)} {/* MANUAL import panel */} {isManual && !readOnly && (
Import data
onFile(e.target.files[0])}/>
fileRef.current && fileRef.current.click()}>Choose CSV… {csv?`${csv.split(/\r?\n/).length-1} rows ready`:'no file'}