@worca/app 1.5.0 → 1.6.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -2
- package/docker/compose.broker.yml +79 -0
- package/docker/compose.isolation.yml +40 -0
- package/package.json +2 -1
- package/src/broker/config.mjs +200 -0
- package/src/broker/copilot.mjs +124 -0
- package/src/broker/limits.mjs +88 -0
- package/src/broker/main.mjs +129 -0
- package/src/broker/scrub.mjs +45 -0
- package/src/broker/service.mjs +558 -0
- package/src/broker/slots.mjs +180 -0
- package/src/broker/store.mjs +171 -0
- package/src/broker/tokens.mjs +116 -0
- package/src/broker/ui/page.css +54 -0
- package/src/broker/ui/page.html +25 -0
- package/src/broker/ui/page.mjs +202 -0
- package/src/broker/ui-server.mjs +217 -0
- package/src/broker/usage.mjs +108 -0
- package/src/broker/vault.mjs +37 -0
- package/src/cli/models.mjs +46 -14
- package/src/cli/render.mjs +21 -0
- package/src/cli/runs.mjs +296 -0
- package/src/cli/worca-cc.mjs +14 -2
- package/src/core/agent-pool.mjs +76 -0
- package/src/core/artifacts.mjs +40 -8
- package/src/core/ask/events.mjs +11 -0
- package/src/core/ask/html-text.mjs +113 -0
- package/src/core/ask/limits.mjs +5 -0
- package/src/core/ask/mcp-stdio.mjs +74 -20
- package/src/core/ask/prompt.mjs +25 -6
- package/src/core/ask/spawn.mjs +55 -5
- package/src/core/ask/store.mjs +1 -1
- package/src/core/ask/tools.mjs +66 -1
- package/src/core/ask/turn.mjs +56 -3
- package/src/core/ask/web-access.mjs +26 -0
- package/src/core/ask/web-deps.mjs +56 -0
- package/src/core/ask/web-fetch.mjs +271 -0
- package/src/core/ask/web-proposal.mjs +76 -0
- package/src/core/auto/classify.mjs +20 -8
- package/src/core/auto/runnable.mjs +28 -0
- package/src/core/billing.mjs +53 -0
- package/src/core/bridge/errors.mjs +77 -5
- package/src/core/bridge/openrouter.mjs +59 -0
- package/src/core/bridge/provider-ops.mjs +202 -10
- package/src/core/bridge/providers/endpoint.mjs +112 -7
- package/src/core/bridge/registry.mjs +13 -0
- package/src/core/bridge/server.mjs +16 -3
- package/src/core/bridge/telemetry.mjs +44 -12
- package/src/core/bridge/translate/common.mjs +31 -0
- package/src/core/bridge/translate/request.mjs +4 -1
- package/src/core/bridge/translate/response.mjs +3 -0
- package/src/core/bridge/translate/schema-keywords.mjs +75 -0
- package/src/core/bridge/translate/stream.mjs +50 -4
- package/src/core/bridge/upstream.mjs +102 -17
- package/src/core/broker-boot.mjs +57 -0
- package/src/core/broker-client.mjs +206 -0
- package/src/core/broker-guard.mjs +112 -0
- package/src/core/broker-routing.mjs +138 -0
- package/src/core/claude-auth.mjs +29 -0
- package/src/core/claude-runner.mjs +200 -9
- package/src/core/config.mjs +9 -12
- package/src/core/failure-policy.mjs +4 -2
- package/src/core/git-info.mjs +49 -5
- package/src/core/github-credentials.mjs +44 -1
- package/src/core/graph/script-runner.mjs +3 -1
- package/src/core/list-prices.mjs +29 -0
- package/src/core/mcp-secrets.mjs +80 -0
- package/src/core/metrics/sync.mjs +2 -1
- package/src/core/model-env.mjs +72 -0
- package/src/core/model-test.mjs +17 -5
- package/src/core/onboarding.mjs +12 -6
- package/src/core/openrouter-free.mjs +159 -0
- package/src/core/orchestrator.mjs +100 -12
- package/src/core/policy/effective.mjs +18 -1
- package/src/core/policy/local.mjs +4 -1
- package/src/core/policy/registry.mjs +12 -2
- package/src/core/preflight.mjs +116 -0
- package/src/core/recoverable-error.mjs +95 -0
- package/src/core/recovery-backoff.mjs +84 -0
- package/src/core/redact.mjs +25 -0
- package/src/core/run-context.mjs +6 -0
- package/src/core/run-harness.mjs +112 -30
- package/src/core/run-report.mjs +2 -1
- package/src/core/settings.mjs +90 -5
- package/src/core/title.mjs +7 -2
- package/src/core/web-allowlist.mjs +95 -0
- package/ui/public/app.js +271 -54
- package/ui/public/ask-model.mjs +1 -0
- package/ui/public/ask-panel.mjs +160 -23
- package/ui/public/bridge-view.mjs +216 -9
- package/ui/public/chat-settings-view.mjs +52 -1
- package/ui/public/credential-badges.mjs +63 -0
- package/ui/public/credentials-view.mjs +57 -0
- package/ui/public/index.html +303 -244
- package/ui/public/models-view.mjs +19 -1
- package/ui/public/openrouter-free-view.mjs +118 -0
- package/ui/public/stats-view.mjs +56 -0
- package/ui/public/style.css +110 -54
- package/ui/public/team-policy-view.mjs +2 -1
- package/ui/public/ws-seq.mjs +24 -0
- package/ui/server.mjs +319 -23
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// delegated listener on the list container.
|
|
9
9
|
|
|
10
10
|
import { bridgedBadge, needsSignInPill, degradationLine, renderConnectionSection, collectConnection, applyConnectionMode } from './bridge-view.mjs';
|
|
11
|
+
import { credentialBadge } from './credential-badges.mjs';
|
|
11
12
|
|
|
12
13
|
function h(doc, tag, cls, text) {
|
|
13
14
|
const n = doc.createElement(tag);
|
|
@@ -17,6 +18,15 @@ function h(doc, tag, cls, text) {
|
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
/** "medium · high" or "all efforts" (the full set carries no signal). */
|
|
21
|
+
/** Credential broker: "your key / no key" for the signed-in person, after a model's name. */
|
|
22
|
+
function keyBadge(parent, m, doc) {
|
|
23
|
+
const b = credentialBadge(m.id);
|
|
24
|
+
if (!b) return;
|
|
25
|
+
const el = h(doc, 'span', `badge ${b.tone} mv-key-badge`, b.text);
|
|
26
|
+
el.title = b.title;
|
|
27
|
+
parent.appendChild(el);
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
export function effortsSummary(efforts, allEfforts) {
|
|
21
31
|
const list = Array.isArray(efforts) ? efforts : [];
|
|
22
32
|
if (!list.length || (allEfforts && list.length === allEfforts.length)) return 'all efforts';
|
|
@@ -112,7 +122,10 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], poli
|
|
|
112
122
|
const btn = h(doc, 'button', 'mv-sec-toggle');
|
|
113
123
|
btn.type = 'button';
|
|
114
124
|
btn.dataset.section = key;
|
|
115
|
-
|
|
125
|
+
// The shared disclosure chevron (style.css .adv-chev), turned down while the group is open.
|
|
126
|
+
const caret = h(doc, 'span', 'mv-sec-caret adv-chev');
|
|
127
|
+
caret.setAttribute('aria-hidden', 'true');
|
|
128
|
+
btn.appendChild(caret);
|
|
116
129
|
btn.appendChild(h(doc, 'h3', 'mv-section-title', title));
|
|
117
130
|
btn.appendChild(h(doc, 'span', 'mv-sec-count', ''));
|
|
118
131
|
head.appendChild(btn);
|
|
@@ -185,6 +198,7 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], poli
|
|
|
185
198
|
const body = h(doc, 'div', 'mv-body');
|
|
186
199
|
const head = h(doc, 'div', 'mv-head');
|
|
187
200
|
head.appendChild(h(doc, 'b', 'mv-name', m.label || m.id));
|
|
201
|
+
keyBadge(head, m, doc);
|
|
188
202
|
if (predefLc.has(m.id.toLowerCase())) head.appendChild(h(doc, 'span', 'badge violet mv-shadow', 'overrides built-in'));
|
|
189
203
|
else if (pluginLc.has(m.id.toLowerCase())) head.appendChild(h(doc, 'span', 'badge violet mv-shadow', 'overrides plugin'));
|
|
190
204
|
const rb = routedBadge(m);
|
|
@@ -238,6 +252,7 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], poli
|
|
|
238
252
|
const body = h(doc, 'div', 'mv-body');
|
|
239
253
|
const head = h(doc, 'div', 'mv-head');
|
|
240
254
|
head.appendChild(h(doc, 'b', 'mv-name', m.label || m.id));
|
|
255
|
+
keyBadge(head, m, doc);
|
|
241
256
|
head.appendChild(h(doc, 'span', 'badge waiting mv-origin', 'project (legacy)'));
|
|
242
257
|
body.appendChild(head);
|
|
243
258
|
body.appendChild(h(doc, 'small', 'mv-summary hint', m.id));
|
|
@@ -262,6 +277,7 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], poli
|
|
|
262
277
|
const body = h(doc, 'div', 'mv-body');
|
|
263
278
|
const head = h(doc, 'div', 'mv-head');
|
|
264
279
|
head.appendChild(h(doc, 'b', 'mv-name', m.label || m.id));
|
|
280
|
+
keyBadge(head, m, doc);
|
|
265
281
|
head.appendChild(h(doc, 'span', 'badge waiting mv-origin', `plugin: ${m.plugin}`));
|
|
266
282
|
if (globalLc.has(m.id.toLowerCase())) head.appendChild(h(doc, 'span', 'badge violet mv-shadowed', 'overridden by your copy'));
|
|
267
283
|
const prb = routedBadge(m);
|
|
@@ -311,6 +327,7 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], poli
|
|
|
311
327
|
const body = h(doc, 'div', 'mv-body');
|
|
312
328
|
const head = h(doc, 'div', 'mv-head');
|
|
313
329
|
head.appendChild(h(doc, 'b', 'mv-name', m.label || m.id));
|
|
330
|
+
keyBadge(head, m, doc);
|
|
314
331
|
const badge = h(doc, 'span', 'badge blue mv-origin', 'policy');
|
|
315
332
|
badge.title = `Team policy on ${m.home}`;
|
|
316
333
|
head.appendChild(badge);
|
|
@@ -339,6 +356,7 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], poli
|
|
|
339
356
|
const row = h(doc, 'div', 'mv-builtin');
|
|
340
357
|
row.dataset.id = m.id;
|
|
341
358
|
row.appendChild(h(doc, 'b', 'mv-name', m.label));
|
|
359
|
+
keyBadge(row, m, doc);
|
|
342
360
|
if (globalLc.has(m.id.toLowerCase()) || pluginLc.has(m.id.toLowerCase())) {
|
|
343
361
|
row.appendChild(h(doc, 'span', 'badge violet mv-shadowed', 'overridden'));
|
|
344
362
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// ui/public/openrouter-free-view.mjs
|
|
2
|
+
// OpenRouter's daily allowance of `:free` requests (src/core/openrouter-free.mjs), where it
|
|
3
|
+
// comes up: the sidebar line under the spend block, a run's cost pill, the new-run form's
|
|
4
|
+
// warning and the Providers card. Pure helpers plus one renderer; app.js fetches
|
|
5
|
+
// /api/openrouter/free-daily and mounts the result.
|
|
6
|
+
|
|
7
|
+
/** Below this share of the day's allowance the line turns amber. */
|
|
8
|
+
export const FREE_LOW_AT = 0.1;
|
|
9
|
+
/** A run's typical free requests when this install has no history yet (a small pipeline run). */
|
|
10
|
+
export const FREE_RUN_DEFAULT = 90;
|
|
11
|
+
|
|
12
|
+
/** "3h 12m" / "12m" / "under a minute". Pure. */
|
|
13
|
+
export function untilText(ms) {
|
|
14
|
+
const min = Math.floor(Math.max(0, ms) / 60_000);
|
|
15
|
+
if (min < 1) return 'under a minute';
|
|
16
|
+
const h = Math.floor(min / 60);
|
|
17
|
+
return h ? `${h}h ${min % 60}m` : `${min}m`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** "00:00 UTC". Pure. */
|
|
21
|
+
export function utcClock(iso) {
|
|
22
|
+
const t = Date.parse(iso || '');
|
|
23
|
+
return Number.isFinite(t) ? `${new Date(t).toISOString().slice(11, 16)} UTC` : '';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 'ok' | 'low' | 'out' for a known status. Pure. */
|
|
27
|
+
export function freeLevel(s) {
|
|
28
|
+
if (!s || !s.known || !(s.limit > 0)) return 'ok';
|
|
29
|
+
if (s.remaining <= 0) return 'out';
|
|
30
|
+
return s.remaining / s.limit < FREE_LOW_AT ? 'low' : 'ok';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The requests a run spent on OpenRouter `:free` models (its steps' bridgeFreeCalls). Pure. */
|
|
34
|
+
export function runFreeRequests(steps) {
|
|
35
|
+
return (Array.isArray(steps) ? steps : []).reduce((n, s) => n + (Number.isFinite(s?.bridgeFreeCalls) ? s.bridgeFreeCalls : 0), 0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** " · 87 free requests" for a cost pill, '' when the run used none. Pure. */
|
|
39
|
+
export function freeRequestsSuffix(steps) {
|
|
40
|
+
const n = runFreeRequests(steps);
|
|
41
|
+
return n > 0 ? ` · ${n} free request${n === 1 ? '' : 's'}` : '';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What a run usually takes on this install: the median of the last `max` finished runs that
|
|
46
|
+
* used free requests, else FREE_RUN_DEFAULT. Pure.
|
|
47
|
+
*/
|
|
48
|
+
export function typicalFreeRun(runs, { max = 10 } = {}) {
|
|
49
|
+
const done = new Set(['completed', 'failed', 'stopped']);
|
|
50
|
+
const counts = (Array.isArray(runs) ? runs : [])
|
|
51
|
+
.filter((r) => r && done.has(r.status))
|
|
52
|
+
.map((r) => runFreeRequests(r.steps))
|
|
53
|
+
.filter((n) => n > 0)
|
|
54
|
+
.slice(0, max)
|
|
55
|
+
.sort((a, b) => a - b);
|
|
56
|
+
if (!counts.length) return FREE_RUN_DEFAULT;
|
|
57
|
+
const mid = Math.floor(counts.length / 2);
|
|
58
|
+
return counts.length % 2 ? counts[mid] : Math.round((counts[mid - 1] + counts[mid]) / 2);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The new-run form's warning when a chosen model is `:free` and fewer requests are left than a
|
|
63
|
+
* run usually takes; null otherwise. Pure.
|
|
64
|
+
*/
|
|
65
|
+
export function newRunFreeWarning(s, { typical = FREE_RUN_DEFAULT, usesFree = false, now = Date.now() } = {}) {
|
|
66
|
+
if (!usesFree || !s || !s.known || s.remaining >= typical) return null;
|
|
67
|
+
const reset = Date.parse(s.resetAt || '');
|
|
68
|
+
const when = Number.isFinite(reset) ? ` (resets ${utcClock(s.resetAt)}, in ${untilText(reset - now)})` : '';
|
|
69
|
+
if (s.remaining <= 0) return `No OpenRouter free requests left today${when}: a free model would pause at its first call. Pick a paid model, or start after the reset.`;
|
|
70
|
+
return `${s.remaining} OpenRouter free request${s.remaining === 1 ? '' : 's'} left today${when}; a run here usually needs ~${typical}.`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The Providers card's line about the allowance, '' when unknown. Pure. */
|
|
74
|
+
export function providerFreeLine(s, { now = Date.now() } = {}) {
|
|
75
|
+
if (!s || !s.known) return '';
|
|
76
|
+
const reset = Date.parse(s.resetAt || '');
|
|
77
|
+
const when = Number.isFinite(reset) ? ` · resets ${utcClock(s.resetAt)} (in ${untilText(reset - now)})` : '';
|
|
78
|
+
return `Free-model requests today: ${s.remaining} of ${s.limit} left${when}.`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The sidebar block: "OpenRouter free · 941 / 1000 today", a meter of what is left, amber
|
|
83
|
+
* below 10%, red at 0, the reset on hover. null when there is nothing to show.
|
|
84
|
+
*/
|
|
85
|
+
export function renderFreeDaily(s, { doc = globalThis.document, now = Date.now() } = {}) {
|
|
86
|
+
if (!s || !s.enabled || !s.known) return null;
|
|
87
|
+
const level = freeLevel(s);
|
|
88
|
+
const btn = doc.createElement('button');
|
|
89
|
+
btn.type = 'button';
|
|
90
|
+
btn.className = `spend-ind free-ind${level === 'low' ? ' warn' : level === 'out' ? ' over' : ''}`;
|
|
91
|
+
const reset = Date.parse(s.resetAt || '');
|
|
92
|
+
btn.title = `OpenRouter free-model requests left today: ${s.remaining} of ${s.limit}` +
|
|
93
|
+
(Number.isFinite(reset) ? ` · resets ${utcClock(s.resetAt)}, in ${untilText(reset - now)}` : '') +
|
|
94
|
+
' — every model call on a :free model is one request';
|
|
95
|
+
const row = doc.createElement('span');
|
|
96
|
+
row.className = 'spend-ind-row';
|
|
97
|
+
const label = doc.createElement('span');
|
|
98
|
+
label.className = 'spend-ind-label';
|
|
99
|
+
label.textContent = 'OpenRouter free today';
|
|
100
|
+
const amt = doc.createElement('span');
|
|
101
|
+
amt.className = 'spend-ind-amt mono';
|
|
102
|
+
amt.textContent = `${s.remaining} / ${s.limit}`;
|
|
103
|
+
row.append(label, amt);
|
|
104
|
+
const meter = doc.createElement('span');
|
|
105
|
+
meter.className = 'spend-ind-meter';
|
|
106
|
+
const fill = doc.createElement('span');
|
|
107
|
+
fill.className = 'spend-ind-meter-fill';
|
|
108
|
+
fill.style.width = `${s.limit > 0 ? Math.max(0, Math.min(100, (s.remaining / s.limit) * 100)) : 0}%`;
|
|
109
|
+
meter.appendChild(fill);
|
|
110
|
+
btn.append(row, meter);
|
|
111
|
+
if (level === 'out') {
|
|
112
|
+
const sub = doc.createElement('small');
|
|
113
|
+
sub.className = 'spend-ind-sub';
|
|
114
|
+
sub.textContent = 'used up · free models pause until the reset';
|
|
115
|
+
btn.appendChild(sub);
|
|
116
|
+
}
|
|
117
|
+
return btn;
|
|
118
|
+
}
|
package/ui/public/stats-view.mjs
CHANGED
|
@@ -678,6 +678,57 @@ export function renderRunsChart(spec, { doc = globalThis.document, fmt = DEFAULT
|
|
|
678
678
|
}
|
|
679
679
|
|
|
680
680
|
/** Full Statistics body: KPI row + the two chart cards (or empty notes). */
|
|
681
|
+
const SLOT_NAMES = { anthropic: 'Anthropic', openai: 'OpenAI', openrouter: 'OpenRouter', copilot: 'Copilot', local: 'Local' };
|
|
682
|
+
const compactCount = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n));
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* "By person" (credential broker, docs/credential-broker.md): model spend per person in the
|
|
686
|
+
* stats window, as the broker metered it — every call is charged to whoever caused it.
|
|
687
|
+
* @param {{people?:object[], error?:string}} byPerson
|
|
688
|
+
*/
|
|
689
|
+
export function renderPeopleCard(byPerson, rangeLabel, { doc = globalThis.document, fmt = DEFAULT_FMT } = {}) {
|
|
690
|
+
const card = chartCard(doc, 'By person', rangeLabel);
|
|
691
|
+
card.classList.add('people-card');
|
|
692
|
+
if (byPerson?.error) {
|
|
693
|
+
card.appendChild(h(doc, 'p', 'hint err', `The credential broker didn't answer: ${byPerson.error}`));
|
|
694
|
+
return card;
|
|
695
|
+
}
|
|
696
|
+
const people = byPerson?.people || [];
|
|
697
|
+
if (!people.length) {
|
|
698
|
+
card.appendChild(h(doc, 'p', 'chart-empty hint', 'No model calls in this period.'));
|
|
699
|
+
return card;
|
|
700
|
+
}
|
|
701
|
+
const top = Math.max(...people.map((p) => p.usd), 0);
|
|
702
|
+
const total = people.reduce((a, p) => a + p.usd, 0);
|
|
703
|
+
const table = h(doc, 'table', 'people-table');
|
|
704
|
+
const head = h(doc, 'tr', null);
|
|
705
|
+
for (const t of ['Person', 'Spend', 'Requests', 'Tokens in / out', 'Providers']) head.appendChild(h(doc, 'th', null, t));
|
|
706
|
+
const thead = h(doc, 'thead', null); thead.appendChild(head); table.appendChild(thead);
|
|
707
|
+
const tbody = h(doc, 'tbody', null);
|
|
708
|
+
for (const p of people) {
|
|
709
|
+
const tr = h(doc, 'tr', null);
|
|
710
|
+
tr.appendChild(h(doc, 'td', 'people-name', p.person === 'local' ? 'No signed-in person' : p.person));
|
|
711
|
+
const spend = h(doc, 'td', 'people-spend');
|
|
712
|
+
const bar = h(doc, 'span', 'people-bar');
|
|
713
|
+
const fill = h(doc, 'span', 'people-bar-fill');
|
|
714
|
+
fill.style.width = `${top > 0 ? Math.max(2, Math.round((p.usd / top) * 100)) : 0}%`;
|
|
715
|
+
bar.appendChild(fill);
|
|
716
|
+
spend.appendChild(h(doc, 'span', 'people-usd', fmt.usd(p.usd)));
|
|
717
|
+
spend.appendChild(bar);
|
|
718
|
+
tr.appendChild(spend);
|
|
719
|
+
tr.appendChild(h(doc, 'td', 'num', compactCount(p.requests)));
|
|
720
|
+
tr.appendChild(h(doc, 'td', 'num', `${compactCount(p.inputTokens + (p.cacheReadTokens || 0))} / ${compactCount(p.outputTokens)}`));
|
|
721
|
+
tr.appendChild(h(doc, 'td', 'people-slots', [...new Set(p.slots.map((s) => `${SLOT_NAMES[s.slot] || s.slot}${s.plan === 'subscription' ? ' (subscription)' : ''}`))].join(', ')));
|
|
722
|
+
tbody.appendChild(tr);
|
|
723
|
+
}
|
|
724
|
+
table.appendChild(tbody);
|
|
725
|
+
const scroll = h(doc, 'div', 'people-scroll');
|
|
726
|
+
scroll.appendChild(table);
|
|
727
|
+
card.appendChild(scroll);
|
|
728
|
+
card.appendChild(h(doc, 'small', 'hint', `${fmt.usd(total)} in total, metered by the credential broker. Each call is charged to the person whose action caused it.`));
|
|
729
|
+
return card;
|
|
730
|
+
}
|
|
731
|
+
|
|
681
732
|
export function renderStatsBody(model, opts = {}) {
|
|
682
733
|
const { doc = globalThis.document } = opts;
|
|
683
734
|
const wrap = h(doc, 'div', null);
|
|
@@ -706,5 +757,10 @@ export function renderStatsBody(model, opts = {}) {
|
|
|
706
757
|
currentBucketStartMs, rangeLabel }, opts));
|
|
707
758
|
}
|
|
708
759
|
wrap.appendChild(grid);
|
|
760
|
+
if (model.byPerson) {
|
|
761
|
+
const people = h(doc, 'div', 'charts-grid people-grid');
|
|
762
|
+
people.appendChild(renderPeopleCard(model.byPerson, rangeLabel, opts));
|
|
763
|
+
wrap.appendChild(people);
|
|
764
|
+
}
|
|
709
765
|
return wrap;
|
|
710
766
|
}
|