segreant 0.1.0 → 0.2.0
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/LICENSING.md +5 -3
- package/README.md +57 -19
- package/bin/segreant-statusline.mjs +20 -0
- package/bin/segreant.mjs +19 -8
- package/dist/alerts/detect.js +13 -0
- package/dist/billing/epistemic.js +4 -0
- package/dist/billing/importer.js +29 -10
- package/dist/billing/reconcile.js +122 -32
- package/dist/billing/scope.js +7 -0
- package/dist/budget/guard.js +33 -4
- package/dist/cli/allocCmd.js +73 -4
- package/dist/cli/askModelPrices.js +59 -0
- package/dist/cli/backupCmd.js +2 -1
- package/dist/cli/basisLines.js +45 -0
- package/dist/cli/billingCmd.js +68 -9
- package/dist/cli/capitalCmd.js +2 -1
- package/dist/cli/connectCmd.js +50 -9
- package/dist/cli/egressCmd.js +15 -3
- package/dist/cli/evidenceCmd.js +6 -3
- package/dist/cli/flags.js +68 -0
- package/dist/cli/homeCmd.js +117 -0
- package/dist/cli/importCmd.js +210 -54
- package/dist/cli/launchCmd.js +2 -1
- package/dist/cli/opsCmd.js +49 -2
- package/dist/cli/planCmd.js +181 -0
- package/dist/cli/pricingDeclareCmd.js +178 -0
- package/dist/cli/quotaCmd.js +190 -0
- package/dist/cli/runCmd.js +203 -51
- package/dist/cli/showCmd.js +37 -11
- package/dist/cli/teamCmd.js +5 -5
- package/dist/cli/ui.js +24 -8
- package/dist/cli/valueCmd.js +189 -14
- package/dist/cli.js +102 -21
- package/dist/config.js +95 -6
- package/dist/connect/antigravity.js +310 -0
- package/dist/connect/claudeCode.js +43 -3
- package/dist/connect/codex.js +38 -1
- package/dist/connect/importShared.js +58 -5
- package/dist/connect/opencode.js +37 -9
- package/dist/connect/protobuf.js +126 -0
- package/dist/connect/registry.js +46 -0
- package/dist/connect/repoRoot.js +112 -0
- package/dist/cost/basis.js +168 -0
- package/dist/cost/declarations.js +37 -0
- package/dist/cost/exactPricing.js +4 -1
- package/dist/cost/firstSight.js +98 -0
- package/dist/cost/overrides.js +230 -0
- package/dist/cost/pricing.js +228 -15
- package/dist/cost/reprice.js +130 -0
- package/dist/dashboard/claim-support.js +11 -8
- package/dist/dashboard/contracts.js +23 -3
- package/dist/dashboard/routes.js +980 -112
- package/dist/dashboard/sessions.js +345 -0
- package/dist/dashboard/settings.js +2 -1
- package/dist/dashboard/value-cache.js +105 -0
- package/dist/dashboard/web/app/components/claimInspector.js +6 -5
- package/dist/dashboard/web/app/components/drawer.js +105 -35
- package/dist/dashboard/web/app/components/ruleFields.js +144 -0
- package/dist/dashboard/web/app/components/spine.js +54 -24
- package/dist/dashboard/web/app/components/workflow.js +82 -0
- package/dist/dashboard/web/app/core/actions.js +734 -23
- package/dist/dashboard/web/app/core/api.js +37 -1
- package/dist/dashboard/web/app/core/chain.js +11 -4
- package/dist/dashboard/web/app/core/claimLayers.js +179 -51
- package/dist/dashboard/web/app/core/claimTypes.js +30 -0
- package/dist/dashboard/web/app/core/fmt.js +19 -2
- package/dist/dashboard/web/app/core/generated-contract.js +23 -3
- package/dist/dashboard/web/app/core/generated-payload-contract.js +1944 -465
- package/dist/dashboard/web/app/core/generated-types.js +1 -1
- package/dist/dashboard/web/app/core/griffin.js +10 -0
- package/dist/dashboard/web/app/core/jobs.js +12 -0
- package/dist/dashboard/web/app/core/period.js +35 -0
- package/dist/dashboard/web/app/core/periodWords.js +10 -0
- package/dist/dashboard/web/app/core/periods.js +32 -0
- package/dist/dashboard/web/app/core/pricingActions.js +264 -0
- package/dist/dashboard/web/app/core/privacyText.js +78 -0
- package/dist/dashboard/web/app/core/reconcileWords.js +63 -0
- package/dist/dashboard/web/app/core/registry.js +79 -46
- package/dist/dashboard/web/app/core/slug.js +9 -0
- package/dist/dashboard/web/app/core/theme.js +48 -0
- package/dist/dashboard/web/app/core/view.js +69 -0
- package/dist/dashboard/web/app/main.js +197 -15
- package/dist/dashboard/web/app/views/allocation.js +249 -61
- package/dist/dashboard/web/app/views/control.js +57 -16
- package/dist/dashboard/web/app/views/data.js +4 -4
- package/dist/dashboard/web/app/views/evidence.js +336 -87
- package/dist/dashboard/web/app/views/privacy.js +13 -0
- package/dist/dashboard/web/app/views/spend.js +30 -15
- package/dist/dashboard/web/app/views/statement.js +189 -0
- package/dist/dashboard/web/app/views/system.js +44 -4
- package/dist/dashboard/web/app/views/value.js +105 -59
- package/dist/dashboard/web/app/views/workbench.js +312 -0
- package/dist/dashboard/web/classic.html +16 -4
- package/dist/dashboard/web/index.html +1 -1
- package/dist/dashboard/web/styles/app.css +536 -23
- package/dist/demo/seed.js +153 -14
- package/dist/economics/ledger.js +1 -1
- package/dist/egress/policy.js +129 -5
- package/dist/export/allocationCsv.js +70 -0
- package/dist/export/billingCsv.js +6 -2
- package/dist/export/csv.js +10 -2
- package/dist/export/economic.js +6 -2
- package/dist/export/statementCsv.js +104 -0
- package/dist/git/correlate.js +86 -9
- package/dist/git/quality.js +107 -24
- package/dist/git/repoScope.js +207 -0
- package/dist/git/survivalStore.js +186 -0
- package/dist/guide.js +9 -4
- package/dist/plans/detect.js +147 -0
- package/dist/proxy/server.js +134 -14
- package/dist/proxy/usage.js +9 -4
- package/dist/quota/limits.js +138 -0
- package/dist/quota/statusline.js +68 -0
- package/dist/quota/view.js +122 -0
- package/dist/scan/knownApps.js +6 -2
- package/dist/scan/scan.js +9 -0
- package/dist/store/allocation.js +14 -0
- package/dist/store/db.js +259 -34
- package/dist/store/realization.js +78 -14
- package/dist/store/schema.js +59 -1
- package/dist/store/scopeEvidence.js +67 -0
- package/dist/util/pool.js +19 -0
- package/dist/util/time.js +7 -0
- package/dist/value/realization.js +321 -30
- package/dist/value/report.js +28 -8
- package/dist/value/selfReported.js +10 -9
- package/docs/ARCHITECTURE.md +8 -4
- package/docs/CAPABILITY-EVIDENCE-CONTRACT.md +33 -11
- package/docs/CLI-JSON-CONTRACT.md +38 -0
- package/docs/DATA-BOUNDARIES.md +66 -6
- package/docs/FAQ.md +43 -9
- package/docs/GETTING-STARTED.md +19 -12
- package/docs/GUIDE.md +81 -18
- package/docs/INTEGRATIONS.md +9 -9
- package/docs/METHODOLOGY.md +3 -2
- package/docs/NEUTRALITY.md +1 -1
- package/docs/PRIVACY.md +51 -0
- package/docs/README.md +1 -0
- package/docs/RELEASE-GATE.md +52 -7
- package/docs/THREAT-MODEL.md +2 -1
- package/package.json +3 -2
- package/pricing/models.json +1366 -60
|
@@ -10,9 +10,11 @@
|
|
|
10
10
|
import { h } from '../core/dom.js';
|
|
11
11
|
import { signal, scopedEffect } from '../core/signal.js';
|
|
12
12
|
import { api } from '../core/api.js';
|
|
13
|
-
import { isPrecise, relative, basisWords, usd, usdFromMicros, count } from '../core/fmt.js';
|
|
13
|
+
import { isPrecise, relative, basisWords, usd, usdFromMicros, count, date, pct } from '../core/fmt.js';
|
|
14
|
+
import { pageNotice, step, field, errorText, holdFocus } from '../components/workflow.js';
|
|
14
15
|
import { actionCard } from './spend.js';
|
|
15
16
|
import { projectRenderedAxes } from '../core/claimTypes.js';
|
|
17
|
+
import { residualBoundIsError, residualBoundWords, dayRange, quantizationWords } from '../core/reconcileWords.js';
|
|
16
18
|
/**
|
|
17
19
|
* The headline of this card, keyed on the BILLED CLAIM's state.
|
|
18
20
|
*
|
|
@@ -54,44 +56,6 @@ const CLAIM_WORDS = {
|
|
|
54
56
|
pill: 'pill-ok',
|
|
55
57
|
},
|
|
56
58
|
};
|
|
57
|
-
/**
|
|
58
|
-
* A `Record` rather than an if-chain, keyed by the wire's own union.
|
|
59
|
-
*
|
|
60
|
-
* That is the gate: adding a state to `offPathBound` in `shared-types.ts`
|
|
61
|
-
* without a rendering here fails the BROWSER typecheck. With an if-chain the
|
|
62
|
-
* new state fell through to the "older than the check" sentence below, which
|
|
63
|
-
* would have said a fresh run predates a field it carries — an unrecognised
|
|
64
|
-
* value rendered as an absent one, which is the defect class this screen keeps
|
|
65
|
-
* finding.
|
|
66
|
-
*/
|
|
67
|
-
const RESIDUAL_BOUND_WORDS = {
|
|
68
|
-
none_local_estimate_exceeds_provider: {
|
|
69
|
-
precise: 'residual bounds nothing — the local estimate exceeds the provider total, so no upper bound on off-path spend survives',
|
|
70
|
-
plain: 'This remainder cannot tell you how much went outside Segreant: our own estimate already came out higher than the provider’s bill.',
|
|
71
|
-
},
|
|
72
|
-
upper_bound_conditional: {
|
|
73
|
-
precise: 'residual is an upper bound on off-path spend while the local estimate does not exceed true on-path billed cost',
|
|
74
|
-
plain: 'At most this much could have gone outside Segreant — assuming our own pricing did not overshoot what you were really charged.',
|
|
75
|
-
},
|
|
76
|
-
unknown_local_total_truncated_by_retention: {
|
|
77
|
-
precise: 'residual bounds nothing — retention deleted request rows from inside this period, so the local total is a known undercount by an unknown amount',
|
|
78
|
-
plain: 'This remainder cannot tell you how much went outside Segreant: some of your own metered requests in this period were deleted by your retention setting, so part of the difference is traffic we did see.',
|
|
79
|
-
},
|
|
80
|
-
};
|
|
81
|
-
/** Whether the run's own bound state is bad news rather than a caveat. */
|
|
82
|
-
function residualBoundIsError(bound) {
|
|
83
|
-
return bound === 'none_local_estimate_exceeds_provider'
|
|
84
|
-
|| bound === 'unknown_local_total_truncated_by_retention';
|
|
85
|
-
}
|
|
86
|
-
function residualBoundWords(bound, precise) {
|
|
87
|
-
const words = bound === undefined ? undefined : RESIDUAL_BOUND_WORDS[bound];
|
|
88
|
-
if (words === undefined) {
|
|
89
|
-
return precise
|
|
90
|
-
? 'this run predates the recorded bound condition, so what the residual bounds is not established'
|
|
91
|
-
: 'This run is older than the check that says what the remainder means, so we cannot tell you.';
|
|
92
|
-
}
|
|
93
|
-
return precise ? words.precise : words.plain;
|
|
94
|
-
}
|
|
95
59
|
/**
|
|
96
60
|
* What a reconciliation would actually match, stated before the credential.
|
|
97
61
|
*
|
|
@@ -173,9 +137,11 @@ function readinessPanel(d) {
|
|
|
173
137
|
if (!truncated)
|
|
174
138
|
return null;
|
|
175
139
|
const before = r.localLedgerRetention.prunedBeforeMs;
|
|
140
|
+
const boundary = typeof before === 'number' && Number.isFinite(before) && before > 0 && before <= 8.64e15
|
|
141
|
+
? new Date(before).toISOString().slice(0, 10) : 'an unrecorded boundary';
|
|
176
142
|
return h('div', { class: 'drawer-warning', style: 'margin-top: var(--s4)' }, h('strong', { text: 'This ledger was emptied by retention' }), h('p', { text: () => (isPrecise()
|
|
177
|
-
? `No OpenAI rows survive, and retention deleted rows before ${
|
|
178
|
-
: `Your older records were deleted, so there is no OpenAI spend left here to check.
|
|
143
|
+
? `No OpenAI rows survive, and retention deleted rows before ${boundary}. Whether any OpenAI spend was metered here cannot be read from the ledger, so nothing about what a reconciliation would match is stated.`
|
|
144
|
+
: `Your older records were deleted, so there is no OpenAI spend left here to check. There's no record of whether there ever was any, only that it is gone.`) }));
|
|
179
145
|
}
|
|
180
146
|
const uncountable = c.importedUsd + c.proxyOffScopeUsd;
|
|
181
147
|
if (c.onDeclaredRouteUsd > 0 || uncountable <= 0)
|
|
@@ -245,60 +211,343 @@ function mappingPanel(d) {
|
|
|
245
211
|
? `Still excluded from: ${m.excludedFrom.join(', ')}.`
|
|
246
212
|
: 'Provider scope authority is present for this payload; review the recorded reconciliation before consuming the result.') }));
|
|
247
213
|
}
|
|
214
|
+
/** A step is done when readiness no longer lists anything that starts with these words. */
|
|
215
|
+
function readinessHas(d, prefix) {
|
|
216
|
+
return (d.readiness?.missing ?? []).some((m) => m.step.startsWith(prefix));
|
|
217
|
+
}
|
|
218
|
+
/** The file's own bytes, base64, so the server hashes exactly what the CLI would. */
|
|
219
|
+
async function fileBase64(file) {
|
|
220
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
221
|
+
let binary = '';
|
|
222
|
+
for (let i = 0; i < bytes.length; i += 0x8000)
|
|
223
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
224
|
+
return btoa(binary);
|
|
225
|
+
}
|
|
226
|
+
function rows(pairs) {
|
|
227
|
+
return h('dl', { class: 'preview-rows' }, ...pairs.map(([k, v]) => h('div', { class: 'preview-row' }, h('dt', { text: k }), h('dd', { text: v }))));
|
|
228
|
+
}
|
|
248
229
|
export function evidenceView() {
|
|
249
230
|
const data = signal(null);
|
|
250
231
|
const error = signal(null);
|
|
232
|
+
const reloads = signal(0);
|
|
233
|
+
const { node: noticeNode, say } = pageNotice();
|
|
234
|
+
const reload = () => {
|
|
235
|
+
reloads.set(reloads() + 1);
|
|
236
|
+
// The spine's Billed band reads /api/billing once; a recorded run must reach it.
|
|
237
|
+
window.dispatchEvent(new CustomEvent('segreant:ledger-changed', { detail: { claim: 'billing' } }));
|
|
238
|
+
};
|
|
251
239
|
scopedEffect(() => {
|
|
240
|
+
reloads();
|
|
252
241
|
void api.billing()
|
|
253
|
-
.then((payload) => data.set(payload))
|
|
254
|
-
.catch((e) => error.set(
|
|
242
|
+
.then((payload) => { const restore = holdFocus(page); error.set(null); data.set(payload); restore(); })
|
|
243
|
+
.catch((e) => error.set(errorText(e)));
|
|
255
244
|
});
|
|
256
|
-
|
|
257
|
-
? 'Provider-side billing evidence and its reconciliation against the local ledger, at project-day grain. Status is never "reconciled"
|
|
258
|
-
: '
|
|
245
|
+
const page = h('div', null, h('div', { class: 'view-head' }, h('h1', { class: 'view-title', text: 'Close' }), h('p', { class: 'view-plain', text: () => isPrecise()
|
|
246
|
+
? 'Provider-side billing evidence and its reconciliation against the local ledger, at project-day grain. Status is never "reconciled", only "reconciled with residual".'
|
|
247
|
+
: 'Spend is our own measurement. Here you check it against what your provider actually billed, and see how much of it could be checked at all.' })), noticeNode, () => {
|
|
259
248
|
const err = error();
|
|
260
|
-
if (err)
|
|
261
|
-
return h('div', { class: 'card' }, h('p', { class: 'drawer-error', role: 'alert', 'aria-live': 'assertive', text: err }));
|
|
262
249
|
const d = data();
|
|
250
|
+
if (!d && err)
|
|
251
|
+
return h('div', { class: 'card' }, h('p', { class: 'drawer-error', role: 'alert', text: err }));
|
|
263
252
|
if (!d)
|
|
264
|
-
return h('div', { class: 'card' }, h('p', { class: 'drawer-muted', role: 'status', 'aria-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
//
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
253
|
+
return h('div', { class: 'card' }, h('p', { class: 'drawer-muted', role: 'status', 'aria-busy': 'true', text: 'Loading…' }));
|
|
254
|
+
const scopeDone = !readinessHas(d, 'declare the route scope') && !readinessHas(d, 'the active scope cannot');
|
|
255
|
+
const observed = !readinessHas(d, 'observe a closed period');
|
|
256
|
+
return h('div', null, statusCard(d), d.demo
|
|
257
|
+
? h('p', { class: 'section-note', role: 'note', text: 'This is sample data, so nothing can be recorded here. Every step still previews. Start Segreant without --demo to close your own month.' })
|
|
258
|
+
: null, step({ n: 1, done: scopeDone }, () => (isPrecise() ? 'Declare the provider project' : 'Say which OpenAI project this machine uses'), () => (isPrecise()
|
|
259
|
+
? 'An operator declaration bound to future proxy rows. Unverified by the provider; historical and imported rows are unchanged.'
|
|
260
|
+
: 'Only spend that goes through Segreant after this, to that project, can be matched against the bill. These are the ids from your OpenAI settings, not keys.'), d.activeScope ? declaredScope(d, reload, say) : null, scopeForm(d.demo, reload, say, scopeDone)), step({ n: 2, done: observed }, () => (isPrecise() ? 'Observe the provider side' : 'Bring in the bill'), () => (isPrecise()
|
|
261
|
+
? 'Import an operator export, then adopt it as the Costs observation. Stamped operator-supplied; a direct read-only pull is stronger evidence.'
|
|
262
|
+
: 'Give Segreant the cost export from your OpenAI account, then use it for the comparison. No key or password is needed for this.'), importForm(d.demo, reload, say), importList(d, reload, say, scopeDone),
|
|
263
|
+
// The stronger route needs an Admin credential, so it stays a card whose
|
|
264
|
+
// drawer states the consequence before any key is involved.
|
|
265
|
+
h('div', { class: 'actions step-aside' }, actionCard('billing-pull'))), step({ n: 3, done: (d.reconciliation?.runs?.length ?? 0) > 0 }, () => (isPrecise() ? 'Reconcile' : 'Compare and record'), () => (isPrecise()
|
|
266
|
+
? 'Preview computes the comparison; Record persists an immutable run and issues it to the kernel.'
|
|
267
|
+
: 'See how our measurement compares with the bill. Nothing is saved until you record it.'), readinessPanel(d), reconcilePanel(d, reload, say), recordedComparisons(d)), h('details', { class: 'section close-details' }, h('summary', { class: 'section-title', text: () => (isPrecise() ? 'Evidence detail' : 'The evidence behind this') }), kernelPanel(d), mappingPanel(d)));
|
|
268
|
+
});
|
|
269
|
+
return page;
|
|
270
|
+
}
|
|
271
|
+
function statusCard(d) {
|
|
272
|
+
// A payload with no stated support said nothing, which is `unknown`.
|
|
273
|
+
const claimState = d.claimSupport ? projectRenderedAxes(d.claimSupport.profile).epistemic : 'unknown';
|
|
274
|
+
const status = CLAIM_WORDS[claimState] ?? CLAIM_WORDS.unknown;
|
|
275
|
+
// Newest recorded run, from the immutable collection the server sends.
|
|
276
|
+
const latest = d.reconciliation?.runs?.[0] ?? null;
|
|
277
|
+
return h('div', { class: 'card' }, h('div', { class: 'card-head' }, h('span', { class: 'card-title', text: 'Where this stands' }), h('span', { class: `pill ${status.pill}`, text: claimState })), h('p', { text: () => (isPrecise() ? status.precise : status.plain) }), d.claimSupport?.note ? h('p', { class: 'basis', text: d.claimSupport.note }) : null, latest
|
|
278
|
+
? h('div', null, h('p', { class: residualBoundIsError(latest.result.offPathBound) ? 'drawer-error' : 'basis',
|
|
279
|
+
text: () => residualBoundWords(latest.result.offPathBound, isPrecise()) }), h('span', { class: 'basis', text: () => `provider side: ${basisWords(latest.result.providerSourceKind)} · last run ${relative(latest.computedAtMs)}` }), latest.result.snapshotStability === 'changed_across_observations'
|
|
280
|
+
? h('span', { class: 'drawer-error', text: () => (isPrecise()
|
|
281
|
+
? `snapshot stability: changed across observations on ${(latest.result.unstableDayStartMs ?? []).length} day(s); this reconciliation is contradicted, not established`
|
|
282
|
+
: 'Careful: the provider reported different figures for the same days at different times. This check disagrees with itself.') })
|
|
283
|
+
: null)
|
|
284
|
+
: h('span', { class: 'basis', text: () => `${count(d.summary.recordCount)} provider line(s) held · no comparison recorded yet` }));
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* What is declared now, and a way to stop it. Clearing previews first, like
|
|
288
|
+
* every write here, and says what it leaves alone: requests already recorded
|
|
289
|
+
* keep their scope and recorded comparisons stay as they were.
|
|
290
|
+
*/
|
|
291
|
+
function declaredScope(d, reload, say) {
|
|
292
|
+
const s = d.activeScope;
|
|
293
|
+
const preview = signal(null);
|
|
294
|
+
const project = s.providerProjectRef ?? 'no project id';
|
|
295
|
+
return h('div', { class: 'facts declared-scope' }, h('div', { class: 'fact fact-stack' }, h('div', { class: 'fact-line' }, h('span', { class: 'fact-key' }, h('span', { text: `${s.billingAccountRef} · ${project}` }), h('span', { class: 'fact-sub', text: `declared ${relative(s.declaredAtMs)} · your statement, not verified by the provider` })), h('span', { class: 'fact-val' }, h('button', { class: 'link-btn', type: 'button', text: 'Stop matching',
|
|
296
|
+
'aria-label': `Stop matching requests to ${project}`,
|
|
297
|
+
onclick: async () => {
|
|
298
|
+
try {
|
|
299
|
+
const r = await api.write.billing({ op: 'scope-clear' });
|
|
300
|
+
preview.set(r.message ?? '');
|
|
301
|
+
}
|
|
302
|
+
catch (e) {
|
|
303
|
+
say(false, errorText(e));
|
|
304
|
+
}
|
|
305
|
+
} }))), () => {
|
|
306
|
+
const message = preview();
|
|
307
|
+
if (message === null)
|
|
308
|
+
return null;
|
|
309
|
+
return h('div', { class: 'run-result' }, h('p', { class: 'basis', text: message }), h('div', { class: 'form-actions' }, d.demo
|
|
310
|
+
? h('p', { class: 'drawer-muted', text: 'Sample data: the declaration cannot be cleared here.' })
|
|
311
|
+
: h('button', { class: 'btn-commit', type: 'button', text: 'Clear the declaration', onclick: async () => {
|
|
312
|
+
try {
|
|
313
|
+
const r = await api.write.billing({ op: 'scope-clear', apply: true });
|
|
314
|
+
say(true, r.message ?? 'Cleared.');
|
|
315
|
+
reload();
|
|
316
|
+
}
|
|
317
|
+
catch (e) {
|
|
318
|
+
say(false, errorText(e));
|
|
319
|
+
}
|
|
320
|
+
} }), h('button', { class: 'btn-ghost', type: 'button', text: 'Keep it', onclick: () => preview.set(null) })));
|
|
321
|
+
}));
|
|
322
|
+
}
|
|
323
|
+
function scopeForm(demo, reload, say, scopeDone) {
|
|
324
|
+
const account = signal('');
|
|
325
|
+
const project = signal('');
|
|
326
|
+
const checked = signal(null);
|
|
327
|
+
const body = () => ({ op: 'scope', accountRef: account().trim(), ...(project().trim() ? { projectRef: project().trim() } : {}) });
|
|
328
|
+
return h('form', {
|
|
329
|
+
class: 'inline-form',
|
|
330
|
+
onsubmit: async (event) => {
|
|
331
|
+
event.preventDefault();
|
|
332
|
+
try {
|
|
333
|
+
const r = await api.write.billing(body());
|
|
334
|
+
checked.set(r.scopePreview ?? {});
|
|
335
|
+
}
|
|
336
|
+
catch (e) {
|
|
337
|
+
checked.set(null);
|
|
338
|
+
say(false, errorText(e));
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
}, scopeDone ? h('p', { class: 'form-hint', text: 'A project is declared. Declaring another replaces it for future traffic.' }) : null, h('div', { class: 'form-row' }, field('scope-account', 'Organisation id', h('input', { id: 'scope-account', class: 'drawer-input', required: true, placeholder: 'org_…', autocomplete: 'off', spellcheck: 'false',
|
|
342
|
+
oninput: (e) => { account.set(e.target.value); checked.set(null); } })), field('scope-project', 'Project id', h('input', { id: 'scope-project', class: 'drawer-input', required: true, placeholder: 'proj_…', autocomplete: 'off', spellcheck: 'false',
|
|
343
|
+
oninput: (e) => { project.set(e.target.value); checked.set(null); } })), h('button', { class: 'btn-ghost', type: 'submit', text: 'Check' })), () => {
|
|
344
|
+
if (!checked())
|
|
345
|
+
return null;
|
|
346
|
+
return h('div', { class: 'run-result' }, rows([
|
|
347
|
+
['Organisation', account().trim()],
|
|
348
|
+
['Project', project().trim()],
|
|
349
|
+
['Applies to', 'OpenAI requests that go through Segreant from now on'],
|
|
350
|
+
['Past and imported spend', 'unchanged; it cannot be matched to the bill'],
|
|
351
|
+
]), h('div', { class: 'form-actions' }, demo
|
|
352
|
+
? h('p', { class: 'drawer-muted', text: 'Sample data: a project cannot be declared here.' })
|
|
353
|
+
: h('button', { class: 'btn-commit', type: 'button', text: 'Declare this project', onclick: async () => {
|
|
354
|
+
try {
|
|
355
|
+
const r = await api.write.billing({ ...body(), apply: true });
|
|
356
|
+
say(true, isPrecise() ? r.message ?? 'Declared.' : 'Project declared for future OpenAI requests through Segreant. Past and imported spend is unchanged; the provider has not verified this declaration.');
|
|
357
|
+
reload();
|
|
358
|
+
}
|
|
359
|
+
catch (e) {
|
|
360
|
+
say(false, errorText(e));
|
|
361
|
+
}
|
|
362
|
+
} })));
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
function importForm(demo, reload, say) {
|
|
366
|
+
const chosen = signal(null);
|
|
367
|
+
const preview = signal(null);
|
|
368
|
+
const busy = signal(false);
|
|
369
|
+
return h('div', { class: 'inline-form' }, h('div', { class: 'form-row' }, field('import-file', 'Cost export (.json)', h('input', {
|
|
370
|
+
id: 'import-file', class: 'drawer-input', type: 'file', accept: '.json,application/json',
|
|
371
|
+
onchange: async (e) => {
|
|
372
|
+
const file = e.target.files?.[0];
|
|
373
|
+
preview.set(null);
|
|
374
|
+
if (!file) {
|
|
375
|
+
chosen.set(null);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
busy.set(true);
|
|
379
|
+
try {
|
|
380
|
+
const picked = { name: file.name, base64: await fileBase64(file) };
|
|
381
|
+
chosen.set(picked);
|
|
382
|
+
const r = await api.write.billing({ op: 'import', fileName: picked.name, contentBase64: picked.base64 });
|
|
383
|
+
preview.set(r.preview ?? null);
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
say(false, errorText(err));
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
busy.set(false);
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
}))), h('p', { class: 'form-hint', text: () => (busy() ? 'Reading the file…' : 'The file is checked here first. Nothing is saved until you import it.') }), () => {
|
|
393
|
+
const p = preview();
|
|
394
|
+
const c = chosen();
|
|
395
|
+
if (!p || !c)
|
|
396
|
+
return null;
|
|
397
|
+
const charges = Object.entries(p.byChargeType).map(([k, n]) => `${count(n)} ${k.replace(/_/g, ' ')}`).join(', ');
|
|
398
|
+
return h('div', { class: 'run-result' }, rows([
|
|
399
|
+
['File', p.fileName],
|
|
400
|
+
['Period', dayRange(p.source.periodStartMs, p.source.periodEndMs)],
|
|
401
|
+
['Lines', `${count(p.recordsSeen)}${charges ? ` (${charges})` : ''}`],
|
|
402
|
+
['Provider total', usdFromMicros(p.providerReportedUsdMicros)],
|
|
403
|
+
['Complete?', p.source.coverage === 'complete' ? 'the export says it is complete (not verified)' : `the export says: ${p.source.coverage}`],
|
|
404
|
+
]), h('div', { class: 'form-actions' }, demo
|
|
405
|
+
? h('p', { class: 'drawer-muted', text: 'Sample data: an export cannot be imported here.' })
|
|
406
|
+
: h('button', { class: 'btn-commit', type: 'button', text: () => `Import ${count(p.recordsSeen)} line${p.recordsSeen === 1 ? '' : 's'}`, onclick: async () => {
|
|
407
|
+
try {
|
|
408
|
+
const r = await api.write.billing({ op: 'import', fileName: c.name, contentBase64: c.base64, apply: true });
|
|
409
|
+
const inserted = typeof r.run?.recordsInserted === 'number' ? r.run.recordsInserted : p.recordsSeen;
|
|
410
|
+
say(true, r.duplicateFile
|
|
411
|
+
? 'This file was already imported. Nothing new was written.'
|
|
412
|
+
: `Imported ${count(inserted)} line${inserted === 1 ? '' : 's'}. They are kept as the provider's figures, apart from our own measurement. Next: use this export for the comparison.`);
|
|
413
|
+
reload();
|
|
414
|
+
}
|
|
415
|
+
catch (e) {
|
|
416
|
+
say(false, errorText(e));
|
|
417
|
+
}
|
|
418
|
+
} })));
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
function importList(d, reload, say, scopeDone) {
|
|
422
|
+
const imports = d.imports ?? [];
|
|
423
|
+
if (imports.length === 0)
|
|
424
|
+
return null;
|
|
425
|
+
return h('div', { class: 'facts' }, ...imports.slice(0, 6).map((imp) => adoptRow(imp, d.demo, reload, say, scopeDone)));
|
|
426
|
+
}
|
|
427
|
+
function adoptRow(imp, demo, reload, say, scopeDone) {
|
|
428
|
+
const plan = signal(null);
|
|
429
|
+
return h('div', { class: 'fact fact-stack' }, h('div', { class: 'fact-line' }, h('span', { class: 'fact-key' }, h('span', { text: imp.fileName }), h('span', { class: 'fact-sub', text: `${dayRange(imp.periodStartMs, imp.periodEndMs)} · ${count(imp.recordsSeen)} lines · imported ${relative(imp.importedAtMs)}` })), h('span', { class: 'fact-val' }, imp.inUse
|
|
430
|
+
? h('span', { class: 'in-use', text: `in use · adopted ${relative(imp.inUse.adoptedAtMs)}` })
|
|
431
|
+
: scopeDone
|
|
432
|
+
? h('button', { class: 'link-btn', type: 'button', text: 'Use for the comparison', 'aria-label': `Use ${imp.fileName} for the comparison`, onclick: async () => {
|
|
433
|
+
try {
|
|
434
|
+
const r = await api.write.billing({ op: 'adopt', importId: imp.importId });
|
|
435
|
+
plan.set(r.plan ?? null);
|
|
436
|
+
}
|
|
437
|
+
catch (e) {
|
|
438
|
+
say(false, errorText(e));
|
|
439
|
+
}
|
|
440
|
+
} })
|
|
441
|
+
: h('span', { text: 'declare the project first' }))), () => {
|
|
442
|
+
const p = plan();
|
|
443
|
+
if (!p)
|
|
444
|
+
return null;
|
|
445
|
+
if (!p.adoptable)
|
|
446
|
+
return h('p', { class: 'drawer-error', text: p.detail ?? p.refusal ?? 'This export cannot be used.' });
|
|
447
|
+
const left = p.excluded && p.excluded.otherOrNoProjectRecordCount > 0
|
|
448
|
+
? `${count(p.excluded.otherOrNoProjectRecordCount)} line(s), ${usdFromMicros(p.excluded.otherOrNoProjectMicros)}, belong to no project or another one and are left out`
|
|
449
|
+
: 'none';
|
|
450
|
+
return h('div', { class: 'run-result' }, rows([
|
|
451
|
+
['Matched to your project', `${count(p.matchedRecordCount)} line(s), ${usdFromMicros(p.matchedMicros)}`],
|
|
452
|
+
['Left out', left],
|
|
453
|
+
['Days', dayRange(p.periodStartMs, p.periodEndMs)],
|
|
454
|
+
['Evidence class', 'supplied by you, not read from the provider; every comparison built on it says so'],
|
|
455
|
+
]), h('div', { class: 'form-actions' }, demo
|
|
456
|
+
? h('p', { class: 'drawer-muted', text: 'Sample data: nothing can be recorded here.' })
|
|
457
|
+
: h('button', { class: 'btn-commit', type: 'button', text: 'Use this export', onclick: async () => {
|
|
458
|
+
try {
|
|
459
|
+
await api.write.billing({ op: 'adopt', importId: imp.importId, apply: true });
|
|
460
|
+
say(true, 'Recorded as the provider side for these days.');
|
|
461
|
+
reload();
|
|
462
|
+
}
|
|
463
|
+
catch (e) {
|
|
464
|
+
say(false, errorText(e));
|
|
465
|
+
}
|
|
466
|
+
} })));
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Every recorded comparison the server sends, newest first, each with its own
|
|
471
|
+
* days. A run is evidence for its own period only, so the list never sums
|
|
472
|
+
* them; opening one shows what was recorded, not a recomputation.
|
|
473
|
+
*/
|
|
474
|
+
function recordedComparisons(d) {
|
|
475
|
+
const runs = d.reconciliation?.runs ?? [];
|
|
476
|
+
if (runs.length === 0)
|
|
477
|
+
return null;
|
|
478
|
+
return h('section', { class: 'section recorded-runs' }, h('h3', { class: 'section-title', text: () => (isPrecise() ? 'Recorded reconciliations' : 'Recorded comparisons') }), h('div', { class: 'facts' }, ...runs.map((run) => {
|
|
479
|
+
const r = run.result;
|
|
480
|
+
const days = dayRange(r.periodStartMs, r.periodEndMs);
|
|
481
|
+
return h('details', { class: 'fact fact-stack recorded-run' }, h('summary', null, h('span', { class: 'fact-line' }, h('span', { class: 'fact-key' }, h('span', { text: days }), h('span', { class: 'fact-sub', text: `${r.providerProjectRef ? `${r.providerProjectRef} · ` : ''}recorded ${relative(run.computedAtMs)}` })), h('span', { class: 'fact-val', text: r.unexplainedVarianceMicros === undefined ? '—' : `${usdFromMicros(r.unexplainedVarianceMicros)} not explained` }))), rows([
|
|
482
|
+
['Billed by the provider', r.providerReportedMicros === undefined ? '—' : usdFromMicros(r.providerReportedMicros)],
|
|
483
|
+
['Measured by Segreant', r.localCapturedMicros === undefined ? '—' : usdFromMicros(r.localCapturedMicros)],
|
|
484
|
+
['Not explained', r.unexplainedVarianceMicros === undefined ? '—' : usdFromMicros(r.unexplainedVarianceMicros)],
|
|
485
|
+
['Days compared', r.coverage ? `${count(r.coverage.daysWithBoth)} with both · ${count(r.coverage.providerOnlyDays)} bill only · ${count(r.coverage.localOnlyDays)} ours only` : '—'],
|
|
486
|
+
['Days that differ', r.coverage && r.materialityUsd !== undefined ? `${count(r.coverage.materialDays)} by more than ${usd(r.materialityUsd)}` : '—'],
|
|
487
|
+
['Provider side', basisWords(r.providerSourceKind)],
|
|
488
|
+
]), h('p', { class: residualBoundIsError(r.offPathBound) ? 'drawer-error' : 'basis', text: () => residualBoundWords(r.offPathBound, isPrecise()) }), () => { const q = quantizationWords(r.localQuantization, isPrecise()); return q ? h('p', { class: 'basis', text: q }) : null; }, r.snapshotStability === 'changed_across_observations'
|
|
489
|
+
? h('p', { class: 'drawer-error', text: 'The provider reported different figures for the same days at different times. This comparison is contradicted.' })
|
|
490
|
+
: null);
|
|
491
|
+
})));
|
|
492
|
+
}
|
|
493
|
+
function reconcilePanel(d, reload, say) {
|
|
494
|
+
const result = signal(null);
|
|
495
|
+
const busy = signal(false);
|
|
496
|
+
// Blank means the server's default. The value previewed is the value
|
|
497
|
+
// recorded: changing it clears the preview, so a run is never recorded under
|
|
498
|
+
// a threshold nobody looked at.
|
|
499
|
+
const materiality = signal('');
|
|
500
|
+
const materialityUsd = () => {
|
|
501
|
+
const raw = materiality().trim();
|
|
502
|
+
return raw === '' ? undefined : Number(raw);
|
|
503
|
+
};
|
|
504
|
+
const remaining = (d.readiness?.missing ?? []).filter((m) => !m.step.startsWith('observe a closed period — route A'));
|
|
505
|
+
const run = async (apply) => {
|
|
506
|
+
busy.set(true);
|
|
507
|
+
try {
|
|
508
|
+
const m = materialityUsd();
|
|
509
|
+
const r = await api.write.billing({ op: 'reconcile', ...(m === undefined ? {} : { materialityUsd: m }), ...(apply ? { apply: true } : {}) });
|
|
510
|
+
if (apply) {
|
|
511
|
+
result.set(null);
|
|
512
|
+
say(true, 'Recorded. The comparison is saved, and the Billed claim above now rests on it.');
|
|
513
|
+
reload();
|
|
514
|
+
}
|
|
515
|
+
else
|
|
516
|
+
result.set(r.result ?? null);
|
|
517
|
+
}
|
|
518
|
+
catch (e) {
|
|
519
|
+
say(false, errorText(e));
|
|
520
|
+
}
|
|
521
|
+
finally {
|
|
522
|
+
busy.set(false);
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
if (d.readiness && !d.readiness.ready) {
|
|
526
|
+
return h('div', { class: 'run-panel' }, h('p', { class: 'drawer-muted', text: 'Not ready to compare. Still to do:' }), h('ul', { class: 'drawer-notes' }, ...remaining.map((m) => h('li', { text: m.step.replace(/^observe a closed period — route B, /, '') }))));
|
|
527
|
+
}
|
|
528
|
+
return h('div', { class: 'run-panel' }, h('div', { class: 'form-row run-row' }, field('reconcile-materiality', 'Ignore daily differences up to ($)', h('input', {
|
|
529
|
+
id: 'reconcile-materiality', class: 'drawer-input', type: 'number', min: '0', step: '0.01', inputmode: 'decimal',
|
|
530
|
+
placeholder: '0.50', 'aria-describedby': 'reconcile-materiality-hint',
|
|
531
|
+
oninput: (e) => { materiality.set(e.target.value); result.set(null); },
|
|
532
|
+
})), h('button', { class: 'btn-ghost', type: 'button', disabled: () => busy(), text: () => (busy() ? 'Working…' : 'Preview the comparison'), onclick: () => void run(false) })), h('p', { id: 'reconcile-materiality-hint', class: 'form-hint', text: 'A day counts as different only when the bill and our measurement differ by more than this. Blank uses $0.50.' }), () => {
|
|
533
|
+
const r = result();
|
|
534
|
+
if (!r)
|
|
535
|
+
return null;
|
|
536
|
+
const residual = r.unexplainedVarianceMicros;
|
|
537
|
+
return h('div', { class: 'run-result' }, rows([
|
|
538
|
+
['Period', dayRange(r.periodStartMs, r.periodEndMs)],
|
|
539
|
+
['Billed by the provider', usdFromMicros(r.providerReportedMicros)],
|
|
540
|
+
['Measured by Segreant', usdFromMicros(r.localCapturedMicros)],
|
|
541
|
+
['Not explained', `${usdFromMicros(residual)}${r.providerReportedMicros > 0 ? ` (${pct(residual / r.providerReportedMicros)} of the bill)` : ''}`],
|
|
542
|
+
['Days compared', `${count(r.coverage.daysWithBoth)} with both · ${count(r.coverage.providerOnlyDays)} bill only · ${count(r.coverage.localOnlyDays)} ours only`],
|
|
543
|
+
['Days that differ', `${count(r.coverage.materialDays)} by more than ${usd(r.materialityUsd)}`],
|
|
544
|
+
['Provider side', basisWords(r.providerSourceKind)],
|
|
545
|
+
]), h('p', { class: residualBoundIsError(r.offPathBound) ? 'drawer-error' : 'basis', text: () => residualBoundWords(r.offPathBound, isPrecise()) }), r.snapshotStability === 'changed_across_observations'
|
|
546
|
+
? h('p', { class: 'drawer-error', text: 'The provider reported different figures for the same days at different times. This comparison would be contradicted.' })
|
|
547
|
+
: null, r.conditions.length > 0
|
|
548
|
+
? h('ul', { class: 'drawer-notes' }, ...r.conditions.map((c) => h('li', { text: c.replace(/_/g, ' ') })))
|
|
549
|
+
: null, h('div', { class: 'form-actions' }, d.demo
|
|
550
|
+
? h('p', { class: 'drawer-muted', text: 'Sample data: a comparison cannot be recorded here.' })
|
|
551
|
+
: h('button', { class: 'btn-commit', type: 'button', disabled: () => busy(), text: 'Record this comparison', onclick: () => void run(true) })));
|
|
303
552
|
});
|
|
304
553
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Privacy — the statement, read from this machine like every other page, so it
|
|
3
|
+
* is there with no network and says the same as docs/PRIVACY.md.
|
|
4
|
+
*/
|
|
5
|
+
import { h } from '../core/dom.js';
|
|
6
|
+
import { PRIVACY_TITLE, PRIVACY_LEAD, PRIVACY_SECTIONS } from '../core/privacyText.js';
|
|
7
|
+
/** Text with `backtick` spans as code. Built as nodes: no string is parsed as HTML. */
|
|
8
|
+
function prose(text) {
|
|
9
|
+
return text.split('`').map((part, i) => (i % 2 === 1 ? h('code', { class: 'inline-code', text: part }) : document.createTextNode(part)));
|
|
10
|
+
}
|
|
11
|
+
export function privacyView() {
|
|
12
|
+
return h('div', { class: 'privacy' }, h('div', { class: 'view-head' }, h('h1', { class: 'view-title', text: PRIVACY_TITLE }), h('p', { class: 'privacy-lead' }, ...prose(PRIVACY_LEAD))), ...PRIVACY_SECTIONS.map((section) => h('section', { class: 'section privacy-section' }, h('h2', { class: 'section-title', text: section.heading }), ...section.paragraphs.map((p) => h('p', null, ...prose(p))), section.items ? h('ul', null, ...section.items.map((item) => h('li', null, ...prose(item)))) : null)));
|
|
13
|
+
}
|
|
@@ -10,17 +10,20 @@
|
|
|
10
10
|
import { h } from '../core/dom.js';
|
|
11
11
|
import { signal, scopedEffect, onCleanup } from '../core/signal.js';
|
|
12
12
|
import { api, RANGES } from '../core/api.js';
|
|
13
|
-
import {
|
|
13
|
+
import { period, setPeriod } from '../core/period.js';
|
|
14
|
+
import { usd, count, isPrecise, register, share } from '../core/fmt.js';
|
|
14
15
|
import { capability } from '../core/registry.js';
|
|
15
16
|
import { openAction } from '../components/drawer.js';
|
|
16
17
|
import { actionSpec, hasRunner } from '../core/actions.js';
|
|
18
|
+
import { workbench } from './workbench.js';
|
|
17
19
|
const AXES = [
|
|
18
20
|
{ id: 'project', label: 'Project', plain: 'which piece of work', precise: 'attribution label — basis varies by row' },
|
|
19
21
|
{ id: 'model', label: 'Model', plain: 'which AI model', precise: 'recorded model id' },
|
|
20
22
|
{ id: 'source', label: 'Source', plain: 'which tool', precise: 'acquisition route' },
|
|
21
23
|
];
|
|
22
24
|
export function spendView() {
|
|
23
|
-
|
|
25
|
+
// The shared period (core/period.ts): choosing Today here moves the Metered claim above with it.
|
|
26
|
+
const range = period;
|
|
24
27
|
const axis = signal('project');
|
|
25
28
|
const data = signal(null);
|
|
26
29
|
const error = signal(null);
|
|
@@ -50,10 +53,10 @@ export function spendView() {
|
|
|
50
53
|
controller.abort();
|
|
51
54
|
});
|
|
52
55
|
});
|
|
53
|
-
return h('div', null, h('div', { class: 'view-head' }, h('div', { class: 'view-head-row' }, h('h1', { class: 'view-title', text: '
|
|
56
|
+
return h('div', null, h('div', { class: 'view-head' }, h('div', { class: 'view-head-row' }, h('h1', { class: 'view-title', text: 'Spend' }), h('div', { class: 'rangebar', role: 'group', 'aria-label': 'Time range' }, ...RANGES.map((r) => h('button', {
|
|
54
57
|
class: 'chip',
|
|
55
58
|
'aria-pressed': () => (range() === r.id ? 'true' : 'false'),
|
|
56
|
-
onclick: () =>
|
|
59
|
+
onclick: () => setPeriod(r.id),
|
|
57
60
|
text: r.label,
|
|
58
61
|
title: r.plain,
|
|
59
62
|
})))), h('p', { class: 'view-plain', text: () => isPrecise()
|
|
@@ -65,8 +68,6 @@ export function spendView() {
|
|
|
65
68
|
const d = data();
|
|
66
69
|
if (!d)
|
|
67
70
|
return h('div', { class: 'card' }, h('p', { class: 'drawer-muted', role: 'status', 'aria-live': 'polite', 'aria-busy': 'true', text: 'Reading the ledger…' }));
|
|
68
|
-
const rows = (axis() === 'project' ? d.byProject : axis() === 'model' ? d.byModel : d.bySource) ?? [];
|
|
69
|
-
const meta = AXES.find((a) => a.id === axis());
|
|
70
71
|
// WHAT THE WINDOW LOST, BESIDE THE TOTAL IT LOST IT FROM (D-175). The
|
|
71
72
|
// server's claim support already reports coverage `partial` and carries
|
|
72
73
|
// the reason in its note, but that note lives in the Claim Inspector
|
|
@@ -78,9 +79,19 @@ export function spendView() {
|
|
|
78
79
|
const lost = d.retention?.truncated === true ? d.retention : null;
|
|
79
80
|
return h('div', null, d.demo ? demoBanner() : null, lost
|
|
80
81
|
? h('p', { class: 'drawer-error', role: 'status', text: () => (isPrecise()
|
|
81
|
-
? `retention deleted ${lost.rowsRemoved} request row(s) from before ${
|
|
82
|
+
? `retention deleted ${lost.rowsRemoved} request row(s) from before ${typeof lost.prunedBeforeMs === 'number' && Number.isFinite(lost.prunedBeforeMs) && lost.prunedBeforeMs > 0 && lost.prunedBeforeMs <= 8.64e15 ? new Date(lost.prunedBeforeMs).toISOString() : 'unknown'}; this window covers what survived, not everything metered in it`
|
|
82
83
|
: 'Some requests in this range were deleted by your retention setting, so these totals cover what is left rather than everything we measured.') })
|
|
83
|
-
: null
|
|
84
|
+
: null);
|
|
85
|
+
},
|
|
86
|
+
// Its own read, outside the overview region, so an overview refresh does
|
|
87
|
+
// not throw away the session the reader has open.
|
|
88
|
+
workbench(), () => {
|
|
89
|
+
const d = data();
|
|
90
|
+
if (!d || error())
|
|
91
|
+
return null;
|
|
92
|
+
const rows = (axis() === 'project' ? d.byProject : axis() === 'model' ? d.byModel : d.bySource) ?? [];
|
|
93
|
+
const meta = AXES.find((a) => a.id === axis());
|
|
94
|
+
return h('section', { class: 'section' }, h('h2', { class: 'section-title', text: 'The whole range, broken down' }), h('div', { class: 'axisbar', role: 'group', 'aria-label': 'Break down by' }, h('span', { class: 'axisbar-label', text: 'Break down by' }), ...AXES.map((a) => h('button', {
|
|
84
95
|
class: 'axis',
|
|
85
96
|
'aria-pressed': () => (axis() === a.id ? 'true' : 'false'),
|
|
86
97
|
onclick: () => axis.set(a.id),
|
|
@@ -89,25 +100,26 @@ export function spendView() {
|
|
|
89
100
|
});
|
|
90
101
|
}
|
|
91
102
|
function demoBanner() {
|
|
92
|
-
return h('div', { class: 'banner banner-demo' }, h('span', { class: 'pill pill-demo', text: 'demo' }), h('p', null, h('strong', { text: 'Sample data. ' }), 'None of this was measured from your machine
|
|
103
|
+
return h('div', { class: 'banner banner-demo' }, h('span', { class: 'pill pill-demo', text: 'demo' }), h('p', null, h('strong', { text: 'Sample data. ' }), 'None of this was measured from your machine. To see your own usage, run the dashboard without --demo and import from Data.'));
|
|
93
104
|
}
|
|
94
105
|
/**
|
|
95
106
|
* The breakdown, drawn as a ledger rather than a table of numbers: each row
|
|
96
107
|
* carries its own proportion as a rule beneath it, so the shape of the spend is
|
|
97
|
-
* legible before any figure is read. The bar is
|
|
98
|
-
*
|
|
108
|
+
* legible before any figure is read. The bar is the row's share of the TOTAL,
|
|
109
|
+
* the same number as the Share column beside it. It used to be scaled to the
|
|
110
|
+
* largest row, so a 57% row drew full width beside "57%" and readers took the
|
|
111
|
+
* bar and the column to disagree (critique 2026-10-12).
|
|
99
112
|
*/
|
|
100
113
|
function ledger(rows, totalUsd) {
|
|
101
114
|
if (rows.length === 0) {
|
|
102
115
|
return h('div', { class: 'card' }, h('p', { class: 'drawer-muted', role: 'status', text: 'Nothing recorded in this window.' }));
|
|
103
116
|
}
|
|
104
117
|
const sorted = [...rows].sort((a, b) => b.costUsd - a.costUsd);
|
|
105
|
-
const max = sorted[0]?.costUsd ?? 0;
|
|
106
118
|
const total = totalUsd > 0 ? totalUsd : sorted.reduce((s, r) => s + r.costUsd, 0);
|
|
107
|
-
return h('div', { class: 'ledger', role: 'table', 'aria-label': 'Metered spend breakdown' }, h('div', { class: 'ledger-head', role: 'row' }, h('span', { role: 'columnheader', text: 'Where it went' }), h('span', { class: 'num cell-calls', role: 'columnheader', text: () => (isPrecise() ? 'Requests' : 'Calls') }), h('span', { class: 'num cell-cost', role: 'columnheader', text: 'Cost' }), h('span', { class: 'num cell-share', role: 'columnheader', text: 'Share' })), ...sorted.map((row) => h('div', { class: 'ledger-row', role: 'row' }, h('span', { class: 'ledger-key', role: 'cell', text: row.label }), h('span', { class: 'num cell-calls', role: 'cell', text: count(row.requests) }), h('span', { class: 'num cell-cost ledger-cost', role: 'cell', text: usd(row.costUsd) }), h('span', { class: 'num cell-share', role: 'cell', text: total > 0 ?
|
|
119
|
+
return h('div', { class: 'ledger', role: 'table', 'aria-label': 'Metered spend breakdown' }, h('div', { class: 'ledger-head', role: 'row' }, h('span', { role: 'columnheader', text: 'Where it went' }), h('span', { class: 'num cell-calls', role: 'columnheader', text: () => (isPrecise() ? 'Requests' : 'Calls') }), h('span', { class: 'num cell-cost', role: 'columnheader', text: 'Cost' }), h('span', { class: 'num cell-share', role: 'columnheader', text: 'Share' })), ...sorted.map((row) => h('div', { class: 'ledger-row', role: 'row' }, h('span', { class: 'ledger-key', role: 'cell', text: row.label }), h('span', { class: 'num cell-calls', role: 'cell', text: count(row.requests) }), h('span', { class: 'num cell-cost ledger-cost', role: 'cell', text: usd(row.costUsd) }), h('span', { class: 'num cell-share', role: 'cell', text: total > 0 ? share(row.costUsd / total) : '—' }), h('span', {
|
|
108
120
|
class: 'ledger-bar',
|
|
109
121
|
'aria-hidden': 'true',
|
|
110
|
-
style: `--fill: ${
|
|
122
|
+
style: `--fill: ${total > 0 ? Math.max(0.5, (row.costUsd / total) * 100) : 0}%`,
|
|
111
123
|
}))));
|
|
112
124
|
}
|
|
113
125
|
/**
|
|
@@ -124,5 +136,8 @@ export function actionCard(id) {
|
|
|
124
136
|
onclick: () => openAction(actionSpec(cap)),
|
|
125
137
|
}, h('span', { class: 'action-label', text: cap.label }), h('span', { class: 'action-plain', text: cap.plain }), h('span', { class: 'action-meta' }, cap.consequence !== 'read'
|
|
126
138
|
? h('span', { class: `tag tag-${cap.consequence}`, text: cap.consequence })
|
|
127
|
-
: null,
|
|
139
|
+
: null,
|
|
140
|
+
// "CLI only" read as a dead end to a reader who never opens a terminal.
|
|
141
|
+
// The drawer still shows what this does and the exact command.
|
|
142
|
+
!hasRunner(cap) ? h('span', { class: 'tag tag-planned', text: () => (isPrecise() ? 'CLI only' : 'runs in the terminal for now') }) : null, () => (register() === 'precise' ? h('span', { class: 'tag', text: cap.command.replace(/^segreant /, '') }) : null)));
|
|
128
143
|
}
|