feat(webui): the Dashboard and each organization's page lead with a row of figure tiles, each a link to what it counts (#1873) #1972
No files matched your search
@@ -506,9 +506,13 @@ html[data-organizations] .ws-doc:hover{background:var(--raised)}
|
||||
html[data-organizations] .ws-doc svg.i{width:14px;height:14px;color:var(--muted)}
|
||||
html[data-organizations] .ws-doc small{font-weight:500;color:var(--muted);font-size:11px}
|
||||
html[data-organizations] .ws-more{font-size:13px;font-weight:600;margin-left:4px;color:var(--action);text-decoration:none}
|
||||
html[data-organizations] .ws-tiles{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}
|
||||
html[data-organizations] .ws-tile{background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:12px 14px;display:grid;gap:6px;align-content:start}
|
||||
html[data-organizations] .ws-tile.is-empty{grid-column:1/-1;border-style:dashed;background:transparent}
|
||||
/* SP8 (#1873): one row of figure tiles under the page heading, each a link to the page that lists what it counts. */
|
||||
html[data-organizations] .ws-tiles{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin:0 0 16px}
|
||||
html[data-organizations] .ws-tiles.six{grid-template-columns:repeat(6,minmax(0,1fr))}
|
||||
html[data-organizations] .ws-tile{background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:12px 14px;display:grid;gap:6px;align-content:start;min-width:0;color:inherit;text-decoration:none}
|
||||
html[data-organizations] a.ws-tile:hover{border-color:color-mix(in srgb,var(--action) 45%,var(--line))}
|
||||
html[data-organizations] a.ws-tile:focus-visible{outline:2px solid var(--action);outline-offset:2px}
|
||||
html[data-organizations] .ws-tile>.ws-label{font-size:12.5px;font-weight:600;letter-spacing:0;text-transform:none}
|
||||
html[data-organizations] .ws-empty-t{font-size:13.5px;font-weight:600;color:var(--strong)}
|
||||
html[data-organizations] .ws-tbl{overflow-x:auto;border:1px solid var(--line);border-radius:8px;background:var(--surface)}
|
||||
html[data-organizations] .ws-tbl table{width:100%;border-collapse:collapse;font-size:13px;margin:0}
|
||||
@@ -580,9 +584,8 @@ html[data-organizations] .ws-tbl .ws-dot.s-working b,html[data-organizations] .w
|
||||
html[data-organizations] .ws-tbl .ws-dot.s-waiting b{color:var(--warning)}
|
||||
html[data-organizations] .ws-tbl .ws-dot.s-error b{color:var(--danger)}
|
||||
html[data-organizations] .ws-tbl .ws-dot.s-idle b,html[data-organizations] .ws-tbl .ws-dot.s-offline b{color:var(--muted)}
|
||||
html[data-organizations] .ws-g5{display:grid;gap:10px;grid-template-columns:repeat(5,minmax(0,1fr))}
|
||||
html[data-organizations] .ws-tile .ws-big{font-size:24px;font-weight:800;color:var(--strong);letter-spacing:-.02em;font-variant-numeric:tabular-nums;line-height:1.1}
|
||||
html[data-organizations] .ws-tile .ws-big small{font-size:13px;font-weight:600;color:var(--muted);letter-spacing:0;margin-left:4px}
|
||||
html[data-organizations] .ws-tile .ws-big small{font-size:13px;font-weight:600;color:var(--muted);letter-spacing:0}
|
||||
html[data-organizations] .ws-tile .ws-delta{font-size:12px;color:var(--muted)}
|
||||
html[data-organizations] .ws-tile .ws-delta b{color:var(--success);font-weight:600}
|
||||
html[data-organizations] .ws-tile.quiet{border-style:dashed;background:transparent}
|
||||
@@ -724,7 +727,7 @@ html[data-organizations] .ws-cmd code{flex:1;min-width:0;overflow-x:auto;white-s
|
||||
html[data-organizations] .ws-cmd .ws-btn{padding:4px 6px}
|
||||
html[data-organizations] #copy-status:empty{display:none}
|
||||
@media (max-width:1100px){
|
||||
html[data-organizations] .ws-g5{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
html[data-organizations] .ws-tiles.six{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
html[data-organizations] .ws-ibx-grid.with-insp{grid-template-columns:minmax(0,1fr)}
|
||||
html[data-organizations] .ws-insp{position:static;max-height:none}
|
||||
}
|
||||
@@ -734,8 +737,8 @@ html[data-organizations] #copy-status:empty{display:none}
|
||||
html[data-organizations] .content:has(.ws){padding:14px 16px 24px}
|
||||
html[data-organizations] .tree{padding:10px 12px 20px}
|
||||
html[data-organizations] .ws h1{font-size:24px}
|
||||
html[data-organizations] .ws-split,html[data-organizations] .ws-tiles{grid-template-columns:minmax(0,1fr)}
|
||||
html[data-organizations] .ws-tile.is-empty{grid-column:auto}
|
||||
html[data-organizations] .ws-split{grid-template-columns:minmax(0,1fr)}
|
||||
html[data-organizations] .ws-tiles,html[data-organizations] .ws-tiles.six{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
html[data-organizations] .ws-sec-h .note{flex-basis:100%;order:3}
|
||||
html[data-organizations] .ws-cfg li{grid-template-columns:18px minmax(0,1fr) auto auto}
|
||||
html[data-organizations] .ws-cfg .v{grid-column:2/-1;grid-row:2}
|
||||
@@ -751,9 +754,6 @@ html[data-organizations] #copy-status:empty{display:none}
|
||||
html[data-organizations] .ws-levels>.span{grid-column:auto}
|
||||
html[data-organizations] .ws-levels>.last.lh{border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
|
||||
html[data-organizations] .ws-levels .mlabel{display:flex}
|
||||
html[data-organizations] .ws-g5{grid-template-columns:minmax(0,1fr)}
|
||||
html[data-organizations] .ws-g5.keep2{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
html[data-organizations] .ws-g5.keep2>:last-child:nth-child(odd){grid-column:1/-1}
|
||||
html[data-organizations] .ws-orgcards{grid-template-columns:minmax(0,1fr)}
|
||||
html[data-organizations] .ws-srow{grid-template-columns:92px 20px minmax(0,1fr)}
|
||||
html[data-organizations] .ws-srow .ws-by{grid-column:1/-1}
|
||||
|
||||
@@ -156,7 +156,7 @@
|
||||
const titleCase = s => String(s || '').replace(/(^|[-_\s])(\w)/g, (_, sep, c) => `${sep ? ' ' : ''}${c.toUpperCase()}`);
|
||||
const clock = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
||||
const secH = (title, note = '', link = '') => `<div class="ws-sec-h"><h2>${title}</h2>${note ? `<span class="note">${note}</span>` : ''}${link}</div>`;
|
||||
const secH = (title, note = '', link = '', attrs = '') => `<div class="ws-sec-h"><h2${attrs ? ` ${attrs}` : ''}>${title}</h2>${note ? `<span class="note">${note}</span>` : ''}${link}</div>`;
|
||||
|
||||
function settingsList(org, ctx) {
|
||||
const { business, settings, instances, orgCount } = ctx;
|
||||
@@ -256,14 +256,19 @@
|
||||
if (!org) return `<article class="ws ws-dash" data-org-dashboard data-org-unavailable><div class="ws-vh"><h1 tabindex="-1">Organization unavailable</h1><p class="sub">${list ? 'This organization is not on your list.' : 'The organization list could not be read.'}</p></div></article>`;
|
||||
// E2E6 P2-8: a stopped bus is not read for the bus-backed editors; each says the bus is stopped.
|
||||
const stopped = !org.busHealthy && org.busStopped === true;
|
||||
// SP8 (#1873): the cross-organization read scoped to this one gives the tiles' figures and, SP1,
|
||||
// while the bus is down, the reason the banner above everything else says. It runs beside the
|
||||
// organization's own reads, not after them.
|
||||
const wsRead = data.workspace({ org: id, sections: ['sessions', 'projects', 'metrics'] }).catch(() => null);
|
||||
const reads = await Promise.allSettled([data.business(id), data.settings(id), data.instances(id), data.threads(id), data.projects(id), data.queue(id), data.agents(id), data.claims(id), ...(stopped ? [] : [window.MosaicAutonomy?.load(id), window.MosaicPacks?.load(id), window.MosaicWorkflows?.load(id)])]);
|
||||
const value = i => (reads[i].status === 'fulfilled' ? reads[i].value : null);
|
||||
const business = value(0), settings = value(1), instances = instancesOf(value(2));
|
||||
const threads = rowsOf(value(3)), projectRows = rowsOf(value(4)), queue = rowsOf(value(5));
|
||||
const agents = rowsOf(value(6)), claims = rowsOf(value(7));
|
||||
const docRead = business?.business ? await docs(id, business.business.projects).catch(() => ({ rows: [], failed: true })) : null;
|
||||
// SP1: a bus that is down says so above everything else, with the reason the workspace read gives.
|
||||
const downRow = org.busHealthy ? null : await data.workspace({ org: id, sections: 'projects' }).then(b => arr(b?.orgs).find(r => r?.org === id) ?? null).catch(() => null);
|
||||
const ws = await wsRead;
|
||||
const wsRow = arr(ws?.orgs).find(r => r?.org === id) ?? null;
|
||||
const downRow = org.busHealthy ? null : wsRow;
|
||||
await window.MosaicConsole?.ready;
|
||||
const name = esc(label(org));
|
||||
const tracker = business?.business?.vars?.['tracker.kind'];
|
||||
@@ -283,7 +288,22 @@
|
||||
const docChips = !docRead ? '<span class="note">Docs unavailable</span>' : docRead.failed ? '<span class="note">Docs unavailable</span>' : docRead.rows.length ? docRead.rows.slice(0, 6).map(d => `<a class="ws-doc" href="#/docs/${enc(d.project)}/${enc(d.path)}" data-focus-key="${esc(`doc:${d.project}:${d.path}`)}" title="${esc(`${d.project}/${d.path}`)}">${icon('doc')}${esc(docName(d.path))}<small>${esc(projectName(d.project))}</small></a>`).join('') : '<span class="note">No docs yet</span>';
|
||||
const docrow = `<div class="ws-docrow ws-chips"><span class="ws-label">Docs</span>${docChips}<a class="ws-more" href="#/docs" data-focus-key="docs-all">All docs</a></div>`;
|
||||
|
||||
const tiles = (title, note, empty) => `${secH(title, note)}<div class="ws-tiles"><div class="ws-tile is-empty"><span class="ws-empty-t">${empty}</span></div></div>`;
|
||||
// SP8: the same tiles as the Dashboard, scoped to this organization, each a link to the system page
|
||||
// filtered to it. A tile whose read gave nothing says why instead of a figure.
|
||||
const mine = arr(ws?.metrics?.orgs).find(x => x?.org === id) ?? null;
|
||||
const off = !ws ? 'the read did not answer' : wsRow?.state === 'ok' ? null : wsRow?.state === 'starting' ? 'the bus is starting' : busWord(org);
|
||||
const whyNot = (names, metric = true) => off || missOf(wsRow, names) || (metric && !mine ? 'no figures were read' : null);
|
||||
const orgAgents = instances && ws ? agentRows([{ id, inst: instances, copies: copiesOf(value(2)) }], ws) : null;
|
||||
const agentsWhy = whyNot(['agents'], false) || (orgAgents ? null : 'the agent read did not answer');
|
||||
const kpi = orgsOf(ws), oq = `org=${enc(id)}`;
|
||||
const orgTile = (key, title, href, why, fig) => kpiTile(kpi, { key, name: title, href, ...(why ? { none: `Not read: ${esc(why)}` } : fig) });
|
||||
const working = orgAgents?.filter(a => a.state === 'working').length ?? 0, waiting = orgAgents?.filter(a => a.state === 'waiting').length ?? 0;
|
||||
const orgTiles = `<div class="ws-tiles" data-org-tiles role="group" aria-label="Key figures">
|
||||
${orgTile('agents', 'Agents working', `#/agents?${oq}&state=working`, agentsWhy, { big: working, small: `of ${orgAgents?.length ?? 0}`, delta: waiting ? `${waiting} waiting on you` : '' })}
|
||||
${orgTile('tasks', 'Open tasks', `#/tasks?${oq}`, whyNot(['tasks']), { big: num(mine?.openTasks) })}
|
||||
${orgTile('needs', 'Needs you', `#/inbox?${oq}`, whyNot(['inbox']), { big: num(mine?.needsYou), small: num(mine?.needsYou) === 1 ? 'item' : 'items', delta: num(mine?.needsYou) && mine?.oldestNeedsYouAt ? `oldest ${span(mine.oldestNeedsYouAt)}` : 'nothing waiting' })}
|
||||
${orgTile('done', 'Done this week', `#/tasks?${oq}`, whyNot(['tasks']), { big: num(mine?.doneThisWeek) })}
|
||||
</div>`;
|
||||
const modelOf = inst => business?.business?.instances?.find(i => i?.instance === inst)?.vars?.model;
|
||||
const live = instances ? Object.entries(instances).filter(([, item]) => running(item)) : null;
|
||||
// A session's project and task is the thread its live run holds a claim on (thread.claims), not
|
||||
@@ -314,12 +334,11 @@
|
||||
<nav class="ws-crumb" aria-label="Breadcrumb"><a href="#/dashboard">Dashboard</a><span aria-hidden="true">›</span><span>${name}</span></nav>
|
||||
<div class="ws-orghead ws-o" style="${colours(org)}">${av(org, 'lg')}<h1 tabindex="-1">${name}</h1><div class="ws-right">${bus}${reload}</div>${meta}</div>
|
||||
${org.busHealthy ? '' : downBanner(org, downRow)}
|
||||
${orgTiles}
|
||||
${docrow}
|
||||
<div class="ws-split">
|
||||
<section aria-label="Measures and sessions">
|
||||
${tiles('KPIs', 'defined in the organization file', 'No KPIs defined in the organization file')}
|
||||
${tiles('Metrics', 'measured by the bus', 'No metrics recorded yet')}
|
||||
${secH('Active agent sessions', live ? (live.length ? `${live.length} running` : 'none running') : '', '<a href="#/agents" data-focus-key="agents-all">All agents</a>')}
|
||||
${secH('Active agent sessions', live ? (live.length ? `${live.length} running` : 'none running') : '', `<a href="#/agents?${oq}" data-focus-key="agents-all">This organization's agents</a>`)}
|
||||
${sessions}
|
||||
${secH('Queue', queueNote, '<a href="#/queue" data-org-queue data-focus-key="queue-open">Open queue</a>')}
|
||||
${queueWhy}
|
||||
@@ -588,6 +607,19 @@
|
||||
// A stacked bar and its per-organization figures, one segment per organization.
|
||||
const bar = (o, parts, style = '') => `<div class="ws-bar"${style ? ` style="${style}"` : ''} aria-hidden="true">${parts.filter(p => p.n > 0).map(p => `<i class="ws-o" style="${o.style(p.id)};flex:${p.n}"></i>`).join('')}</div>`;
|
||||
const by = (o, parts, fmt = n => n) => `<div class="ws-by">${parts.map(p => `<span>${av(o.meta(p.id))}<span class="ws-sr">${esc(o.name(p.id))}</span>${esc(fmt(p.n))}</span>`).join('')}</div>`;
|
||||
// Why an organization's instances read gave nothing, in a word or two.
|
||||
const READ_ERR = { 403: 'refused', 0: 'signed out' };
|
||||
const instWhy = r => READ_ERR[r.err?.status] || r.err?.code || 'no answer';
|
||||
// SP8 (#1873): one KPI tile, a link to the page that lists what it counts. A tile with no figure
|
||||
// (`big` null) is drawn quiet with `none` in its place; `miss` names the organizations the figure
|
||||
// leaves out, already escaped. The figure is never shown as complete while one is missing.
|
||||
const kpiTile = (o, { key, name, href, big = null, small = '', parts = null, delta = '', miss = [], none = 'Not read', attrs = '' }) => {
|
||||
const quiet = big == null;
|
||||
const left = miss.length ? `<span class="ws-delta ws-miss" data-ws-miss="${key}">${quiet ? '' : 'Not read in '}${andList(miss)}</span>` : '';
|
||||
const head = `<a class="ws-tile${quiet ? ' quiet' : ''}" href="${esc(href)}" data-ws-tile="${key}" data-focus-key="${esc(`tile:${key}`)}"${quiet ? ` data-ws-miss="${key}"` : ''}${attrs}><span class="ws-label">${name}</span>`;
|
||||
if (quiet) return `${head}<span class="ws-empty-t">${none}</span>${left}</a>`;
|
||||
return `${head}<span class="ws-big">${big}${small ? ` <small>${small}</small>` : ''}</span>${parts ? bar(o, parts) + by(o, parts) : ''}${delta ? `<span class="ws-delta">${delta}</span>` : ''}${left}</a>`;
|
||||
};
|
||||
const CLASS_CHIP = { gated: 'gated', 'within-role': 'within', routine: 'routine', 'cross-role': 'cross' };
|
||||
// The one map of decision classes to words: the class chips and the Inbox Class filter both read it.
|
||||
const TYPE_WORD = { gated: 'Gated', 'within-role': 'Within role', 'cross-role': 'Cross-role', routine: 'Routine' };
|
||||
@@ -608,33 +640,46 @@
|
||||
const o = orgsOf(body), m = body.metrics, t = m?.totals, per = arr(m?.orgs);
|
||||
const inbox = arr(body.inbox), sessions = arr(body.sessions), projectRows = arr(body.projects), tasks = arr(body.tasks);
|
||||
if (!noneRead(o, ['inbox'])) tellInbox(body.inbox);
|
||||
const sessN = s => num(s?.working) + num(s?.waiting) + num(s?.idle) + num(s?.live);
|
||||
// The reads each tile's figures come from (workspace-read.mjs orgRows).
|
||||
const SRC = { needs: ['inbox'], sessions: ['agents', 'claims'], tasks: ['tasks'], projects: ['projects'] };
|
||||
// Per-organization figures name only the organizations that made the tile's reads.
|
||||
const parts = (key, f) => { const ok = readers(o, SRC[key]); return per.filter(x => ok.has(x.org)).map(x => ({ id: x.org, n: num(f(x)) })); };
|
||||
const answered = o.rows.filter(r => r.state === 'ok').length;
|
||||
const tile = (key, name, big, small, ps, delta) => {
|
||||
const m = missed(o, SRC[key]);
|
||||
if (!readers(o, SRC[key]).size) return `<div class="ws-tile quiet" data-ws-tile="${key}" data-ws-miss="${key}"><span class="ws-label">${name}</span><span class="ws-empty-t">Not read</span><span class="ws-delta">${andList(m)}</span></div>`;
|
||||
return `<div class="ws-tile" data-ws-tile="${key}"><span class="ws-label">${name}</span><span class="ws-big">${big}${small ? `<small>${small}</small>` : ''}</span>${ps ? bar(o, ps) + by(o, ps) : ''}<span class="ws-delta">${delta}</span>${m.length ? `<span class="ws-delta ws-miss" data-ws-miss="${key}">Not read in ${andList(m)}</span>` : ''}</div>`;
|
||||
};
|
||||
const tiles = t && answered ? `<div class="ws-g5 keep2" data-ws-tiles>
|
||||
${tile('needs', 'Needs you', num(t.needsYou), t.needsYou === 1 ? 'item' : 'items', parts('needs', x => x.needsYou), t.oldestNeedsYouAt ? `oldest ${span(t.oldestNeedsYouAt)}` : 'nothing waiting')}
|
||||
${tile('sessions', 'Sessions', sessN(t.sessions), `${num(t.sessions?.working)} working`, parts('sessions', x => sessN(x.sessions)), `${num(t.sessions?.waiting)} waiting on you · ${num(t.sessions?.idle)} idle${num(t.sessions?.live) ? ` · ${num(t.sessions.live)} live ${t.sessions.live === 1 ? 'thread' : 'threads'}` : ''}`)}
|
||||
${tile('tasks', 'Open tasks', num(t.openTasks), '', parts('tasks', x => x.openTasks), `<b>${num(t.doneThisWeek)}</b> done this week`)}
|
||||
${tile('projects', 'Projects', num(t.projects?.active) + num(t.projects?.archived), `${num(t.projects?.active)} active`, parts('projects', x => num(x.projects?.active) + num(x.projects?.archived)), `${num(t.projects?.archived)} archived`)}
|
||||
<div class="ws-tile quiet" data-ws-tile="spend"><span class="ws-label">Model spend</span><span class="ws-empty-t">No spend figures yet</span><span class="ws-delta">No model prices are read yet, so today's use has no cost figure.</span></div>
|
||||
</div>` : '<p class="ws-panel tight note" data-ws-tiles>No organization answered, so there are no totals yet.</p>';
|
||||
|
||||
// SP1 (sample r2-01): one row per organization. A figure shows only when its read gave it; an
|
||||
// organization that did not answer shows its state and reason instead of figures.
|
||||
const SRC = { needs: ['inbox'], sessions: ['agents', 'claims'], tasks: ['tasks'], done: ['tasks'], projects: ['projects'] };
|
||||
const listRow = id => (Array.isArray(listed) ? listed.find(x => x?.id === id) : null);
|
||||
const projectsRead = readers(o, SRC.projects), inboxRead = readers(o, SRC.needs), sessionsRead = readers(o, SRC.sessions);
|
||||
const none = '<span class="muted" title="not read">–</span>';
|
||||
// The bus state as the workspace read found it; "stopped" only when the membership list says the owner stopped it.
|
||||
const ROW_STATE = { ok: ['s-working', '●', 'bus running'], starting: ['s-waiting', '◐', 'bus starting'], timeout: ['s-error', '▲', 'bus not answering'], invalid: ['s-error', '▲', 'invalid'] };
|
||||
const rowState = (r, fromList) => (r.state === 'down' ? ['s-offline', '○', fromList?.busStopped === true ? 'bus stopped' : 'bus down'] : ROW_STATE[r.state] || orgState(r));
|
||||
|
||||
// SP8 (#1873): one row of tiles, each a link to the page that lists what it counts. A figure adds up
|
||||
// only the organizations whose reads gave it, and its tile names the rest: one that did not answer
|
||||
// by its bus state, one that answered but failed a read by that read's reason.
|
||||
const offWord = r => rowState(r, listRow(r.org))[2];
|
||||
const notIn = names => [...o.unread.map(r => `${esc(o.name(r.org))} (${offWord(r)})`), ...missed(o, names)];
|
||||
const parts = (key, f) => { const ok = readers(o, SRC[key]); return per.filter(x => ok.has(x.org)).map(x => ({ id: x.org, n: num(f(x)) })); };
|
||||
const sum = ps => ps.reduce((a, p) => a + p.n, 0);
|
||||
const fig = (key, ps) => (m && readers(o, SRC[key]).size ? sum(ps) : null);
|
||||
// A response with no metrics section leaves every figure tile quiet; it says so rather than a bare "Not read".
|
||||
const noFig = m ? {} : { none: 'Not read: no figures were read' };
|
||||
const answered = o.rows.filter(r => r.state === 'ok').length;
|
||||
// Agents working of all agents: the system Agents page's rows, read the way that page reads them.
|
||||
// An organization whose agent read failed is named, never counted as having none.
|
||||
const agentReads = await readInstances(o.rows.filter(r => r.state === 'ok' && !missOf(r, ['agents'])).map(r => r.org));
|
||||
const agentOk = agentReads.filter(r => r.inst), agentAll = agentRows(agentOk, body);
|
||||
const agentParts = agentOk.map(r => ({ id: r.id, n: agentAll.filter(a => a.org === r.id && a.state === 'working').length }));
|
||||
const waitingOn = agentAll.filter(a => a.state === 'waiting').length;
|
||||
const ps = { needs: parts('needs', x => x.needsYou), tasks: parts('tasks', x => x.openTasks), done: parts('done', x => x.doneThisWeek), projects: parts('projects', x => x.projects?.active) };
|
||||
const needsN = fig('needs', ps.needs);
|
||||
const archived = per.filter(x => readers(o, SRC.projects).has(x.org)).reduce((a, x) => a + num(x.projects?.archived), 0);
|
||||
const tiles = !o.rows.length ? '<p class="ws-panel tight note" data-ws-tiles>No organizations yet, so there is nothing to count.</p>' : `<div class="ws-tiles six" data-ws-tiles role="group" aria-label="Key figures">
|
||||
${kpiTile(o, { key: 'agents', name: 'Agents working', href: '#/agents?state=working', big: agentOk.length ? sum(agentParts) : null, small: `of ${agentAll.length}`, parts: agentParts, delta: waitingOn ? `${waitingOn} waiting on you` : '', miss: [...notIn(['agents']), ...agentReads.filter(r => !r.inst).map(r => `${esc(o.name(r.id))} (${esc(instWhy(r))})`)] })}
|
||||
${kpiTile(o, { key: 'orgs', name: 'Organizations', href: '#/dashboard', big: answered, small: `of ${o.rows.length} running`, delta: o.unread.length ? andList(o.unread.map(r => `${esc(o.name(r.org))} ${offWord(r)}`)) : 'every bus answered', attrs: ' data-ws-jump="orgs"' })}
|
||||
${kpiTile(o, { key: 'needs', name: 'Needs you', href: '#/inbox', big: needsN, small: needsN === 1 ? 'item' : 'items', parts: ps.needs, delta: needsN && t?.oldestNeedsYouAt ? `oldest ${span(t.oldestNeedsYouAt)}` : 'nothing waiting', miss: notIn(SRC.needs), ...noFig })}
|
||||
${kpiTile(o, { key: 'tasks', name: 'Open tasks', href: '#/tasks', big: fig('tasks', ps.tasks), parts: ps.tasks, miss: notIn(SRC.tasks), ...noFig })}
|
||||
${kpiTile(o, { key: 'done', name: 'Done this week', href: '#/tasks', big: fig('done', ps.done), parts: ps.done, miss: notIn(SRC.done), ...noFig })}
|
||||
${kpiTile(o, { key: 'projects', name: 'Projects', href: selHref('/projects'), big: fig('projects', ps.projects), small: 'active', parts: ps.projects, delta: `${archived} archived`, miss: notIn(SRC.projects), ...noFig })}
|
||||
</div>`;
|
||||
|
||||
// SP1 (sample r2-01): one row per organization. A figure shows only when its read gave it; an
|
||||
// organization that did not answer shows its state and reason instead of figures.
|
||||
const projectsRead = readers(o, SRC.projects), inboxRead = readers(o, SRC.needs), sessionsRead = readers(o, SRC.sessions);
|
||||
const none = '<span class="muted" title="not read">–</span>';
|
||||
const orgTable = o.rows.map(r => {
|
||||
const id = r.org, fromList = listRow(id), x = per.find(p => p.org === id);
|
||||
const [cls, glyph, word] = rowState(r, fromList);
|
||||
@@ -685,11 +730,11 @@
|
||||
${tiles}
|
||||
<div class="ws-split ws-home-split">
|
||||
<section aria-label="Organizations, projects and tasks">
|
||||
${secH('Organizations', `read ${esc(hm(body.readAt))}`)}
|
||||
${secH('Organizations', `read ${esc(hm(body.readAt))}`, '', 'tabindex="-1" data-ws-orgs-h data-focus-key="orgs-h"')}
|
||||
${orgList}
|
||||
${secH('Recent project activity', 'latest change per project; there is no activity feed yet', `<a href="${esc(selHref('/projects'))}" data-focus-key="home-projects">All projects</a>`)}
|
||||
${trail}${missLine(o, ['projects'], 'recent')}
|
||||
${secH('Tasks by state', 'open tasks, every tracker', `<a href="${esc(selHref('/tasks'))}" data-focus-key="home-tasks">Open tasks</a>`)}
|
||||
${secH('Tasks by state', 'open tasks, every tracker', '<a href="#/tasks" data-focus-key="home-tasks">Open tasks</a>')}
|
||||
${states}${missLine(o, ['tasks'], 'states')}
|
||||
</section>
|
||||
<section aria-label="Needs you and sessions">
|
||||
@@ -906,26 +951,17 @@
|
||||
// Fire is drawn only on the system page and only there, and posts to the row's organization (bus.js).
|
||||
const AGENT_STATE = [['working', 'Working'], ['waiting', 'Waiting on you'], ['idle', 'Idle'], ['running', 'Running'], ['not-running', 'Not running'], ['cant-launch', "Can't launch"]];
|
||||
const AGENT_STATE_KEY = { working: 'working', 'waiting on you': 'waiting', idle: 'idle', running: 'running', 'not running': 'not-running', "can't launch": 'cant-launch' };
|
||||
async function agentsPage(q, opts = {}) {
|
||||
// SP5 review F11: a scope that is not an organization id is refused, never widened to the system list.
|
||||
if (opts.scope != null && !(typeof opts.scope === 'string' && ORG_ID.test(opts.scope))) return '<section class="ws ws-agents-all" data-ws-agents data-ws-failed><div class="ws-vh"><h1 tabindex="-1">Agents</h1><p class="sub">That is not an organization address.</p></div></section>';
|
||||
const scope = opts.scope ?? null;
|
||||
const org = scope ? [scope] : listParam(q, 'org', id => ORG_ID.test(id));
|
||||
const want = String(q?.get?.('state') || '');
|
||||
const stateF = AGENT_STATE.some(([k]) => k === want) ? want : null;
|
||||
let body;
|
||||
try { [body] = await Promise.all([data.workspace({ sections: ['sessions', 'projects'] }), ensureThemes()]); took(body); }
|
||||
catch (err) { return failedPage('ws-agents-all', 'data-ws-agents', 'Agents', err); }
|
||||
const every = orgsOf(body);
|
||||
const o = org ? { ...every, rows: every.rows.filter(r => org.includes(r.org)), unread: every.unread.filter(r => org.includes(r.org)) } : every;
|
||||
const askable = (scope ? o.rows : every.rows).filter(r => r.state === 'ok');
|
||||
// Each answering organization's instances, and (system page only) whether this Console decides there.
|
||||
const reads = await Promise.all(askable.map(r => Promise.all([
|
||||
data.instances(r.org).then(v => ({ inst: instancesOf(v), copies: copiesOf(v) }), err => ({ inst: null, copies: {}, err })),
|
||||
!scope && typeof opts.fireIn === 'function' ? opts.fireIn(r.org).then(v => v === true, () => false) : false,
|
||||
]).then(([v, fire]) => ({ id: r.org, ...v, fire }))));
|
||||
const sessions = arr(body.sessions).filter(x => x?.kind === 'role' && typeof x.org === 'string' && typeof x.role === 'string');
|
||||
const projects = arr(body.projects).filter(x => typeof x?.org === 'string');
|
||||
// Each named organization's instances read, and whether this Console decides there when `fireIn` is given.
|
||||
const readInstances = (ids, fireIn) => Promise.all(ids.map(id => Promise.all([
|
||||
data.instances(id).then(v => ({ inst: instancesOf(v), copies: copiesOf(v) }), err => ({ inst: null, copies: {}, err })),
|
||||
typeof fireIn === 'function' ? fireIn(id).then(v => v === true, () => false) : false,
|
||||
]).then(([v, fire]) => ({ id, ...v, fire }))));
|
||||
// The agent rows of every organization whose instances read answered: its own instances and the root
|
||||
// and project copies its bus booted with, each in the state the system Agents page shows. SP8: the
|
||||
// Dashboard and organization tiles count these same rows, so a tile's figure is the page's row count.
|
||||
function agentRows(reads, body) {
|
||||
const sessions = arr(body?.sessions).filter(x => x?.kind === 'role' && typeof x.org === 'string' && typeof x.role === 'string');
|
||||
const projects = arr(body?.projects).filter(x => typeof x?.org === 'string');
|
||||
const projectRef = (id, p) => { const hit = projects.find(x => x.org === id && (x.id === p || x.key === p)); return hit ? { id: hit.id, name: hit.name || hit.key || hit.id } : { id: p, name: p }; };
|
||||
const all = [];
|
||||
for (const r of reads) {
|
||||
@@ -946,6 +982,24 @@
|
||||
}
|
||||
}
|
||||
for (const a of all) a.state = AGENT_STATE_KEY[a.dot[2]] || 'not-running';
|
||||
return all;
|
||||
}
|
||||
async function agentsPage(q, opts = {}) {
|
||||
// SP5 review F11: a scope that is not an organization id is refused, never widened to the system list.
|
||||
if (opts.scope != null && !(typeof opts.scope === 'string' && ORG_ID.test(opts.scope))) return '<section class="ws ws-agents-all" data-ws-agents data-ws-failed><div class="ws-vh"><h1 tabindex="-1">Agents</h1><p class="sub">That is not an organization address.</p></div></section>';
|
||||
const scope = opts.scope ?? null;
|
||||
const org = scope ? [scope] : listParam(q, 'org', id => ORG_ID.test(id));
|
||||
const want = String(q?.get?.('state') || '');
|
||||
const stateF = AGENT_STATE.some(([k]) => k === want) ? want : null;
|
||||
let body;
|
||||
try { [body] = await Promise.all([data.workspace({ sections: ['sessions', 'projects'] }), ensureThemes()]); took(body); }
|
||||
catch (err) { return failedPage('ws-agents-all', 'data-ws-agents', 'Agents', err); }
|
||||
const every = orgsOf(body);
|
||||
const o = org ? { ...every, rows: every.rows.filter(r => org.includes(r.org)), unread: every.unread.filter(r => org.includes(r.org)) } : every;
|
||||
const askable = (scope ? o.rows : every.rows).filter(r => r.state === 'ok');
|
||||
// Each answering organization's instances, and (system page only) whether this Console decides there.
|
||||
const reads = await readInstances(askable.map(r => r.org), scope ? null : opts.fireIn);
|
||||
const all = agentRows(reads, body);
|
||||
const counts = new Map();
|
||||
for (const a of all) counts.set(a.org, (counts.get(a.org) || 0) + 1);
|
||||
const inOrg = all.filter(a => !org || org.includes(a.org)).sort((x, y) => x.org.localeCompare(y.org) || x.name.localeCompare(y.name) || x.key.localeCompare(y.key));
|
||||
@@ -972,9 +1026,8 @@
|
||||
// answered but whose instances read failed is named too, never counted as having no agents.
|
||||
const why = r => `${stateWords(r).replace(/, read .*$/, '')}${r.reason ? `: ${esc(r.reason)}` : ''}${r.at ? ` (read ${esc(hm(r.at))})` : ''}`;
|
||||
const failed = reads.filter(r => !r.inst && (!org || org.includes(r.id)));
|
||||
const READ_ERR = { 403: 'refused', 0: 'signed out' };
|
||||
const unread = (o.unread.length ? `<p class="note ws-unread-note" data-ws-unread>Not read: ${andList(o.unread.map(why))}.</p>` : '')
|
||||
+ (failed.length ? `<p class="note ws-miss" data-ws-miss="instances">Agents not read in ${andList(failed.map(r => `${esc(o.name(r.id))} (${esc(READ_ERR[r.err?.status] || r.err?.code || 'no answer')})`))}.</p>` : '')
|
||||
+ (failed.length ? `<p class="note ws-miss" data-ws-miss="instances">Agents not read in ${andList(failed.map(r => `${esc(o.name(r.id))} (${esc(instWhy(r))})`))}.</p>` : '')
|
||||
+ missLine(o, ['agents', 'tasks'], 'agents', 'What agents work on was not read in');
|
||||
const anyRead = reads.some(r => r.inst && (!org || org.includes(r.id)));
|
||||
const filtered = (org && !scope) || stateF;
|
||||
@@ -1046,6 +1099,13 @@
|
||||
const pick = e.target.closest('[data-ws-select]');
|
||||
if (pick && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.button === 0) { e.preventDefault(); setQuery({ d: pick.dataset.wsSelect }); }
|
||||
});
|
||||
// SP8: the Organizations tile counts the Dashboard's own table, so a plain click moves focus there.
|
||||
document.addEventListener('click', e => {
|
||||
const jump = e.target.closest?.('[data-ws-jump="orgs"]');
|
||||
if (!jump || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
|
||||
const to = document.querySelector('[data-ws-orgs-h]');
|
||||
if (to) { e.preventDefault(); to.focus(); }
|
||||
});
|
||||
document.addEventListener('change', e => {
|
||||
if (e.target.matches?.('[data-ws-projects] [data-ws-status]')) setQuery({ status: e.target.value === 'active' ? null : e.target.value });
|
||||
else if (e.target.matches?.('[data-ws-projects] [data-ws-state]')) setQuery({ state: e.target.value || null });
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
// SP8 (#1873, single pane slice 8): the Dashboard leads with one row of KPI tiles, each built from a
|
||||
// number an existing read returns and each a link to the page that lists what it counts, with the
|
||||
// filter in the address where that page has one. A tile whose number some organization could not give
|
||||
// names that organization; it never shows a lower number as if it were complete. An organization's
|
||||
// page leads with the same tiles scoped to it, each linking to the system page filtered to it.
|
||||
// Every bus is a fake: acme and beta answer, gamma's bus is stopped. The real GET /api/workspace and
|
||||
// the real per-organization routes serve the pages.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
|
||||
const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString();
|
||||
|
||||
// fail: { org: [read names] } makes those reads refuse; `none` empties the organization list.
|
||||
function fixture({ fail = {}, none = false } = {}) {
|
||||
const LIST = none ? [] : [
|
||||
{ id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true },
|
||||
{ id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true },
|
||||
{ id: 'gamma', label: 'Gamma Home', theme: 'rosewood', busHealthy: false, busStopped: true },
|
||||
];
|
||||
const decision = (id, question, role, minutes) => ({ id, question, class: 'within-role', blocking: false, raised_by_role: role, at: at(minutes), options: [{ key: 'yes', text: 'Yes' }] });
|
||||
const DATA = {
|
||||
acme: {
|
||||
projects: [{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) }, { id: 'p-old', repo: 'old', name: 'Old', archived: true, at: at(9000) }],
|
||||
tasks: [{ task_ref: 'local:1', project_key: 'site', at: at(20), fields: { title: 'Hero copy', done: false } }],
|
||||
agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40) } }],
|
||||
inbox: [decision('dec-acme-1', 'Ship the hero?', 'coder', 10)],
|
||||
instances: { coder: { definition: 'coder', running: ['run-1'] }, chief: { definition: 'chief-of-staff', running: [] } },
|
||||
},
|
||||
beta: {
|
||||
projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }],
|
||||
tasks: [
|
||||
{ task_ref: 'local:7', project_key: 'shop', at: at(50), fields: { title: 'Price list', done: false } },
|
||||
{ task_ref: 'local:8', project_key: 'shop', at: at(70), fields: { title: 'Cart copy', done: false } },
|
||||
{ task_ref: 'local:9', project_key: 'shop', at: at(60 * 24), fields: { title: 'Logo', done: true } },
|
||||
],
|
||||
agents: [{ role: 'pm', holder_run: 'run-9', harness: 'claude', state: 'waiting', at: at(15) }],
|
||||
inbox: [decision('dec-beta-1', 'Pick a launch price', 'pm', 5), decision('dec-beta-2', 'Name the shop', 'pm', 90)],
|
||||
instances: { pm: { definition: 'pm', running: ['run-9'] }, writer: { definition: 'writer', running: [] } },
|
||||
},
|
||||
};
|
||||
const value = (id, name) => (fail[id]?.includes(name) ? Promise.reject(Object.assign(new Error('off'), { code: 'channel-disabled' })) : Promise.resolve(DATA[id]?.[name] ?? (['usage', 'limits'].includes(name) ? {} : [])));
|
||||
const opts = id => ({
|
||||
bus: { inbox: () => value(id, 'inbox'), agents: () => value(id, 'agents'), 'thread.claims': () => value(id, 'claims'), tasks: () => value(id, 'tasks'), usage: () => value(id, 'usage'), limits: () => value(id, 'limits'), decisions: () => value(id, 'decisions') },
|
||||
chat: { threads: () => value(id, 'threads'), projects: () => value(id, 'projects') },
|
||||
});
|
||||
const member = org => ({
|
||||
id: org.id, doc: { appearance: { theme: org.theme } },
|
||||
status: async () => (org.busHealthy ? { healthy: true, pid: 4242, boot: null } : { healthy: false, boot: { ok: false, at: at(90), lastGood: null, reason: 'stopped by the owner' } }),
|
||||
options: () => opts(org.id),
|
||||
});
|
||||
return {
|
||||
list: async () => LIST,
|
||||
members: async () => LIST.map(member),
|
||||
resolve: async id => {
|
||||
const org = LIST.find(o => o.id === id); if (!org) return null;
|
||||
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } };
|
||||
return {
|
||||
busHealthy: true,
|
||||
reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo), appearance: { theme: org.theme } } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
|
||||
bus: {
|
||||
messages: async () => ({ messages: [], unread: 0 }), agents: async () => DATA[id].agents, 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [],
|
||||
instances: () => (fail[id]?.includes('instances') ? Promise.reject(Object.assign(new Error('off'), { code: 'bus-unavailable' })) : Promise.resolve({ revision: 'a'.repeat(64), definitions: {}, instances: DATA[id].instances })),
|
||||
},
|
||||
chat: { projects: async () => DATA[id].projects, threads: async () => [] },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function setup(t, { viewport = [1440, 900], hash = '#/dashboard', fail, none } = {}) {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ fail, none }), log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(...viewport);
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
await b.navigate(`${base}/${hash}`);
|
||||
return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
|
||||
}
|
||||
const tileKeys = scope => `[...document.querySelectorAll(${JSON.stringify(`${scope} [data-ws-tile]`)})].map(e => e.dataset.wsTile)`;
|
||||
const hrefs = scope => `Object.fromEntries([...document.querySelectorAll(${JSON.stringify(`${scope} [data-ws-tile]`)})].map(e => [e.dataset.wsTile, e.tagName === 'A' ? e.getAttribute('href') : null]))`;
|
||||
const big = (scope, key) => `document.querySelector(${JSON.stringify(`${scope} [data-ws-tile=${key}] .ws-big`)})?.textContent.replace(/\\s+/g, ' ').trim() ?? null`;
|
||||
const homeReady = 'document.querySelector("[data-ws-home] [data-ws-tiles] [data-ws-tile=agents] .ws-big")';
|
||||
|
||||
test('The Dashboard tiles add both answering organizations\' numbers, one row, each tile a link to the page that lists it', async t => {
|
||||
const { b, wait, text } = await setup(t);
|
||||
await wait(homeReady);
|
||||
// One row: no second tile group anywhere on the page, and the row leads the page under its heading.
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-home] [data-ws-tiles]").length'), 1);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-ws-home] .ws-vh").nextElementSibling.hasAttribute("data-ws-tiles")'), true);
|
||||
assert.deepEqual(await b.evaluate(tileKeys('[data-ws-home]')), ['agents', 'orgs', 'needs', 'tasks', 'done', 'projects']);
|
||||
// Agents working of total: acme's coder works; beta's pm waits; chief and writer run nothing.
|
||||
assert.equal(await b.evaluate(big('[data-ws-home]', 'agents')), '1 of 4');
|
||||
assert.equal(await b.evaluate(big('[data-ws-home]', 'orgs')), '2 of 3 running');
|
||||
assert.equal(await b.evaluate(big('[data-ws-home]', 'needs')), '3 items');
|
||||
assert.equal(await b.evaluate(big('[data-ws-home]', 'tasks')), '3');
|
||||
assert.equal(await b.evaluate(big('[data-ws-home]', 'done')), '1');
|
||||
assert.equal(await b.evaluate(big('[data-ws-home]', 'projects')), '2 active');
|
||||
// Each organization's share, named for a screen reader.
|
||||
assert.match(await text('[data-ws-tile=needs] .ws-by'), /Acme Studio\s*1[\s\S]*Beta Works\s*2/);
|
||||
assert.match(await text('[data-ws-tile=tasks] .ws-by'), /Acme Studio\s*1[\s\S]*Beta Works\s*2/);
|
||||
assert.match(await text('[data-ws-tile=agents] .ws-by'), /Acme Studio\s*1[\s\S]*Beta Works\s*0/);
|
||||
// Every tile is a link, with the filter in the address where the page has one.
|
||||
const links = await b.evaluate(hrefs('[data-ws-home]'));
|
||||
assert.equal(links.agents, '#/agents?state=working');
|
||||
assert.equal(links.orgs, '#/dashboard');
|
||||
assert.equal(links.needs, '#/inbox');
|
||||
assert.equal(links.tasks, '#/tasks');
|
||||
assert.equal(links.done, '#/tasks');
|
||||
assert.match(links.projects, /^#\/orgs\/[a-z0-9._-]+\/projects$/);
|
||||
// No sample numbers and no trend line: no tile draws an svg sparkline.
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-tiles] svg:not(.i)").length'), 0);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-tile=spend],[data-ws-tile=sessions]").length'), 0);
|
||||
// The Open tasks link under Tasks by state goes to the system board too.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-focus-key=home-tasks]").getAttribute("href")'), '#/tasks');
|
||||
});
|
||||
|
||||
test('A stopped organization is named on every tile its numbers would have joined, and on none it counted', async t => {
|
||||
const { b, wait, text } = await setup(t);
|
||||
await wait(homeReady);
|
||||
for (const key of ['agents', 'needs', 'tasks', 'done', 'projects']) {
|
||||
assert.match(await text(`[data-ws-tile=${key}] [data-ws-miss=${key}]`) ?? '', /Not read in Gamma Home \(bus stopped\)/, key);
|
||||
}
|
||||
// The Organizations tile counts it as not running and says which one.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-ws-tile=orgs] [data-ws-miss]")'), null);
|
||||
assert.match(await text('[data-ws-tile=orgs]'), /Gamma Home bus stopped/);
|
||||
});
|
||||
|
||||
test('Following a tile lands on its page showing what the tile counted', async t => {
|
||||
const { b, wait, text } = await setup(t);
|
||||
await wait(homeReady);
|
||||
await b.evaluate('document.querySelector("[data-ws-tile=agents]").click()');
|
||||
await wait('location.hash === "#/agents?state=working" && document.querySelector("[data-ws-agent]")');
|
||||
await wait('JSON.stringify([...document.querySelectorAll("[data-ws-agent]")].map(r => r.dataset.wsAgent)) === JSON.stringify(["acme:coder"])');
|
||||
assert.match(await text('[data-ws-astate=""] .n'), /^4$/);
|
||||
await b.evaluate('location.hash = "#/dashboard"');
|
||||
await wait(homeReady);
|
||||
await b.evaluate('document.querySelector("[data-ws-tile=needs]").click()');
|
||||
await wait('location.hash === "#/inbox" && document.querySelectorAll("[data-ws-item]").length === 3');
|
||||
await b.evaluate('location.hash = "#/dashboard"');
|
||||
await wait(homeReady);
|
||||
// The Organizations tile's list is this page's own table: following it keeps the page and moves focus there.
|
||||
await b.evaluate('document.querySelector("[data-ws-tile=orgs]").click()');
|
||||
await wait('document.activeElement?.closest?.("[data-ws-orgs-h]")');
|
||||
assert.equal(await b.evaluate('location.hash'), '#/dashboard');
|
||||
});
|
||||
|
||||
test('An answering organization whose agent read failed is named on the Agents tile, never counted as none', async t => {
|
||||
const { b, wait, text } = await setup(t, { fail: { beta: ['instances'] } });
|
||||
await wait(homeReady);
|
||||
assert.equal(await b.evaluate(big('[data-ws-home]', 'agents')), '1 of 2');
|
||||
assert.match(await text('[data-ws-tile=agents] [data-ws-miss=agents]'), /Beta Works/);
|
||||
assert.doesNotMatch(await text('[data-ws-tile=agents] .ws-by') ?? '', /Beta Works/);
|
||||
});
|
||||
|
||||
test('When no answering organization could read its agents, the Agents tile says Not read instead of a number', async t => {
|
||||
const { b, wait, text } = await setup(t, { fail: { acme: ['instances'], beta: ['instances'] } });
|
||||
await wait('document.querySelector("[data-ws-tile=agents]")');
|
||||
assert.equal(await b.evaluate(big('[data-ws-home]', 'agents')), null);
|
||||
assert.match(await text('[data-ws-tile=agents]'), /Not read/);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-ws-tile=agents]").getAttribute("href")'), '#/agents?state=working');
|
||||
});
|
||||
|
||||
test('With no organizations there is nothing to count, so no tile shows a zero', async t => {
|
||||
// With no organization the workspace pages are not drawn; the Console's own board keeps its dashes.
|
||||
const { b, wait } = await setup(t, { none: true });
|
||||
await wait('document.querySelector("#dashboard-kpis .kpi strong")');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll(".ws-tile, .ws-big").length'), 0);
|
||||
assert.deepEqual(await b.evaluate('[...new Set([...document.querySelectorAll("#dashboard-kpis .kpi strong")].map(e => e.textContent.trim()))]'), ['–']);
|
||||
});
|
||||
|
||||
test('An organization page leads with its own tiles, each linking to the system page filtered to it', async t => {
|
||||
const { b, wait, text } = await setup(t, { hash: '#/orgs/beta' });
|
||||
await wait('document.querySelector("[data-org-dashboard] [data-org-tiles] [data-ws-tile=agents] .ws-big")');
|
||||
// The tiles come straight after the header, before the docs row.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-org-dashboard] .ws-orghead").nextElementSibling.hasAttribute("data-org-tiles")'), true);
|
||||
assert.deepEqual(await b.evaluate(tileKeys('[data-org-tiles]')), ['agents', 'tasks', 'needs', 'done']);
|
||||
assert.equal(await b.evaluate(big('[data-org-tiles]', 'agents')), '0 of 2');
|
||||
assert.equal(await b.evaluate(big('[data-org-tiles]', 'tasks')), '2');
|
||||
assert.equal(await b.evaluate(big('[data-org-tiles]', 'needs')), '2 items');
|
||||
assert.equal(await b.evaluate(big('[data-org-tiles]', 'done')), '1');
|
||||
assert.deepEqual(await b.evaluate(hrefs('[data-org-tiles]')), { agents: '#/agents?org=beta&state=working', tasks: '#/tasks?org=beta', needs: '#/inbox?org=beta', done: '#/tasks?org=beta' });
|
||||
// The empty KPI and metric placeholders are gone; there is one tile row.
|
||||
assert.doesNotMatch(await text('[data-org-dashboard]'), /No KPIs defined|No metrics recorded yet/);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-org-dashboard] .ws-tiles, [data-org-dashboard] [data-org-tiles]").length'), 1);
|
||||
// Following Needs you shows beta's two items on the system Inbox.
|
||||
await b.evaluate('document.querySelector("[data-org-tiles] [data-ws-tile=needs]").click()');
|
||||
await wait('location.hash === "#/inbox?org=beta" && document.querySelectorAll("[data-ws-item]").length === 2');
|
||||
// Acme's own page counts only acme.
|
||||
await b.evaluate('location.hash = "#/orgs/acme"');
|
||||
await wait('document.querySelector("[data-org-dashboard] [data-org-tiles] [data-ws-tile=agents] .ws-big") && location.hash.startsWith("#/orgs/acme")');
|
||||
await wait(`${big('[data-org-tiles]', 'agents')} === "1 of 2"`);
|
||||
assert.equal(await b.evaluate(big('[data-org-tiles]', 'needs')), '1 item');
|
||||
assert.equal(await b.evaluate(big('[data-org-tiles]', 'done')), '0');
|
||||
await b.evaluate('document.querySelector("[data-org-tiles] [data-ws-tile=agents]").click()');
|
||||
await wait('location.hash === "#/agents?org=acme&state=working" && JSON.stringify([...document.querySelectorAll("[data-ws-agent]")].map(r => r.dataset.wsAgent)) === JSON.stringify(["acme:coder"])');
|
||||
});
|
||||
|
||||
test('A stopped organization\'s page shows its tiles as not read, still linking to its filtered pages', async t => {
|
||||
const { b, wait, text } = await setup(t, { hash: '#/orgs/gamma' });
|
||||
await wait('document.querySelector("[data-org-dashboard] [data-org-tiles]")');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-org-tiles] .ws-big").length'), 0);
|
||||
for (const key of ['agents', 'tasks', 'needs', 'done']) assert.match(await text(`[data-org-tiles] [data-ws-tile=${key}]`), /Not read: the bus is stopped/, key);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-org-tiles] [data-ws-tile=needs]").getAttribute("href")'), '#/inbox?org=gamma');
|
||||
});
|
||||
|
||||
test('At 390 wide the tiles sit two to a row with no sideways scroll, on the Dashboard and an organization page', async t => {
|
||||
const { b, wait } = await setup(t, { viewport: [390, 844] });
|
||||
await wait(homeReady);
|
||||
const cols = scope => b.evaluate(`new Set([...document.querySelectorAll(${JSON.stringify(`${scope} [data-ws-tile]`)})].map(e => Math.round(e.getBoundingClientRect().left))).size`);
|
||||
assert.equal(await cols('[data-ws-home]'), 2);
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, 'dashboard');
|
||||
await b.evaluate('location.hash = "#/orgs/acme"');
|
||||
await wait('document.querySelector("[data-org-tiles] [data-ws-tile=agents] .ws-big")');
|
||||
assert.equal(await cols('[data-org-tiles]'), 2);
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, 'organization page');
|
||||
});
|
||||
@@ -136,9 +136,10 @@ test('organization dashboard renders every section from the per-organization rea
|
||||
assert.match(head.colour, /^#[0-9a-f]{6}$/i);
|
||||
// Docs come from the project files reads: only documents, named by project.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-doc")].map(a => a.innerText.replace(/\\s+/g, " ").trim())'), ['North star Spring launch', 'Launch plan Spring launch', 'Brand voice guide Brand refresh']);
|
||||
// KPIs and metrics: one honest empty tile each, with no invented number.
|
||||
const tiles = await b.evaluate('[...document.querySelectorAll(".ws-tiles")].map(t => [t.querySelectorAll(".ws-tile").length, t.querySelector(".ws-tile").classList.contains("is-empty"), t.innerText.replace(/\\s+/g, " ").trim()])');
|
||||
assert.deepEqual(tiles, [[1, true, 'No KPIs defined in the organization file'], [1, true, 'No metrics recorded yet']]);
|
||||
// SP8 (#1873): one row of figure tiles. This fixture gives no cross-organization read (no members),
|
||||
// so each tile says it was not read and shows no number.
|
||||
const tiles = await b.evaluate('[...document.querySelectorAll(".ws-tiles")].map(t => [[...t.querySelectorAll("[data-ws-tile]")].map(e => e.dataset.wsTile), t.querySelectorAll(".ws-big").length, t.innerText.match(/Not read: the read did not answer/g)?.length ?? 0])');
|
||||
assert.deepEqual(tiles, [[['agents', 'tasks', 'needs', 'done'], 0, 4]]);
|
||||
// Active agent sessions: the running instances, the thread their live run holds (chief's older
|
||||
// Kickoff notes is not it), the model with its Change link, and the state the bus reports. With no
|
||||
// reported state (montage-producer) the row says only running.
|
||||
@@ -177,7 +178,7 @@ test('organization dashboard empty states say so in one short line', async t =>
|
||||
await waitFor(b)('document.querySelector("[data-org-dashboard] [data-org-settings]")');
|
||||
const text = await b.evaluate('document.querySelector("[data-org-dashboard]").innerText');
|
||||
// Section labels are uppercased by CSS, so innerText carries the uppercase form.
|
||||
for (const line of [/Docs\s+No docs yet/i, /No KPIs defined in the organization file/, /No metrics recorded yet/, /Active agent sessions\s+none running/i, /No active agent sessions/, /Queue\s+no rows/i, /Tracker: not set/])
|
||||
for (const line of [/Docs\s+No docs yet/i, /Agents working\s+Not read/, /Done this week\s+Not read/, /Active agent sessions\s+none running/i, /No active agent sessions/, /Queue\s+no rows/i, /Tracker: not set/])
|
||||
assert.match(text, line);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-org-settings] > li").length'), SETTINGS.length);
|
||||
});
|
||||
@@ -189,9 +190,9 @@ test('organization dashboard presents sections and honest unavailable controls',
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/organization`);
|
||||
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('[data-org-dashboard]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('dashboard missing')})()`);
|
||||
const text=await b.evaluate('document.querySelector("[data-org-dashboard]").innerText');
|
||||
for(const label of ['Docs','KPIs','Metrics','Active agent sessions','Settings','Queue']) assert.match(text,new RegExp(label,'i'));
|
||||
for(const label of ['Docs','Agents working','Open tasks','Needs you','Done this week','Active agent sessions','Settings','Queue']) assert.match(text,new RegExp(label,'i'));
|
||||
assert.match(text,/reload organization file/i);
|
||||
assert.match(text,/no metrics recorded yet/i);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-org-tiles] .ws-big").length'),0);
|
||||
assert.match(text,/bus unavailable, read \d/);
|
||||
assert.match(text,/Sessions unavailable/);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-org-reload]").disabled'),true);
|
||||
|
||||
@@ -87,12 +87,11 @@ test('Dashboard renders every section across organizations, and a stopped one sh
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page] .t")?.textContent'), 'Dashboard');
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]").getAttribute("href")'), '#/dashboard');
|
||||
assert.equal(await text('#bus-view h1'), 'Dashboard');
|
||||
// Tiles: needs you, sessions, open tasks, projects, and an honest line for spend.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-tile]")].map(e => e.dataset.wsTile)'), ['needs', 'sessions', 'tasks', 'projects', 'spend']);
|
||||
// SP8 (#1873): one row of figure tiles, none for spend or any figure no read returns.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-tile]")].map(e => e.dataset.wsTile)'), ['agents', 'orgs', 'needs', 'tasks', 'done', 'projects']);
|
||||
assert.match(await text('[data-ws-tile=needs]'), /Needs you\s*2\s*items/i);
|
||||
assert.match(await text('[data-ws-tile=projects]'), /Projects\s*4\s*3 active/i);
|
||||
assert.match(await text('[data-ws-tile=spend]'), /No spend figures yet/);
|
||||
assert.doesNotMatch(await text('[data-ws-tile=spend]'), /\$|\d/);
|
||||
assert.match(await text('[data-ws-tile=projects]'), /Projects\s*3 active[\s\S]*1 archived/i);
|
||||
assert.doesNotMatch(await text('[data-ws-tiles]'), /spend|\$/i);
|
||||
// SP1: one organization row each: two running, the stopped one with its state and reason.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-orgrow]")].map(e => [e.dataset.wsOrgrow, e.dataset.state])'), [['acme', 'ok'], ['beta', 'ok'], ['gamma', 'down']]);
|
||||
assert.match(await text('[data-ws-orgrow=gamma]'), /Gamma Home/);
|
||||
@@ -284,8 +283,9 @@ test('A read that an answering organization could not make says not read, never
|
||||
// beta answers, but its project and inbox reads refuse.
|
||||
const { b, wait, text } = await setup(t, { fail: { beta: ['projects', 'inbox'] } });
|
||||
await wait('document.querySelector("[data-ws-tiles]")');
|
||||
assert.match(await text('[data-ws-tile=projects] [data-ws-miss=projects]'), /Not read in Beta Works \(the chat channel is off\)/);
|
||||
assert.match(await text('[data-ws-tile=needs] [data-ws-miss=needs]'), /Not read in Beta Works/);
|
||||
// The tiles also name Gamma Home, whose bus is down.
|
||||
assert.match(await text('[data-ws-tile=projects] [data-ws-miss=projects]'), /Not read in Gamma Home \(bus down\) and Beta Works \(the chat channel is off\)/);
|
||||
assert.match(await text('[data-ws-tile=needs] [data-ws-miss=needs]'), /Not read in .*Beta Works/);
|
||||
assert.match(await text('[data-ws-miss=recent]'), /Not read in Beta Works/);
|
||||
assert.match(await text('[data-ws-miss=needs]:not([data-ws-tile] *)'), /Not read in Beta Works/);
|
||||
assert.doesNotMatch(await text('.ws-home .sub'), /and \d+ projects?/, 'no project total when a project read failed');
|
||||
@@ -329,10 +329,10 @@ test('Project rows and per-organization figures say not read when the reads behi
|
||||
// Dashboard: the per-organization figures leave Beta out instead of showing its 0.
|
||||
await b.evaluate('location.hash = "#/orgs/acme/"');
|
||||
await wait('document.querySelector("[data-ws-tiles]")');
|
||||
for (const tile of ['needs', 'sessions', 'tasks']) {
|
||||
for (const tile of ['needs', 'agents', 'tasks', 'done']) {
|
||||
const by = await b.evaluate(`document.querySelector('[data-ws-tile=${tile}] .ws-by')?.textContent ?? ''`);
|
||||
assert.doesNotMatch(by, /Beta Works/, tile);
|
||||
assert.match(await text(`[data-ws-tile=${tile}] [data-ws-miss=${tile}]`), /Not read in Beta Works/, tile);
|
||||
assert.match(await text(`[data-ws-tile=${tile}] [data-ws-miss=${tile}]`), /Beta Works \(the chat channel is off\)/, tile);
|
||||
}
|
||||
assert.doesNotMatch(await b.evaluate(`[...document.querySelectorAll('[data-ws-states] .ws-srow')].find(r => /Done this week/.test(r.textContent))?.querySelector('.ws-by')?.textContent ?? ''`), /Beta Works/);
|
||||
});
|
||||
@@ -340,8 +340,10 @@ test('Project rows and per-organization figures say not read when the reads behi
|
||||
test('With every organization down the dashboard and inbox show no totals and no empty-list claims', async t => {
|
||||
const { b, wait, text } = await setup(t, { down: ['acme', 'beta'] });
|
||||
await wait('document.querySelector("[data-ws-tiles]")');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-home] .ws-big").length'), 0);
|
||||
assert.match(await text('[data-ws-tiles]'), /No organization answered/);
|
||||
// Only the Organizations tile has a figure, and it is complete: none of the three runs.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-home] .ws-big")].map(e => e.closest("[data-ws-tile]").dataset.wsTile)'), ['orgs']);
|
||||
assert.match(await text('[data-ws-tile=orgs] .ws-big'), /^0 of 3 running$/);
|
||||
for (const tile of ['agents', 'needs', 'tasks', 'done', 'projects']) assert.match(await text(`[data-ws-tile=${tile}]`), /Not read[\s\S]*Acme Studio \(bus down\), Beta Works \(bus down\) and Gamma Home \(bus /, tile);
|
||||
assert.equal(await text('[data-ws-needs]'), 'Decisions not read');
|
||||
assert.equal(await text('[data-ws-states]'), 'Tasks not read');
|
||||
assert.equal(await text('[data-ws-recent]'), 'Projects not read');
|
||||
|
||||
Reference in new issue
Block a user