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
|
@@ -1,24 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Allocate — whose budget the money lands on, and what that number is not.
|
|
3
3
|
*
|
|
4
4
|
* Allocated cost is the third claim in the spine, and the one most often
|
|
5
5
|
* mistaken for the first. It is a DERIVED split of local estimates: a rule said
|
|
6
6
|
* this request belongs to that cost centre. It is not a provider bill, it is not
|
|
7
|
-
* what anyone owes, and it is
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* what anyone owes, and it is excluded from budget enforcement, RoI and model
|
|
8
|
+
* recommendations, because a showback split is not evidence about value or about
|
|
9
|
+
* money actually charged.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* This page used to describe allocation and send you to the terminal to do it.
|
|
12
|
+
* It is now the job itself, in the order the engine needs it: cost centres, then
|
|
13
|
+
* rules, then a run. A run is previewed first and recorded only on a second,
|
|
14
|
+
* separate action, the GUI's form of the CLI's `--apply`. The basis and the
|
|
15
|
+
* exclusions stay at the top, in one strip, so the figure is never met without
|
|
16
|
+
* them.
|
|
16
17
|
*/
|
|
17
18
|
import { h } from '../core/dom.js';
|
|
19
|
+
import { slug } from '../core/slug.js';
|
|
20
|
+
import { localMidnightIso, dayAfterIso, monthRange, REASON_WORDS } from '../core/periods.js';
|
|
18
21
|
import { signal, scopedEffect } from '../core/signal.js';
|
|
19
22
|
import { api } from '../core/api.js';
|
|
20
|
-
import { isPrecise, relative, count } from '../core/fmt.js';
|
|
21
|
-
import {
|
|
23
|
+
import { isPrecise, relative, count, usdFromMicros, date, share as shareOf } from '../core/fmt.js';
|
|
24
|
+
import { pageNotice, step, field, errorText, holdFocus } from '../components/workflow.js';
|
|
25
|
+
import { ruleFields, describeRule } from '../components/ruleFields.js';
|
|
22
26
|
/** The payload's `excludedFrom` ids, said in words an operator can act on. */
|
|
23
27
|
const EXCLUSION_WORDS = {
|
|
24
28
|
request_metered_spend: 'the metered spend figure',
|
|
@@ -29,62 +33,246 @@ const EXCLUSION_WORDS = {
|
|
|
29
33
|
export function allocationView() {
|
|
30
34
|
const data = signal(null);
|
|
31
35
|
const error = signal(null);
|
|
36
|
+
const projects = signal([]);
|
|
37
|
+
const models = signal([]);
|
|
38
|
+
const reloads = signal(0);
|
|
39
|
+
const reload = () => reloads.set(reloads() + 1);
|
|
40
|
+
const { node: noticeNode, say } = pageNotice();
|
|
41
|
+
const reloadAll = () => {
|
|
42
|
+
reload();
|
|
43
|
+
// The spine above this page reads /api/allocation once. Without this it kept
|
|
44
|
+
// saying "no cost centres and no rules yet" under a page that had just saved both.
|
|
45
|
+
window.dispatchEvent(new CustomEvent('segreant:ledger-changed', { detail: { claim: 'allocation' } }));
|
|
46
|
+
};
|
|
32
47
|
scopedEffect(() => {
|
|
48
|
+
reloads();
|
|
33
49
|
void api.allocation()
|
|
34
|
-
.then((payload) => data.set(payload))
|
|
35
|
-
.catch((e) => error.set(
|
|
50
|
+
.then((payload) => { const restore = holdFocus(page); error.set(null); data.set(payload); restore(); })
|
|
51
|
+
.catch((e) => error.set(errorText(e)));
|
|
36
52
|
});
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
53
|
+
// Project names to match on. A typo in a match makes a rule that claims
|
|
54
|
+
// nothing, and the run would only say "no rule claimed it" afterwards.
|
|
55
|
+
void api.overview('all')
|
|
56
|
+
.then((o) => {
|
|
57
|
+
projects.set(o.byProject.map((p) => p.label).filter((l) => l && l !== '(unattributed)'));
|
|
58
|
+
models.set([...new Set(o.byModel.map((m) => m.label).filter(Boolean))]);
|
|
59
|
+
})
|
|
60
|
+
.catch(() => { projects.set([]); models.set([]); });
|
|
61
|
+
const page = h('div', null, h('div', { class: 'view-head' }, h('h1', { class: 'view-title', text: 'Allocate' }), h('p', { class: 'view-plain', text: () => isPrecise()
|
|
62
|
+
? 'A derived split of locally estimated cost across cost centres, by recorded rule. Showback only: no settlement, no chargeback.'
|
|
63
|
+
: 'Decide which team or budget each dollar of AI spend is attributed to. This splits our own estimate. It is not a bill, and nobody is charged.' })), noticeNode, () => {
|
|
40
64
|
const err = error();
|
|
41
|
-
if (err)
|
|
42
|
-
return h('div', { class: 'card' }, h('p', { class: 'drawer-error', role: 'alert', 'aria-live': 'assertive', text: err }));
|
|
43
65
|
const d = data();
|
|
66
|
+
if (!d && err)
|
|
67
|
+
return h('div', { class: 'card' }, h('p', { class: 'drawer-error', role: 'alert', text: err }));
|
|
44
68
|
if (!d)
|
|
45
|
-
return h('div', { class: 'card' }, h('p', { class: 'drawer-muted', role: 'status', 'aria-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
class: `pill ${d.reconciliation?.everRun ? 'pill-ok' : 'pill-unverified'}`,
|
|
62
|
-
text: d.reconciliation?.everRun ? 'checked' : 'unchecked',
|
|
63
|
-
})), d.reconciliation?.everRun
|
|
64
|
-
? h('p', { text: () => (isPrecise()
|
|
65
|
-
? `The underlying ledger has been reconciled against provider billing; last run ${relative(d.reconciliation.latestComputedAtMs)}.`
|
|
66
|
-
: `We have checked the underlying spend against a real provider bill — last time ${relative(d.reconciliation.latestComputedAtMs)}.`) })
|
|
67
|
-
: h('p', { text: () => (isPrecise()
|
|
68
|
-
? 'No reconciliation run recorded. Every figure below splits a total that has never been compared against provider billing, so the residual beneath it is unbounded.'
|
|
69
|
-
: 'Nobody has checked the underlying spend against a real provider bill yet. The split below is exact arithmetic on a number we estimated ourselves.') })), configured
|
|
70
|
-
? h('div', null, section(() => (isPrecise() ? 'Cost centres' : 'Who the money is attributed to'), h('div', { class: 'facts' }, ...centres.map((c) => h('div', { class: 'fact' }, h('span', { class: 'fact-key', text: c.label ?? c.name ?? c.id }), h('span', { class: 'fact-val', text: c.id }))))), section(() => (isPrecise() ? 'Allocation rules' : 'The rules that decide the split'), h('div', { class: 'facts' },
|
|
71
|
-
// A revoked rule is shown, not hidden: a past run was computed
|
|
72
|
-
// under it, and a reader comparing runs needs to see why the
|
|
73
|
-
// split changed rather than find a rule silently absent.
|
|
74
|
-
...rules.map((r) => h('div', { class: `fact${r.revokedAtMs ? ' fact-off' : ''}` }, h('span', { class: 'fact-key', text: r.id }), h('span', { class: 'fact-val', text: r.revokedAtMs
|
|
75
|
-
? `revoked · was ${r.method} v${r.version}`
|
|
76
|
-
: `${r.method} · v${r.version}` }))))), section(() => (isPrecise() ? 'Recorded runs' : 'Times this has been worked out'), runs.length > 0
|
|
77
|
-
? h('p', { class: 'basis', text: () => (isPrecise()
|
|
78
|
-
? `${count(runs.length)} recorded run${runs.length === 1 ? '' : 's'}; each is a snapshot under the rule version in force at the time.`
|
|
79
|
-
: `${count(runs.length)} saved result${runs.length === 1 ? '' : 's'}. Each one records the rules as they were on the day.`) })
|
|
80
|
-
: h('p', { class: 'drawer-muted', text: () => (isPrecise()
|
|
81
|
-
? 'Rules and centres exist, but no allocation run has been recorded.'
|
|
82
|
-
: 'You have set the rules up, but never run the split.') })))
|
|
83
|
-
: h('div', { class: 'notyet', style: 'margin-top: var(--s5)' }, h('h2', { text: () => (isPrecise() ? 'No allocation model defined' : 'Nothing is being split yet') }), h('p', { text: () => (isPrecise()
|
|
84
|
-
? `${centres.length} cost centre${centres.length === 1 ? '' : 's'} and ${rules.length} rule${rules.length === 1 ? '' : 's'} recorded. Both are required before a run can produce a split.`
|
|
85
|
-
: 'To split spend across teams or projects, Segreant needs at least one cost centre to attribute to, and one rule that decides what goes where.') })), section(() => (isPrecise() ? 'Actions on this layer' : 'Things you can do with this'), h('div', { class: 'actions' }, actionCard('alloc-centres'), actionCard('alloc-rules'), actionCard('alloc-run'), actionCard('project'))));
|
|
69
|
+
return h('div', { class: 'card' }, h('p', { class: 'drawer-muted', role: 'status', 'aria-busy': 'true', text: 'Loading…' }));
|
|
70
|
+
const open = d.costCentres.filter((c) => c.archivedAtMs == null);
|
|
71
|
+
// Any rule not revoked can claim spend in some period: a superseded version or
|
|
72
|
+
// one with an end date still governs the past it covered, which is exactly
|
|
73
|
+
// what a preview of an earlier period asks about.
|
|
74
|
+
const live = d.rules.filter((r) => r.revokedAtMs == null);
|
|
75
|
+
// People type names; the engine keys on ids. Show what they typed.
|
|
76
|
+
const names = new Map(d.costCentres.map((c) => [c.costCentreId, c.name]));
|
|
77
|
+
const nameOf = (id) => names.get(id) ?? id;
|
|
78
|
+
return h('div', null, basisStrip(d), d.demo
|
|
79
|
+
? h('p', { class: 'section-note', role: 'note', text: 'This is sample data, so centres and rules cannot be saved here. You can still preview a run. Start Segreant without --demo to set up your own.' })
|
|
80
|
+
: null, step(1, () => (isPrecise() ? 'Cost centres' : 'Who carries the cost'), () => (isPrecise() ? 'Accounting positions that receive allocated spend.' : 'The teams, clients or budgets spend can be attributed to.'), centreList(d.costCentres, d.demo, reloadAll, say), d.demo ? null : centreForm(reloadAll, say)), step(2, () => (isPrecise() ? 'Rules' : 'What goes where'), () => (isPrecise() ? 'Priority-ordered; lower wins. Edits retain the old version and every recorded run. New previews follow effective dates.' : 'Each rule picks some spend and says which centre it belongs to. Changing a rule keeps old versions and recorded splits. A new preview follows the rule dates, so it can change an earlier period.'), ruleList(d.rules, d.demo, reloadAll, say, nameOf), d.demo ? null : open.length > 0
|
|
81
|
+
? ruleForm(open, projects, models, reloadAll, say)
|
|
82
|
+
: h('p', { class: 'drawer-muted', text: 'Add a cost centre first. A rule needs somewhere to send spend.' })), step(3, () => (isPrecise() ? 'Run' : 'Work out the split'), () => (isPrecise() ? 'Preview computes; Record persists an immutable run.' : 'Preview the split for a month. Nothing is saved until you record it.'), live.length > 0 || d.demo
|
|
83
|
+
? runPanel(d.demo, reloadAll, say, nameOf)
|
|
84
|
+
: h('p', { class: 'drawer-muted', text: 'Add a rule first. Without one, every dollar would show as unallocated.' })), history(d.runs));
|
|
86
85
|
});
|
|
86
|
+
return page;
|
|
87
87
|
}
|
|
88
|
-
function
|
|
89
|
-
|
|
88
|
+
function basisStrip(d) {
|
|
89
|
+
const checked = d.reconciliation?.everRun;
|
|
90
|
+
return h('div', { class: 'card card-basis basis-strip' }, h('div', { class: 'card-head' }, h('span', { class: 'card-title', text: () => (isPrecise() ? 'Basis' : 'What this number is') }), h('span', { class: 'pill pill-estimate', text: 'showback only' }), h('span', { class: checked ? 'pill pill-unverified' : 'pill pill-empty', text: checked ? 'provider comparison on record' : 'no provider comparison on record' })), h('p', { text: () => (checked
|
|
91
|
+
? `A split of our own cost estimate. A provider comparison is on record (${relative(d.reconciliation.latestComputedAtMs)}); it covers its own project and dates and does not verify the inputs to this allocation.`
|
|
92
|
+
: 'A split of our own cost estimate. No provider comparison is on record; this split is exact arithmetic on our estimate, not a verified bill.') }), h('p', { class: 'basis', text: () => `Never feeds ${(d.excludedFrom ?? []).map((id) => EXCLUSION_WORDS[id] ?? id.replace(/_/g, ' ')).join(', ')}.` }));
|
|
93
|
+
}
|
|
94
|
+
function centreList(centres, demo, reload, say) {
|
|
95
|
+
if (centres.length === 0)
|
|
96
|
+
return h('p', { class: 'drawer-muted', text: 'No cost centres yet.' });
|
|
97
|
+
return h('div', { class: 'facts' }, ...centres.map((c) => {
|
|
98
|
+
const archived = c.archivedAtMs != null;
|
|
99
|
+
return h('div', { class: `fact${archived ? ' fact-off' : ''}` }, h('span', { class: 'fact-key' }, h('span', { text: c.name }), c.owner ? h('span', { class: 'fact-sub', text: c.owner }) : null), h('span', { class: 'fact-val' }, h('span', { text: archived ? `archived · ${c.costCentreId}` : c.costCentreId }), !archived && !demo
|
|
100
|
+
? h('button', {
|
|
101
|
+
class: 'link-btn',
|
|
102
|
+
'aria-label': `Archive ${c.name}`,
|
|
103
|
+
onclick: async () => {
|
|
104
|
+
try {
|
|
105
|
+
say(true, (await api.write.allocation({ op: 'archive', id: c.costCentreId })).message);
|
|
106
|
+
reload();
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
say(false, errorText(e));
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
text: 'Archive',
|
|
113
|
+
})
|
|
114
|
+
: null));
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
function centreForm(reload, say) {
|
|
118
|
+
const name = signal('');
|
|
119
|
+
const owner = signal('');
|
|
120
|
+
const id = () => slug(name());
|
|
121
|
+
return h('form', {
|
|
122
|
+
class: 'inline-form',
|
|
123
|
+
onsubmit: async (event) => {
|
|
124
|
+
event.preventDefault();
|
|
125
|
+
if (!id())
|
|
126
|
+
return say(false, 'Give the cost centre a name with at least one letter or number.');
|
|
127
|
+
try {
|
|
128
|
+
const r = await api.write.allocation({ op: 'centre', id: id(), name: name().trim(), ...(owner().trim() ? { owner: owner().trim() } : {}) });
|
|
129
|
+
say(true, r.message);
|
|
130
|
+
name.set('');
|
|
131
|
+
owner.set('');
|
|
132
|
+
event.target.reset();
|
|
133
|
+
reload();
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
say(false, errorText(e));
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
}, h('div', { class: 'form-row' }, field('centre-name', 'Name', h('input', { id: 'centre-name', class: 'drawer-input', required: true, maxlength: '120', placeholder: 'for example Engineering', autocomplete: 'off', oninput: (e) => name.set(e.target.value) })), field('centre-owner', 'Owner (optional)', h('input', { id: 'centre-owner', class: 'drawer-input', maxlength: '120', placeholder: 'who answers for it', autocomplete: 'off', oninput: (e) => owner.set(e.target.value) })), h('button', { class: 'btn-commit', type: 'submit', text: 'Add cost centre' })), h('p', { class: 'form-hint', text: () => (id() ? `Saved as "${id()}".` : '') }));
|
|
140
|
+
}
|
|
141
|
+
function ruleList(rules, demo, reload, say, nameOf) {
|
|
142
|
+
if (rules.length === 0)
|
|
143
|
+
return h('p', { class: 'drawer-muted', text: 'No rules yet.' });
|
|
144
|
+
return h('div', { class: 'facts' },
|
|
145
|
+
// A revoked or superseded version is shown, not hidden: a past run was
|
|
146
|
+
// computed under it, and a reader comparing runs needs to see why the split
|
|
147
|
+
// changed rather than find a rule silently absent.
|
|
148
|
+
...rules.map((r) => {
|
|
149
|
+
// Superseded means a later version of the same rule exists. An end date the
|
|
150
|
+
// operator set is not supersession: `--until` and "Stops on" set it too, and
|
|
151
|
+
// calling those rules superseded misdescribed a rule that is still the
|
|
152
|
+
// current version.
|
|
153
|
+
const superseded = rules.some((o) => o.ruleId === r.ruleId && o.version > r.version);
|
|
154
|
+
const ended = !superseded && r.effectiveToMs != null && r.effectiveToMs <= Date.now();
|
|
155
|
+
const state = r.revokedAtMs != null ? 'revoked' : superseded ? 'superseded' : ended ? 'ended' : null;
|
|
156
|
+
const dates = [
|
|
157
|
+
r.effectiveFromMs > 0 ? `from ${date(r.effectiveFromMs)}` : '',
|
|
158
|
+
r.effectiveToMs != null && !superseded ? `${ended ? 'ended' : 'until'} ${date(r.effectiveToMs)}` : '',
|
|
159
|
+
].filter(Boolean).join(' · ');
|
|
160
|
+
return h('div', { class: `fact${state ? ' fact-off' : ''}` }, h('span', { class: 'fact-key' }, h('span', { text: describeRule(r, nameOf) }), dates ? h('span', { class: 'fact-sub', text: dates }) : null), h('span', { class: 'fact-val' }, h('span', { text: `${r.ruleId} v${r.version}${state && state !== 'ended' ? ` · ${state}` : ` · p${r.priority}`}` }), !state && !demo
|
|
161
|
+
? h('button', {
|
|
162
|
+
class: 'link-btn',
|
|
163
|
+
'aria-label': `Revoke rule ${r.ruleId}`,
|
|
164
|
+
onclick: async () => {
|
|
165
|
+
try {
|
|
166
|
+
say(true, (await api.write.allocation({ op: 'revoke', id: r.ruleId })).message);
|
|
167
|
+
reload();
|
|
168
|
+
}
|
|
169
|
+
catch (e) {
|
|
170
|
+
say(false, errorText(e));
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
text: 'Revoke',
|
|
174
|
+
})
|
|
175
|
+
: null));
|
|
176
|
+
}));
|
|
177
|
+
}
|
|
178
|
+
function ruleForm(centres, projects, models, reload, say) {
|
|
179
|
+
const fields = ruleFields(centres, projects, models);
|
|
180
|
+
return h('form', {
|
|
181
|
+
class: 'inline-form',
|
|
182
|
+
onsubmit: async (event) => {
|
|
183
|
+
event.preventDefault();
|
|
184
|
+
const draft = fields.build();
|
|
185
|
+
if (typeof draft === 'string')
|
|
186
|
+
return say(false, draft);
|
|
187
|
+
try {
|
|
188
|
+
const r = await api.write.allocation(draft.body);
|
|
189
|
+
say(true, isPrecise() ? r.message : 'Rule saved. Recorded splits keep their results; preview again to see this rule\'s effect.');
|
|
190
|
+
reload();
|
|
191
|
+
}
|
|
192
|
+
catch (e) {
|
|
193
|
+
say(false, errorText(e));
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
}, ...fields.children, h('div', { class: 'form-actions' }, h('button', { class: 'btn-commit', type: 'submit', text: 'Save rule' })));
|
|
197
|
+
}
|
|
198
|
+
function runPanel(demo, reload, say, nameOf) {
|
|
199
|
+
const ranges = [monthRange(-1), monthRange(0)];
|
|
200
|
+
const CUSTOM = ranges.length;
|
|
201
|
+
const chosen = signal(0);
|
|
202
|
+
const customFrom = signal('');
|
|
203
|
+
const customThrough = signal('');
|
|
204
|
+
const preview = signal(null);
|
|
205
|
+
const busy = signal(false);
|
|
206
|
+
const run = async (apply) => {
|
|
207
|
+
let r;
|
|
208
|
+
if (chosen() === CUSTOM) {
|
|
209
|
+
if (!customFrom() || !customThrough())
|
|
210
|
+
return say(false, 'Choose both the first and the last day of the period.');
|
|
211
|
+
if (customThrough() < customFrom())
|
|
212
|
+
return say(false, 'The last day must not be before the first day.');
|
|
213
|
+
// Both days are included: the period ends at the local midnight after the last one.
|
|
214
|
+
r = { from: localMidnightIso(customFrom()), to: dayAfterIso(customThrough()) };
|
|
215
|
+
}
|
|
216
|
+
else
|
|
217
|
+
r = ranges[chosen()];
|
|
218
|
+
busy.set(true);
|
|
219
|
+
try {
|
|
220
|
+
const res = await api.write.allocation({ op: 'run', from: r.from, to: r.to, apply });
|
|
221
|
+
if (res.result)
|
|
222
|
+
preview.set(res.result);
|
|
223
|
+
// The preview panel is its own answer; a toast over it would cover the
|
|
224
|
+
// figures it announces. Only a write is confirmed.
|
|
225
|
+
if (apply)
|
|
226
|
+
say(true, 'Recorded. The split is saved and listed under Saved splits.');
|
|
227
|
+
if (apply) {
|
|
228
|
+
preview.set(null);
|
|
229
|
+
reload();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
catch (e) {
|
|
233
|
+
say(false, errorText(e));
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
busy.set(false);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
return h('div', { class: 'run-panel' }, h('div', { class: 'form-row run-row' }, field('run-period', 'Period', h('select', {
|
|
240
|
+
id: 'run-period', class: 'drawer-input',
|
|
241
|
+
onchange: (e) => { chosen.set(Number(e.target.value)); preview.set(null); },
|
|
242
|
+
}, ...ranges.map((r, i) => h('option', { value: String(i), text: r.label })), h('option', { value: String(CUSTOM), text: 'Choose dates…' }))), () => chosen() === CUSTOM
|
|
243
|
+
? h('div', { class: 'form-row run-dates' }, field('run-from', 'First day', h('input', { id: 'run-from', class: 'drawer-input', type: 'date',
|
|
244
|
+
oninput: (e) => { customFrom.set(e.target.value); preview.set(null); } })), field('run-through', 'Last day', h('input', { id: 'run-through', class: 'drawer-input', type: 'date',
|
|
245
|
+
oninput: (e) => { customThrough.set(e.target.value); preview.set(null); } })))
|
|
246
|
+
: null, h('button', { class: 'btn-ghost', disabled: () => busy(), onclick: () => void run(false), text: () => (busy() ? 'Working…' : 'Preview split') })), () => {
|
|
247
|
+
const p = preview();
|
|
248
|
+
if (!p)
|
|
249
|
+
return null;
|
|
250
|
+
return h('div', { class: 'run-result' }, runTable(p, nameOf), p.retention?.truncated
|
|
251
|
+
? h('p', { class: 'section-note', role: 'note', text: 'Some records in this period were deleted by retention. The split covers what remains, so the unallocated part may be understated.' })
|
|
252
|
+
: null, h('div', { class: 'form-actions' }, demo
|
|
253
|
+
? h('p', { class: 'drawer-muted', text: 'Sample data: a run cannot be recorded here.' })
|
|
254
|
+
: h('button', { class: 'btn-commit', disabled: () => busy(), onclick: () => void run(true), text: 'Record this run' })));
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
function runTable(p, nameOf) {
|
|
258
|
+
const share = (m) => (p.totalMicros > 0 ? shareOf(m / p.totalMicros, 1) : '—');
|
|
259
|
+
return h('div', { class: 'table-wrap' }, h('table', null, h('caption', { class: 'sr-only', text: 'Allocation preview by cost centre' }), h('thead', null, h('tr', null, h('th', { text: 'Cost centre' }), h('th', { class: 'num', text: 'Amount' }), h('th', { class: 'num', text: 'Share' }), h('th', { class: 'num', text: 'Requests' }))), h('tbody', null, ...p.byCostCentre.map((c) => h('tr', null, h('td', { class: 'name-cell', text: nameOf(c.costCentreId) }), h('td', { class: 'num', text: usdFromMicros(c.allocatedMicros) }), h('td', { class: 'num', text: share(c.allocatedMicros) }), h('td', { class: 'num', text: count(c.requestCount) }))), ...p.unallocated.map((u) => h('tr', { class: 'row-unalloc' }, h('td', { class: 'name-cell' }, h('span', { text: `Unallocated: ${REASON_WORDS[u.reason] ?? u.reason.replace(/_/g, ' ')}` }), u.topProjects.length > 0
|
|
260
|
+
? h('span', { class: 'fact-sub', text: `mostly ${u.topProjects.slice(0, 3).map((t) => t.project).join(', ')}` })
|
|
261
|
+
: null), h('td', { class: 'num', text: usdFromMicros(u.micros) }), h('td', { class: 'num', text: share(u.micros) }), h('td', { class: 'num', text: count(u.requestCount) })))), h('tfoot', null, h('tr', null, h('td', { text: 'Total estimated spend' }), h('td', { class: 'num', text: usdFromMicros(p.totalMicros) }), h('td', { class: 'num', text: p.totalMicros > 0 ? '100%' : '—' }), h('td', null)))));
|
|
262
|
+
}
|
|
263
|
+
function history(runs) {
|
|
264
|
+
return h('section', { class: 'section' }, h('h2', { class: 'section-title', text: () => (isPrecise() ? 'Recorded runs' : 'Saved splits') }), runs.length === 0
|
|
265
|
+
? h('p', { class: 'drawer-muted', text: 'None recorded yet.' })
|
|
266
|
+
: h('div', { class: 'table-wrap' }, h('table', null, h('thead', null, h('tr', null, h('th', { text: 'Period' }), h('th', { class: 'num', text: 'Total' }), h('th', { class: 'num', text: 'Allocated' }), h('th', { text: 'Recorded' }))), h('tbody', null, ...runs.map((r) => {
|
|
267
|
+
const period = `${date(r.result.periodStartMs)} – ${date(r.result.periodEndMs)}`;
|
|
268
|
+
return h('tr', null,
|
|
269
|
+
// The download sits with the run it names rather than in a
|
|
270
|
+
// column of its own, which pushed the table past a phone screen.
|
|
271
|
+
// It is the recorded run exactly as stored: every line and
|
|
272
|
+
// unallocated reason, adding up to the total to the microdollar.
|
|
273
|
+
h('td', null, h('span', { class: 'run-period', text: period }), h('a', {
|
|
274
|
+
class: 'run-download', href: api.allocationRunCsvHref(r.allocationRunId), download: '',
|
|
275
|
+
'aria-label': `Download the split for ${period} as CSV`, text: 'Export CSV',
|
|
276
|
+
})), h('td', { class: 'num', text: usdFromMicros(r.result.totalMicros) }), h('td', { class: 'num', text: r.result.totalMicros > 0 ? shareOf(r.result.allocatedMicros / r.result.totalMicros, 1) : '—' }), h('td', { text: relative(r.computedAtMs) }));
|
|
277
|
+
})))));
|
|
90
278
|
}
|
|
@@ -32,6 +32,9 @@ import { signal, scopedEffect } from '../core/signal.js';
|
|
|
32
32
|
import { api } from '../core/api.js';
|
|
33
33
|
import { usd, count, pct, isPrecise } from '../core/fmt.js';
|
|
34
34
|
import { actionCard } from './spend.js';
|
|
35
|
+
import { capability } from '../core/registry.js';
|
|
36
|
+
import { openAction } from '../components/drawer.js';
|
|
37
|
+
import { actionSpec } from '../core/actions.js';
|
|
35
38
|
export function controlView() {
|
|
36
39
|
const settings = signal(null);
|
|
37
40
|
const today = signal(null);
|
|
@@ -51,7 +54,7 @@ export function controlView() {
|
|
|
51
54
|
}
|
|
52
55
|
});
|
|
53
56
|
});
|
|
54
|
-
return h('div', null, h('div', { class: 'view-head' }, h('h1', { class: 'view-title', text: '
|
|
57
|
+
return h('div', null, h('div', { class: 'view-head' }, h('h1', { class: 'view-title', text: 'Protect' }), h('p', { class: 'view-plain', text: () => isPrecise()
|
|
55
58
|
? 'Enforcement caps applied at the proxy, and the advisory recommendation derived from observed spend.'
|
|
56
59
|
: 'The limits that stop AI spend before it happens — and how much of your spend they can actually reach.' })), () => {
|
|
57
60
|
const err = error();
|
|
@@ -82,9 +85,16 @@ export function controlView() {
|
|
|
82
85
|
// Live governance alerts, above the caps that produced them. The server
|
|
83
86
|
// already computed these; until now no screen rendered them, so an
|
|
84
87
|
// operator whose cap was exhausted had to infer it from a percentage.
|
|
85
|
-
alerts.length > 0 ? alertList(alerts) : null, h('div', { class: 'card card-basis' }, h('div', { class: 'card-head' }, h('span', { class: 'card-title', text: () => (isPrecise() ? 'Daily cap' : 'Your daily limit') }),
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
alerts.length > 0 ? alertList(alerts) : null, h('div', { class: 'card card-basis' }, h('div', { class: 'card-head' }, h('span', { class: 'card-title', text: () => (isPrecise() ? 'Daily cap' : 'Your daily limit') }),
|
|
89
|
+
// The action that changes this number sits on it. It was a card at
|
|
90
|
+
// the foot of a 2,200 px page (critique 2026-10-12).
|
|
91
|
+
budgetButton(),
|
|
92
|
+
// Green "enforced" over a day already past its limit read as a
|
|
93
|
+
// contradiction (critique 2026-10-12). Past the limit, the badge
|
|
94
|
+
// says what the proxy is doing now.
|
|
95
|
+
h('span', {
|
|
96
|
+
class: `pill ${cap === null ? 'pill-unverified' : spentToday !== null && spentToday >= cap ? 'pill-warn' : 'pill-ok'}`,
|
|
97
|
+
text: cap === null ? 'unlimited' : spentToday !== null && spentToday >= cap ? 'limit reached · blocking' : 'enforced in path',
|
|
88
98
|
})), cap === null
|
|
89
99
|
? h('div', null, h('div', { class: 'stat band-unset', text: 'no cap set' }), h('span', { class: 'basis', text: () => (isPrecise()
|
|
90
100
|
? 'No daily cap is configured. Requests are never blocked on spend.'
|
|
@@ -95,7 +105,11 @@ export function controlView() {
|
|
|
95
105
|
style: `--fill: ${Math.min(100, cap > 0 ? (spentToday / cap) * 100 : 0)}%`,
|
|
96
106
|
})), h('span', { class: 'basis', text: () => (isPrecise()
|
|
97
107
|
? `${usd(spentToday)} counted toward the cap today — ${pct(cap > 0 ? spentToday / cap : 0, 0)} of the cap`
|
|
98
|
-
: `${usd(spentToday)} spent today, which is ${pct(cap > 0 ? spentToday / cap : 0, 0)} of your limit`) }),
|
|
108
|
+
: `${usd(spentToday)} spent today, which is ${pct(cap > 0 ? spentToday / cap : 0, 0)} of your limit`) }), spentToday >= cap
|
|
109
|
+
? h('span', { class: 'basis', text: () => (isPrecise()
|
|
110
|
+
? 'The guard blocks once the day\'s enforced total is at or over the cap. The request that crossed it and any already in flight complete, so a day can close above the cap.'
|
|
111
|
+
: 'A limit stops new requests once the day\'s total reaches it. The request that crossed it, and any already running, still finish, so a day can end above its limit.') })
|
|
112
|
+
: null, importedOutsideCap > 0
|
|
99
113
|
? h('span', { class: 'basis', text: () => (isPrecise()
|
|
100
114
|
? `${usd(importedOutsideCap)} imported today is excluded from the cap (it cannot be blocked).`
|
|
101
115
|
: `Plus ${usd(importedOutsideCap)} imported from your tools' logs today, which the limit doesn't count because it can't be stopped.`) })
|
|
@@ -164,6 +178,16 @@ function alertCoveragePanel(coverage, alertCount) {
|
|
|
164
178
|
* with the figure that triggered it rather than as a bare warning. A warning an
|
|
165
179
|
* operator cannot check is a warning they learn to dismiss.
|
|
166
180
|
*/
|
|
181
|
+
function budgetButton() {
|
|
182
|
+
const cap = capability('budget');
|
|
183
|
+
if (!cap)
|
|
184
|
+
return null;
|
|
185
|
+
return h('button', {
|
|
186
|
+
class: 'btn-ghost card-action',
|
|
187
|
+
onclick: () => openAction(actionSpec(cap)),
|
|
188
|
+
text: () => (isPrecise() ? 'Set cap' : 'Change limit'),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
167
191
|
function alertList(alerts) {
|
|
168
192
|
const order = { critical: 0, warn: 1, info: 2 };
|
|
169
193
|
const sorted = [...alerts].sort((a, b) => (order[a.severity] ?? 3) - (order[b.severity] ?? 3));
|
|
@@ -175,34 +199,51 @@ function alertList(alerts) {
|
|
|
175
199
|
* from one recommended from a month of it, and the difference must be visible
|
|
176
200
|
* before anyone acts on it.
|
|
177
201
|
*/
|
|
202
|
+
/** A note that ends in a command ("...with: segreant budget --x on") shows it as code that does not wrap mid-flag. */
|
|
203
|
+
function noteWithCommand(note) {
|
|
204
|
+
const at = note.lastIndexOf(': segreant ');
|
|
205
|
+
if (at < 0)
|
|
206
|
+
return h('li', { text: note });
|
|
207
|
+
return h('li', {}, note.slice(0, at + 2), h('code', { class: 'inline-code', text: note.slice(at + 2) }));
|
|
208
|
+
}
|
|
178
209
|
function recommendation(rec, currentCap) {
|
|
179
210
|
const ready = rec.canApply === true;
|
|
180
211
|
const value = rec.recommendedDailyUsd ?? null;
|
|
212
|
+
// Zero days on a proxy-only basis is not thin history: nothing the cap
|
|
213
|
+
// governs has run yet, whatever was imported. The rationale below says why.
|
|
214
|
+
const noProxied = rec.basisDays === 0 && rec.spendBasis === 'live_proxy';
|
|
181
215
|
return h('div', { class: 'card', style: 'margin-top: var(--s4)' }, h('div', { class: 'card-head' }, h('span', { class: 'card-title', text: () => (isPrecise() ? 'Recommended cap' : 'What we would suggest') }), h('span', {
|
|
182
216
|
class: `pill ${ready ? 'pill-ok' : 'pill-unverified'}`,
|
|
183
|
-
text: ready ? 'enough data' : 'not enough data',
|
|
217
|
+
text: ready ? 'enough data' : noProxied ? 'no proxied spend' : 'not enough data',
|
|
184
218
|
})), value !== null
|
|
185
219
|
? h('div', { class: 'stat', text: usd(value) })
|
|
186
220
|
: h('div', { class: 'stat band-unset', text: 'not established' }), h('span', { class: 'basis', text: () => (isPrecise()
|
|
187
221
|
? `derived from ${count(rec.basisDays)} day(s) of observed spend on a ${rec.spendBasis === 'live_proxy' ? 'live proxy' : 'all observed'} basis; minimum ${count(rec.minActiveDays)} active days`
|
|
188
|
-
: `worked out from ${count(rec.basisDays)} days of your actual spend`) }), !ready
|
|
222
|
+
: `worked out from ${count(rec.basisDays)} days of your actual spend`) }), !ready && noProxied
|
|
189
223
|
? h('p', { class: 'scope-note', text: () => (isPrecise()
|
|
190
|
-
? '
|
|
191
|
-
: '
|
|
192
|
-
:
|
|
224
|
+
? 'No proxied spend in this window: a cap governs requests through the proxy, and none have run.'
|
|
225
|
+
: 'Nothing has gone through the proxy yet, and that is the spend a limit controls.') })
|
|
226
|
+
: !ready
|
|
193
227
|
? h('p', { class: 'scope-note', text: () => (isPrecise()
|
|
194
|
-
? '
|
|
195
|
-
: '
|
|
196
|
-
: null
|
|
228
|
+
? 'Status is not review-ready: too few active days stand behind this to recommend acting on it.'
|
|
229
|
+
: 'There is not enough history yet for this to be a suggestion worth acting on.') })
|
|
230
|
+
: currentCap !== null && value !== null && Math.abs(currentCap - value) < 0.005
|
|
231
|
+
? h('p', { class: 'scope-note', text: () => (isPrecise()
|
|
232
|
+
? 'Your configured cap already matches this recommendation.'
|
|
233
|
+
: 'Your current limit already matches this.') })
|
|
234
|
+
: null,
|
|
235
|
+
// With no basis day there is no typical, busy or worst day: zeros here
|
|
236
|
+
// would read as measured. The rationale says why instead.
|
|
237
|
+
rec.observed && (rec.basisDays ?? 0) > 0
|
|
197
238
|
? h('div', { class: 'facts' }, h('div', { class: 'fact' }, h('span', { class: 'fact-key', text: () => (isPrecise() ? 'median daily' : 'a typical day') }), h('span', { class: 'fact-val', text: usd(rec.observed.medianDaily) })), h('div', { class: 'fact' }, h('span', { class: 'fact-key', text: () => (isPrecise() ? 'p90 daily' : 'a busy day') }), h('span', { class: 'fact-val', text: usd(rec.observed.p90Daily) })), h('div', { class: 'fact' }, h('span', { class: 'fact-key', text: () => (isPrecise() ? 'max daily' : 'the worst day seen') }), h('span', { class: 'fact-val', text: usd(rec.observed.maxDaily) })))
|
|
198
239
|
: null,
|
|
199
240
|
// Projected waste is the number worth acting on, so it is not buried in the
|
|
200
241
|
// rationale list. It is explicitly a projection, and says so.
|
|
201
242
|
typeof rec.projectedMonthlyWasteUsd === 'number' && rec.projectedMonthlyWasteUsd > 0
|
|
202
243
|
? h('div', { class: 'waste-call' }, h('span', { class: 'waste-fig', text: `${usd(rec.projectedMonthlyWasteUsd)}/mo` }), h('span', { class: 'waste-say', text: () => (isPrecise()
|
|
203
|
-
? `projected spend not converting to
|
|
204
|
-
: `
|
|
244
|
+
? `projected spend not converting to realized outcomes, at the current realized-value rate (${pct(rec.realizedSpendShare, 0)})`
|
|
245
|
+
: `a month, at the current rate, goes to work that is not kept — a projection, not a measurement`) }))
|
|
205
246
|
: null, rec.rationale?.length
|
|
206
|
-
? h('ul', { class: 'drawer-notes' }, ...rec.rationale.map(
|
|
247
|
+
? h('ul', { class: 'drawer-notes' }, ...rec.rationale.map(noteWithCommand))
|
|
207
248
|
: null);
|
|
208
249
|
}
|
|
@@ -61,9 +61,9 @@ export function dataView() {
|
|
|
61
61
|
const missing = list.filter((i) => !i.available);
|
|
62
62
|
const o = overview();
|
|
63
63
|
const isDemo = o?.demo === true;
|
|
64
|
-
const empty =
|
|
64
|
+
const empty = o !== null && o.summary.requests === 0;
|
|
65
65
|
return h('div', null, isDemo
|
|
66
|
-
? h('div', { class: 'banner banner-demo' }, h('span', { class: 'pill pill-demo', text: 'demo' }), h('p', null, h('strong', { text: 'You are looking at sample data. ' }), '
|
|
66
|
+
? h('div', { class: 'banner banner-demo' }, h('span', { class: 'pill pill-demo', text: 'demo' }), h('p', null, h('strong', { text: 'You are looking at sample data. ' }), 'Imports are off while this is sample data, and nothing here writes into it. To bring in your own usage, run the dashboard without --demo.'))
|
|
67
67
|
: empty
|
|
68
68
|
? h('div', { class: 'banner' }, h('p', null, h('strong', { text: 'Nothing imported yet. ' }), 'Every other screen will stay empty until something here brings usage in.'))
|
|
69
69
|
: null,
|
|
@@ -130,7 +130,7 @@ export function dataView() {
|
|
|
130
130
|
}), o && !empty
|
|
131
131
|
? h('div', { class: 'card' }, h('div', { class: 'card-head' }, h('span', { class: 'card-title', text: () => (isPrecise() ? 'Currently held' : 'What Segreant has so far') })), h('div', { class: 'stat', text: count(o.summary.requests) }), h('span', { class: 'basis', text: () => (isPrecise()
|
|
132
132
|
? `recorded requests totalling ${usd(o.summary.costUsd)} of metered cost`
|
|
133
|
-
: `AI calls recorded,
|
|
134
|
-
: null, h('section', { class: 'section' }, h('h2', { class: 'section-title', text: () => (isPrecise() ? 'Actions on this layer' : 'Things you can do with this') }), h('div', { class: 'actions' }, actionCard('import'), actionCard('scan'), actionCard('connect'), actionCard('sources'), actionCard('baseline'), actionCard('demo'))));
|
|
133
|
+
: `AI calls recorded, with ${usd(o.summary.costUsd)} of metered cost estimates`) }))
|
|
134
|
+
: null, h('section', { class: 'section' }, h('h2', { class: 'section-title', text: () => (isPrecise() ? 'Actions on this layer' : 'Things you can do with this') }), h('div', { class: 'actions' }, actionCard('import'), actionCard('scan'), actionCard('connect'), actionCard('sources'), actionCard('baseline'), actionCard('pricing-unknown'), actionCard('reprice'), actionCard('demo'))));
|
|
135
135
|
});
|
|
136
136
|
}
|