Phase 8.5 was fully built on the Rust side (WorkspaceManager with
create/handoff/search/shortlist/activity/get/list, persisted to
object storage, zero-copy handoff between agents). Nothing surfaced
it in the recruiter UI. This page closes that gap.
/workspaces — split-pane UI:
Left: scrollable list of all workspaces, sorted by updated_at.
Each card shows name, tier pill (daily/weekly/monthly/pinned),
current owner, count of shortlisted candidates + activity events.
Right: selected workspace detail with five sections:
1. Header — name, tier, owner, created/updated dates, description,
previous-owners audit trail (each handoff is preserved)
2. Actions row — Hand off, Shortlist candidate, Save search, Log activity
3. Shortlist — candidates flagged with dataset + record_id + notes
4. Saved searches — named SQL queries the staffer wants to rerun
5. Activity — chronological (newest first) log of what happened
Four modals for the add/edit actions (create, handoff, shortlist,
save-search, log-activity). All forms POST through the existing
/api/* passthrough to the gateway's /workspaces/* routes.
End-to-end verified live:
1. Sarah creates 'Demo: Toledo Week 17' workspace
2. Shortlists Helen Sanchez (W500K-4661) with notes about prior endorsements
3. Logs activity: 'called — Helen confirmed Tuesday 7am shift'
4. Hands off to Kim with reason 'end of shift'
5. Kim opens the workspace: owner=kim, previous_owners=[{sarah→kim}],
sees all 3 prior events + the shortlisted Helen
— no data copy, pointer swap only (Phase 8.5 design)
Security: all dynamic content built via el(tag,cls,text) DOM helper.
Zero innerHTML on API-derived strings. Modal close-on-backdrop-click
is guarded to the backdrop element.
Nav updated across all 7 pages. Workspaces is the 7th tab.
Dashboard · Walkthrough · Architecture · Spec · Onboard · Alerts · Workspaces.
250 lines
11 KiB
HTML
250 lines
11 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en"><head>
|
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Lakehouse — Alerts</title>
|
|
<style>
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
body{font-family:'Inter',-apple-system,system-ui,sans-serif;background:#090c10;color:#b0b8c4;font-size:14px;line-height:1.55;-webkit-font-smoothing:antialiased}
|
|
a{color:#58a6ff;text-decoration:none}
|
|
a:hover{color:#79c0ff}
|
|
|
|
.bar{background:#0d1117;padding:0 24px;height:56px;border-bottom:1px solid #171d27;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:10}
|
|
.bar h1{font-size:14px;font-weight:600;color:#e6edf3;letter-spacing:-0.2px}
|
|
.bar nav{display:flex;gap:2px}
|
|
.bar nav a{font-size:12px;color:#545d68;padding:6px 14px;border-radius:6px;transition:all 0.15s}
|
|
.bar nav a:hover{color:#e6edf3;background:#161b22}
|
|
.bar nav a.active{color:#e6edf3;background:#1c2333}
|
|
|
|
.wrap{max-width:900px;margin:0 auto;padding:28px 20px 60px}
|
|
|
|
h2{color:#e6edf3;font-size:20px;font-weight:700;letter-spacing:-0.3px;margin-bottom:4px}
|
|
.lede{color:#8b949e;font-size:13px;margin-bottom:18px;line-height:1.6;max-width:680px}
|
|
|
|
.card{background:#0d1117;border:1px solid #171d27;border-radius:12px;padding:18px;margin:10px 0}
|
|
|
|
.row{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid #171d27}
|
|
.row:last-child{border-bottom:none}
|
|
.row .k{color:#8b949e;font-size:12px}
|
|
.row .v{color:#e6edf3;font-weight:500;font-size:13px;font-family:ui-monospace,Menlo,monospace}
|
|
|
|
label{display:block;color:#8b949e;font-size:12px;margin-bottom:6px;margin-top:12px}
|
|
label:first-child{margin-top:0}
|
|
input[type=text],input[type=number],input[type=url]{width:100%;padding:10px 14px;background:#161b22;border:1px solid #21262d;border-radius:8px;color:#e6edf3;font-size:13px;outline:none;font-family:ui-monospace,Menlo,monospace}
|
|
input:focus{border-color:#388bfd}
|
|
.switch{display:flex;align-items:center;gap:8px;margin-top:4px}
|
|
.switch input{width:auto;accent-color:#1f6feb}
|
|
.switch span{color:#c9d1d9;font-size:13px}
|
|
|
|
.btn{padding:10px 18px;background:#1f6feb;border:none;border-radius:8px;color:#fff;font-size:13px;font-weight:600;cursor:pointer;margin-right:8px;margin-top:14px}
|
|
.btn:hover{background:#388bfd}
|
|
.btn:disabled{opacity:0.4;cursor:wait}
|
|
.btn.ghost{background:transparent;border:1px solid #21262d;color:#c9d1d9}
|
|
.btn.ghost:hover{background:#161b22}
|
|
.btn.green{background:#2ea043}
|
|
.btn.green:hover{background:#3fb950}
|
|
|
|
.entry{background:#161b22;border-radius:6px;padding:10px 14px;margin-bottom:6px;font-size:12px;font-family:ui-monospace,Menlo,monospace;white-space:pre-wrap;color:#c9d1d9;line-height:1.6}
|
|
.entry .at{color:#545d68;font-size:10px;display:block;margin-bottom:4px}
|
|
|
|
.tip{color:#8b949e;font-size:12px;line-height:1.7;padding:10px 14px;border-left:2px solid #21262d;margin:10px 0}
|
|
.tip strong{color:#c9d1d9}
|
|
.tip code{background:#161b22;padding:1px 5px;border-radius:3px;font-family:ui-monospace,Menlo,monospace;font-size:11px;color:#79c0ff}
|
|
|
|
.result{padding:14px;border-radius:8px;margin-top:10px;font-size:12px;line-height:1.6}
|
|
.result.ok{background:#0d2818;border:1px solid #2ea04360;color:#86efac}
|
|
.result.err{background:#3a1a1a;border:1px solid #f8514960;color:#fca5a5}
|
|
.result.dim{background:#161b22;border:1px solid #21262d;color:#8b949e}
|
|
|
|
.chan{display:inline-block;padding:3px 10px;border-radius:10px;font-size:10px;font-weight:600;margin-right:6px;letter-spacing:0.3px}
|
|
.chan.console{background:#1a2744;color:#79c0ff}
|
|
.chan.file{background:#1a3a2a;color:#3fb950}
|
|
.chan.webhook{background:#2a1a3a;color:#bc8cff}
|
|
|
|
.footer{border-top:1px solid #171d27;padding:20px;text-align:center;color:#3d444d;font-size:11px}
|
|
|
|
@media(max-width:720px){
|
|
.wrap{padding:20px 12px 40px}
|
|
.bar nav{display:none}
|
|
}
|
|
</style></head>
|
|
<body>
|
|
|
|
<div class="bar">
|
|
<h1>Lakehouse — Alerts</h1>
|
|
<nav>
|
|
<a href=".">Dashboard</a>
|
|
<a href="console">Walkthrough</a>
|
|
<a href="proof">Architecture</a>
|
|
<a href="spec">Spec</a>
|
|
<a href="onboard">Onboard</a>
|
|
<a href="alerts" class="active">Alerts</a>
|
|
<a href="workspaces">Workspaces</a>
|
|
</nav>
|
|
</div>
|
|
|
|
<div class="wrap">
|
|
|
|
<h2>Push alerts — make the system find you</h2>
|
|
<div class="lede">The daemon runs in the background and dispatches a concise digest whenever something worth notifying changes: a role escalates to tight or critical, a new staffing deadline falls within your warn window, or the playbook memory compounds meaningfully. Phone-first shops don't open dashboards — they need the system to reach out.</div>
|
|
|
|
<div class="card">
|
|
<h3 style="color:#e6edf3;font-size:15px;margin-bottom:10px">Current status</h3>
|
|
<div id="status"><div style="color:#8b949e">Loading…</div></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3 style="color:#e6edf3;font-size:15px;margin-bottom:10px">Configuration</h3>
|
|
<form id="cfg-form">
|
|
<label class="switch"><input type="checkbox" id="enabled"> <span>Daemon enabled — fire digests on the interval</span></label>
|
|
<label>Check interval (minutes)</label>
|
|
<input type="number" id="interval" min="1" max="1440" value="15">
|
|
<label>Deadline warn window (days)</label>
|
|
<input type="number" id="deadline_warn_days" min="1" max="90" value="7">
|
|
<label>Webhook URL (Slack, Discord, or any POST-accepting endpoint — optional)</label>
|
|
<input type="url" id="webhook_url" placeholder="https://hooks.slack.com/services/... or https://discord.com/api/webhooks/...">
|
|
<label>Webhook label (your name for this channel)</label>
|
|
<input type="text" id="webhook_label" placeholder="e.g. #staffing-ops">
|
|
<div>
|
|
<button type="button" class="btn" id="save-btn">Save</button>
|
|
<button type="button" class="btn green" id="fire-btn">Fire a test digest now</button>
|
|
</div>
|
|
<div id="save-result"></div>
|
|
</form>
|
|
<div class="tip">
|
|
<strong>Webhook behavior.</strong> The daemon POSTs JSON <code>{text, digest}</code> to your webhook URL.
|
|
Slack and Discord both accept this shape if the URL is an incoming-webhook. The <code>text</code>
|
|
field is pre-formatted human-readable; the <code>digest</code> field is structured for a bot to parse.
|
|
Interval changes take effect on next server restart.
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3 style="color:#e6edf3;font-size:15px;margin-bottom:10px">Recent digests</h3>
|
|
<div id="recent"><div style="color:#8b949e">Loading…</div></div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div class="footer">Lakehouse · alerts · daemon log: <code>mcp-server/data/notifications.jsonl</code></div>
|
|
|
|
<script>
|
|
var P=location.pathname.indexOf('/lakehouse')>=0?'/lakehouse':'';
|
|
var A=location.origin+P;
|
|
|
|
function el(t,c,x){var e=document.createElement(t);if(c)e.className=c;if(x!==undefined)e.textContent=String(x);return e}
|
|
function apiGet(p){return fetch(A+p).then(function(r){return r.json()})}
|
|
function apiPost(p,b){return fetch(A+p,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})}).then(function(r){return r.json()})}
|
|
|
|
function loadStatus(){
|
|
apiGet('/alerts/config').then(function(r){
|
|
var host=document.getElementById('status');host.textContent='';
|
|
var cfg=r.config||{};
|
|
var st=r.state||{};
|
|
var rows=[
|
|
['Daemon',cfg.enabled?'enabled':'disabled'],
|
|
['Interval',(cfg.interval_minutes||'?')+' minutes'],
|
|
['Deadline warn window',(cfg.deadline_warn_days||7)+' days'],
|
|
['Webhook',cfg.webhook_url?(cfg.webhook_label||'configured'):'none'],
|
|
['Last run',st.last_run_at?new Date(st.last_run_at).toLocaleString():'never'],
|
|
];
|
|
rows.forEach(function(r){
|
|
var row=el('div','row');
|
|
row.appendChild(el('span','k',r[0]));
|
|
row.appendChild(el('span','v',r[1]));
|
|
host.appendChild(row);
|
|
});
|
|
// Populate form
|
|
document.getElementById('enabled').checked=!!cfg.enabled;
|
|
document.getElementById('interval').value=cfg.interval_minutes||15;
|
|
document.getElementById('deadline_warn_days').value=cfg.deadline_warn_days||7;
|
|
document.getElementById('webhook_url').value=cfg.webhook_url||'';
|
|
document.getElementById('webhook_label').value=cfg.webhook_label||'';
|
|
}).catch(function(e){
|
|
var host=document.getElementById('status');host.textContent='';
|
|
host.appendChild(el('div',null,'status unavailable: '+(e.message||e)));
|
|
});
|
|
}
|
|
|
|
function loadRecent(){
|
|
apiGet('/alerts/recent').then(function(r){
|
|
var host=document.getElementById('recent');host.textContent='';
|
|
var entries=r.entries||[];
|
|
if(entries.length===0){
|
|
host.appendChild(el('div',null,'No digests yet. The daemon fires every '+(document.getElementById('interval').value||15)+' minutes and only dispatches when something changes. Click "Fire a test digest now" to force one.'));
|
|
host.firstChild.style.color='#8b949e';
|
|
return;
|
|
}
|
|
entries.forEach(function(e){
|
|
var card=el('div','entry');
|
|
if(e.at) card.appendChild(el('span','at',new Date(e.at).toLocaleString()));
|
|
card.appendChild(document.createTextNode(e.text||JSON.stringify(e.digest)));
|
|
host.appendChild(card);
|
|
});
|
|
}).catch(function(e){
|
|
var host=document.getElementById('recent');host.textContent='';
|
|
host.appendChild(el('div',null,'recent digests unavailable: '+(e.message||e)));
|
|
});
|
|
}
|
|
|
|
function save(){
|
|
var btn=document.getElementById('save-btn');
|
|
btn.disabled=true;
|
|
var body={
|
|
enabled:document.getElementById('enabled').checked,
|
|
interval_minutes:Number(document.getElementById('interval').value)||15,
|
|
deadline_warn_days:Number(document.getElementById('deadline_warn_days').value)||7,
|
|
webhook_url:document.getElementById('webhook_url').value.trim(),
|
|
webhook_label:document.getElementById('webhook_label').value.trim(),
|
|
};
|
|
apiPost('/alerts/config',body).then(function(r){
|
|
btn.disabled=false;
|
|
var out=document.getElementById('save-result');out.textContent='';
|
|
if(r.saved){
|
|
var ok=el('div','result ok',r.note||'saved.');out.appendChild(ok);
|
|
loadStatus();
|
|
} else {
|
|
out.appendChild(el('div','result err',JSON.stringify(r)));
|
|
}
|
|
}).catch(function(e){
|
|
btn.disabled=false;
|
|
var out=document.getElementById('save-result');out.textContent='';
|
|
out.appendChild(el('div','result err','save failed: '+(e.message||e)));
|
|
});
|
|
}
|
|
|
|
function fire(){
|
|
var btn=document.getElementById('fire-btn');
|
|
btn.disabled=true;btn.textContent='Firing…';
|
|
apiPost('/alerts/fire',{}).then(function(r){
|
|
btn.disabled=false;btn.textContent='Fire a test digest now';
|
|
var out=document.getElementById('save-result');out.textContent='';
|
|
if(!r.fired){
|
|
out.appendChild(el('div','result dim',r.reason||'nothing to dispatch'));
|
|
} else {
|
|
var ok=el('div','result ok');
|
|
ok.appendChild(document.createTextNode('Dispatched to: '));
|
|
(r.channels||[]).forEach(function(c){
|
|
var chip=el('span','chan '+c,c);ok.appendChild(chip);
|
|
});
|
|
if(r.errors&&r.errors.length){
|
|
ok.appendChild(document.createElement('br'));
|
|
ok.appendChild(document.createTextNode('errors: '+r.errors.join(' | ')));
|
|
}
|
|
out.appendChild(ok);
|
|
setTimeout(loadRecent,500);
|
|
}
|
|
}).catch(function(e){
|
|
btn.disabled=false;btn.textContent='Fire a test digest now';
|
|
var out=document.getElementById('save-result');out.textContent='';
|
|
out.appendChild(el('div','result err','fire failed: '+(e.message||e)));
|
|
});
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded',function(){
|
|
loadStatus();loadRecent();
|
|
document.getElementById('save-btn').addEventListener('click',save);
|
|
document.getElementById('fire-btn').addEventListener('click',fire);
|
|
});
|
|
</script>
|
|
</body></html>
|