/* 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 (
Configure every pipe feeding the dashboard. {Object.keys(feeds).length} platform feed{Object.keys(feeds).length===1?'':'s'} live.