toolroll 0.9.86 → 0.9.88
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/CHANGELOG.md +86 -0
- package/dist/acp-check.d.ts +9 -0
- package/dist/acp-check.js +40 -28
- package/dist/agents-board.d.ts +12 -0
- package/dist/agents-board.js +26 -0
- package/dist/approval-rules-ui.d.ts +4 -1
- package/dist/approval-rules-ui.js +31 -15
- package/dist/assignment-ui.js +1 -1
- package/dist/backup-ui.d.ts +5 -4
- package/dist/backup-ui.js +65 -32
- package/dist/browser/terminal.css +1 -1
- package/dist/browser/terminal.js +57 -54
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +35 -70
- package/dist/browser-crew.js +1 -1
- package/dist/browser-workspace.d.ts +329 -183
- package/dist/browser-workspace.js +10 -9
- package/dist/chat-controls.d.ts +1 -1
- package/dist/chat-controls.js +1 -1
- package/dist/chat-settings-page.d.ts +29 -9
- package/dist/chat-settings-page.js +59 -28
- package/dist/check-levels-ui.d.ts +2 -1
- package/dist/check-levels-ui.js +67 -58
- package/dist/contracts/cli.d.ts +43 -0
- package/dist/contracts/cli.js +4 -0
- package/dist/contracts/console-api.d.ts +1 -1
- package/dist/contracts/console-api.js +1 -1
- package/dist/credentials-ui.d.ts +5 -3
- package/dist/credentials-ui.js +58 -31
- package/dist/design/kit.css +722 -0
- package/dist/design/kit.d.ts +208 -0
- package/dist/design/kit.js +177 -0
- package/dist/design/tokens.css +31 -1
- package/dist/discord-settings.js +8 -4
- package/dist/export-ui.d.ts +6 -4
- package/dist/export-ui.js +18 -8
- package/dist/flow-changed.d.ts +14 -0
- package/dist/flow-changed.js +53 -0
- package/dist/flow-gallery-ui.d.ts +41 -4
- package/dist/flow-gallery-ui.js +95 -63
- package/dist/flow-starters-ui.d.ts +3 -9
- package/dist/flow-starters-ui.js +12 -23
- package/dist/flow-unattended-words.d.ts +20 -0
- package/dist/flow-unattended-words.js +24 -0
- package/dist/flow-unattended.d.ts +9 -5
- package/dist/flow-unattended.js +45 -23
- package/dist/flows-cli.js +7 -3
- package/dist/flows-ui.d.ts +12 -6
- package/dist/flows-ui.js +141 -30
- package/dist/integrations-ui.d.ts +6 -4
- package/dist/integrations-ui.js +34 -40
- package/dist/integrations.d.ts +2 -2
- package/dist/integrations.js +4 -4
- package/dist/kits-ui.d.ts +8 -10
- package/dist/kits-ui.js +29 -33
- package/dist/kits.js +1 -1
- package/dist/knowledge-ui.js +76 -19
- package/dist/ledger-view.d.ts +2 -0
- package/dist/ledger-view.js +41 -38
- package/dist/models-ui.d.ts +8 -3
- package/dist/models-ui.js +82 -59
- package/dist/monitoring-ui.d.ts +4 -2
- package/dist/monitoring-ui.js +44 -13
- package/dist/needs-you.d.ts +1 -1
- package/dist/needs-you.js +8 -0
- package/dist/operate-remote-arguments.js +1 -0
- package/dist/operate-remote.js +1 -1
- package/dist/operate.d.ts +1 -1
- package/dist/operate.js +38 -4
- package/dist/passwordless.d.ts +1 -1
- package/dist/passwordless.js +1 -1
- package/dist/people-audit-ui.d.ts +2 -1
- package/dist/people-audit-ui.js +25 -23
- package/dist/policy-ui.d.ts +3 -2
- package/dist/policy-ui.js +27 -21
- package/dist/policy.d.ts +3 -0
- package/dist/policy.js +14 -1
- package/dist/project-delete-ui.d.ts +4 -2
- package/dist/project-delete-ui.js +135 -126
- package/dist/project-knowledge.d.ts +10 -10
- package/dist/publish.d.ts +11 -0
- package/dist/publish.js +16 -3
- package/dist/pull-request-ui.d.ts +2 -1
- package/dist/pull-request-ui.js +39 -28
- package/dist/recipe-ui.d.ts +4 -2
- package/dist/recipe-ui.js +14 -4
- package/dist/release-adapters.d.ts +3 -0
- package/dist/release-adapters.js +22 -2
- package/dist/release-cli.js +81 -9
- package/dist/release.d.ts +23 -0
- package/dist/release.js +46 -4
- package/dist/releases.js +1 -1
- package/dist/request-budget-ui.d.ts +4 -2
- package/dist/request-budget-ui.js +30 -19
- package/dist/retention-ui.d.ts +4 -3
- package/dist/retention-ui.js +15 -12
- package/dist/scope.d.ts +3 -0
- package/dist/scope.js +6 -0
- package/dist/serve.d.ts +0 -1
- package/dist/serve.js +51 -7
- package/dist/server/agents-computers.d.ts +9 -0
- package/dist/server/agents-computers.js +67 -0
- package/dist/server/chat.js +16 -16
- package/dist/server/chrome.d.ts +1 -1
- package/dist/server/chrome.js +19 -9
- package/dist/server/console.css +24 -12
- package/dist/server/flows.js +64 -36
- package/dist/server/home.d.ts +35 -0
- package/dist/server/home.js +104 -0
- package/dist/server/http.d.ts +3 -0
- package/dist/server/http.js +5 -0
- package/dist/server/kit-standalone.d.ts +22 -0
- package/dist/server/kit-standalone.js +70 -0
- package/dist/server/kit.d.ts +419 -0
- package/dist/server/kit.js +369 -0
- package/dist/server/pages.d.ts +2 -1
- package/dist/server/pages.js +213 -174
- package/dist/server/people-tokens.js +26 -85
- package/dist/server/render-history.d.ts +51 -0
- package/dist/server/render-history.js +228 -0
- package/dist/server/render-new-task.d.ts +29 -0
- package/dist/server/render-new-task.js +96 -0
- package/dist/server/render-next.d.ts +63 -0
- package/dist/server/render-next.js +109 -0
- package/dist/server/render-pages.d.ts +22 -190
- package/dist/server/render-pages.js +123 -575
- package/dist/server/render-people.d.ts +24 -3
- package/dist/server/render-people.js +93 -72
- package/dist/server/render-projects.d.ts +103 -0
- package/dist/server/render-projects.js +247 -0
- package/dist/server/render-results.d.ts +5 -24
- package/dist/server/render-results.js +48 -185
- package/dist/server/render-settings.d.ts +5 -74
- package/dist/server/render-settings.js +102 -493
- package/dist/server/render-tasks.d.ts +35 -5
- package/dist/server/render-tasks.js +114 -85
- package/dist/server/route-table.js +20 -5
- package/dist/server/settings.js +276 -255
- package/dist/server/task-views.d.ts +8 -0
- package/dist/server/task-views.js +85 -2
- package/dist/server/tasks.d.ts +8 -0
- package/dist/server/tasks.js +95 -40
- package/dist/server/terminals.js +1 -1
- package/dist/server/welcome.d.ts +22 -0
- package/dist/server/welcome.js +69 -0
- package/dist/settings-groups-ui.d.ts +279 -0
- package/dist/settings-groups-ui.js +615 -0
- package/dist/settings-kit.d.ts +42 -6
- package/dist/settings-kit.js +69 -7
- package/dist/setup-guide.d.ts +2 -2
- package/dist/skills-ui.js +59 -7
- package/dist/slack-settings.js +12 -5
- package/dist/spend-ui.d.ts +45 -26
- package/dist/spend-ui.js +124 -56
- package/dist/sso-ui.d.ts +5 -2
- package/dist/sso-ui.js +63 -31
- package/dist/storage-ui.d.ts +3 -2
- package/dist/storage-ui.js +56 -50
- package/dist/store.d.ts +112 -3
- package/dist/store.js +292 -20
- package/dist/surface.js +5 -1
- package/dist/teams-settings.d.ts +2 -1
- package/dist/teams-settings.js +24 -41
- package/dist/telegram-settings.d.ts +0 -4
- package/dist/telegram-settings.js +52 -25
- package/dist/toolroll-update-ui.d.ts +8 -4
- package/dist/toolroll-update-ui.js +55 -45
- package/dist/tools-ui.d.ts +5 -3
- package/dist/tools-ui.js +107 -42
- package/dist/work-index.js +11 -7
- package/dist/workspace-ui.d.ts +1 -1
- package/dist/workspace-ui.js +7 -4
- package/package.json +1 -1
package/dist/spend-ui.js
CHANGED
|
@@ -1,28 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Spend page (docs/plans/spend-dashboard.md, slice 1)
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* and
|
|
2
|
+
* The Spend page (docs/plans/spend-dashboard.md, slice 1), in the kit's Settings frame (group "spend"): the monthly
|
|
3
|
+
* limits first (the one that needs you on top), then what agent work cost in a month, with API dollars, plan usage
|
|
4
|
+
* and unknown cost kept apart and never added together, broken down by project and model (and, on demand, role,
|
|
5
|
+
* person and the costliest runs), the plan windows, and what isn't recorded here. An instance operator's page.
|
|
6
|
+
* A limit changes in place: Edit limit opens its form under the row (`?edit=<target>`), saved with a step-up.
|
|
6
7
|
*/
|
|
7
|
-
import { html, postForm
|
|
8
|
+
import { html, postForm } from "./html.js";
|
|
9
|
+
import { approvalAsk, termsOf } from "./design/kit.js";
|
|
10
|
+
import { acts, btn, chip, disclose, dock, empty, field, input, note, notice, segLinks, settingsShell } from "./server/kit.js";
|
|
8
11
|
import { budgetLabel, usd } from "./spend.js";
|
|
9
12
|
import { runHref, taskHref } from "./server/http.js";
|
|
10
13
|
const projectName = (repo) => repo.split("/").filter(Boolean).pop() ?? repo;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
`.spend
|
|
14
|
-
`.spend
|
|
15
|
-
`.spend-
|
|
16
|
-
`.spend-
|
|
17
|
-
`.spend-
|
|
18
|
-
`.
|
|
19
|
-
`.
|
|
20
|
-
`.
|
|
21
|
-
`.
|
|
22
|
-
`.spend
|
|
23
|
-
`.spend-
|
|
24
|
-
|
|
25
|
-
`.spend
|
|
14
|
+
/** The page's own layout: the limit rows and meters, the summary figures and the quiet bars. Colour from tokens only. */
|
|
15
|
+
export const SPEND_CSS = `.spend{display:grid;gap:24px;min-width:0}.spend-sec{display:grid;gap:0}.spend-sec .k-secthead{margin-bottom:0}.spend-sec .k-secthead small{font-weight:400;color:var(--so-muted);font-size:12.5px}` +
|
|
16
|
+
`.spend-lim{display:grid;gap:8px;padding:12px 0;border-bottom:1px solid var(--so-line)}.spend-lim-h{display:grid;grid-template-columns:minmax(0,1fr) auto 124px;gap:12px;align-items:center}` +
|
|
17
|
+
`.spend-lim-l{display:grid;gap:2px;min-width:0}.spend-lim-t{font-weight:500;display:inline-flex;align-items:center;gap:8px;flex-wrap:wrap;overflow-wrap:anywhere}.spend-lim-x{font-size:12.5px;color:var(--so-muted)}` +
|
|
18
|
+
`.spend-fig{font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap}.spend-lim-a{display:flex;justify-content:flex-end}.spend-lim-a .k-btn{min-width:108px}` +
|
|
19
|
+
`.spend-meter{display:block;height:6px;border-radius:3px;background:var(--so-soft);overflow:hidden}.spend-meter>i{display:block;height:100%;border-radius:3px;background:var(--so-neutral-ink)}.spend-meter.over>i{background:var(--so-danger)}` +
|
|
20
|
+
`.spend-edit{display:grid;gap:12px;padding:4px 0 4px}.spend-edit-row{display:flex;flex-wrap:wrap;gap:12px 20px;align-items:flex-end}.spend-edit-row .k-field{flex:0 1 220px}.spend-edit-row .k-checks{min-height:32px}` +
|
|
21
|
+
`.spend-edit>.k-field{max-width:300px}.spend-month{display:flex;align-items:center;gap:12px;flex-wrap:wrap}` +
|
|
22
|
+
`.spend-sum{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));border-top:1px solid var(--so-line);border-bottom:1px solid var(--so-line)}.spend-sum>div{display:grid;gap:4px;align-content:start;padding:16px 16px 16px 0;min-width:0}` +
|
|
23
|
+
`.spend-sum>div+div{border-left:1px solid var(--so-line);padding-left:16px}.spend-sum-k{font-size:12px;color:var(--so-muted)}.spend-sum-v{font-size:20px;font-weight:600;letter-spacing:-.01em;font-variant-numeric:tabular-nums}.spend-sum-x{font-size:12px;color:var(--so-muted);line-height:1.45}` +
|
|
24
|
+
`.spend-bars{display:grid}.spend-bar{display:grid;grid-template-columns:minmax(0,200px) minmax(0,1fr) 84px;gap:12px;align-items:center;min-height:40px;padding:6px 0;border-bottom:1px solid var(--so-line)}` +
|
|
25
|
+
`.spend-bar.h{min-height:28px;font-size:11.5px;color:var(--so-muted)}.spend-bar.tot{font-weight:600;border-bottom:0}.spend-bar-n{min-width:0;overflow-wrap:anywhere}.spend-bar-n small{display:block;font-size:11.5px;color:var(--so-muted)}` +
|
|
26
|
+
`.spend-lane{position:relative;height:14px}.spend-lane>i{position:absolute;left:0;top:4px;height:6px;border-radius:3px;background:var(--so-neutral-ink);min-width:3px}.spend-bar-v{text-align:right;font-variant-numeric:tabular-nums}` +
|
|
27
|
+
`.spend-runs{display:grid}.spend-run{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 12px;padding:8px 0;border-bottom:1px solid var(--so-line)}.spend-run small{grid-column:1/-1;color:var(--so-muted);font-size:12px;overflow-wrap:anywhere}` +
|
|
28
|
+
`.spend-wins{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;padding-top:12px}.spend-win{display:grid;gap:6px;min-width:0}.spend-win-h{display:flex;justify-content:space-between;gap:8px}.spend-win-h b{font-weight:500}` +
|
|
29
|
+
`.spend-win.warning .spend-meter>i,.spend-win.danger .spend-meter>i{background:var(--so-danger)}.spend .k-disclose{padding-top:4px}.spend .k-disclose[open]>summary{margin-bottom:8px}` +
|
|
30
|
+
`@media(max-width:760px){.spend-lim-h{grid-template-columns:minmax(0,1fr) auto}.spend-lim-a{grid-column:1/-1}.spend-lim-a .k-btn{width:100%}.spend-sum{grid-template-columns:minmax(0,1fr)}` +
|
|
31
|
+
`.spend-sum>div+div{border-left:0;border-top:1px solid var(--so-line);padding-left:0}.spend-bar{grid-template-columns:minmax(0,120px) minmax(0,1fr) 68px;gap:8px;font-size:12.5px}` +
|
|
32
|
+
`.spend-edit-row .k-field{flex:1 1 100%}.spend .k-btn.k-btn-primary.k-docked{display:none}}`;
|
|
26
33
|
const ROLE_WORDS = {
|
|
27
34
|
builder: "Builds", repair: "Repairs", planner: "Plans", reviewer: "Reviews", scout: "Scouting",
|
|
28
35
|
lead: "Lead chat", chat: "Chat", subagent: "Subagents", flow: "Flows", evals: "Evals",
|
|
@@ -38,6 +45,9 @@ export const NOT_COUNTED = [
|
|
|
38
45
|
/** Dollars for the page: a positive amount under a cent reads "<$0.01", never a false $0. */
|
|
39
46
|
export const money = (microusd) => microusd > 0 && microusd < 10_000 ? "<$0.01" : usd(microusd);
|
|
40
47
|
const each = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
48
|
+
const monthWords = (name, style = "long") => new Intl.DateTimeFormat("en-US", { month: style, year: "numeric", timeZone: "UTC" }).format(new Date(`${name}-01T00:00:00.000Z`));
|
|
49
|
+
/** A limit Settings flags as Needs you: one that pauses work and is at or near its limit. */
|
|
50
|
+
export const limitNeedsYou = (budget) => budget.hardStop && budget.percent >= 90;
|
|
41
51
|
/** A line's plan usage in words: the reported usage, or that none was reported. Never $0 for unreported usage. */
|
|
42
52
|
const usageWords = (line) => line.usageCount === 0 ? "—" : line.usageMicrousd > 0 ? `${money(line.usageMicrousd)}${line.usageUnreported > 0 ? ` + ${line.usageUnreported} not reported` : ""}` : "not reported";
|
|
43
53
|
/** A line's unknown cost in words: how many, and a saved charge as an estimate. Never $0. */
|
|
@@ -46,52 +56,110 @@ const unknownWords = (line) => {
|
|
|
46
56
|
return line.unknown > 0 ? `${line.unknown}${counted === "" ? "" : ` · ${counted}`}` : counted === "" ? "—" : counted;
|
|
47
57
|
};
|
|
48
58
|
const apiWords = (line) => line.apiMicrousd > 0 ? money(line.apiMicrousd) : "—";
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
59
|
+
const sec = (id, title, state, body, attrs = "") => html `<section class="k-sec spend-sec" aria-labelledby="spend-${id}"${attrs}><div class="k-secthead"><h3 id="spend-${id}">${title}${state ? html ` <small>· ${state}</small>` : ""}</h3></div>${body}</section>`;
|
|
60
|
+
/** A breakdown as quiet bars of API dollars; plan usage and unknown cost stay on the line, never added in. */
|
|
61
|
+
function bars(head, lines, name, max, top = 12, detail = () => "") {
|
|
62
|
+
const rows = lines.slice(0, top).map(line => {
|
|
63
|
+
const sub = [detail(line), line.usageCount > 0 ? `plan usage ${usageWords(line)}` : "", line.unknown > 0 || line.estimatedMicrousd > 0 ? `unknown ${unknownWords(line)}` : ""].filter(Boolean).join(" · ");
|
|
64
|
+
const width = max > 0 ? Math.max(0, Math.min(100, (line.apiMicrousd / max) * 100)) : 0;
|
|
65
|
+
return html `<div class="spend-bar" role="row"><span class="spend-bar-n" role="cell">${name(line.key)}${sub === "" ? "" : html `<small data-amount-kind="${line.usageCount > 0 ? "subscription" : "unknown"}">${sub}</small>`}</span>\
|
|
66
|
+
<span class="spend-lane" role="cell" aria-hidden="true">${line.apiMicrousd > 0 ? html `<i style="width:${width.toFixed(1)}%"></i>` : ""}</span><span class="spend-bar-v" role="cell" data-amount-kind="api">${apiWords(line)}</span></div>`;
|
|
67
|
+
});
|
|
68
|
+
const total = lines.reduce((sum, line) => sum + line.apiMicrousd, 0);
|
|
69
|
+
return html `<div class="spend-bars" role="table" aria-label="${head}"><div class="spend-bar h" role="row"><span role="columnheader">${head}</span><span role="columnheader"></span><span class="spend-bar-v" role="columnheader">API dollars</span></div>${rows}\
|
|
70
|
+
<div class="spend-bar tot" role="row"><span role="cell">Total</span><span role="cell"></span><span class="spend-bar-v" role="cell">${total > 0 ? money(total) : "—"}</span></div></div>`;
|
|
53
71
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
72
|
+
const editHref = (target, month, current) => `/spend?${month === current ? "" : `month=${month}&`}edit=${encodeURIComponent(target)}`;
|
|
73
|
+
/** The open limit form, under its row: amount and pause, validated next to the field before any password; saving
|
|
74
|
+
* is a step-up (/spend/budget proves the password again, in the write). */
|
|
75
|
+
function limitForm(row, edit, view, targets) {
|
|
76
|
+
const id = `limit-${edit.target.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").slice(0, 60) || "new"}`;
|
|
77
|
+
const facts = view.facts ?? { passwordless: false, remembered: false };
|
|
78
|
+
const amount = edit.usd ?? (row?.budget ? String(Math.round(row.budget.limitMicrousd / 1_000_000)) : "");
|
|
79
|
+
const stop = edit.stop ?? row?.budget?.hardStop ?? true;
|
|
80
|
+
const choose = row === null && targets !== undefined
|
|
81
|
+
? field({ id: `${id}-for`, label: "For", control: html `<span class="k-selw wide"><select class="k-sel" id="${id}-for" name="target" required>${["People", "Subagents"].map(group => {
|
|
82
|
+
const members = targets.filter(one => one.group === group);
|
|
83
|
+
return members.length === 0 ? "" : html `<optgroup label="${group}">${members.map(one => html `<option value="${one.value}"${one.value === edit.target ? html ` selected` : ""}>${one.label}</option>`)}</optgroup>`;
|
|
84
|
+
})}</select></span>` })
|
|
85
|
+
: "";
|
|
86
|
+
const password = approvalAsk("step-up", facts) === "password"
|
|
87
|
+
? field({ id: `${id}-pw`, label: "Your password", control: input({ id: `${id}-pw`, name: "password", type: "password", attrs: { autocomplete: "current-password", required: true } }) })
|
|
88
|
+
: "";
|
|
89
|
+
const back = view.month === view.current ? "/spend" : `/spend?month=${view.month}`;
|
|
90
|
+
const body = html `${choose}<div class="spend-edit-row">${field({ id: `${id}-usd`, label: "Monthly limit (US dollars)", error: edit.fieldError ?? null,
|
|
91
|
+
control: input({ id: `${id}-usd`, name: "usd", value: amount, error: Boolean(edit.fieldError), attrs: { inputmode: "numeric", autocomplete: "off", "data-spend-usd": "", autofocus: true } }) })}\
|
|
92
|
+
<div class="k-checks"><label><input type="checkbox" name="stop" value="1"${stop ? html ` checked` : ""}>Pause new API work at the limit</label></div></div>\
|
|
93
|
+
${note(`Unchecked means alerts only. Plan usage never counts toward a limit. ${termsOf("step-up", facts)}`)}${password}\
|
|
94
|
+
${acts(btn("Save limit", { variant: "primary", type: "submit", docked: true, attrs: { name: "action", value: "save" } }), row?.budget ? btn("Remove limit", { variant: "ghost", type: "submit", attrs: { name: "action", value: "remove", formnovalidate: true, "data-spend-remove": "" } }) : "", btn("Cancel", { variant: "ghost", href: back }))}${edit.problem ? notice(edit.problem) : ""}`;
|
|
95
|
+
return postForm("/spend/budget", body, { attrs: { class: "spend-edit", id, "data-spend-limit": "", novalidate: true }, hidden: row === null ? {} : { target: row.target } });
|
|
60
96
|
}
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
const
|
|
97
|
+
function limitRow(row, view, current, open) {
|
|
98
|
+
const budget = row.budget;
|
|
99
|
+
const hot = budget !== null && limitNeedsYou(budget);
|
|
100
|
+
const over = budget !== null && budget.spentMicrousd >= budget.limitMicrousd;
|
|
101
|
+
const hint = budget === null ? "No limit." : over && budget.hardStop ? "Used up: new API work waits until next month or a higher limit."
|
|
102
|
+
: `${budget.hardStop ? "Pauses new API work at the limit." : "Alerts only."}${budget.unpriced > 0 ? ` ${each(budget.unpriced, "piece")} of work without a price.` : ""}`;
|
|
103
|
+
const fig = budget === null ? `${money(row.spentMicrousd)} so far` : `${money(budget.spentMicrousd)} of ${usd(budget.limitMicrousd)} · ${budget.percent}%`;
|
|
104
|
+
const action = open !== null ? "" : btn(budget === null ? "Set a limit" : "Edit limit", { sm: true, href: editHref(row.target, view.month, current) });
|
|
105
|
+
const meter = budget === null ? "" : html `<span class="spend-meter${over ? " over" : ""}" role="meter" aria-valuemin="0" aria-valuemax="${budget.limitMicrousd}" aria-valuenow="${budget.spentMicrousd}" aria-label="${row.label}: ${fig}"><i style="width:${Math.min(100, Math.max(0, budget.percent))}%"></i></span>`;
|
|
106
|
+
return html `<div class="spend-lim"${budget === null ? "" : html ` data-budget="${budget.id}"`}><div class="spend-lim-h"><span class="spend-lim-l"><span class="spend-lim-t">${row.label}${hot ? chip("Needs you", "needs") : ""}</span><span class="spend-lim-x">${hint}</span></span>\
|
|
107
|
+
<span class="spend-fig">${fig}</span><span class="spend-lim-a">${action}</span></div>${meter}${open === null ? "" : limitForm(row, open, view, undefined)}</div>`;
|
|
108
|
+
}
|
|
109
|
+
export function spendHtml(view, said) {
|
|
110
|
+
const current = view.current ?? view.month;
|
|
64
111
|
const nameOfSubagent = (key) => view.subagentNames.get(Number(key)) ?? `Subagent ${key}`;
|
|
112
|
+
const edit = view.edit ?? null;
|
|
113
|
+
const saved = (view.budgets ?? []).map(budget => ({ target: `${budget.scope}:${budget.key}`, budget, spentMicrousd: budget.spentMicrousd,
|
|
114
|
+
label: budget.scope === "installation" ? "Everything" : budget.scope === "project" ? projectName(budget.key) : budgetLabel(budget, budget.scope === "subagent" ? nameOfSubagent(budget.key) : undefined).replace(/'s$/, "") }));
|
|
115
|
+
// The limit Settings flags (Needs you) sits first; then the saved limits, then what has none yet.
|
|
116
|
+
const rows = [...saved.filter(one => one.budget !== null && limitNeedsYou(one.budget)), ...saved.filter(one => one.budget === null || !limitNeedsYou(one.budget)), ...(view.unlimited ?? [])];
|
|
117
|
+
const openFor = (row) => edit !== null && edit.target === row.target ? edit : null;
|
|
118
|
+
const targets = view.targets ?? [];
|
|
119
|
+
const adding = edit !== null && !rows.some(row => row.target === edit.target) && targets.length > 0 ? edit : null;
|
|
120
|
+
const add = targets.length === 0 ? "" : html `<div class="spend-lim"><div class="spend-lim-h"><span class="spend-lim-l"><span class="spend-lim-t">A person or a subagent</span><span class="spend-lim-x">A limit for one person’s or one subagent’s work.</span></span><span></span>\
|
|
121
|
+
<span class="spend-lim-a">${adding === null ? btn("Add a limit", { sm: true, href: editHref("new", view.month, current) }) : ""}</span></div>${adding === null ? "" : limitForm(null, adding, view, targets)}</div>`;
|
|
122
|
+
const status = said.problem ? notice(said.problem) : said.said ? html `<p class="k-note" role="status">${said.said}</p>` : "";
|
|
123
|
+
const limits = view.budgets === null ? "" : sec("limits", "Monthly limits", monthWords(current, "long").split(" ")[0], html `${status}${rows.map(row => limitRow(row, view, current, openFor(row)))}${add}`);
|
|
124
|
+
const { totals } = view.report;
|
|
65
125
|
const usageValue = totals.usageCount === 0 ? "None" : totals.usageMicrousd > 0 ? money(totals.usageMicrousd) : "Not reported";
|
|
66
126
|
const usageMeta = totals.usageCount === 0 ? "No plan usage recorded"
|
|
67
127
|
: totals.usageMicrousd > 0 ? `of usage at API prices${totals.usageUnreported > 0 ? ` · ${each(totals.usageUnreported, "piece")} of work not reported` : ""}` : `${each(totals.usageCount, "piece")} of work`;
|
|
68
128
|
const unknownValue = totals.unknown === 0 ? "None" : String(totals.unknown);
|
|
69
129
|
const unknownMeta = [totals.unknown > 0 ? `${totals.unknown === 1 ? "piece" : "pieces"} of work with billing not proved or cost not recorded` : "Nothing with an unknown cost",
|
|
70
130
|
...(totals.estimatedMicrousd > 0 ? [`counted as ${money(totals.estimatedMicrousd)} (estimated)`] : [])].join(" · ");
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
<div
|
|
74
|
-
<div
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
131
|
+
const label = monthWords(view.month, "long");
|
|
132
|
+
const figures = html `<section class="spend-sum" aria-label="${label}">\
|
|
133
|
+
<div data-amount-kind="api" data-microusd="${totals.apiMicrousd}"><span class="spend-sum-k">API dollars · ${label}${view.month === current ? " so far" : ""}</span><span class="spend-sum-v">${money(totals.apiMicrousd)}</span><span class="spend-sum-x">Recorded charges to API keys</span></div>\
|
|
134
|
+
<div data-amount-kind="subscription" data-microusd="${totals.usageMicrousd}" data-unreported="${totals.usageUnreported}"><span class="spend-sum-k">Plan usage</span><span class="spend-sum-v">${usageValue}</span><span class="spend-sum-x">${usageMeta}</span></div>\
|
|
135
|
+
<div data-amount-kind="unknown" data-count="${totals.unknown}" data-estimated-microusd="${totals.estimatedMicrousd}"><span class="spend-sum-k">Unknown</span><span class="spend-sum-v">${unknownValue}</span><span class="spend-sum-x">${unknownMeta}</span></div></section>`;
|
|
136
|
+
const monthItems = [{ key: view.previous, label: monthWords(view.previous, "short"), href: `/spend?month=${view.previous}` }, { key: view.month, label: monthWords(view.month, "short"), href: `/spend?month=${view.month}` },
|
|
137
|
+
...(view.next === null ? [] : [{ key: view.next, label: monthWords(view.next, "short"), href: `/spend?month=${view.next}` }])];
|
|
138
|
+
const monthBar = html `<div class="spend-month">${segLinks("Month", view.month, monthItems)}${btn("Download CSV", { variant: "ghost", sm: true, href: `/spend?month=${view.month}&format=csv`, attrs: { download: "" } })}</div>`;
|
|
139
|
+
const nothing = view.report.byProject.length === 0 && view.report.byModel.length === 0 && totals.unknown === 0 && totals.usageCount === 0;
|
|
140
|
+
const max = Math.max(0, ...view.report.byProject.map(line => line.apiMicrousd), ...view.report.byModel.map(line => line.apiMicrousd));
|
|
141
|
+
const runs = view.report.topRuns.length === 0 ? "" : html `<div class="spend-runs">${view.report.topRuns.map(run => html `<div class="spend-run" data-run="${run.runId}"><span><a class="k-link" href="${taskHref(run.taskId)}">${run.taskId}</a> · <a class="k-link" href="${runHref(run.runId)}">${ROLE_WORDS[run.role]} ${run.runId}</a></span>\
|
|
142
|
+
<span class="k-num">${money(run.microusd)}</span><small>${run.project === null ? "" : `${projectName(run.project)} · `}<span class="k-mono">${run.provider} · ${run.model}</span></small></div>`)}</div>`;
|
|
143
|
+
const more = view.report.byRole.length === 0 && view.report.byPerson.length === 0 && runs === "" ? "" : disclose("By role, by person and the costliest runs", html `${view.report.byRole.length === 0 ? "" : bars("Role", view.report.byRole, key => ROLE_WORDS[key] ?? key, max)}${view.report.byPerson.length === 0 ? "" : bars("Person", view.report.byPerson, key => key, max)}${runs}`);
|
|
144
|
+
const breakdowns = nothing ? empty({ title: `Nothing spent in ${label}.` }) : html `${view.report.byProject.length === 0 ? "" : sec("by-project", "By project", "", bars("Project", view.report.byProject, projectName, max))}${view.report.byModel.length === 0 ? "" : sec("by-model", "By model", "", bars("Model", view.report.byModel, key => key, max, 12, line => line.chosen.length === 0 ? "" : `chosen as ${line.chosen.join(", ")}`))}${more}`;
|
|
145
|
+
const windows = view.windows === null || view.windows.tiles.length === 0 ? "" : sec("windows", "Plan windows", "", html `<div class="spend-wins">${view.windows.tiles.map(tile => html `<div class="spend-win ${tile.tone}" data-limit="${tile.key}"${tile.title === null ? "" : html ` title="${tile.title}"`}>\
|
|
146
|
+
<div class="spend-win-h"><b>${tile.name} ${tile.window}</b><span class="k-num">${tile.value}${tile.unit}</span></div><span class="spend-meter" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(tile.percent)}" aria-label="${tile.name} ${tile.window}: ${tile.value}${tile.unit}"><i style="width:${Math.min(100, Math.max(0, tile.percent))}%"></i></span><span class="k-note">${tile.detail}</span></div>`)}</div>`);
|
|
147
|
+
const notCounted = html `<p class="k-note" data-not-counted>Not counted here: ${NOT_COUNTED.map(one => one.charAt(0).toLowerCase() + one.slice(1)).join("; ")}. Evals never count toward a limit.</p>`;
|
|
148
|
+
const docked = edit !== null && (rows.some(row => row.target === edit.target) || adding !== null)
|
|
149
|
+
? dock(btn("Save limit", { variant: "primary", type: "submit", attrs: { form: `limit-${edit.target.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").slice(0, 60) || "new"}`, name: "action", value: "save" } }))
|
|
150
|
+
: "";
|
|
151
|
+
const body = html `<div class="spend">${limits}${monthBar}${figures}${breakdowns}${windows}${notCounted}</div>${docked}`;
|
|
152
|
+
return settingsShell({ group: "spend", title: "Spend", lead: "What agents cost, by project and by model, with the limits that stop them. Dates are UTC months.", body,
|
|
153
|
+
flags: { spend: saved.some(one => one.budget !== null && limitNeedsYou(one.budget)) } });
|
|
80
154
|
}
|
|
81
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const members = view.targets.filter(one => one.group === group);
|
|
90
|
-
return members.length === 0 ? "" : html `<optgroup label="${group}">${members.map(one => html `<option value="${one.value}">${one.label}</option>`)}</optgroup>`;
|
|
91
|
-
});
|
|
92
|
-
const add = html `<details${view.budgets.length === 0 ? html ` open` : ""}><summary>Add a limit</summary>${postForm("/spend/budget", html `<label>For<select name="target" required>${options}</select></label><label>Monthly limit (US dollars)<input type="number" name="usd" min="1" step="1" required></label><label class="choice"><input type="checkbox" name="stop" value="1" checked> Pause new API work at 100%</label>${password}<button type="submit" name="action" value="save">Add limit</button>`, { attrs: { class: "budget-form" } })}</details>`;
|
|
93
|
-
const models = html `<section class="spend-models"><h2>Models</h2>${view.models.length === 0 ? "" : html `<dl>${view.models.map(one => html `<dt>${one.role}</dt><dd>${one.model}</dd>`)}</dl>`}<p><a href="/settings/models">Change models</a></p></section>`;
|
|
94
|
-
return html `<article class="spend"><div class="spend-head"><h1>Spend</h1><p class="spend-month"><a href="/spend">See this month's spend</a></p></div>${note}<section><h2>Monthly limits</h2>${budgets.length === 0 ? html `<p class="meta">No monthly limits yet.</p>` : budgets}${add}</section>${models}</article>`;
|
|
155
|
+
/** Checks the amount next to its field before any password is asked or the form leaves the page; the server checks
|
|
156
|
+
* it again. Remove skips it. */
|
|
157
|
+
export function spendScript() {
|
|
158
|
+
return `(function(){document.addEventListener("submit",function(e){var f=e.target;if(!f||!f.matches||!f.matches("form[data-spend-limit]"))return;
|
|
159
|
+
var s=e.submitter;if(s&&s.hasAttribute("data-spend-remove"))return;var u=f.querySelector("[data-spend-usd]");if(!u)return;
|
|
160
|
+
var v=u.value.replace(/[$,\\s]/g,""),ok=/^\\d+$/.test(v)&&+v>=1&&+v<=10000000,err=document.getElementById(u.id+"-err");
|
|
161
|
+
if(ok){u.removeAttribute("aria-invalid");if(err)err.textContent="";u.value=v;return;}
|
|
162
|
+
e.preventDefault();u.setAttribute("aria-invalid","true");if(err)err.textContent="Use a whole number of dollars, at least $1, like 40. Nothing changed.";u.focus();},true);})();`;
|
|
95
163
|
}
|
|
96
164
|
/** One row per piece of spend, for a spreadsheet (the command line's `spend --csv`). */
|
|
97
165
|
export function spendCsv(items, subagentNames) {
|
package/dist/sso-ui.d.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { OidcSettings } from "./oidc.js";
|
|
7
7
|
import { type Html } from "./html.js";
|
|
8
|
-
|
|
8
|
+
/** The old card look; the page is built from the kit now. Kept for chrome.ts, which still concatenates it. */
|
|
9
|
+
export declare const SSO_CSS = "";
|
|
9
10
|
export type SsoSettingsView = {
|
|
10
11
|
settings: OidcSettings | null;
|
|
11
12
|
/** The address to register with the provider as this app's sign-in redirect. */
|
|
@@ -17,7 +18,9 @@ export type SsoSettingsView = {
|
|
|
17
18
|
/** Whether the person looking is linked to the provider already. */
|
|
18
19
|
linked: boolean;
|
|
19
20
|
};
|
|
20
|
-
|
|
21
|
+
/** Whether an address only this computer can open (localhost, 127.x, ::1): a provider can't send people back to it. */
|
|
22
|
+
export declare function loopbackAddress(address: string): boolean;
|
|
23
|
+
export declare function ssoSettingsHtml(view: SsoSettingsView, outcome: {
|
|
21
24
|
said?: string | null;
|
|
22
25
|
problem?: string | null;
|
|
23
26
|
}): Html;
|
package/dist/sso-ui.js
CHANGED
|
@@ -1,38 +1,70 @@
|
|
|
1
|
-
import { html, postForm } from "./html.js";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
import { html, joinHtml, postForm } from "./html.js";
|
|
2
|
+
import { acts, btn, codeLine, disclose, formRow, infoLine, input, note, notice, radios, select, settingsSection } from "./server/kit.js";
|
|
3
|
+
import { kitSectionForm, SECRET_OPT_OUTS } from "./settings-kit.js";
|
|
4
|
+
/** The old card look; the page is built from the kit now. Kept for chrome.ts, which still concatenates it. */
|
|
5
|
+
export const SSO_CSS = "";
|
|
6
|
+
/** Whether an address only this computer can open (localhost, 127.x, ::1): a provider can't send people back to it. */
|
|
7
|
+
export function loopbackAddress(address) {
|
|
8
|
+
let host;
|
|
9
|
+
try {
|
|
10
|
+
host = new URL(address).hostname.toLowerCase();
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
return host === "localhost" || host.endsWith(".localhost") || /^127(\.\d{1,3}){3}$/.test(host) || host === "[::1]" || host === "::1" || host === "0.0.0.0";
|
|
16
|
+
}
|
|
8
17
|
function ruleRow(index, rule, projects) {
|
|
9
18
|
const chosen = rule === null ? [] : rule.projects === "all" ? ["all"] : rule.projects;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
19
|
+
const id = `sso-rule-${index}`;
|
|
20
|
+
const box = (value, label, at) => html `<label for="${id}-p${at}"><input type="checkbox" id="${id}-p${at}" name="projects-${index}" value="${value}"${chosen.includes(value) ? html ` checked` : ""}> ${label}</label>`;
|
|
21
|
+
return formRow(`Rule ${index + 1}`, index === 0 ? "The first rule that matches a person decides." : null, joinHtml([
|
|
22
|
+
input({ id: `${id}-group`, name: `group-${index}`, value: rule?.group ?? "", attrs: { placeholder: index === 0 ? "Group, for example: engineering" : "Group", autocomplete: "off", "aria-label": `Rule ${index + 1} group` } }),
|
|
23
|
+
select({ id: `${id}-role`, name: `role-${index}`, label: `Rule ${index + 1} role`, current: rule?.role ?? "operator", items: [["operator", "Can manage and approve"], ["viewer", "Can view"]] }),
|
|
24
|
+
html `<div class="k-checks" role="group" aria-label="Rule ${index + 1} projects">${box("all", "All projects", 0)}${projects.map((one, at) => box(one.path, one.name, at + 1))}</div>`,
|
|
25
|
+
]), "wide");
|
|
13
26
|
}
|
|
14
|
-
export function ssoSettingsHtml(view,
|
|
27
|
+
export function ssoSettingsHtml(view, outcome) {
|
|
15
28
|
const s = view.settings;
|
|
29
|
+
const answer = outcome.problem ? notice(outcome.problem) : outcome.said ? infoLine(outcome.said) : "";
|
|
16
30
|
const rows = [...(s?.rules ?? []), null, null].slice(0, Math.max(3, (s?.rules.length ?? 0) + 1)).map((rule, index) => ruleRow(index, rule, view.projects));
|
|
17
|
-
const note = notice.problem ? html `<p class="problem" role="alert">${notice.problem}</p>` : notice.said ? html `<p role="status">${notice.said}</p>` : "";
|
|
18
31
|
const redirect = view.redirect === null
|
|
19
|
-
?
|
|
20
|
-
:
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
? note("Open this page from the address people use (your https address) to see the redirect address to register.")
|
|
33
|
+
: joinHtml([
|
|
34
|
+
formRow("Redirect address", "Register this with the provider as the sign-in redirect.", codeLine(view.redirect), "wide"),
|
|
35
|
+
loopbackAddress(view.redirect)
|
|
36
|
+
? infoLine("This page is open on a local address, which only this computer can reach, so a provider can’t send people back to it. Open this page from the address people use (your https address) and register that one instead.")
|
|
37
|
+
: "",
|
|
38
|
+
]);
|
|
39
|
+
const check = s === null ? "" : postForm("/settings/sign-in", acts(btn("Check the provider", { sm: true, type: "submit" })), { hidden: { action: "test" } });
|
|
40
|
+
const provider = settingsSection({ id: "provider", title: "Provider", state: s === null ? " · off" : ` · ${s.label}`,
|
|
41
|
+
lead: "People sign in with your organisation’s identity provider (Okta, Microsoft Entra, Google, anything that speaks OpenID Connect), and its groups decide what they may do.",
|
|
42
|
+
body: joinHtml([redirect,
|
|
43
|
+
kitSectionForm({ proof: "sign-in", action: "/settings/sign-in", id: "sign-in-form", gate: s !== null, hidden: { action: "save" },
|
|
44
|
+
save: { label: s === null ? "Turn on sign-in with the provider" : "Save" }, confirm: s === null ? "Turn on sign-in with the provider" : "Change sign-in with the provider",
|
|
45
|
+
body: joinHtml([
|
|
46
|
+
formRow("Provider address (issuer)", null, input({ id: "sso-issuer", name: "issuer", type: "url", value: s?.issuer ?? "", attrs: { placeholder: "https://acme.okta.com/oauth2/default", required: true, inputmode: "url", autocomplete: "off" } })),
|
|
47
|
+
formRow("Client ID", null, input({ id: "sso-client-id", name: "client-id", value: s?.clientId ?? "", attrs: { required: true, autocomplete: "off" } })),
|
|
48
|
+
formRow("Client secret", s?.clientSecret ? "Saved. Leave blank to keep it." : "Leave blank for a public client.", input({ id: "sso-client-secret", name: "client-secret", type: "password", attrs: { autocomplete: "off", ...SECRET_OPT_OUTS, "aria-label": "Client secret" } })),
|
|
49
|
+
disclose("More", joinHtml([
|
|
50
|
+
formRow("Button says “Sign in with …”", null, input({ id: "sso-label", name: "label", value: s?.label ?? "", attrs: { placeholder: "Okta" } })),
|
|
51
|
+
formRow("Scopes", null, input({ id: "sso-scopes", name: "scopes", value: s?.scopes ?? "openid email profile" })),
|
|
52
|
+
formRow("Groups claim", null, input({ id: "sso-groups-claim", name: "groups-claim", value: s?.groupsClaim ?? "groups" })),
|
|
53
|
+
])),
|
|
54
|
+
formRow("Who may sign in", "A person’s first matching group sets their role and projects, again at every sign-in. No match, no entry. Use * for everyone.", html ``, "wide"),
|
|
55
|
+
...rows,
|
|
56
|
+
formRow("Passwords", null, radios({ name: "passwords", legend: "Passwords", current: s?.passwords === "operators" ? "operators" : "everyone", items: [
|
|
57
|
+
{ value: "everyone", label: "Everyone may still use a password" },
|
|
58
|
+
{ value: "operators", label: "Only instance operators", text: "A way in if the provider is down." }
|
|
59
|
+
] }), "wide"),
|
|
60
|
+
]) }),
|
|
61
|
+
check,
|
|
62
|
+
]) });
|
|
63
|
+
const account = s === null ? "" : settingsSection({ id: "your-account", title: "Your account",
|
|
64
|
+
body: view.linked ? note(`You sign in with ${s.label}.`)
|
|
65
|
+
: joinHtml([note(`Sign in with ${s.label} as this account from now on.`), acts(btn(`Link to ${s.label}`, { sm: true, href: "/login/sso?link=1" }))]) });
|
|
66
|
+
const off = s === null ? "" : settingsSection({ id: "turn-off", title: "Turn off",
|
|
67
|
+
body: kitSectionForm({ proof: "sign-in", action: "/settings/sign-in", id: "sign-in-off", hidden: { action: "remove" }, save: { label: `Turn off sign-in with ${s.label}`, variant: "danger" },
|
|
68
|
+
confirm: `Turn off sign-in with ${s.label}`, body: note(`People who signed in with ${s.label} can’t sign in until it’s on again, unless they have a password.`) }) });
|
|
69
|
+
return joinHtml([answer, provider, account, off]);
|
|
38
70
|
}
|
package/dist/storage-ui.d.ts
CHANGED
|
@@ -3,7 +3,8 @@ import { type CheckoutPlan } from "./checkout-cleanup.js";
|
|
|
3
3
|
import { type SweepRecord } from "./storage-sweep.js";
|
|
4
4
|
import { type SweepSchedule } from "./storage-sweep-schedule.js";
|
|
5
5
|
import type { LandedSummary } from "./landed-cleanup.js";
|
|
6
|
-
export
|
|
6
|
+
/** The page is kit pieces now; nothing of the old look is left. Kept as an export for the shared stylesheet. */
|
|
7
|
+
export declare const STORAGE_CSS = "";
|
|
7
8
|
export type StorageView = {
|
|
8
9
|
plan: CheckoutPlan;
|
|
9
10
|
csrf: string;
|
|
@@ -16,7 +17,7 @@ export type StorageView = {
|
|
|
16
17
|
/** When the checkouts were last measured, and its Check now (docs/plans/get-side-effects.md). */
|
|
17
18
|
measured?: Html;
|
|
18
19
|
};
|
|
19
|
-
export declare function storageHtml(view: StorageView,
|
|
20
|
+
export declare function storageHtml(view: StorageView, said: {
|
|
20
21
|
said?: string | null;
|
|
21
22
|
problem?: string | null;
|
|
22
23
|
}): Html;
|