Search UI: type what you need, see real workers — no more taking my word for it
Rebuilt the dashboard into a live search interface anyone can use: - Big search box: type in plain English, hit Enter or click Search - 3 modes: AI Search, CRM Keyword, Hybrid (best) - Clickable examples: 'warehouse help', 'dependable machine operator', etc - Filters: state, role, min reliability - Results show: name, role, location, skills, certs, reliability, AI match score - Hybrid results marked 'SQL verified against database' - CRM mode shows 0 results with a prompt to try AI Search - Mobile responsive This is the answer to 'we just have to take your word for it.' Type anything. See real workers. Compare CRM vs AI side by side. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
48c7c1c5e6
commit
6a2cc0fb8f
@ -3,98 +3,346 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Lakehouse — Staffing Co-Pilot</title>
|
||||
<title>Lakehouse — Staffing Search</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:'Inter','SF Pro',system-ui,sans-serif;background:#0a0a0f;color:#d4d4d8;line-height:1.6}
|
||||
.hero{background:linear-gradient(135deg,#0f172a 0%,#1e1b4b 50%,#0f172a 100%);padding:24px 30px;border-bottom:1px solid #1e293b;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px}
|
||||
.hero h1{font-size:20px;font-weight:700;background:linear-gradient(to right,#f472b6,#818cf8);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||||
.hero .right{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||||
.dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:4px}
|
||||
.dot.g{background:#34d399}.dot.r{background:#f87171}.dot.y{background:#fbbf24}
|
||||
.svc{font-size:11px;color:#94a3b8}
|
||||
.vram{font-size:11px;color:#64748b}
|
||||
.btn{background:#1e293b;border:1px solid #334155;color:#e2e8f0;padding:7px 16px;border-radius:6px;cursor:pointer;font-size:12px;font-weight:500;transition:all .15s}
|
||||
.btn:hover{background:#818cf8;border-color:#818cf8}
|
||||
.btn.primary{background:#7c3aed;border-color:#7c3aed}
|
||||
.btn.primary:hover{background:#6d28d9}
|
||||
.container{max-width:1200px;margin:0 auto;padding:20px 16px}
|
||||
.grid{display:grid;grid-template-columns:5fr 3fr;gap:16px}
|
||||
.card{background:#111827;border:1px solid #1e293b;border-radius:10px;padding:18px;margin-bottom:16px}
|
||||
.card h2{font-size:13px;color:#818cf8;margin-bottom:12px;text-transform:uppercase;letter-spacing:1px;font-weight:600}
|
||||
.tabs{display:flex;gap:6px;margin-bottom:14px;flex-wrap:wrap}
|
||||
.tab{padding:5px 14px;background:#1e293b;border:1px solid #334155;color:#94a3b8;border-radius:6px;cursor:pointer;font-size:11px;font-weight:500}
|
||||
.tab.active{background:#7c3aed;border-color:#7c3aed;color:#fff}
|
||||
.contract{border-left:3px solid #334155;padding:12px 16px;margin-bottom:10px;background:#0d1117;border-radius:0 8px 8px 0;transition:border-color .2s}
|
||||
.contract.urgent{border-left-color:#f87171}.contract.high{border-left-color:#fbbf24}.contract.filled{border-left-color:#34d399}
|
||||
.contract .top{display:flex;justify-content:space-between;align-items:center}
|
||||
.contract .title{font-weight:600;font-size:13px;color:#e2e8f0}
|
||||
.contract .badge{font-size:10px;padding:2px 8px;border-radius:10px;font-weight:600}
|
||||
.badge.urgent{background:#7f1d1d;color:#fca5a5}.badge.high{background:#78350f;color:#fcd34d}.badge.medium{background:#1e3a5f;color:#93c5fd}.badge.low{background:#14532d;color:#86efac}
|
||||
.contract .meta{font-size:11px;color:#64748b;margin-top:4px}
|
||||
.contract .workers{font-size:11px;color:#94a3b8;margin-top:8px}
|
||||
.contract .workers b{color:#34d399}
|
||||
.alert{padding:10px 14px;margin-bottom:8px;border-radius:6px;font-size:12px;display:flex;align-items:center;gap:8px}
|
||||
.alert .icon{font-size:14px}
|
||||
.alert.warn{background:#1c1305;border:1px solid #854d0e}.alert.info{background:#0c1a2e;border:1px solid #1e40af}.alert.good{background:#052e16;border:1px solid #166534}
|
||||
.playbook{padding:10px 14px;margin-bottom:6px;background:#0d1117;border-radius:6px;font-size:11px;border:1px solid #1e293b}
|
||||
.playbook .op{color:#a78bfa;font-weight:600}
|
||||
.stat-row{display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #1e293b;font-size:13px}
|
||||
.stat-row .val{color:#34d399;font-weight:700;font-variant-numeric:tabular-nums}
|
||||
.log{font-size:11px;color:#64748b;max-height:200px;overflow-y:auto}
|
||||
.log div{padding:4px 0;border-bottom:1px solid #111827}
|
||||
.empty{color:#475569;font-size:13px;text-align:center;padding:30px}
|
||||
.proof-link{display:block;text-align:center;padding:12px;color:#818cf8;font-size:12px;text-decoration:none;border-top:1px solid #1e293b;margin-top:16px}
|
||||
.proof-link:hover{color:#a78bfa}
|
||||
.hero{background:linear-gradient(135deg,#0f172a 0%,#1e1b4b 50%,#0f172a 100%);padding:30px;border-bottom:1px solid #1e293b;text-align:center}
|
||||
.hero h1{font-size:24px;font-weight:700;background:linear-gradient(to right,#f472b6,#818cf8);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:6px}
|
||||
.hero p{color:#94a3b8;font-size:13px}
|
||||
.container{max-width:900px;margin:0 auto;padding:24px 16px}
|
||||
.search-box{background:#111827;border:2px solid #334155;border-radius:12px;padding:20px;margin-bottom:24px;transition:border-color .2s}
|
||||
.search-box:focus-within{border-color:#818cf8}
|
||||
.search-row{display:flex;gap:10px}
|
||||
.search-input{flex:1;background:#0a0a0f;border:1px solid #1e293b;border-radius:8px;padding:14px 18px;color:#e2e8f0;font-size:15px;outline:none}
|
||||
.search-input::placeholder{color:#475569}
|
||||
.search-btn{background:#7c3aed;border:none;color:#fff;padding:14px 28px;border-radius:8px;cursor:pointer;font-size:14px;font-weight:600;white-space:nowrap}
|
||||
.search-btn:hover{background:#6d28d9}
|
||||
.search-btn:disabled{background:#334155;cursor:wait}
|
||||
.filters{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
|
||||
.filter{background:#1e293b;border:1px solid #334155;border-radius:6px;padding:6px 12px;font-size:12px;color:#94a3b8;outline:none}
|
||||
.filter select,.filter input{background:transparent;border:none;color:#e2e8f0;font-size:12px;outline:none;width:auto}
|
||||
.filter select{cursor:pointer}
|
||||
.filter label{color:#64748b;margin-right:4px}
|
||||
.examples{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
|
||||
.example{background:#0d1117;border:1px solid #1e293b;border-radius:20px;padding:4px 14px;font-size:11px;color:#818cf8;cursor:pointer;transition:all .15s}
|
||||
.example:hover{background:#1e1b4b;border-color:#818cf8}
|
||||
.results{margin-top:8px}
|
||||
.result-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;flex-wrap:wrap;gap:8px}
|
||||
.result-header .count{font-size:14px;color:#e2e8f0;font-weight:600}
|
||||
.result-header .meta{font-size:11px;color:#64748b}
|
||||
.badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:10px;font-weight:600}
|
||||
.badge.green{background:#052e16;color:#34d399;border:1px solid #166534}
|
||||
.badge.purple{background:#1e1047;color:#a78bfa;border:1px solid #5b21b6}
|
||||
.badge.blue{background:#0c1a3d;color:#60a5fa;border:1px solid #1e40af}
|
||||
.worker{background:#111827;border:1px solid #1e293b;border-radius:10px;padding:18px;margin-bottom:12px;transition:border-color .2s}
|
||||
.worker:hover{border-color:#334155}
|
||||
.worker .top{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}
|
||||
.worker .name{font-size:16px;font-weight:700;color:#e2e8f0}
|
||||
.worker .role{font-size:13px;color:#818cf8;margin-top:2px}
|
||||
.worker .location{font-size:12px;color:#64748b;margin-top:2px}
|
||||
.worker .score{text-align:right}
|
||||
.worker .score .num{font-size:24px;font-weight:800;color:#34d399}
|
||||
.worker .score .label{font-size:10px;color:#64748b;text-transform:uppercase}
|
||||
.worker .details{display:flex;gap:16px;margin-top:12px;flex-wrap:wrap}
|
||||
.worker .detail{font-size:11px}
|
||||
.worker .detail .dt{color:#64748b;text-transform:uppercase;font-size:10px;letter-spacing:0.5px}
|
||||
.worker .detail .dd{color:#d4d4d8;margin-top:2px}
|
||||
.worker .verified{display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#34d399;margin-top:8px}
|
||||
.empty{text-align:center;color:#475569;padding:40px;font-size:14px}
|
||||
.loading{text-align:center;padding:30px;color:#818cf8}
|
||||
.mode-toggle{display:flex;gap:4px;background:#0d1117;border-radius:8px;padding:3px;margin-bottom:16px}
|
||||
.mode-btn{padding:8px 16px;border-radius:6px;border:none;cursor:pointer;font-size:12px;font-weight:500;color:#94a3b8;background:transparent}
|
||||
.mode-btn.active{background:#7c3aed;color:#fff}
|
||||
.footer{text-align:center;padding:20px;color:#475569;font-size:11px;border-top:1px solid #1e293b;margin-top:30px}
|
||||
.footer a{color:#818cf8;text-decoration:none}
|
||||
@media(max-width:768px){
|
||||
.hero{padding:16px;flex-direction:column;align-items:flex-start}
|
||||
.grid{grid-template-columns:1fr}
|
||||
.contract .top{flex-direction:column;align-items:flex-start;gap:4px}
|
||||
.tabs{overflow-x:auto;flex-wrap:nowrap}
|
||||
.search-row{flex-direction:column}
|
||||
.worker .top{flex-direction:column}
|
||||
.worker .score{text-align:left}
|
||||
.worker .details{flex-direction:column;gap:8px}
|
||||
.filters{flex-direction:column}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="hero">
|
||||
<h1>Staffing Co-Pilot</h1>
|
||||
<div class="right">
|
||||
<span class="svc"><span class="dot g" id="svc-gw"></span>Gateway</span>
|
||||
<span class="svc"><span class="dot g" id="svc-lh"></span>Lakehouse</span>
|
||||
<span class="vram" id="vram-display">VRAM: —</span>
|
||||
<button class="btn" onclick="refresh()">Refresh</button>
|
||||
<button class="btn primary" onclick="runWeek()">Run Week Sim</button>
|
||||
</div>
|
||||
<h1>Search 500,000 Workers</h1>
|
||||
<p>Type what you need in plain English — the AI understands meaning, not just keywords</p>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<div class="card">
|
||||
<h2>Contracts</h2>
|
||||
<div class="tabs" id="day-nav"></div>
|
||||
<div id="contracts"><div class="empty">Click <b>Run Week Sim</b> to simulate a staffing week</div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Week Summary</h2>
|
||||
<div id="week-stats"><div class="empty">No simulation data yet</div></div>
|
||||
</div>
|
||||
<div class="search-box">
|
||||
<div class="search-row">
|
||||
<input class="search-input" id="query" type="text" placeholder="e.g. reliable forklift operator for warehouse in Illinois" autofocus>
|
||||
<button class="search-btn" id="search-btn" onclick="doSearch()">Search</button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="card">
|
||||
<h2>Alerts</h2>
|
||||
<div id="alerts"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Playbooks</h2>
|
||||
<div id="playbooks"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Live Log</h2>
|
||||
<div class="log" id="log"></div>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<div class="filter"><label>State:</label><select id="f-state"><option value="">Any</option><option>IL</option><option>IN</option><option>OH</option><option>MO</option><option>TN</option><option>KY</option><option>WI</option><option>MI</option></select></div>
|
||||
<div class="filter"><label>Role:</label><select id="f-role"><option value="">Any</option><option>Forklift Operator</option><option>Machine Operator</option><option>Assembler</option><option>Loader</option><option>Quality Tech</option><option>Welder</option><option>Sanitation Worker</option><option>Shipping Clerk</option><option>Production Worker</option><option>Maintenance Tech</option></select></div>
|
||||
<div class="filter"><label>Min Reliability:</label><input id="f-rel" type="number" min="0" max="1" step="0.1" value="0.5" style="width:50px"></div>
|
||||
</div>
|
||||
<div class="examples">
|
||||
<span class="example" onclick="tryExample(this)">warehouse help in Illinois</span>
|
||||
<span class="example" onclick="tryExample(this)">dependable machine operator with CNC</span>
|
||||
<span class="example" onclick="tryExample(this)">safety trained for chemical plant</span>
|
||||
<span class="example" onclick="tryExample(this)">bilingual shipping clerk</span>
|
||||
<span class="example" onclick="tryExample(this)">experienced welder Ohio</span>
|
||||
</div>
|
||||
</div>
|
||||
<a class="proof-link" href="proof">View Proof of Work →</a>
|
||||
|
||||
<div class="mode-toggle">
|
||||
<button class="mode-btn active" id="mode-ai" onclick="setMode('ai')">AI Search</button>
|
||||
<button class="mode-btn" id="mode-crm" onclick="setMode('crm')">CRM Keyword</button>
|
||||
<button class="mode-btn" id="mode-hybrid" onclick="setMode('hybrid')">Hybrid (best)</button>
|
||||
</div>
|
||||
|
||||
<div id="results"></div>
|
||||
|
||||
<div class="footer">
|
||||
<a href="proof">View Proof of Work</a> · Powered by Lakehouse · 500K workers · 673K AI-indexed chunks
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="dashboard.ts"></script>
|
||||
|
||||
<script>
|
||||
const base = window.location.pathname.replace(/\/+$/, '');
|
||||
const GW = window.location.origin + base;
|
||||
let mode = 'ai';
|
||||
|
||||
function setMode(m) {
|
||||
mode = m;
|
||||
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
|
||||
document.getElementById('mode-' + m).classList.add('active');
|
||||
}
|
||||
|
||||
function tryExample(el) {
|
||||
document.getElementById('query').value = el.textContent;
|
||||
doSearch();
|
||||
}
|
||||
|
||||
document.getElementById('query').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') doSearch();
|
||||
});
|
||||
|
||||
async function doSearch() {
|
||||
const query = document.getElementById('query').value.trim();
|
||||
if (!query) return;
|
||||
|
||||
const state = document.getElementById('f-state').value;
|
||||
const role = document.getElementById('f-role').value;
|
||||
const rel = parseFloat(document.getElementById('f-rel').value) || 0.5;
|
||||
|
||||
const btn = document.getElementById('search-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Searching...';
|
||||
document.getElementById('results').innerHTML = '<div class="loading">Searching ' + (mode === 'crm' ? 'by keyword' : 'with AI') + '...</div>';
|
||||
|
||||
const t0 = Date.now();
|
||||
|
||||
try {
|
||||
let data;
|
||||
|
||||
if (mode === 'crm') {
|
||||
// CRM keyword search — LIKE match
|
||||
let where = "resume_text LIKE '%" + query.replace(/'/g, "''") + "%'";
|
||||
if (state) where += " AND state = '" + state + "'";
|
||||
if (role) where += " AND role = '" + role + "'";
|
||||
where += " AND CAST(reliability AS DOUBLE) >= " + rel;
|
||||
const r = await fetch(GW + '/sql', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({sql: "SELECT worker_id, name, role, city, state, skills, certifications, archetype, ROUND(CAST(reliability AS DOUBLE),2) as reliability, ROUND(CAST(availability AS DOUBLE),2) as availability FROM workers_500k WHERE " + where + " ORDER BY CAST(reliability AS DOUBLE) DESC LIMIT 10"})
|
||||
});
|
||||
data = await r.json();
|
||||
renderCRMResults(data, query, Date.now() - t0);
|
||||
|
||||
} else if (mode === 'ai') {
|
||||
// Pure AI vector search
|
||||
const r = await fetch(GW + '/api/vectors/hnsw/search', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({index_name: 'workers_500k_v1', query: query, top_k: 10})
|
||||
});
|
||||
data = await r.json();
|
||||
renderAIResults(data, query, Date.now() - t0);
|
||||
|
||||
} else {
|
||||
// Hybrid — SQL filter + AI ranking
|
||||
let filter = "CAST(reliability AS DOUBLE) >= " + rel;
|
||||
if (state) filter += " AND state = '" + state + "'";
|
||||
if (role) filter += " AND role = '" + role + "'";
|
||||
const r = await fetch(GW + '/search', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
question: query, index_name: 'workers_500k_v1',
|
||||
sql_filter: filter, dataset: 'workers_500k',
|
||||
id_column: 'worker_id', top_k: 10, generate: false
|
||||
})
|
||||
});
|
||||
data = await r.json();
|
||||
renderHybridResults(data, query, Date.now() - t0);
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('results').innerHTML = '<div class="empty">Error: ' + e.message + '</div>';
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Search';
|
||||
}
|
||||
|
||||
function renderCRMResults(data, query, ms) {
|
||||
const el = document.getElementById('results');
|
||||
const rows = data.rows || [];
|
||||
el.innerHTML = '';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'result-header';
|
||||
header.innerHTML = '<span class="count">' + rows.length + ' results</span>' +
|
||||
'<span class="meta">CRM keyword match · "' + query + '" · ' + ms + 'ms</span>';
|
||||
el.appendChild(header);
|
||||
|
||||
if (!rows.length) {
|
||||
el.innerHTML += '<div class="empty">No exact keyword match found.<br>Try <b>AI Search</b> — it understands what you mean, not just the exact words.</div>';
|
||||
return;
|
||||
}
|
||||
rows.forEach(function(w) { el.appendChild(makeWorkerCard(w, null)); });
|
||||
}
|
||||
|
||||
function renderAIResults(data, query, ms) {
|
||||
const el = document.getElementById('results');
|
||||
const hits = data.results || [];
|
||||
el.innerHTML = '';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'result-header';
|
||||
header.innerHTML = '<span class="count">' + hits.length + ' results</span>' +
|
||||
'<span class="meta">AI vector search · understood meaning · ' + ms + 'ms</span>';
|
||||
el.appendChild(header);
|
||||
|
||||
if (!hits.length) {
|
||||
el.innerHTML += '<div class="empty">No results found.</div>';
|
||||
return;
|
||||
}
|
||||
hits.forEach(function(h) {
|
||||
const parts = (h.chunk_text || '').split('—');
|
||||
const name = (parts[0] || '').trim();
|
||||
const rest = (parts[1] || '').trim();
|
||||
const roleMatch = rest.match(/^(.+?) in (.+?)\./);
|
||||
const skillsMatch = rest.match(/Skills: ([^.]+)/);
|
||||
const certsMatch = rest.match(/Certs?: ([^.]+)/);
|
||||
const relMatch = rest.match(/Reliability: ([\d.]+)/);
|
||||
const availMatch = rest.match(/Availability: ([\d.]+)/);
|
||||
const archMatch = rest.match(/Archetype: (\w+)/);
|
||||
const w = {
|
||||
name: name, role: roleMatch ? roleMatch[1].trim() : '',
|
||||
city: roleMatch ? roleMatch[2].split(',')[0].trim() : '',
|
||||
state: roleMatch ? (roleMatch[2].split(',')[1] || '').trim() : '',
|
||||
skills: skillsMatch ? skillsMatch[1] : '', certifications: certsMatch ? certsMatch[1] : '',
|
||||
reliability: relMatch ? relMatch[1] : '', availability: availMatch ? availMatch[1] : '',
|
||||
archetype: archMatch ? archMatch[1] : '', worker_id: h.doc_id
|
||||
};
|
||||
el.appendChild(makeWorkerCard(w, h.score));
|
||||
});
|
||||
}
|
||||
|
||||
function renderHybridResults(data, query, ms) {
|
||||
const el = document.getElementById('results');
|
||||
const sources = data.sources || [];
|
||||
el.innerHTML = '';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'result-header';
|
||||
const badges = '<span class="badge green">' + (data.sql_matches || 0) + ' SQL matches</span> ' +
|
||||
'<span class="badge purple">AI ranked top ' + sources.length + '</span> ' +
|
||||
'<span class="badge blue">' + ms + 'ms</span>';
|
||||
header.innerHTML = '<span class="count">' + sources.length + ' results</span><span class="meta">' + badges + '</span>';
|
||||
el.appendChild(header);
|
||||
|
||||
if (!sources.length) {
|
||||
el.innerHTML += '<div class="empty">No matches with current filters. Try broadening the state or role filter.</div>';
|
||||
return;
|
||||
}
|
||||
sources.forEach(function(s) {
|
||||
const parts = (s.chunk_text || '').split('—');
|
||||
const name = (parts[0] || '').trim();
|
||||
const rest = (parts[1] || '').trim();
|
||||
const roleMatch = rest.match(/^(.+?) in (.+?)\./);
|
||||
const skillsMatch = rest.match(/Skills: ([^.]+)/);
|
||||
const certsMatch = rest.match(/Certs?: ([^.]+)/);
|
||||
const relMatch = rest.match(/Reliability: ([\d.]+)/);
|
||||
const availMatch = rest.match(/Availability: ([\d.]+)/);
|
||||
const w = {
|
||||
name: name, role: roleMatch ? roleMatch[1].trim() : '',
|
||||
city: roleMatch ? roleMatch[2].split(',')[0].trim() : '',
|
||||
state: roleMatch ? (roleMatch[2].split(',')[1] || '').trim() : '',
|
||||
skills: skillsMatch ? skillsMatch[1] : '', certifications: certsMatch ? certsMatch[1] : '',
|
||||
reliability: relMatch ? relMatch[1] : '', availability: availMatch ? availMatch[1] : '',
|
||||
worker_id: s.doc_id
|
||||
};
|
||||
const card = makeWorkerCard(w, s.score);
|
||||
if (s.sql_verified) {
|
||||
const v = document.createElement('div');
|
||||
v.className = 'verified';
|
||||
v.textContent = '✓ SQL verified against database';
|
||||
card.appendChild(v);
|
||||
}
|
||||
el.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function makeWorkerCard(w, score) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'worker';
|
||||
|
||||
const top = document.createElement('div');
|
||||
top.className = 'top';
|
||||
|
||||
const info = document.createElement('div');
|
||||
const nameEl = document.createElement('div');
|
||||
nameEl.className = 'name';
|
||||
nameEl.textContent = w.name || w.worker_id || '—';
|
||||
info.appendChild(nameEl);
|
||||
if (w.role) { const r = document.createElement('div'); r.className = 'role'; r.textContent = w.role; info.appendChild(r); }
|
||||
if (w.city || w.state) { const l = document.createElement('div'); l.className = 'location'; l.textContent = [w.city, w.state].filter(Boolean).join(', '); info.appendChild(l); }
|
||||
top.appendChild(info);
|
||||
|
||||
if (score !== null && score !== undefined) {
|
||||
const sc = document.createElement('div');
|
||||
sc.className = 'score';
|
||||
const num = document.createElement('div');
|
||||
num.className = 'num';
|
||||
num.textContent = (score * 100).toFixed(0) + '%';
|
||||
sc.appendChild(num);
|
||||
const label = document.createElement('div');
|
||||
label.className = 'label';
|
||||
label.textContent = 'AI match';
|
||||
sc.appendChild(label);
|
||||
top.appendChild(sc);
|
||||
}
|
||||
card.appendChild(top);
|
||||
|
||||
const details = document.createElement('div');
|
||||
details.className = 'details';
|
||||
if (w.skills) addDetail(details, 'Skills', w.skills.replace(/\|/g, ', '));
|
||||
if (w.certifications && w.certifications !== 'none') addDetail(details, 'Certs', w.certifications.replace(/\|/g, ', '));
|
||||
if (w.reliability) addDetail(details, 'Reliability', w.reliability);
|
||||
if (w.availability) addDetail(details, 'Availability', w.availability);
|
||||
if (w.archetype) addDetail(details, 'Type', w.archetype);
|
||||
card.appendChild(details);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function addDetail(parent, label, value) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'detail';
|
||||
const dt = document.createElement('div');
|
||||
dt.className = 'dt';
|
||||
dt.textContent = label;
|
||||
d.appendChild(dt);
|
||||
const dd = document.createElement('div');
|
||||
dd.className = 'dd';
|
||||
dd.textContent = value;
|
||||
d.appendChild(dd);
|
||||
parent.appendChild(d);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,232 +1 @@
|
||||
// Detect if we're behind nginx (/lakehouse/ prefix) or direct (:3700)
|
||||
const base = window.location.pathname.startsWith("/lakehouse") ? "/lakehouse" : "";
|
||||
const GW = window.location.origin + base;
|
||||
let simData: any = null;
|
||||
let currentDay = 0;
|
||||
|
||||
async function api(path: string, body?: any) {
|
||||
const opts: RequestInit = body
|
||||
? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }
|
||||
: {};
|
||||
const r = await fetch(GW + path, opts);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function addLog(msg: string) {
|
||||
const el = document.getElementById("log")!;
|
||||
const div = document.createElement("div");
|
||||
div.textContent = `${new Date().toLocaleTimeString()} ${msg}`;
|
||||
el.prepend(div);
|
||||
while (el.children.length > 50) el.lastChild?.remove();
|
||||
}
|
||||
|
||||
function renderContracts(day: any) {
|
||||
const el = document.getElementById("contracts")!;
|
||||
el.replaceChildren();
|
||||
if (!day?.contracts?.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "empty";
|
||||
empty.textContent = "No contracts for this day";
|
||||
el.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
for (const c of day.contracts) {
|
||||
const div = document.createElement("div");
|
||||
const isFilled = c.filled >= c.headcount;
|
||||
div.className = `contract ${isFilled ? "filled" : c.priority === "urgent" ? "urgent" : c.priority === "high" ? "high" : ""}`;
|
||||
|
||||
const top = document.createElement("div");
|
||||
top.className = "top";
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.className = "title";
|
||||
title.textContent = `${c.id} — ${c.client}`;
|
||||
top.appendChild(title);
|
||||
|
||||
const badge = document.createElement("span");
|
||||
badge.className = `badge ${c.priority}`;
|
||||
badge.textContent = c.priority.toUpperCase();
|
||||
top.appendChild(badge);
|
||||
div.appendChild(top);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "meta";
|
||||
meta.textContent = `${c.role} × ${c.headcount} · ${c.city || c.state} · Start: ${c.start} · ${c.filled}/${c.headcount} filled`;
|
||||
div.appendChild(meta);
|
||||
|
||||
if (c.matches?.length) {
|
||||
const workers = document.createElement("div");
|
||||
workers.className = "workers";
|
||||
const names = c.matches.map((m: any) => m.name || m.doc_id).join(", ");
|
||||
const b = document.createElement("b");
|
||||
b.textContent = `${c.matches.length} matched: `;
|
||||
workers.appendChild(b);
|
||||
workers.appendChild(document.createTextNode(names));
|
||||
div.appendChild(workers);
|
||||
}
|
||||
|
||||
if (c.notes) {
|
||||
const notes = document.createElement("div");
|
||||
notes.className = "meta";
|
||||
notes.style.marginTop = "4px";
|
||||
notes.style.fontStyle = "italic";
|
||||
notes.textContent = c.notes;
|
||||
div.appendChild(notes);
|
||||
}
|
||||
|
||||
el.appendChild(div);
|
||||
}
|
||||
}
|
||||
|
||||
function renderDayNav() {
|
||||
const nav = document.getElementById("day-nav")!;
|
||||
nav.replaceChildren();
|
||||
if (!simData?.days) return;
|
||||
simData.days.forEach((d: any, i: number) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = `tab ${i === currentDay ? "active" : ""}`;
|
||||
const filled = d.contracts.reduce((s: number, c: any) => s + c.filled, 0);
|
||||
const needed = d.contracts.reduce((s: number, c: any) => s + c.headcount, 0);
|
||||
btn.textContent = `${d.label} (${filled}/${needed})`;
|
||||
btn.onclick = () => { currentDay = i; renderDayNav(); renderContracts(simData.days[i]); };
|
||||
nav.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
function renderWeekStats() {
|
||||
const el = document.getElementById("week-stats")!;
|
||||
el.replaceChildren();
|
||||
if (!simData?.summary) return;
|
||||
const s = simData.summary;
|
||||
const rows: [string, string][] = [
|
||||
["Total contracts", String(s.total_contracts)],
|
||||
["Total positions", String(s.total_needed)],
|
||||
["Filled", String(s.total_filled)],
|
||||
["Fill rate", `${s.fill_pct}%`],
|
||||
["Emergencies", String(s.emergencies)],
|
||||
["Handoffs", String(s.handoffs)],
|
||||
["Playbooks", String(s.playbook_entries)],
|
||||
];
|
||||
for (const [label, val] of rows) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "stat-row";
|
||||
const l = document.createElement("span");
|
||||
l.textContent = label;
|
||||
const v = document.createElement("span");
|
||||
v.className = "val";
|
||||
v.textContent = val;
|
||||
row.appendChild(l);
|
||||
row.appendChild(v);
|
||||
el.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAlerts() {
|
||||
const el = document.getElementById("alerts")!;
|
||||
el.replaceChildren();
|
||||
try {
|
||||
const r = await api("/sql", { sql: "SELECT archetype, COUNT(*) cnt FROM ethereal_workers WHERE archetype IN ('erratic','silent') GROUP BY archetype" });
|
||||
for (const row of r.rows || []) {
|
||||
const div = document.createElement("div");
|
||||
div.className = `alert ${row.archetype === "erratic" ? "warn" : "info"}`;
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "icon";
|
||||
icon.textContent = row.archetype === "erratic" ? "⚠" : "📵";
|
||||
div.appendChild(icon);
|
||||
div.appendChild(document.createTextNode(`${row.cnt} ${row.archetype} workers flagged`));
|
||||
el.appendChild(div);
|
||||
}
|
||||
const good = document.createElement("div");
|
||||
good.className = "alert good";
|
||||
const gi = document.createElement("span");
|
||||
gi.className = "icon";
|
||||
gi.textContent = "✓";
|
||||
good.appendChild(gi);
|
||||
good.appendChild(document.createTextNode("All services running"));
|
||||
el.appendChild(good);
|
||||
} catch {
|
||||
const div = document.createElement("div");
|
||||
div.className = "alert warn";
|
||||
div.textContent = "Could not load alerts";
|
||||
el.appendChild(div);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlaybooks() {
|
||||
const el = document.getElementById("playbooks")!;
|
||||
el.replaceChildren();
|
||||
try {
|
||||
const r = await api("/playbooks", {});
|
||||
const pbs = r.playbooks || [];
|
||||
if (!pbs.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "empty";
|
||||
empty.textContent = "Run the simulation to build playbooks";
|
||||
el.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
for (const p of pbs.slice(0, 6)) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "playbook";
|
||||
const op = document.createElement("span");
|
||||
op.className = "op";
|
||||
op.textContent = (p.operation || "").slice(0, 50);
|
||||
div.appendChild(op);
|
||||
div.appendChild(document.createElement("br"));
|
||||
div.appendChild(document.createTextNode((p.result || "").slice(0, 80)));
|
||||
el.appendChild(div);
|
||||
}
|
||||
} catch {
|
||||
const el2 = document.getElementById("playbooks")!;
|
||||
el2.textContent = "Could not load playbooks";
|
||||
}
|
||||
}
|
||||
|
||||
async function checkServices() {
|
||||
try {
|
||||
await fetch(GW + "/health");
|
||||
document.getElementById("svc-gw")!.className = "dot g";
|
||||
} catch {
|
||||
document.getElementById("svc-gw")!.className = "dot r";
|
||||
}
|
||||
try {
|
||||
const r = await api("/vram");
|
||||
const used = r.gpu?.used_mib || "?";
|
||||
const total = r.gpu?.total_mib || "?";
|
||||
const models = (r.ollama_loaded || []).map((m: any) => m.name).join(", ");
|
||||
document.getElementById("vram-display")!.textContent = `GPU: ${used}/${total} MiB ${models ? "· " + models : ""}`;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
(window as any).runWeek = async function () {
|
||||
addLog("Starting week simulation...");
|
||||
try {
|
||||
const r = await fetch(GW + "/simulation/run", { method: "POST" });
|
||||
simData = await r.json();
|
||||
if (simData.error) {
|
||||
addLog(`Error: ${simData.error}`);
|
||||
return;
|
||||
}
|
||||
renderDayNav();
|
||||
currentDay = 0;
|
||||
renderContracts(simData.days[0]);
|
||||
renderWeekStats();
|
||||
addLog(`Week done: ${simData.summary.total_filled}/${simData.summary.total_needed} filled (${simData.summary.fill_pct}%)`);
|
||||
} catch (e) {
|
||||
addLog(`Failed: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
(window as any).refresh = async function () {
|
||||
addLog("Refreshing...");
|
||||
await checkServices();
|
||||
await loadAlerts();
|
||||
await loadPlaybooks();
|
||||
};
|
||||
|
||||
// Init
|
||||
checkServices();
|
||||
loadAlerts();
|
||||
loadPlaybooks();
|
||||
addLog("Dashboard loaded");
|
||||
setInterval(checkServices, 30000);
|
||||
// Replaced by inline script in dashboard.html
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user