Phase 1 UI: index actions + duplicate-review queue; bump to 0.1.0:45
- frontend: System Status page extended with one-click index actions (update/rebuild/find-duplicates, with live job status) and a human-in-the-loop duplicate-review queue (approve=merge / reject=keep-separate per candidate). - StartOS version 0.1.0:45 (image-only; schema via the in-app migration runner). Backend + new routes verified end-to-end via the running HTTP server. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+351
-8
@@ -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 <div style={{ padding: '20px' }}><SkeletonBlock lines={8} /></div>;
|
||||
if (error) return <div className="toast error" style={{ position: 'static' }}>{error}</div>;
|
||||
@@ -9202,6 +9430,121 @@
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="section">
|
||||
<div className="section-title">Index Actions</div>
|
||||
<div className="index-action-status">
|
||||
{jobRunning ? (
|
||||
<span className="index-job-pill running">
|
||||
<span className="index-job-dot" /> running: {indexJob.kind || 'job'}
|
||||
{indexJob.started_at ? ` · started ${formatDate(indexJob.started_at)}` : ''}
|
||||
</span>
|
||||
) : indexJob && indexJob.result ? (
|
||||
<span className="index-job-pill">
|
||||
last: {indexJob.kind ? `${indexJob.kind} — ` : ''}{indexJob.result}
|
||||
{indexJob.finished_at ? ` · ${formatDate(indexJob.finished_at)}` : ''}
|
||||
</span>
|
||||
) : (
|
||||
<span className="index-job-pill idle">No index job has run yet.</span>
|
||||
)}
|
||||
</div>
|
||||
{jobRunning && indexJob.tail ? (
|
||||
<pre className="index-job-tail">{indexJob.tail}</pre>
|
||||
) : null}
|
||||
<div className="index-action-buttons">
|
||||
<button
|
||||
onClick={() => runIndexAction('/api/index/update', 'update search index')}
|
||||
disabled={jobRunning || actionBusy}
|
||||
>
|
||||
Update search index
|
||||
</button>
|
||||
<button
|
||||
onClick={() => runIndexAction('/api/index/rebuild', 'rebuild search index', 'Rebuild the entire search index? This re-embeds every record and can take several minutes.')}
|
||||
disabled={jobRunning || actionBusy}
|
||||
>
|
||||
Rebuild search index
|
||||
</button>
|
||||
<button
|
||||
onClick={() => runIndexAction('/api/entities/find-duplicates', 'find duplicate contacts')}
|
||||
disabled={jobRunning || actionBusy}
|
||||
>
|
||||
Find duplicate contacts
|
||||
</button>
|
||||
</div>
|
||||
<div className="index-action-hint">
|
||||
Update is a fast incremental pass. Rebuild re-embeds everything. Find duplicates runs the local model and fills the review queue below.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<div className="section">
|
||||
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '16px' }}>
|
||||
Duplicate Review
|
||||
<span className="approval-pill">{candidates.length} pending</span>
|
||||
</div>
|
||||
{candidatesLoading ? (
|
||||
<SkeletonBlock lines={4} />
|
||||
) : candidates.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '20px 0' }}>No duplicates to review.</div>
|
||||
) : (
|
||||
<div className="merge-candidate-list">
|
||||
{candidates.map((c) => {
|
||||
const verdict = (c.verdict || '').toLowerCase();
|
||||
const confidencePct = c.confidence != null
|
||||
? `${Math.round((c.confidence <= 1 ? c.confidence * 100 : c.confidence))}%`
|
||||
: null;
|
||||
const busy = decidingId === c.id;
|
||||
return (
|
||||
<div key={c.id} className="merge-candidate-card">
|
||||
<div className="merge-candidate-people">
|
||||
<div className="merge-candidate-person">
|
||||
<div className="merge-candidate-name">{c.name_a || '(no name)'}</div>
|
||||
<div className="merge-candidate-email">{c.email_a || '—'}</div>
|
||||
</div>
|
||||
<div className="merge-candidate-vs">vs</div>
|
||||
<div className="merge-candidate-person">
|
||||
<div className="merge-candidate-name">{c.name_b || '(no name)'}</div>
|
||||
<div className="merge-candidate-email">{c.email_b || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
{c.context ? (
|
||||
<div className="merge-candidate-context">{c.context}</div>
|
||||
) : null}
|
||||
<div className="merge-candidate-suggestion">
|
||||
<span className={`badge ${verdict === 'same' ? 'badge-funded' : 'badge-other'}`}>
|
||||
Suggestion: {c.verdict || 'unknown'}
|
||||
</span>
|
||||
{confidencePct ? (
|
||||
<span className="merge-candidate-confidence">{confidencePct} confidence</span>
|
||||
) : null}
|
||||
</div>
|
||||
{c.reason ? (
|
||||
<div className="merge-candidate-reason">{c.reason}</div>
|
||||
) : null}
|
||||
<div className="merge-candidate-actions">
|
||||
<button
|
||||
onClick={() => decideCandidate(c, 'approve')}
|
||||
disabled={busy}
|
||||
>
|
||||
Same person — merge
|
||||
</button>
|
||||
<button
|
||||
className="button-secondary"
|
||||
onClick={() => decideCandidate(c, 'reject')}
|
||||
disabled={busy}
|
||||
>
|
||||
Different — keep separate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="section">
|
||||
<div className="section-title">Thesis</div>
|
||||
<div className="kpi-grid" style={{ marginBottom: 0 }}>
|
||||
@@ -9486,7 +9829,7 @@
|
||||
{page === 'pipeline' && <PipelinePage token={token} onShowToast={showToast} />}
|
||||
{page === 'communications' && <CommunicationsPage token={token} onShowToast={showToast} />}
|
||||
{page === 'thesis' && <ThesisPage token={token} user={user} onShowToast={showToast} />}
|
||||
{page === 'system-status' && <SystemStatusPage token={token} />}
|
||||
{page === 'system-status' && <SystemStatusPage token={token} user={user} onShowToast={showToast} />}
|
||||
{page === 'feature-requests' && <FeatureRequestsPage token={token} onShowToast={showToast} user={user} />}
|
||||
{page === 'instructions' && <InstructionsPage />}
|
||||
{page === 'settings' && (
|
||||
|
||||
Reference in New Issue
Block a user