diff --git a/frontend/index.html b/frontend/index.html index 481ae84..9edf578 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1280,6 +1280,152 @@ padding-top: 16px; } + .index-action-status { + margin-bottom: 14px; + } + + .index-job-pill { + display: inline-flex; + align-items: center; + gap: 8px; + font-family: 'IBM Plex Mono', monospace; + font-size: 12px; + color: #c7d3e0; + background-color: #1b2837; + border-radius: 999px; + padding: 6px 12px; + } + + .index-job-pill.idle { + color: #8ea2b7; + } + + .index-job-pill.running { + color: #fcd34d; + background-color: #f59e0b22; + } + + .index-job-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: #fcd34d; + animation: index-job-pulse 1.2s ease-in-out infinite; + } + + @keyframes index-job-pulse { + 0%, 100% { opacity: 0.35; } + 50% { opacity: 1; } + } + + .index-job-tail { + margin: 0 0 14px; + max-height: 140px; + overflow: auto; + background-color: #0d1622; + border: 1px solid #263548; + border-radius: 6px; + padding: 10px 12px; + font-family: 'IBM Plex Mono', monospace; + font-size: 11px; + color: #8ea2b7; + white-space: pre-wrap; + } + + .index-action-buttons { + display: flex; + flex-wrap: wrap; + gap: 10px; + } + + .index-action-hint { + margin-top: 12px; + font-size: 12px; + color: #8ea2b7; + line-height: 1.5; + } + + .merge-candidate-list { + display: flex; + flex-direction: column; + gap: 14px; + } + + .merge-candidate-card { + background-color: #0d1622; + border: 1px solid #263548; + border-radius: 8px; + padding: 16px; + } + + .merge-candidate-people { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + } + + .merge-candidate-person { + flex: 1; + min-width: 160px; + } + + .merge-candidate-name { + font-size: 14px; + font-weight: 600; + color: #e5edf5; + } + + .merge-candidate-email { + margin-top: 2px; + font-size: 12px; + color: #8ea2b7; + font-family: 'IBM Plex Mono', monospace; + } + + .merge-candidate-vs { + flex-shrink: 0; + font-size: 11px; + font-weight: 600; + color: #8ea2b7; + text-transform: uppercase; + letter-spacing: 0.08em; + } + + .merge-candidate-context { + margin-top: 10px; + font-size: 12px; + color: #c7d3e0; + } + + .merge-candidate-suggestion { + margin-top: 12px; + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + } + + .merge-candidate-confidence { + font-family: 'IBM Plex Mono', monospace; + font-size: 12px; + color: #8ea2b7; + } + + .merge-candidate-reason { + margin-top: 8px; + font-size: 13px; + line-height: 1.5; + color: #c7d3e0; + } + + .merge-candidate-actions { + margin-top: 14px; + display: flex; + gap: 10px; + flex-wrap: wrap; + } + .confirmation-dialog { position: fixed; top: 0; @@ -9118,26 +9264,108 @@ ); }; - const SystemStatusPage = ({ token }) => { + const SystemStatusPage = ({ token, user, onShowToast }) => { + const isAdmin = user?.role === 'admin'; const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); + const [actionBusy, setActionBusy] = useState(false); + const [candidates, setCandidates] = useState([]); + const [candidatesLoading, setCandidatesLoading] = useState(false); + const [decidingId, setDecidingId] = useState(null); + + const loadStatus = useCallback(async () => { + const result = await api('/api/system/status', {}, token); + setData(result.data); + return result.data; + }, [token]); useEffect(() => { + let cancelled = false; const load = async () => { try { setLoading(true); - const result = await api('/api/system/status', {}, token); - setData(result.data); - setError(''); + await loadStatus(); + if (!cancelled) setError(''); } catch (err) { - setError(getErrorMessage(err, 'Failed to load system status')); + if (!cancelled) setError(getErrorMessage(err, 'Failed to load system status')); } finally { - setLoading(false); + if (!cancelled) setLoading(false); } }; load(); - }, [token]); + return () => { cancelled = true; }; + }, [loadStatus]); + + const indexJob = data?.index_job || null; + const jobRunning = !!(indexJob && indexJob.running); + + // Poll the status endpoint while an index job is running so the + // indicator + last result update without a manual refresh. + useEffect(() => { + if (!jobRunning) return undefined; + let cancelled = false; + const interval = setInterval(async () => { + try { + if (!cancelled) await loadStatus(); + } catch (_) { /* transient; next tick retries */ } + }, 3000); + return () => { cancelled = true; clearInterval(interval); }; + }, [jobRunning, loadStatus]); + + const loadCandidates = useCallback(async () => { + if (!isAdmin) return; + try { + setCandidatesLoading(true); + const result = await api('/api/entities/merge-candidates', {}, token); + setCandidates(Array.isArray(result.candidates) ? result.candidates : []); + } catch (err) { + onShowToast(getErrorMessage(err, 'Failed to load duplicate review queue'), 'error'); + } finally { + setCandidatesLoading(false); + } + }, [isAdmin, token, onShowToast]); + + useEffect(() => { + loadCandidates(); + }, [loadCandidates]); + + const runIndexAction = async (endpoint, label, confirmMessage) => { + if (jobRunning || actionBusy) return; + if (confirmMessage && !window.confirm(confirmMessage)) return; + try { + setActionBusy(true); + const result = await api(endpoint, { method: 'POST' }, token); + const kind = result?.data?.kind || label; + onShowToast(`Started: ${kind}`, 'success'); + } catch (err) { + if (err?.status === 409 || err?.payload?.error === 'job_running') { + onShowToast('An index job is already running', 'error'); + } else { + onShowToast(getErrorMessage(err, `Failed to start ${label}`), 'error'); + } + } finally { + setActionBusy(false); + try { await loadStatus(); } catch (_) { /* ignore refresh failure */ } + } + }; + + const decideCandidate = async (candidate, decision) => { + if (decidingId) return; + try { + setDecidingId(candidate.id); + await api(`/api/entities/merge-candidates/${candidate.id}`, { + method: 'POST', + body: JSON.stringify({ decision }) + }, token); + setCandidates((prev) => prev.filter((c) => c.id !== candidate.id)); + onShowToast(decision === 'approve' ? 'Contacts merged' : 'Kept separate', 'success'); + } catch (err) { + onShowToast(getErrorMessage(err, 'Failed to record decision'), 'error'); + } finally { + setDecidingId(null); + } + }; if (loading) return
{indexJob.tail}
+ ) : null}
+