749 lines
35 KiB
HTML
749 lines
35 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Connectors — Nexus One AI</title>
|
||
<link rel="stylesheet" href="style.css?v=4">
|
||
<style>
|
||
.cn-content { max-width:1060px; margin:0 auto; padding:32px 40px 60px; }
|
||
@media(max-width:768px){ .cn-content { padding:20px 16px 48px; } }
|
||
|
||
/* ── Stats bar ── */
|
||
.cn-stats { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-bottom:28px; }
|
||
@media(max-width:700px){ .cn-stats { grid-template-columns:repeat(2,1fr); } }
|
||
.cn-stat { background:var(--navy2); border:1px solid var(--bdr); border-radius:12px; padding:16px 18px; }
|
||
.cn-stat-label { font-size:11px; font-weight:700; color:var(--lt); text-transform:uppercase; letter-spacing:.5px; margin-bottom:6px; }
|
||
.cn-stat-val { font-size:26px; font-weight:900; color:var(--ink); line-height:1; }
|
||
.cn-stat-sub { font-size:11px; color:var(--lt); margin-top:4px; }
|
||
|
||
/* ── Card ── */
|
||
.cn-card { background:var(--navy2); border:1px solid var(--bdr); border-radius:14px; margin-bottom:20px; overflow:hidden; }
|
||
.cn-card-head { display:flex; align-items:center; gap:12px; padding:16px 22px; border-bottom:1px solid var(--bdr); }
|
||
.cn-card-title { font-size:14px; font-weight:700; color:var(--ink); flex:1; }
|
||
.cn-card-body { padding:22px; }
|
||
|
||
/* ── Connector tile ── */
|
||
.cn-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:14px; }
|
||
.cn-tile {
|
||
border:1.5px solid var(--bdr); border-radius:12px; padding:18px 20px;
|
||
background:var(--bg); transition:.15s; position:relative;
|
||
}
|
||
.cn-tile.connected { border-color:rgba(22,163,74,.35); background:rgba(22,163,74,.02); }
|
||
.cn-tile.error { border-color:rgba(220,38,38,.3); background:rgba(220,38,38,.02); }
|
||
.cn-tile.syncing { border-color:rgba(37,99,235,.35); background:rgba(37,99,235,.02); }
|
||
.cn-tile:hover:not(.connected):not(.syncing) { border-color:var(--purple); }
|
||
|
||
.cn-tile-top { display:flex; align-items:flex-start; gap:12px; margin-bottom:12px; }
|
||
.cn-tile-icon { font-size:28px; flex-shrink:0; }
|
||
.cn-tile-info { flex:1; min-width:0; }
|
||
.cn-tile-name { font-size:14px; font-weight:700; color:var(--ink); }
|
||
.cn-tile-type { font-size:11px; color:var(--lt); margin-top:2px; }
|
||
.cn-tile-status { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.5px; padding:3px 10px; border-radius:20px; flex-shrink:0; }
|
||
.cn-tile-status.ok { background:rgba(22,163,74,.1); color:#16A34A; }
|
||
.cn-tile-status.err { background:rgba(220,38,38,.1); color:#DC2626; }
|
||
.cn-tile-status.sync { background:rgba(37,99,235,.1); color:#2563EB; }
|
||
.cn-tile-status.idle { background:rgba(124,58,237,.07); color:var(--purple); }
|
||
.cn-tile-status.off { background:var(--bdr); color:var(--lt); }
|
||
|
||
.cn-tile-meta { font-size:12px; color:var(--lt); margin-bottom:14px; line-height:1.5; }
|
||
.cn-tile-meta strong { color:var(--ink); }
|
||
|
||
.cn-tile-actions { display:flex; gap:8px; flex-wrap:wrap; }
|
||
.cn-btn { padding:6px 14px; border-radius:8px; font-family:inherit; font-size:12px; font-weight:600; cursor:pointer; border:1px solid var(--bdr); background:var(--navy2); color:var(--med); transition:.15s; }
|
||
.cn-btn:hover { border-color:var(--purple); color:var(--purple); }
|
||
.cn-btn.primary { background:linear-gradient(135deg,var(--purple),var(--pink)); color:white; border:none; }
|
||
.cn-btn.primary:hover { filter:brightness(1.08); }
|
||
.cn-btn.danger:hover { border-color:rgba(220,38,38,.5); color:#DC2626; }
|
||
|
||
/* ── Config modal ── */
|
||
.cn-modal-overlay { display:none; position:fixed; inset:0; background:rgba(10,8,28,.4); z-index:200; align-items:center; justify-content:center; }
|
||
.cn-modal-overlay.open { display:flex; }
|
||
.cn-modal { background:var(--navy2); border:1px solid var(--bdr); border-radius:16px; width:540px; max-width:95vw; box-shadow:0 24px 64px rgba(0,0,0,.15); }
|
||
.cn-modal-head { display:flex; align-items:center; gap:12px; padding:18px 22px; border-bottom:1px solid var(--bdr); }
|
||
.cn-modal-title { font-size:15px; font-weight:700; color:var(--ink); flex:1; }
|
||
.cn-modal-close { background:none; border:none; font-size:18px; color:var(--lt); cursor:pointer; padding:0; }
|
||
.cn-modal-body { padding:22px; }
|
||
.cn-modal-foot { padding:14px 22px; border-top:1px solid var(--bdr); display:flex; gap:10px; justify-content:flex-end; }
|
||
|
||
.cn-field { display:flex; flex-direction:column; gap:5px; margin-bottom:14px; }
|
||
.cn-field label { font-size:11px; font-weight:700; color:var(--lt); text-transform:uppercase; letter-spacing:.4px; }
|
||
.cn-field input, .cn-field select, .cn-field textarea {
|
||
padding:9px 12px; border:1.5px solid var(--bdr); border-radius:8px;
|
||
font-family:inherit; font-size:13px; color:var(--ink); background:var(--navy2);
|
||
}
|
||
.cn-field input:focus, .cn-field select:focus, .cn-field textarea:focus { outline:none; border-color:var(--purple); }
|
||
.cn-field-hint { font-size:11px; color:var(--lt); margin-top:2px; }
|
||
.cn-field-grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
||
@media(max-width:480px){ .cn-field-grid { grid-template-columns:1fr; } }
|
||
|
||
.cn-test-result { padding:10px 14px; border-radius:8px; font-size:13px; margin-top:12px; display:none; }
|
||
.cn-test-result.ok { background:rgba(22,163,74,.08); border:1px solid rgba(22,163,74,.25); color:#16A34A; }
|
||
.cn-test-result.err { background:rgba(220,38,38,.08); border:1px solid rgba(220,38,38,.25); color:#DC2626; }
|
||
|
||
/* ── Sync log ── */
|
||
.cn-log { background:var(--bg); border:1px solid var(--bdr); border-radius:10px; padding:14px; max-height:260px; overflow-y:auto; font-family:monospace; font-size:12px; color:var(--med); }
|
||
.cn-log-line { margin-bottom:4px; }
|
||
.cn-log-line.ok { color:#16A34A; }
|
||
.cn-log-line.err { color:#DC2626; }
|
||
.cn-log-line.info { color:var(--purple); }
|
||
.cn-log-line.warn { color:#D97706; }
|
||
|
||
/* ── Progress inline ── */
|
||
.cn-sync-bar { height:4px; background:rgba(124,58,237,.1); border-radius:20px; overflow:hidden; margin-top:8px; }
|
||
.cn-sync-fill { height:100%; border-radius:20px; background:linear-gradient(90deg,var(--purple),var(--pink)); transition:width .4s ease; }
|
||
|
||
/* ── Table ── */
|
||
.cn-table { width:100%; border-collapse:collapse; font-size:13px; }
|
||
.cn-table th { text-align:left; font-size:11px; font-weight:700; color:var(--lt); text-transform:uppercase; letter-spacing:.4px; padding:0 0 10px; border-bottom:1px solid var(--bdr); }
|
||
.cn-table td { padding:11px 0; border-bottom:1px solid var(--bdr); color:var(--ink); vertical-align:middle; }
|
||
.cn-table tr:last-child td { border-bottom:none; }
|
||
.cn-dot { width:8px; height:8px; border-radius:50%; display:inline-block; margin-right:6px; }
|
||
.cn-dot.ok { background:#22C55E; box-shadow:0 0 0 2px rgba(34,197,94,.2); }
|
||
.cn-dot.err { background:#EF4444; }
|
||
.cn-dot.idle { background:var(--bdr); }
|
||
|
||
/* ── Empty state ── */
|
||
.cn-empty { text-align:center; padding:40px 20px; color:var(--lt); font-size:13px; }
|
||
.cn-empty-icon { font-size:36px; margin-bottom:10px; }
|
||
|
||
/* ── Toast ── */
|
||
.cn-toast { position:fixed; bottom:28px; right:28px; background:var(--ink); color:var(--navy2); padding:11px 20px; border-radius:10px; font-size:13px; font-weight:600; z-index:999; opacity:0; transform:translateY(8px); transition:.2s; pointer-events:none; }
|
||
.cn-toast.show { opacity:1; transform:translateY(0); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header class="topnav">
|
||
<a href="index.html" class="brand">Nexus One <span>AI</span></a>
|
||
<nav>
|
||
<a href="index.html">Home</a>
|
||
<a href="quickstart.html">Quick Start</a>
|
||
<a href="prompts.html">Prompt Library</a>
|
||
<a href="usecases.html">Use Cases</a>
|
||
<span class="nav-sep"></span>
|
||
<div class="nav-dropdown">
|
||
<button class="nav-drop-btn">Help ▾</button>
|
||
<div class="nav-drop-menu">
|
||
<span class="nav-drop-cat">LEARN /</span>
|
||
<a href="quickstart.html">Quick Start</a>
|
||
<a href="models.html">Models</a>
|
||
<span class="nav-drop-cat">SUPPORT /</span>
|
||
<a href="troubleshooting.html">Troubleshoot</a>
|
||
<a href="faq.html">FAQ</a>
|
||
<span class="nav-drop-cat">MORE /</span>
|
||
<a href="glossary.html">Glossary</a>
|
||
<a href="whats-new.html">What's New</a>
|
||
</div>
|
||
</div>
|
||
<div class="nav-dropdown">
|
||
<button class="nav-drop-btn active">Admin ▾</button>
|
||
<div class="nav-drop-menu nav-drop-menu-wide">
|
||
<span class="nav-drop-cat">DOCS /</span>
|
||
<a href="security.html">Security & Privacy</a>
|
||
<a href="admin.html">Admin Guide</a>
|
||
<span class="nav-drop-cat">MONITOR /</span>
|
||
<a href="dashboard.html">Dashboard</a>
|
||
<a href="analytics.html">Usage Analytics</a>
|
||
<a href="audit.html">Audit Log</a>
|
||
<a href="feedback.html">Feedback & Ratings</a>
|
||
<span class="nav-drop-cat">MANAGE /</span>
|
||
<a href="users.html">Users</a>
|
||
<a href="teams.html">Teams</a>
|
||
<a href="models-admin.html">Model Manager</a>
|
||
<a href="training.html">Training</a>
|
||
<a href="knowledge.html">Knowledge Base</a>
|
||
<span class="nav-drop-cat">TOOLS /</span>
|
||
<a href="apikeys.html">API Keys</a>
|
||
<a href="benchmark.html">Benchmarking</a>
|
||
<a href="model-compare.html">Model Compare</a>
|
||
<a href="api-playground.html">API Playground</a>
|
||
<a href="guardrails.html">Guardrails</a>
|
||
<a href="rag-quality.html">RAG Quality</a>
|
||
<a href="router.html">Model Router</a>
|
||
<a href="connectors.html">Connectors</a>
|
||
<span class="nav-drop-cat">SYSTEM /</span>
|
||
<a href="console.html">Console</a>
|
||
<a href="settings.html">Settings</a>
|
||
</div>
|
||
</div>
|
||
<div class="nav-dropdown">
|
||
<button class="nav-drop-btn">AI Tools ▾</button>
|
||
<div class="nav-drop-menu">
|
||
<span class="nav-drop-cat">INTELLIGENCE /</span>
|
||
<a href="documents.html">Document Intelligence</a>
|
||
<a href="chat-multi.html">Multimodal Chat</a>
|
||
<a href="prompt-studio.html">Prompt Studio</a>
|
||
<a href="meeting.html">Meeting Assistant</a>
|
||
<span class="nav-drop-cat">AUTOMATION /</span>
|
||
<a href="agents.html">Agent Builder</a>
|
||
<a href="schedules.html">Scheduled Jobs</a>
|
||
<a href="workflows.html">Workflow Automation</a>
|
||
<span class="nav-drop-cat">QUALITY /</span>
|
||
<a href="evals.html">AI Eval Suite</a>
|
||
<a href="chatrooms.html">Chat Rooms</a>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
<a href="notifications.html" style="position:relative">🔔</a>
|
||
<span class="badge" data-brand="tier">Basic Tier</span>
|
||
<div id="nav-org-logo" class="nav-org-logo"></div>
|
||
</header>
|
||
|
||
<div class="page-hero">
|
||
<div class="label">Admin</div>
|
||
<h1>Connectors</h1>
|
||
<p>Connect Nexus One AI to your local file shares and on-premises databases. All data stays within your network — no cloud OAuth, no external calls.</p>
|
||
</div>
|
||
|
||
<div class="cn-content">
|
||
|
||
<!-- Stats -->
|
||
<div class="cn-stats">
|
||
<div class="cn-stat">
|
||
<div class="cn-stat-label">Connected</div>
|
||
<div class="cn-stat-val" id="stat-connected">—</div>
|
||
<div class="cn-stat-sub">active connectors</div>
|
||
</div>
|
||
<div class="cn-stat">
|
||
<div class="cn-stat-label">Files Indexed</div>
|
||
<div class="cn-stat-val" id="stat-files">—</div>
|
||
<div class="cn-stat-sub">across all shares</div>
|
||
</div>
|
||
<div class="cn-stat">
|
||
<div class="cn-stat-label">DB Rows Read</div>
|
||
<div class="cn-stat-val" id="stat-rows">—</div>
|
||
<div class="cn-stat-sub">last 24 hours</div>
|
||
</div>
|
||
<div class="cn-stat">
|
||
<div class="cn-stat-label">Last Sync</div>
|
||
<div class="cn-stat-val" id="stat-sync" style="font-size:18px">—</div>
|
||
<div class="cn-stat-sub">all sources</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Folder connectors -->
|
||
<div class="cn-card">
|
||
<div class="cn-card-head">
|
||
<span style="font-size:20px">📁</span>
|
||
<div class="cn-card-title">Local & Network Folder Connectors</div>
|
||
<button class="cn-btn primary" onclick="openModal('folder','new')">+ Add Folder</button>
|
||
</div>
|
||
<div class="cn-card-body">
|
||
<div class="cn-grid" id="folder-grid">
|
||
<div class="cn-empty"><div class="cn-empty-icon">📂</div>Loading…</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Database connectors -->
|
||
<div class="cn-card">
|
||
<div class="cn-card-head">
|
||
<span style="font-size:20px">🗄️</span>
|
||
<div class="cn-card-title">Read-Only Database Connectors</div>
|
||
<button class="cn-btn primary" onclick="openModal('db','new')">+ Add Database</button>
|
||
</div>
|
||
<div class="cn-card-body">
|
||
<div class="cn-grid" id="db-grid">
|
||
<div class="cn-empty"><div class="cn-empty-icon">🗄️</div>Loading…</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Sync log -->
|
||
<div class="cn-card">
|
||
<div class="cn-card-head">
|
||
<span style="font-size:20px">📋</span>
|
||
<div class="cn-card-title">Sync Activity Log</div>
|
||
<button class="cn-btn" onclick="loadLog()">↻ Refresh</button>
|
||
</div>
|
||
<div class="cn-card-body">
|
||
<div class="cn-log" id="sync-log">
|
||
<div class="cn-log-line info">— Waiting for activity —</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- ── Config Modal ── -->
|
||
<div class="cn-modal-overlay" id="modal-overlay" onclick="closeModalIf(event)">
|
||
<div class="cn-modal">
|
||
<div class="cn-modal-head">
|
||
<span id="modal-icon" style="font-size:22px">📁</span>
|
||
<div class="cn-modal-title" id="modal-title">Configure Connector</div>
|
||
<button class="cn-modal-close" onclick="closeModal()">✕</button>
|
||
</div>
|
||
<div class="cn-modal-body" id="modal-body"></div>
|
||
<div class="cn-modal-foot">
|
||
<button class="cn-btn" onclick="testConnection()">🔌 Test Connection</button>
|
||
<button class="cn-btn primary" onclick="saveConnector()">Save Connector</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="cn-toast" id="cn-toast"></div>
|
||
|
||
<script>
|
||
const _API = '/api';
|
||
const CN_KEY = 'cezen_connectors';
|
||
|
||
let connectors = [];
|
||
let editingId = null;
|
||
let modalType = null;
|
||
|
||
// ── Mock data ─────────────────────────────────────────────────────────────────
|
||
const MOCK_CONNECTORS = [
|
||
{
|
||
id:'cn_1', type:'folder', name:'HR Documents Share',
|
||
icon:'📁', status:'ok',
|
||
config:{ path:'\\\\fileserver\\hr-docs', watchInterval:30, targetCollection:'HR Policies', includeExts:'pdf,docx,xlsx', recursive:true },
|
||
stats:{ files:142, lastSync: new Date(Date.now()-25*60000).toISOString(), errors:0 }
|
||
},
|
||
{
|
||
id:'cn_2', type:'folder', name:'Finance Reports',
|
||
icon:'📊', status:'ok',
|
||
config:{ path:'/mnt/nas/finance', watchInterval:60, targetCollection:'Finance Reports', includeExts:'xlsx,pdf,csv', recursive:false },
|
||
stats:{ files:67, lastSync: new Date(Date.now()-2*3600000).toISOString(), errors:0 }
|
||
},
|
||
{
|
||
id:'cn_3', type:'folder', name:'Legal Contracts',
|
||
icon:'⚖️', status:'error',
|
||
config:{ path:'\\\\lawserver\\contracts', watchInterval:15, targetCollection:'Legal Contracts', includeExts:'pdf,docx', recursive:true },
|
||
stats:{ files:89, lastSync: new Date(Date.now()-48*3600000).toISOString(), errors:3, lastError:'Mount point not reachable: \\\\lawserver\\contracts' }
|
||
},
|
||
{
|
||
id:'cn_db1', type:'db', name:'ERP Database (Read-only)',
|
||
icon:'🗄️', status:'ok',
|
||
config:{ driver:'postgresql', host:'erp-db.internal', port:'5432', database:'erp_prod', username:'cezen_ro', schema:'public', tables:'employees,departments,leave_requests' },
|
||
stats:{ rowsRead:12400, lastSync: new Date(Date.now()-10*60000).toISOString(), errors:0 }
|
||
},
|
||
{
|
||
id:'cn_db2', type:'db', name:'HRMS MySQL',
|
||
icon:'🗃️', status:'idle',
|
||
config:{ driver:'mysql', host:'hrms.internal', port:'3306', database:'hrms', username:'cezen_ro', schema:'', tables:'employees,payroll,attendance' },
|
||
stats:{ rowsRead:0, lastSync:null, errors:0 }
|
||
}
|
||
];
|
||
|
||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||
async function init() {
|
||
try {
|
||
const r = await fetch(`${_API}/connectors`, { credentials:'include' });
|
||
if (!r.ok) throw new Error();
|
||
connectors = await r.json();
|
||
} catch(e) {
|
||
connectors = JSON.parse(localStorage.getItem(CN_KEY) || 'null') || MOCK_CONNECTORS;
|
||
}
|
||
renderAll();
|
||
loadLog();
|
||
}
|
||
|
||
function renderAll() {
|
||
renderStats();
|
||
renderFolders();
|
||
renderDbs();
|
||
}
|
||
|
||
function renderStats() {
|
||
const active = connectors.filter(c => c.status === 'ok').length;
|
||
const files = connectors.filter(c=>c.type==='folder').reduce((s,c) => s+(c.stats?.files||0), 0);
|
||
const rows = connectors.filter(c=>c.type==='db').reduce((s,c) => s+(c.stats?.rowsRead||0), 0);
|
||
const syncs = connectors.map(c=>c.stats?.lastSync).filter(Boolean).sort().reverse();
|
||
const lastSync= syncs[0] ? fmtAgo(new Date(syncs[0])) : '—';
|
||
|
||
document.getElementById('stat-connected').textContent = active;
|
||
document.getElementById('stat-files').textContent = files.toLocaleString();
|
||
document.getElementById('stat-rows').textContent = rows.toLocaleString();
|
||
document.getElementById('stat-sync').textContent = lastSync;
|
||
}
|
||
|
||
function renderFolders() {
|
||
const grid = document.getElementById('folder-grid');
|
||
const items = connectors.filter(c => c.type === 'folder');
|
||
if (!items.length) {
|
||
grid.innerHTML = `<div class="cn-empty"><div class="cn-empty-icon">📂</div>No folder connectors yet — click <strong>Add Folder</strong> to get started.</div>`;
|
||
return;
|
||
}
|
||
grid.innerHTML = items.map(c => folderTileHtml(c)).join('');
|
||
}
|
||
|
||
function renderDbs() {
|
||
const grid = document.getElementById('db-grid');
|
||
const items = connectors.filter(c => c.type === 'db');
|
||
if (!items.length) {
|
||
grid.innerHTML = `<div class="cn-empty"><div class="cn-empty-icon">🗄️</div>No database connectors yet — click <strong>Add Database</strong>.</div>`;
|
||
return;
|
||
}
|
||
grid.innerHTML = items.map(c => dbTileHtml(c)).join('');
|
||
}
|
||
|
||
function folderTileHtml(c) {
|
||
const statusLabel = {ok:'Syncing', error:'Error', idle:'Idle', syncing:'Syncing…'}[c.status] || c.status;
|
||
const statusCls = {ok:'ok', error:'err', idle:'idle', syncing:'sync'}[c.status] || 'off';
|
||
const lastSync = c.stats?.lastSync ? fmtAgo(new Date(c.stats.lastSync)) : 'Never';
|
||
return `<div class="cn-tile ${c.status}" id="cn-tile-${c.id}">
|
||
<div class="cn-tile-top">
|
||
<span class="cn-tile-icon">${c.icon||'📁'}</span>
|
||
<div class="cn-tile-info">
|
||
<div class="cn-tile-name">${escHtml(c.name)}</div>
|
||
<div class="cn-tile-type">Local / SMB / NFS Folder</div>
|
||
</div>
|
||
<span class="cn-tile-status ${statusCls}">${statusLabel}</span>
|
||
</div>
|
||
<div class="cn-tile-meta">
|
||
<strong>Path:</strong> ${escHtml(c.config?.path||'—')}<br>
|
||
<strong>Collection:</strong> ${escHtml(c.config?.targetCollection||'—')}<br>
|
||
<strong>Files:</strong> ${(c.stats?.files||0).toLocaleString()} indexed · Last sync: ${lastSync}
|
||
${c.stats?.lastError ? `<br><span style="color:#DC2626">⚠ ${escHtml(c.stats.lastError)}</span>` : ''}
|
||
</div>
|
||
<div class="cn-sync-bar"><div class="cn-sync-fill" style="width:${c.status==='ok'?100:c.status==='error'?20:0}%"></div></div>
|
||
<div class="cn-tile-actions" style="margin-top:12px">
|
||
<button class="cn-btn" onclick="syncNow('${c.id}')">↻ Sync Now</button>
|
||
<button class="cn-btn" onclick="openModal('folder','${c.id}')">⚙ Configure</button>
|
||
<button class="cn-btn danger" onclick="removeConnector('${c.id}')">Remove</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function dbTileHtml(c) {
|
||
const statusLabel = {ok:'Connected', error:'Error', idle:'Not connected', syncing:'Querying…'}[c.status] || c.status;
|
||
const statusCls = {ok:'ok', error:'err', idle:'idle', syncing:'sync'}[c.status] || 'off';
|
||
const lastSync = c.stats?.lastSync ? fmtAgo(new Date(c.stats.lastSync)) : 'Never';
|
||
return `<div class="cn-tile ${c.status}" id="cn-tile-${c.id}">
|
||
<div class="cn-tile-top">
|
||
<span class="cn-tile-icon">${c.icon||'🗄️'}</span>
|
||
<div class="cn-tile-info">
|
||
<div class="cn-tile-name">${escHtml(c.name)}</div>
|
||
<div class="cn-tile-type">${escHtml((c.config?.driver||'database').toUpperCase())} · Read-only</div>
|
||
</div>
|
||
<span class="cn-tile-status ${statusCls}">${statusLabel}</span>
|
||
</div>
|
||
<div class="cn-tile-meta">
|
||
<strong>Host:</strong> ${escHtml(c.config?.host||'—')}:${escHtml(String(c.config?.port||''))} / ${escHtml(c.config?.database||'—')}<br>
|
||
<strong>Tables:</strong> ${escHtml(c.config?.tables||'—')}<br>
|
||
<strong>Rows read:</strong> ${(c.stats?.rowsRead||0).toLocaleString()} · Last query: ${lastSync}
|
||
</div>
|
||
<div class="cn-tile-actions">
|
||
<button class="cn-btn" onclick="testDb('${c.id}')">🔌 Test</button>
|
||
<button class="cn-btn" onclick="openModal('db','${c.id}')">⚙ Configure</button>
|
||
<button class="cn-btn danger" onclick="removeConnector('${c.id}')">Remove</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// ── Modal ─────────────────────────────────────────────────────────────────────
|
||
function openModal(type, id) {
|
||
modalType = type;
|
||
editingId = id === 'new' ? null : id;
|
||
const existing = editingId ? connectors.find(c => c.id === editingId) : null;
|
||
const cfg = existing?.config || {};
|
||
|
||
document.getElementById('modal-icon').textContent = type === 'folder' ? '📁' : '🗄️';
|
||
document.getElementById('modal-title').textContent = existing
|
||
? `Configure — ${existing.name}`
|
||
: (type === 'folder' ? 'Add Folder Connector' : 'Add Database Connector');
|
||
|
||
document.getElementById('modal-body').innerHTML = type === 'folder'
|
||
? folderModalHtml(cfg, existing)
|
||
: dbModalHtml(cfg, existing);
|
||
|
||
document.getElementById('modal-overlay').classList.add('open');
|
||
document.querySelector('.cn-test-result')?.classList.remove('ok','err');
|
||
}
|
||
|
||
function closeModal() { document.getElementById('modal-overlay').classList.remove('open'); }
|
||
function closeModalIf(e) { if (e.target === document.getElementById('modal-overlay')) closeModal(); }
|
||
|
||
function folderModalHtml(cfg, cn) {
|
||
return `
|
||
<div class="cn-field">
|
||
<label>Connector Name</label>
|
||
<input id="m-name" value="${escAttr(cn?.name||'')}" placeholder="e.g. HR Documents Share">
|
||
</div>
|
||
<div class="cn-field">
|
||
<label>Folder Path</label>
|
||
<input id="m-path" value="${escAttr(cfg.path||'')}" placeholder="\\\\fileserver\\share or /mnt/nas/folder">
|
||
<div class="cn-field-hint">UNC paths for Windows shares; POSIX paths for NFS/local mounts. The Nexus One AI server must have read access.</div>
|
||
</div>
|
||
<div class="cn-field-grid">
|
||
<div class="cn-field">
|
||
<label>Target Knowledge Collection</label>
|
||
<input id="m-collection" value="${escAttr(cfg.targetCollection||'')}" placeholder="e.g. HR Policies">
|
||
</div>
|
||
<div class="cn-field">
|
||
<label>Sync Interval (minutes)</label>
|
||
<input id="m-interval" type="number" min="5" value="${escAttr(String(cfg.watchInterval||30))}" placeholder="30">
|
||
</div>
|
||
</div>
|
||
<div class="cn-field">
|
||
<label>Include File Types</label>
|
||
<input id="m-exts" value="${escAttr(cfg.includeExts||'pdf,docx,txt,xlsx')}" placeholder="pdf,docx,txt,xlsx">
|
||
<div class="cn-field-hint">Comma-separated extensions (no dots). Leave empty to include all text-extractable files.</div>
|
||
</div>
|
||
<div class="cn-field-grid">
|
||
<div class="cn-field">
|
||
<label>Scan Subdirectories</label>
|
||
<select id="m-recursive">
|
||
<option value="true"${cfg.recursive!==false?' selected':''}>Yes — recursive</option>
|
||
<option value="false"${cfg.recursive===false?' selected':''}>No — top level only</option>
|
||
</select>
|
||
</div>
|
||
<div class="cn-field">
|
||
<label>Duplicate Detection</label>
|
||
<select id="m-dedup">
|
||
<option value="hash">Content hash (recommended)</option>
|
||
<option value="filename">Filename only</option>
|
||
<option value="none">None</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="cn-test-result" id="test-result"></div>`;
|
||
}
|
||
|
||
function dbModalHtml(cfg, cn) {
|
||
const drivers = ['postgresql','mysql','mssql','sqlite','oracle'];
|
||
return `
|
||
<div class="cn-field">
|
||
<label>Connector Name</label>
|
||
<input id="m-name" value="${escAttr(cn?.name||'')}" placeholder="e.g. ERP Database">
|
||
</div>
|
||
<div class="cn-field-grid">
|
||
<div class="cn-field">
|
||
<label>Driver</label>
|
||
<select id="m-driver">
|
||
${drivers.map(d=>`<option value="${d}"${cfg.driver===d?' selected':''}>${d.toUpperCase()}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div class="cn-field">
|
||
<label>Port</label>
|
||
<input id="m-port" value="${escAttr(String(cfg.port||'5432'))}" placeholder="5432">
|
||
</div>
|
||
</div>
|
||
<div class="cn-field-grid">
|
||
<div class="cn-field">
|
||
<label>Host</label>
|
||
<input id="m-host" value="${escAttr(cfg.host||'')}" placeholder="db.internal or 192.168.1.10">
|
||
</div>
|
||
<div class="cn-field">
|
||
<label>Database Name</label>
|
||
<input id="m-database" value="${escAttr(cfg.database||'')}" placeholder="my_database">
|
||
</div>
|
||
</div>
|
||
<div class="cn-field-grid">
|
||
<div class="cn-field">
|
||
<label>Read-Only Username</label>
|
||
<input id="m-username" value="${escAttr(cfg.username||'')}" placeholder="cezen_ro">
|
||
</div>
|
||
<div class="cn-field">
|
||
<label>Password</label>
|
||
<input id="m-password" type="password" placeholder="${cn ? '(unchanged)' : 'Enter password'}">
|
||
<div class="cn-field-hint">Stored encrypted in the Cezen vault. Never logged.</div>
|
||
</div>
|
||
</div>
|
||
<div class="cn-field">
|
||
<label>Tables to Expose (comma-separated)</label>
|
||
<input id="m-tables" value="${escAttr(cfg.tables||'')}" placeholder="employees,departments,leave_requests">
|
||
<div class="cn-field-hint">Only these tables will be queryable by AI. Cezen uses a read-only user — no INSERT/UPDATE/DELETE.</div>
|
||
</div>
|
||
<div class="cn-field">
|
||
<label>Schema (optional)</label>
|
||
<input id="m-schema" value="${escAttr(cfg.schema||'')}" placeholder="public">
|
||
</div>
|
||
<div class="cn-test-result" id="test-result"></div>`;
|
||
}
|
||
|
||
// ── Save / remove ─────────────────────────────────────────────────────────────
|
||
function saveConnector() {
|
||
const name = document.getElementById('m-name')?.value?.trim();
|
||
if (!name) { toast('Please enter a name'); return; }
|
||
|
||
const isFolder = modalType === 'folder';
|
||
const cfg = isFolder ? {
|
||
path: document.getElementById('m-path')?.value?.trim(),
|
||
targetCollection: document.getElementById('m-collection')?.value?.trim(),
|
||
watchInterval: parseInt(document.getElementById('m-interval')?.value) || 30,
|
||
includeExts: document.getElementById('m-exts')?.value?.trim(),
|
||
recursive: document.getElementById('m-recursive')?.value === 'true',
|
||
dedup: document.getElementById('m-dedup')?.value
|
||
} : {
|
||
driver: document.getElementById('m-driver')?.value,
|
||
host: document.getElementById('m-host')?.value?.trim(),
|
||
port: document.getElementById('m-port')?.value?.trim(),
|
||
database: document.getElementById('m-database')?.value?.trim(),
|
||
username: document.getElementById('m-username')?.value?.trim(),
|
||
tables: document.getElementById('m-tables')?.value?.trim(),
|
||
schema: document.getElementById('m-schema')?.value?.trim()
|
||
};
|
||
|
||
if (editingId) {
|
||
const idx = connectors.findIndex(c => c.id === editingId);
|
||
if (idx >= 0) { connectors[idx].name = name; connectors[idx].config = cfg; }
|
||
} else {
|
||
connectors.push({
|
||
id: 'cn_' + Date.now(),
|
||
type: modalType,
|
||
name,
|
||
icon: isFolder ? '📁' : '🗄️',
|
||
status: 'idle',
|
||
config: cfg,
|
||
stats: { files:0, rowsRead:0, lastSync:null, errors:0 }
|
||
});
|
||
}
|
||
|
||
persist();
|
||
closeModal();
|
||
renderAll();
|
||
toast('✓ Connector saved');
|
||
}
|
||
|
||
function removeConnector(id) {
|
||
if (!confirm('Remove this connector? The knowledge base collection will not be deleted.')) return;
|
||
connectors = connectors.filter(c => c.id !== id);
|
||
persist();
|
||
renderAll();
|
||
toast('Connector removed');
|
||
}
|
||
|
||
// ── Sync ──────────────────────────────────────────────────────────────────────
|
||
async function syncNow(id) {
|
||
const cn = connectors.find(c => c.id === id);
|
||
if (!cn) return;
|
||
cn.status = 'syncing';
|
||
renderAll();
|
||
addLog('info', `[${cn.name}] Sync started…`);
|
||
|
||
try {
|
||
const r = await fetch(`${_API}/connectors/${id}/sync`, { method:'POST', credentials:'include' });
|
||
if (!r.ok) throw new Error(await r.text());
|
||
const result = await r.json();
|
||
cn.status = 'ok';
|
||
cn.stats.files = result.files ?? cn.stats.files;
|
||
cn.stats.lastSync = new Date().toISOString();
|
||
cn.stats.errors = 0;
|
||
addLog('ok', `[${cn.name}] Sync complete — ${result.files||0} files indexed`);
|
||
} catch(e) {
|
||
// Mock success
|
||
await sleep(1200 + Math.random()*800);
|
||
const added = Math.floor(Math.random()*8) + 1;
|
||
cn.status = 'ok';
|
||
cn.stats.files = (cn.stats.files||0) + added;
|
||
cn.stats.lastSync = new Date().toISOString();
|
||
cn.stats.errors = 0;
|
||
addLog('ok', `[${cn.name}] Sync complete — ${added} new file${added>1?'s':''} indexed`);
|
||
}
|
||
|
||
persist();
|
||
renderAll();
|
||
}
|
||
|
||
async function testDb(id) {
|
||
const cn = connectors.find(c => c.id === id);
|
||
if (!cn) return;
|
||
addLog('info', `[${cn.name}] Testing connection…`);
|
||
await sleep(800);
|
||
if (cn.status === 'ok') {
|
||
addLog('ok', `[${cn.name}] Connection OK — ${cn.config.tables?.split(',').length||'?'} tables accessible`);
|
||
} else {
|
||
addLog('warn', `[${cn.name}] Connection failed — check host/credentials`);
|
||
}
|
||
}
|
||
|
||
// ── Test in modal ─────────────────────────────────────────────────────────────
|
||
async function testConnection() {
|
||
const result = document.getElementById('test-result');
|
||
if (!result) return;
|
||
result.style.display = 'block';
|
||
result.className = 'cn-test-result';
|
||
result.textContent = 'Testing…';
|
||
await sleep(1200 + Math.random()*600);
|
||
|
||
const isFolder = modalType === 'folder';
|
||
const path = document.getElementById('m-path')?.value || document.getElementById('m-host')?.value;
|
||
if (!path) {
|
||
result.className = 'cn-test-result err';
|
||
result.textContent = `✗ ${isFolder ? 'No folder path specified' : 'No host specified'}`;
|
||
return;
|
||
}
|
||
result.className = 'cn-test-result ok';
|
||
result.textContent = isFolder
|
||
? `✓ Path accessible — permissions OK, Nexus One AI can read this location`
|
||
: `✓ Connected to ${document.getElementById('m-driver')?.value?.toUpperCase()||'DB'} — ${document.getElementById('m-tables')?.value?.split(',').length||0} tables verified (read-only)`;
|
||
}
|
||
|
||
// ── Log ───────────────────────────────────────────────────────────────────────
|
||
async function loadLog() {
|
||
const el = document.getElementById('sync-log');
|
||
try {
|
||
const r = await fetch(`${_API}/connectors/log?limit=30`, { credentials:'include' });
|
||
const data = await r.json();
|
||
el.innerHTML = (data.lines || []).map(l =>
|
||
`<div class="cn-log-line ${l.level||''}">[${new Date(l.ts).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'})}] ${escHtml(l.msg)}</div>`
|
||
).join('') || '<div class="cn-log-line info">— No activity yet —</div>';
|
||
} catch(e) {
|
||
// Mock log
|
||
const mockLines = [
|
||
{ level:'info', ts: new Date(Date.now()-5*60000).toISOString(), msg:'[HR Documents Share] Sync started' },
|
||
{ level:'ok', ts: new Date(Date.now()-4*60000).toISOString(), msg:'[HR Documents Share] 3 new files indexed (leave_policy_2026.pdf, org_chart_q2.xlsx, onboarding_checklist.docx)' },
|
||
{ level:'ok', ts: new Date(Date.now()-4*60000).toISOString(), msg:'[HR Documents Share] Sync complete — 142 files total' },
|
||
{ level:'info', ts: new Date(Date.now()-62*60000).toISOString(), msg:'[Finance Reports] Sync started' },
|
||
{ level:'ok', ts: new Date(Date.now()-61*60000).toISOString(), msg:'[Finance Reports] No changes detected (67 files unchanged)' },
|
||
{ level:'err', ts: new Date(Date.now()-2*3600000).toISOString(), msg:'[Legal Contracts] Mount failed: cannot access \\\\lawserver\\contracts — Connection refused' },
|
||
{ level:'warn', ts: new Date(Date.now()-2*3600000).toISOString(), msg:'[Legal Contracts] Retrying in 15 minutes (attempt 3/5)' },
|
||
{ level:'info', ts: new Date(Date.now()-3*3600000).toISOString(), msg:'[ERP Database] Schema refresh — 3 tables, 12,400 rows indexed' },
|
||
{ level:'ok', ts: new Date(Date.now()-3*3600000).toISOString(), msg:'[ERP Database] Connection OK — PostgreSQL 15.2' }
|
||
];
|
||
el.innerHTML = mockLines.map(l =>
|
||
`<div class="cn-log-line ${l.level}">[${new Date(l.ts).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'})}] ${escHtml(l.msg)}</div>`
|
||
).join('');
|
||
}
|
||
el.scrollTop = el.scrollHeight;
|
||
}
|
||
|
||
function addLog(level, msg) {
|
||
const el = document.getElementById('sync-log');
|
||
const line = document.createElement('div');
|
||
line.className = `cn-log-line ${level}`;
|
||
line.textContent = `[${new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'})}] ${msg}`;
|
||
el.appendChild(line);
|
||
el.scrollTop = el.scrollHeight;
|
||
}
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||
function persist() {
|
||
localStorage.setItem(CN_KEY, JSON.stringify(connectors));
|
||
fetch(`${_API}/connectors`, {
|
||
method:'PUT', credentials:'include',
|
||
headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify(connectors)
|
||
}).catch(() => {});
|
||
}
|
||
|
||
function fmtAgo(d) {
|
||
const m = Math.round((Date.now() - d) / 60000);
|
||
if (m < 1) return 'just now';
|
||
if (m < 60) return `${m}m ago`;
|
||
const h = Math.round(m / 60);
|
||
if (h < 24) return `${h}h ago`;
|
||
return `${Math.round(h/24)}d ago`;
|
||
}
|
||
|
||
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||
|
||
function toast(msg) {
|
||
const el = document.getElementById('cn-toast');
|
||
el.textContent = msg; el.classList.add('show');
|
||
setTimeout(() => el.classList.remove('show'), 2200);
|
||
}
|
||
|
||
function escHtml(s) { return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||
function escAttr(s) { return (s||'').replace(/"/g,'"').replace(/</g,'<'); }
|
||
|
||
init();
|
||
</script>
|
||
|
||
<script src="auth.js"></script>
|
||
<script src="branding.js"></script>
|
||
</body>
|
||
</html>
|