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:
Keysat
2026-06-05 11:19:43 -05:00
parent cd3cca725c
commit 3354a0b354
4 changed files with 388 additions and 12 deletions
+351 -8
View File
@@ -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' && (
+3 -2
View File
@@ -9,8 +9,9 @@ export const PACKAGE_TITLE = 'Ten31 Database'
// * Cleanup: 0.1.0:40 (seed removed + multi-threaded server + abuser auto-ban)
// * 0.1.0:41 (frontend persists auth across refreshes)
// * 0.1.0:42 (Gmail integration) / 0.1.0:43 (Gmail POST-body hotfix)
// * Current: 0.1.0:44 (Phase-0 ingest + MCP server in image; build-index action)
export const PACKAGE_VERSION = '0.1.0:44'
// * 0.1.0:44 (Phase-0 ingest + MCP server in image; build-index action)
// * Current: 0.1.0:45 (Phase-1 thesis system; dual approval; merge review; in-app index)
export const PACKAGE_VERSION = '0.1.0:45'
export const DATA_MOUNT_PATH = '/data'
export const WEB_PORT = 8080
+3 -2
View File
@@ -5,8 +5,9 @@ import { v_0_1_0_41 } from './v0.1.0.41'
import { v_0_1_0_42 } from './v0.1.0.42'
import { v_0_1_0_43 } from './v0.1.0.43'
import { v_0_1_0_44 } from './v0.1.0.44'
import { v_0_1_0_45 } from './v0.1.0.45'
export const versionGraph = VersionGraph.of({
current: v_0_1_0_44,
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43],
current: v_0_1_0_45,
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44],
})
+31
View File
@@ -0,0 +1,31 @@
import { VersionInfo } from '@start9labs/start-sdk'
// Phase-1 release: the Architect's thesis system + in-app data/index controls.
//
// Adds (web server + image; all schema via the CRM's own migration runner —
// NO StartOS data migration, the live /data volume is preserved):
// * Thesis substrate (migration 0002): versioned per-segment thesis lines, a
// node tree + revisions, dual-approval review (thesis_reviews), segments.
// * Entity-merge review (migration 0003): the fuzzy/Qwen tier writes merge
// CANDIDATES for human approve/reject instead of auto-merging.
// * Web UI: a "Thesis" dual-approval review view and a "System Status" view
// (entity/index health, one-click index actions, duplicate-review queue).
// * Server routes: thesis review/approval, /api/system/status, UI-triggered
// index jobs (rebuild/update/find-duplicates), merge-candidate decisions.
export const v_0_1_0_45 = VersionInfo.of({
version: '0.1.0:45',
releaseNotes: {
en_US: [
'Phase 1: the Architect thesis system. Adds versioned, per-segment thesis',
'lines with a dual partner sign-off review in the web app; a human-in-the-loop',
'duplicate-contact review queue (the local model suggests, you approve/reject);',
'one-click index actions and a system-status dashboard in the CRM; and the',
'supporting schema, created additively by the CRM migration runner — your',
'data is preserved.',
].join(' '),
},
migrations: {
up: async () => {},
down: async () => {},
},
})