feat(webui): sp8, the Dashboard leads with one row of figure tiles and an organization's page with its own, each a link to the page that lists what it counts (#1873, lane sp8-kpis)
The Dashboard's tiles are Agents working of all agents, Organizations running, Needs you, Open tasks, Done this week and Projects, each added up only from the organizations whose reads gave it; a tile names every organization it leaves out and why. The Sessions and Model spend tiles go: the first is the Agents tile now, the second had no figure. An organization's page replaces its KPIs and Metrics placeholders with Agents working, Open tasks, Needs you and Done this week for that organization, each a link to the system page filtered to it, from one cross-organization read scoped to it. The system Agents page's row building is shared so a tile's figure is that page's row count.
This commit is contained in:
1 parent
a9912983e6
commit
9400e0d94d
5 files changed
+360
-80
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}
|
||||
|
||||
@@ -262,8 +262,11 @@
|
||||
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);
|
||||
// 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.
|
||||
const ws = await data.workspace({ org: id, sections: ['sessions', 'projects', 'metrics'] }).catch(() => null);
|
||||
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 +286,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 +332,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 +605,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 +638,44 @@
|
||||
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);
|
||||
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) })}
|
||||
${kpiTile(o, { key: 'tasks', name: 'Open tasks', href: '#/tasks', big: fig('tasks', ps.tasks), parts: ps.tasks, miss: notIn(SRC.tasks) })}
|
||||
${kpiTile(o, { key: 'done', name: 'Done this week', href: '#/tasks', big: fig('done', ps.done), parts: ps.done, miss: notIn(SRC.done) })}
|
||||
${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) })}
|
||||
</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 +726,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))}`).replace('<h2>', '<h2 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 +947,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 +978,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 +1022,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 +1095,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