@oxy-hq/sdk 2.6.0 → 2.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +110 -5
- package/dist/email.cjs.map +1 -1
- package/dist/index.cjs +734 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +820 -12
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +820 -12
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +718 -9
- package/dist/index.mjs.map +1 -1
- package/dist/{react-BAiiXftp.cjs → react-CDYGAfGA.cjs} +20 -14
- package/dist/react-CDYGAfGA.cjs.map +1 -0
- package/dist/{react-DnBdQ8dG.d.cts → react-DBG6Pfp_.d.cts} +54 -2
- package/dist/react-DBG6Pfp_.d.cts.map +1 -0
- package/dist/{react-DnBdQ8dG.d.mts → react-DBG6Pfp_.d.mts} +54 -2
- package/dist/react-DBG6Pfp_.d.mts.map +1 -0
- package/dist/{react-5HGW_0oy.mjs → react-sACIu6Ea.mjs} +19 -13
- package/dist/react-sACIu6Ea.mjs.map +1 -0
- package/dist/shell.cjs +25 -26
- package/dist/shell.cjs.map +1 -1
- package/dist/shell.css +28 -6
- package/dist/shell.d.cts +1 -1
- package/dist/shell.d.cts.map +1 -1
- package/dist/shell.d.mts +1 -1
- package/dist/shell.d.mts.map +1 -1
- package/dist/shell.mjs +25 -26
- package/dist/shell.mjs.map +1 -1
- package/package.json +9 -9
- package/dist/react-5HGW_0oy.mjs.map +0 -1
- package/dist/react-BAiiXftp.cjs.map +0 -1
- package/dist/react-DnBdQ8dG.d.cts.map +0 -1
- package/dist/react-DnBdQ8dG.d.mts.map +0 -1
package/dist/index.cjs
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
// @oxy/sdk - TypeScript SDK for Oxy data platform
|
|
2
2
|
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
3
|
-
const require_react = require('./react-
|
|
3
|
+
const require_react = require('./react-CDYGAfGA.cjs');
|
|
4
|
+
let react = require("react");
|
|
5
|
+
react = require_react.__toESM(react, 1);
|
|
4
6
|
|
|
5
7
|
//#region src/anomalies.ts
|
|
8
|
+
/** Which buckets a write may touch when the caller didn't say. Live statuses
|
|
9
|
+
* for ack/dismiss; all three for a reopen, which exists to reach dismissed
|
|
10
|
+
* ones. */
|
|
11
|
+
function defaultScope(status) {
|
|
12
|
+
return status === "new" ? [
|
|
13
|
+
"new",
|
|
14
|
+
"acknowledged",
|
|
15
|
+
"dismissed"
|
|
16
|
+
] : ["new", "acknowledged"];
|
|
17
|
+
}
|
|
6
18
|
/**
|
|
7
19
|
* Client for `/semantic/anomalies*`. Construct via `OxyClient.anomalies`
|
|
8
20
|
* rather than instantiating directly — the getter wires the request helper
|
|
@@ -31,18 +43,25 @@ var AnomaliesClient = class {
|
|
|
31
43
|
return qs ? `?${qs}` : "";
|
|
32
44
|
}
|
|
33
45
|
/**
|
|
34
|
-
* List anomalies in the inbox,
|
|
46
|
+
* List anomalies in the inbox, ranked worst-first by event severity (active
|
|
47
|
+
* events before dismissed). Pass `order: "recent"` for latest-first.
|
|
35
48
|
*
|
|
36
49
|
* @example
|
|
37
50
|
* ```typescript
|
|
38
51
|
* // Open / unresolved anomalies only
|
|
39
52
|
* const { anomalies } = await client.anomalies.list({ status: "new" });
|
|
53
|
+
*
|
|
54
|
+
* // Second page of 25 events
|
|
55
|
+
* const page2 = await client.anomalies.list({ limit: 25, offset: 25 });
|
|
56
|
+
* console.log(`${(page2.offset ?? 25) + 1}+ of ${page2.total ?? "?"}`);
|
|
40
57
|
* ```
|
|
41
58
|
*/
|
|
42
59
|
async list(options = {}) {
|
|
43
60
|
const extra = {};
|
|
44
61
|
if (options.status) extra.status = options.status;
|
|
45
|
-
if (options.limit) extra.limit = String(options.limit);
|
|
62
|
+
if (options.limit !== void 0) extra.limit = String(options.limit);
|
|
63
|
+
if (options.offset !== void 0) extra.offset = String(options.offset);
|
|
64
|
+
if (options.order) extra.order = options.order;
|
|
46
65
|
return this.request(this.path(this.buildQuery(extra)));
|
|
47
66
|
}
|
|
48
67
|
/**
|
|
@@ -50,11 +69,20 @@ var AnomaliesClient = class {
|
|
|
50
69
|
* workspace, runs the detector, and upserts matching rows into the
|
|
51
70
|
* inbox. Returns counts of scanned / failed / persisted.
|
|
52
71
|
*
|
|
72
|
+
* Long-running: the server waits up to 55 s, then returns
|
|
73
|
+
* `pending: true` with zeroed counts while the scan finishes in the
|
|
74
|
+
* background. Always check `pending` before treating `0` as "nothing
|
|
75
|
+
* found", and refetch with {@link list} shortly after.
|
|
76
|
+
*
|
|
53
77
|
* @example
|
|
54
78
|
* ```typescript
|
|
55
79
|
* // Scan against a known-good reference date (matches the seed dataset)
|
|
56
80
|
* const result = await client.anomalies.scan({ as_of: "2025-12-15" });
|
|
57
|
-
*
|
|
81
|
+
* if (result.pending) {
|
|
82
|
+
* console.log("scan still running — refetch shortly");
|
|
83
|
+
* } else {
|
|
84
|
+
* console.log(`${result.anomalies_persisted} anomalies detected`);
|
|
85
|
+
* }
|
|
58
86
|
* ```
|
|
59
87
|
*/
|
|
60
88
|
async scan(options = {}) {
|
|
@@ -73,15 +101,156 @@ var AnomaliesClient = class {
|
|
|
73
101
|
});
|
|
74
102
|
}
|
|
75
103
|
/**
|
|
104
|
+
* Update many anomalies in one request — the batch form of
|
|
105
|
+
* {@link updateStatus}. Identifiers outside the workspace are skipped rather
|
|
106
|
+
* than erroring, so `updated` (rows written) can be lower than what you sent.
|
|
107
|
+
* At most 2000 identifiers across both lists.
|
|
108
|
+
*
|
|
109
|
+
* **Prefer `eventIds`.** Inbox actions are per *event*, and a list response
|
|
110
|
+
* caps how many buckets it returns per event — so acking the bucket ids you
|
|
111
|
+
* received can leave the tail of a long chain behind, `new`, under a clean
|
|
112
|
+
* success. Naming the event lets the server write all of it. `ids` is for
|
|
113
|
+
* rows with no `event_id` (detected before events existed), which can only
|
|
114
|
+
* be named individually.
|
|
115
|
+
*
|
|
116
|
+
* `onlyStatuses` says which of an event's buckets may move. An event can span
|
|
117
|
+
* statuses, so an unbounded write resurrects buckets that were dismissed on
|
|
118
|
+
* purpose — which is why omitting it takes a scope rather than no bound at
|
|
119
|
+
* all: the live statuses (`["new", "acknowledged"]`) for an ack or dismiss,
|
|
120
|
+
* and all three for `status: "new"`, since reopening is how a dismissed
|
|
121
|
+
* anomaly comes back. The server applies that same default, so the safe
|
|
122
|
+
* behaviour does not depend on going through this client. Pass `[]` to opt
|
|
123
|
+
* out of the bound entirely.
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* ```typescript
|
|
127
|
+
* const { anomalies } = await client.anomalies.list({ status: "new", limit: 50, offset: 0 });
|
|
128
|
+
* // Both lists: events by id, and pre-event rows (no `event_id`) by their own.
|
|
129
|
+
* const eventIds = [...new Set(anomalies.flatMap((a) => (a.event_id ? [a.event_id] : [])))];
|
|
130
|
+
* const ids = anomalies.filter((a) => !a.event_id).map((a) => a.id);
|
|
131
|
+
* const { updated } = await client.anomalies.updateStatusBulk(
|
|
132
|
+
* { ids, eventIds, onlyStatuses: ["new", "acknowledged"] },
|
|
133
|
+
* "acknowledged"
|
|
134
|
+
* );
|
|
135
|
+
* ```
|
|
136
|
+
*/
|
|
137
|
+
async updateStatusBulk(target, status) {
|
|
138
|
+
return this.request(this.path(`/status${this.buildQuery()}`), {
|
|
139
|
+
method: "POST",
|
|
140
|
+
body: JSON.stringify({
|
|
141
|
+
ids: target.ids ?? [],
|
|
142
|
+
event_ids: target.eventIds ?? [],
|
|
143
|
+
only_statuses: target.onlyStatuses ?? defaultScope(status),
|
|
144
|
+
status
|
|
145
|
+
})
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
76
149
|
* Run the metric-tree `explain` for an anomaly and cache the result on
|
|
77
|
-
* the row. Subsequent calls return the cached `ExplainResult` instantly
|
|
150
|
+
* the row. Subsequent calls return the cached `ExplainResult` instantly;
|
|
151
|
+
* pass `{ refresh: true }` to bust the cache and recompute.
|
|
152
|
+
*
|
|
153
|
+
* The uncached path runs a 20-30 s recursive driver search — budget for it
|
|
154
|
+
* (or read `explain_cache` off the row from {@link list} when it's already
|
|
155
|
+
* populated).
|
|
78
156
|
*/
|
|
79
|
-
async explain(anomalyId) {
|
|
80
|
-
const
|
|
81
|
-
|
|
157
|
+
async explain(anomalyId, options = {}) {
|
|
158
|
+
const extra = {};
|
|
159
|
+
if (options.refresh) extra.refresh = "true";
|
|
160
|
+
return this.request(this.path(`/${encodeURIComponent(anomalyId)}/explain${this.buildQuery(extra)}`), { method: "POST" });
|
|
82
161
|
}
|
|
83
162
|
};
|
|
84
163
|
|
|
164
|
+
//#endregion
|
|
165
|
+
//#region src/custom-app/base64.ts
|
|
166
|
+
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
167
|
+
/** Reverse lookup; 255 marks "not a base64 character". */
|
|
168
|
+
const B64R = /* @__PURE__ */ (() => {
|
|
169
|
+
const t = (/* @__PURE__ */ new Uint8Array(256)).fill(255);
|
|
170
|
+
for (let i = 0; i < 64; i++) t[B64.charCodeAt(i)] = i;
|
|
171
|
+
return t;
|
|
172
|
+
})();
|
|
173
|
+
/**
|
|
174
|
+
* Chunk size for building output in segments. Byte-at-a-time `+=` allocates a
|
|
175
|
+
* rope node per byte, and `String.fromCharCode.apply` blows the argument limit
|
|
176
|
+
* on large inputs; 8k avoids both.
|
|
177
|
+
*/
|
|
178
|
+
const CHUNK = 8192;
|
|
179
|
+
function asBytes(input) {
|
|
180
|
+
if (input instanceof Uint8Array) return input;
|
|
181
|
+
if (input instanceof ArrayBuffer) return new Uint8Array(input);
|
|
182
|
+
return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Encode bytes as standard (padded) base64.
|
|
186
|
+
*
|
|
187
|
+
* ```ts
|
|
188
|
+
* const pdf = new Uint8Array(await renderReport());
|
|
189
|
+
* await ctx.email.send({
|
|
190
|
+
* to: ctx.user.email,
|
|
191
|
+
* subject: "Report",
|
|
192
|
+
* text: "attached",
|
|
193
|
+
* attachments: [{ filename: "report.pdf", content: bytesToBase64(pdf) }]
|
|
194
|
+
* });
|
|
195
|
+
* ```
|
|
196
|
+
*
|
|
197
|
+
* For **text** you generated, skip this entirely and pass the string with
|
|
198
|
+
* `encoding: "utf8"` — it needs no encoder and stays byte-exact for non-ASCII.
|
|
199
|
+
*/
|
|
200
|
+
function bytesToBase64(input) {
|
|
201
|
+
const bytes = asBytes(input);
|
|
202
|
+
const parts = [];
|
|
203
|
+
let buf = "";
|
|
204
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
205
|
+
const b0 = bytes[i];
|
|
206
|
+
const b1 = i + 1 < bytes.length ? bytes[i + 1] : 0;
|
|
207
|
+
const b2 = i + 2 < bytes.length ? bytes[i + 2] : 0;
|
|
208
|
+
const n = b0 << 16 | b1 << 8 | b2;
|
|
209
|
+
buf += B64[n >> 18 & 63] + B64[n >> 12 & 63] + (i + 1 < bytes.length ? B64[n >> 6 & 63] : "=") + (i + 2 < bytes.length ? B64[n & 63] : "=");
|
|
210
|
+
if (buf.length >= CHUNK) {
|
|
211
|
+
parts.push(buf);
|
|
212
|
+
buf = "";
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
parts.push(buf);
|
|
216
|
+
return parts.join("");
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Decode standard base64 to bytes — e.g. the body from
|
|
220
|
+
* `ctx.storage.get(key, { encoding: "base64" })`.
|
|
221
|
+
*
|
|
222
|
+
* Throws on malformed input rather than returning a short buffer: a truncated
|
|
223
|
+
* decode that reports success is a corrupt file nobody notices.
|
|
224
|
+
*/
|
|
225
|
+
function base64ToBytes(base64) {
|
|
226
|
+
let s = String(base64).replace(/[ \t\n\f\r]/g, "");
|
|
227
|
+
if (s.length % 4 === 0) {
|
|
228
|
+
let pad = 0;
|
|
229
|
+
while (pad < 2 && s.charCodeAt(s.length - 1) === 61) {
|
|
230
|
+
s = s.slice(0, -1);
|
|
231
|
+
pad++;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (s.indexOf("=") >= 0) throw new TypeError("base64ToBytes: '=' may only appear as trailing padding");
|
|
235
|
+
if (s.length % 4 === 1) throw new TypeError("base64ToBytes: invalid base64 length");
|
|
236
|
+
const out = new Uint8Array(s.length * 3 >> 2);
|
|
237
|
+
let o = 0;
|
|
238
|
+
let buf = 0;
|
|
239
|
+
let bits = 0;
|
|
240
|
+
for (let i = 0; i < s.length; i++) {
|
|
241
|
+
const code = s.charCodeAt(i);
|
|
242
|
+
const v = code < 256 ? B64R[code] : 255;
|
|
243
|
+
if (v === 255) throw new TypeError(`base64ToBytes: invalid base64 character '${s[i]}'`);
|
|
244
|
+
buf = buf << 6 | v;
|
|
245
|
+
bits += 6;
|
|
246
|
+
if (bits >= 8) {
|
|
247
|
+
bits -= 8;
|
|
248
|
+
out[o++] = buf >> bits & 255;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return out.subarray(0, o);
|
|
252
|
+
}
|
|
253
|
+
|
|
85
254
|
//#endregion
|
|
86
255
|
//#region src/custom-app/debug.ts
|
|
87
256
|
/**
|
|
@@ -105,6 +274,547 @@ async function getCustomAppDebug(resolved) {
|
|
|
105
274
|
return snapshot;
|
|
106
275
|
}
|
|
107
276
|
|
|
277
|
+
//#endregion
|
|
278
|
+
//#region src/custom-app/metric-tree-fetch.ts
|
|
279
|
+
/** Base path for the metric-tree endpoints of `projectId`. */
|
|
280
|
+
function metricTreePath(projectId) {
|
|
281
|
+
return `/api/projects/${projectId}/semantic/metric-tree`;
|
|
282
|
+
}
|
|
283
|
+
/** GET `url`, decoding JSON or throwing a typed {@link OxyApiError}. */
|
|
284
|
+
async function getJson(fetcher, url, signal) {
|
|
285
|
+
const resp = await fetcher(url, {
|
|
286
|
+
method: "GET",
|
|
287
|
+
signal
|
|
288
|
+
});
|
|
289
|
+
if (!resp.ok) throw await require_react.apiErrorFromResponse(resp);
|
|
290
|
+
return await resp.json();
|
|
291
|
+
}
|
|
292
|
+
/** POST `body` (tagged `v: 1`) to `url`, decoding JSON or throwing. */
|
|
293
|
+
async function postJson(fetcher, url, body, signal) {
|
|
294
|
+
const resp = await fetcher(url, {
|
|
295
|
+
method: "POST",
|
|
296
|
+
headers: { "content-type": "application/json" },
|
|
297
|
+
body: JSON.stringify({
|
|
298
|
+
v: 1,
|
|
299
|
+
...body
|
|
300
|
+
}),
|
|
301
|
+
signal
|
|
302
|
+
});
|
|
303
|
+
if (!resp.ok) throw await require_react.apiErrorFromResponse(resp);
|
|
304
|
+
return await resp.json();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
//#endregion
|
|
308
|
+
//#region src/custom-app/metric-tree-hooks.tsx
|
|
309
|
+
/**
|
|
310
|
+
* Internal engine shared by every metric-tree hook. Runs `run(signal)`
|
|
311
|
+
* whenever `key` changes (and on `refetch`), tracks loading/error, and
|
|
312
|
+
* cancels in-flight work on unmount or input change.
|
|
313
|
+
*
|
|
314
|
+
* `key` is the deep-compare fingerprint of the request; a `null` key
|
|
315
|
+
* means "no request yet" and leaves the hook idle without firing.
|
|
316
|
+
*/
|
|
317
|
+
function useMetricTreeEndpoint(key, run, enabled) {
|
|
318
|
+
const [data, setData] = react.useState(null);
|
|
319
|
+
const [loading, setLoading] = react.useState(enabled && key !== null);
|
|
320
|
+
const [error, setError] = react.useState(null);
|
|
321
|
+
const [_nonce, setNonce] = react.useState(0);
|
|
322
|
+
const runRef = react.useRef(run);
|
|
323
|
+
runRef.current = run;
|
|
324
|
+
react.useEffect(() => {
|
|
325
|
+
if (!enabled || key === null) {
|
|
326
|
+
setLoading(false);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const ctrl = new AbortController();
|
|
330
|
+
let cancelled = false;
|
|
331
|
+
setLoading(true);
|
|
332
|
+
setError(null);
|
|
333
|
+
runRef.current(ctrl.signal).then((result) => {
|
|
334
|
+
if (cancelled) return;
|
|
335
|
+
setData(result);
|
|
336
|
+
setLoading(false);
|
|
337
|
+
}).catch((err) => {
|
|
338
|
+
if (cancelled) return;
|
|
339
|
+
if (err instanceof DOMException && err.name === "AbortError") return;
|
|
340
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
341
|
+
setLoading(false);
|
|
342
|
+
});
|
|
343
|
+
return () => {
|
|
344
|
+
cancelled = true;
|
|
345
|
+
ctrl.abort();
|
|
346
|
+
};
|
|
347
|
+
}, [key, enabled]);
|
|
348
|
+
return {
|
|
349
|
+
data,
|
|
350
|
+
loading,
|
|
351
|
+
error,
|
|
352
|
+
refetch: react.useCallback(() => setNonce((n) => n + 1), [])
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* The project's metric tree — measures (nodes) and their component /
|
|
357
|
+
* driver relationships (edges) — or the subtree rooted at `opts.root`.
|
|
358
|
+
* The structural backbone every other metric-tree analysis reads against.
|
|
359
|
+
*/
|
|
360
|
+
function useMetricTree(opts = {}) {
|
|
361
|
+
const { projectId, fetcher } = require_react.useOxyApp();
|
|
362
|
+
const enabled = opts.enabled !== false;
|
|
363
|
+
const root = opts.root;
|
|
364
|
+
return useMetricTreeEndpoint(projectId ? JSON.stringify({
|
|
365
|
+
projectId,
|
|
366
|
+
root
|
|
367
|
+
}) : null, (signal) => {
|
|
368
|
+
const qs = root ? `?root=${encodeURIComponent(root)}` : "";
|
|
369
|
+
return getJson(fetcher, `${metricTreePath(projectId)}${qs}`, signal);
|
|
370
|
+
}, enabled);
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Ranked drivers of `measureId`, by influence — the "what moves this
|
|
374
|
+
* measure" question. Pass `null` to stay idle until a measure is chosen.
|
|
375
|
+
*/
|
|
376
|
+
function useSensitivity(measureId, opts = {}) {
|
|
377
|
+
const { projectId, fetcher } = require_react.useOxyApp();
|
|
378
|
+
const enabled = opts.enabled !== false;
|
|
379
|
+
return useMetricTreeEndpoint(projectId && measureId ? JSON.stringify({
|
|
380
|
+
projectId,
|
|
381
|
+
measureId
|
|
382
|
+
}) : null, (signal) => {
|
|
383
|
+
const path = `${metricTreePath(projectId)}/${encodeURIComponent(measureId)}/sensitivity`;
|
|
384
|
+
return getJson(fetcher, path, signal);
|
|
385
|
+
}, enabled);
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Propagate hypothetical `(measure, delta)` changes upward through the
|
|
389
|
+
* tree and return the estimated impact on every downstream measure — a
|
|
390
|
+
* pure metric-tree walk, no warehouse query. Pass `null` to stay idle.
|
|
391
|
+
*/
|
|
392
|
+
function usePredict(changes, opts = {}) {
|
|
393
|
+
const { projectId, fetcher } = require_react.useOxyApp();
|
|
394
|
+
const enabled = opts.enabled !== false;
|
|
395
|
+
return useMetricTreeEndpoint(projectId && changes ? JSON.stringify({
|
|
396
|
+
projectId,
|
|
397
|
+
changes
|
|
398
|
+
}) : null, (signal) => postJson(fetcher, `${metricTreePath(projectId)}/predict`, { changes }, signal), enabled);
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Period-over-period root-cause decomposition: recursively splits the
|
|
402
|
+
* target measure by components and dimensions until the move concentrates.
|
|
403
|
+
* This is the heavy one — it can fire many warehouse queries and the
|
|
404
|
+
* server caps it at 45s. Pass `null` to defer until periods are chosen.
|
|
405
|
+
*/
|
|
406
|
+
function useExplain(request, opts = {}) {
|
|
407
|
+
const { projectId, fetcher } = require_react.useOxyApp();
|
|
408
|
+
const enabled = opts.enabled !== false;
|
|
409
|
+
return useMetricTreeEndpoint(projectId && request ? JSON.stringify({
|
|
410
|
+
projectId,
|
|
411
|
+
request
|
|
412
|
+
}) : null, (signal) => postJson(fetcher, `${metricTreePath(projectId)}/explain`, request, signal), enabled);
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Single-period distribution of a measure — an {@link ExplainResult}
|
|
416
|
+
* against an auto-derived immediately-prior baseline. Same renderers as
|
|
417
|
+
* `useExplain`; ignore the delta fields for a pure distribution view.
|
|
418
|
+
*/
|
|
419
|
+
function useDistribution(request, opts = {}) {
|
|
420
|
+
const { projectId, fetcher } = require_react.useOxyApp();
|
|
421
|
+
const enabled = opts.enabled !== false;
|
|
422
|
+
return useMetricTreeEndpoint(projectId && request ? JSON.stringify({
|
|
423
|
+
projectId,
|
|
424
|
+
request
|
|
425
|
+
}) : null, (signal) => postJson(fetcher, `${metricTreePath(projectId)}/distribution`, request, signal), enabled);
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Segment opportunity sizing for a measure over a period: finds
|
|
429
|
+
* underperforming segments and sizes the addressable upside of closing
|
|
430
|
+
* each rate gap against a benchmark peer. Pass `null` to stay idle until
|
|
431
|
+
* a target + period are chosen.
|
|
432
|
+
*/
|
|
433
|
+
function useOpportunity(request, opts = {}) {
|
|
434
|
+
const { projectId, fetcher } = require_react.useOxyApp();
|
|
435
|
+
const enabled = opts.enabled !== false;
|
|
436
|
+
return useMetricTreeEndpoint(projectId && request ? JSON.stringify({
|
|
437
|
+
projectId,
|
|
438
|
+
request
|
|
439
|
+
}) : null, (signal) => postJson(fetcher, `${metricTreePath(projectId)}/opportunity`, request, signal), enabled);
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* The queryable time dimensions per view (`view.dim` ids) — what a
|
|
443
|
+
* bundle offers as the period axis for `explain` / `opportunity` /
|
|
444
|
+
* `distribution` instead of hardcoding a curated map.
|
|
445
|
+
*/
|
|
446
|
+
function useTimeDimensions(opts = {}) {
|
|
447
|
+
const { projectId, fetcher } = require_react.useOxyApp();
|
|
448
|
+
const enabled = opts.enabled !== false;
|
|
449
|
+
return useMetricTreeEndpoint(projectId ? JSON.stringify({
|
|
450
|
+
projectId,
|
|
451
|
+
kind: "time-dimensions"
|
|
452
|
+
}) : null, (signal) => getJson(fetcher, `${metricTreePath(projectId)}/time-dimensions`, signal), enabled);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
//#endregion
|
|
456
|
+
//#region src/custom-app/sse.ts
|
|
457
|
+
/**
|
|
458
|
+
* Read a `text/event-stream` response, invoking `onEvent` with each parsed
|
|
459
|
+
* JSON frame. Frames that fail to parse are skipped (a malformed frame must
|
|
460
|
+
* not tear down the whole stream). Resolves when the body closes.
|
|
461
|
+
*/
|
|
462
|
+
async function readJsonSseStream(resp, onEvent) {
|
|
463
|
+
const reader = resp.body?.getReader();
|
|
464
|
+
if (!reader) throw new Error("SSE response has no body stream");
|
|
465
|
+
const decoder = new TextDecoder();
|
|
466
|
+
let buffer = "";
|
|
467
|
+
for (;;) {
|
|
468
|
+
const { done, value } = await reader.read();
|
|
469
|
+
if (done) break;
|
|
470
|
+
buffer += decoder.decode(value, { stream: true });
|
|
471
|
+
let sep;
|
|
472
|
+
while ((sep = buffer.indexOf("\n\n")) !== -1) {
|
|
473
|
+
const frame = buffer.slice(0, sep);
|
|
474
|
+
buffer = buffer.slice(sep + 2);
|
|
475
|
+
let data = "";
|
|
476
|
+
for (const line of frame.split("\n")) if (line.startsWith("data:")) data += line.slice(5).trim();
|
|
477
|
+
if (!data) continue;
|
|
478
|
+
let parsed;
|
|
479
|
+
try {
|
|
480
|
+
parsed = JSON.parse(data);
|
|
481
|
+
} catch {
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
onEvent(parsed);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
//#endregion
|
|
490
|
+
//#region src/custom-app/world-model-hooks.tsx
|
|
491
|
+
/** Base path for the world-model endpoints of the active project. */
|
|
492
|
+
function worldModelPath(projectId) {
|
|
493
|
+
return `/api/projects/${projectId}/semantic/world-model`;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* The world-model graph — entities (nodes), their measures/dimensions, and
|
|
497
|
+
* how measures promote across the entity hierarchy (edges). Applies the
|
|
498
|
+
* project's `.world-model.yml` display config server-side.
|
|
499
|
+
*
|
|
500
|
+
* @remarks
|
|
501
|
+
* This returns the raw semantic-layer entity graph. For the higher-level
|
|
502
|
+
* node-paradigm interface (`world.metric(id)` speaking `expand` / `explain` /
|
|
503
|
+
* `size`), use {@link useWorldModel} from `./world-node` instead.
|
|
504
|
+
*/
|
|
505
|
+
function useWorldModelGraph(opts = {}) {
|
|
506
|
+
const { projectId, fetcher } = require_react.useOxyApp();
|
|
507
|
+
const enabled = opts.enabled !== false;
|
|
508
|
+
const [data, setData] = react.useState(null);
|
|
509
|
+
const [loading, setLoading] = react.useState(enabled && !!projectId);
|
|
510
|
+
const [error, setError] = react.useState(null);
|
|
511
|
+
const [_nonce, setNonce] = react.useState(0);
|
|
512
|
+
react.useEffect(() => {
|
|
513
|
+
if (!enabled || !projectId) {
|
|
514
|
+
setLoading(false);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const ctrl = new AbortController();
|
|
518
|
+
let cancelled = false;
|
|
519
|
+
setLoading(true);
|
|
520
|
+
setError(null);
|
|
521
|
+
fetcher(worldModelPath(projectId), {
|
|
522
|
+
method: "GET",
|
|
523
|
+
signal: ctrl.signal
|
|
524
|
+
}).then(async (resp) => {
|
|
525
|
+
if (!resp.ok) throw await require_react.apiErrorFromResponse(resp);
|
|
526
|
+
return await resp.json();
|
|
527
|
+
}).then((result) => {
|
|
528
|
+
if (cancelled) return;
|
|
529
|
+
setData(result);
|
|
530
|
+
setLoading(false);
|
|
531
|
+
}).catch((err) => {
|
|
532
|
+
if (cancelled) return;
|
|
533
|
+
if (err instanceof DOMException && err.name === "AbortError") return;
|
|
534
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
535
|
+
setLoading(false);
|
|
536
|
+
});
|
|
537
|
+
return () => {
|
|
538
|
+
cancelled = true;
|
|
539
|
+
ctrl.abort();
|
|
540
|
+
};
|
|
541
|
+
}, [
|
|
542
|
+
enabled,
|
|
543
|
+
projectId,
|
|
544
|
+
fetcher
|
|
545
|
+
]);
|
|
546
|
+
return {
|
|
547
|
+
data,
|
|
548
|
+
loading,
|
|
549
|
+
error,
|
|
550
|
+
refetch: react.useCallback(() => setNonce((n) => n + 1), [])
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* List the instances (rows) of `entityId` — a bounded, searchable picker
|
|
555
|
+
* over the entity's primary keys + display label. Pass `null` for `entityId`
|
|
556
|
+
* to stay idle until an entity is chosen.
|
|
557
|
+
*/
|
|
558
|
+
function useWorldModelInstances(entityId, opts = {}) {
|
|
559
|
+
const { projectId, fetcher } = require_react.useOxyApp();
|
|
560
|
+
const enabled = opts.enabled !== false;
|
|
561
|
+
const { search, limit } = opts;
|
|
562
|
+
const [data, setData] = react.useState(null);
|
|
563
|
+
const [loading, setLoading] = react.useState(enabled && !!projectId && !!entityId);
|
|
564
|
+
const [error, setError] = react.useState(null);
|
|
565
|
+
const [_nonce, setNonce] = react.useState(0);
|
|
566
|
+
react.useEffect(() => {
|
|
567
|
+
if (!enabled || !projectId || !entityId) {
|
|
568
|
+
setLoading(false);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const ctrl = new AbortController();
|
|
572
|
+
let cancelled = false;
|
|
573
|
+
setLoading(true);
|
|
574
|
+
setError(null);
|
|
575
|
+
const params = new URLSearchParams({ entity: entityId });
|
|
576
|
+
if (search) params.set("search", search);
|
|
577
|
+
if (limit != null) params.set("limit", String(limit));
|
|
578
|
+
fetcher(`${worldModelPath(projectId)}/instances?${params}`, {
|
|
579
|
+
method: "GET",
|
|
580
|
+
signal: ctrl.signal
|
|
581
|
+
}).then(async (resp) => {
|
|
582
|
+
if (!resp.ok) throw await require_react.apiErrorFromResponse(resp);
|
|
583
|
+
return await resp.json();
|
|
584
|
+
}).then((result) => {
|
|
585
|
+
if (cancelled) return;
|
|
586
|
+
setData(result);
|
|
587
|
+
setLoading(false);
|
|
588
|
+
}).catch((err) => {
|
|
589
|
+
if (cancelled) return;
|
|
590
|
+
if (err instanceof DOMException && err.name === "AbortError") return;
|
|
591
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
592
|
+
setLoading(false);
|
|
593
|
+
});
|
|
594
|
+
return () => {
|
|
595
|
+
cancelled = true;
|
|
596
|
+
ctrl.abort();
|
|
597
|
+
};
|
|
598
|
+
}, [
|
|
599
|
+
enabled,
|
|
600
|
+
projectId,
|
|
601
|
+
entityId,
|
|
602
|
+
search,
|
|
603
|
+
limit,
|
|
604
|
+
fetcher
|
|
605
|
+
]);
|
|
606
|
+
return {
|
|
607
|
+
data,
|
|
608
|
+
loading,
|
|
609
|
+
error,
|
|
610
|
+
refetch: react.useCallback(() => setNonce((n) => n + 1), [])
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
/** Fold one measure-breakdown SSE frame into the accumulated graph. */
|
|
614
|
+
function foldBreakdown(prev, ev) {
|
|
615
|
+
switch (ev.kind) {
|
|
616
|
+
case "init": return {
|
|
617
|
+
root: ev.root,
|
|
618
|
+
nodes: ev.nodes.map((n) => ({
|
|
619
|
+
...n,
|
|
620
|
+
value: null,
|
|
621
|
+
unvalued_reason: null
|
|
622
|
+
})),
|
|
623
|
+
edges: ev.edges
|
|
624
|
+
};
|
|
625
|
+
case "value":
|
|
626
|
+
if (!prev) return prev;
|
|
627
|
+
return {
|
|
628
|
+
...prev,
|
|
629
|
+
nodes: prev.nodes.map((n) => n.id === ev.node_id ? {
|
|
630
|
+
...n,
|
|
631
|
+
value: ev.value,
|
|
632
|
+
unvalued_reason: ev.unvalued_reason
|
|
633
|
+
} : n)
|
|
634
|
+
};
|
|
635
|
+
default: return prev;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Stream the driver-tree breakdown of one instance's measure — the metric
|
|
640
|
+
* decomposition (add/sub/mul/div component graph) with each node's value
|
|
641
|
+
* filling in as it resolves. This is the per-instance RCA view. Pass `null`
|
|
642
|
+
* for `measure` to stay idle.
|
|
643
|
+
*/
|
|
644
|
+
function useMeasureBreakdown(entityId, keyValue, measure) {
|
|
645
|
+
const { projectId, fetcher } = require_react.useOxyApp();
|
|
646
|
+
const [breakdown, setBreakdown] = react.useState(null);
|
|
647
|
+
const [loading, setLoading] = react.useState(false);
|
|
648
|
+
const [done, setDone] = react.useState(false);
|
|
649
|
+
const [error, setError] = react.useState(null);
|
|
650
|
+
react.useEffect(() => {
|
|
651
|
+
if (!projectId || !entityId || !keyValue || !measure) {
|
|
652
|
+
setLoading(false);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const ctrl = new AbortController();
|
|
656
|
+
let cancelled = false;
|
|
657
|
+
setBreakdown(null);
|
|
658
|
+
setLoading(true);
|
|
659
|
+
setDone(false);
|
|
660
|
+
setError(null);
|
|
661
|
+
const params = new URLSearchParams({
|
|
662
|
+
entity: entityId,
|
|
663
|
+
key: keyValue,
|
|
664
|
+
measure
|
|
665
|
+
});
|
|
666
|
+
fetcher(`${worldModelPath(projectId)}/measure-breakdown?${params}`, {
|
|
667
|
+
method: "GET",
|
|
668
|
+
signal: ctrl.signal
|
|
669
|
+
}).then(async (resp) => {
|
|
670
|
+
if (!resp.ok) throw await require_react.apiErrorFromResponse(resp);
|
|
671
|
+
await readJsonSseStream(resp, (ev) => {
|
|
672
|
+
if (cancelled) return;
|
|
673
|
+
if (ev.kind === "done") {
|
|
674
|
+
setDone(true);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
setBreakdown((prev) => foldBreakdown(prev, ev));
|
|
678
|
+
});
|
|
679
|
+
if (!cancelled) setLoading(false);
|
|
680
|
+
}).catch((err) => {
|
|
681
|
+
if (cancelled) return;
|
|
682
|
+
if (err instanceof DOMException && err.name === "AbortError") return;
|
|
683
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
684
|
+
setLoading(false);
|
|
685
|
+
});
|
|
686
|
+
return () => {
|
|
687
|
+
cancelled = true;
|
|
688
|
+
ctrl.abort();
|
|
689
|
+
};
|
|
690
|
+
}, [
|
|
691
|
+
projectId,
|
|
692
|
+
entityId,
|
|
693
|
+
keyValue,
|
|
694
|
+
measure,
|
|
695
|
+
fetcher
|
|
696
|
+
]);
|
|
697
|
+
return {
|
|
698
|
+
breakdown,
|
|
699
|
+
loading,
|
|
700
|
+
done,
|
|
701
|
+
error
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
//#endregion
|
|
706
|
+
//#region src/custom-app/world-node.tsx
|
|
707
|
+
/**
|
|
708
|
+
* Thrown by the value verbs (`explain` / `size`) when called on a handle that
|
|
709
|
+
* has been `drill`ed. The metric-tree backend cannot yet scope these analyses
|
|
710
|
+
* to a segment, so failing loud beats returning population numbers for a
|
|
711
|
+
* question that asked about one segment.
|
|
712
|
+
*/
|
|
713
|
+
var WorldModelScopeUnsupportedError = class extends Error {
|
|
714
|
+
constructor(verb, scope) {
|
|
715
|
+
super(`${verb} on a drilled (scoped) node is not yet supported by the backend (scope: ${JSON.stringify(scope)}). Call ${verb} on the un-drilled node for population-level analysis.`);
|
|
716
|
+
this.code = "world_model_scope_unsupported";
|
|
717
|
+
this.name = "WorldModelScopeUnsupportedError";
|
|
718
|
+
this.scope = scope;
|
|
719
|
+
}
|
|
720
|
+
};
|
|
721
|
+
/**
|
|
722
|
+
* Build a {@link WorldModelApi} over a project id and fetcher. Framework-
|
|
723
|
+
* agnostic — `useWorldModel()` wraps this for React, but it is directly
|
|
724
|
+
* unit-testable with a mock fetcher.
|
|
725
|
+
*/
|
|
726
|
+
function createWorldModel(projectId, fetcher) {
|
|
727
|
+
const base = () => {
|
|
728
|
+
if (!projectId) throw new Error("World Model unavailable: no active project (are you inside <OxyAppProvider>?)");
|
|
729
|
+
return metricTreePath(projectId);
|
|
730
|
+
};
|
|
731
|
+
const tree = (root, signal) => {
|
|
732
|
+
const qs = root ? `?root=${encodeURIComponent(root)}` : "";
|
|
733
|
+
return getJson(fetcher, `${base()}${qs}`, signal);
|
|
734
|
+
};
|
|
735
|
+
const makeHandle = (id, scope) => {
|
|
736
|
+
const scoped = Object.keys(scope).length > 0;
|
|
737
|
+
return {
|
|
738
|
+
id,
|
|
739
|
+
scope,
|
|
740
|
+
async node(signal) {
|
|
741
|
+
const found = (await tree(id, signal)).nodes.find((n) => n.id === id);
|
|
742
|
+
if (!found) throw new Error(`measure '${id}' not found in the metric tree`);
|
|
743
|
+
return found;
|
|
744
|
+
},
|
|
745
|
+
async expand(signal) {
|
|
746
|
+
const t = await tree(id, signal);
|
|
747
|
+
const byId = new Map(t.nodes.map((n) => [n.id, n]));
|
|
748
|
+
const children = [];
|
|
749
|
+
for (const edge of t.edges) {
|
|
750
|
+
if (edge.from !== id) continue;
|
|
751
|
+
const childNode = byId.get(edge.to);
|
|
752
|
+
if (!childNode) continue;
|
|
753
|
+
children.push({
|
|
754
|
+
node: childNode,
|
|
755
|
+
edge,
|
|
756
|
+
handle: makeHandle(edge.to, scope)
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
return children;
|
|
760
|
+
},
|
|
761
|
+
drivers(signal) {
|
|
762
|
+
return getJson(fetcher, `${base()}/${encodeURIComponent(id)}/sensitivity`, signal);
|
|
763
|
+
},
|
|
764
|
+
explain(opts, signal) {
|
|
765
|
+
if (scoped) throw new WorldModelScopeUnsupportedError("explain", scope);
|
|
766
|
+
return postJson(fetcher, `${base()}/explain`, {
|
|
767
|
+
target: id,
|
|
768
|
+
...opts
|
|
769
|
+
}, signal);
|
|
770
|
+
},
|
|
771
|
+
size(opts, signal) {
|
|
772
|
+
if (scoped) throw new WorldModelScopeUnsupportedError("size", scope);
|
|
773
|
+
return postJson(fetcher, `${base()}/opportunity`, {
|
|
774
|
+
target: id,
|
|
775
|
+
...opts
|
|
776
|
+
}, signal);
|
|
777
|
+
},
|
|
778
|
+
drill(next) {
|
|
779
|
+
return makeHandle(id, {
|
|
780
|
+
...scope,
|
|
781
|
+
...next
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
};
|
|
786
|
+
return {
|
|
787
|
+
projectId,
|
|
788
|
+
tree,
|
|
789
|
+
metric: (id) => makeHandle(id, {})
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* The World Model node interface, scoped to the active `<OxyAppProvider>`
|
|
794
|
+
* project. Returns a stable {@link WorldModelApi} — grab a node with
|
|
795
|
+
* `world.metric(id)` and let it speak the verbs.
|
|
796
|
+
*
|
|
797
|
+
* @example
|
|
798
|
+
* ```tsx
|
|
799
|
+
* const world = useWorldModel();
|
|
800
|
+
* const revenue = world.metric("orders.net_revenue");
|
|
801
|
+
* const children = await revenue.expand(); // components + drivers
|
|
802
|
+
* const rca = await revenue.explain({
|
|
803
|
+
* time_dimension: "orders.order_date",
|
|
804
|
+
* current_period: ["2026-06-01", "2026-06-30"],
|
|
805
|
+
* previous_period: ["2026-05-01", "2026-05-31"],
|
|
806
|
+
* });
|
|
807
|
+
* ```
|
|
808
|
+
*
|
|
809
|
+
* @remarks
|
|
810
|
+
* This is the node-paradigm hook. For the raw semantic-layer entity/measure
|
|
811
|
+
* graph, use {@link useWorldModelGraph} instead.
|
|
812
|
+
*/
|
|
813
|
+
function useWorldModel() {
|
|
814
|
+
const { projectId, fetcher } = require_react.useOxyApp();
|
|
815
|
+
return react.useMemo(() => createWorldModel(projectId ?? null, fetcher), [projectId, fetcher]);
|
|
816
|
+
}
|
|
817
|
+
|
|
108
818
|
//#endregion
|
|
109
819
|
//#region src/metricTree.ts
|
|
110
820
|
/**
|
|
@@ -240,20 +950,36 @@ exports.OxyAnswer = require_react.OxyAnswer;
|
|
|
240
950
|
exports.OxyApiError = require_react.OxyApiError;
|
|
241
951
|
exports.OxyAppProvider = require_react.OxyAppProvider;
|
|
242
952
|
exports.OxyChat = require_react.OxyChat;
|
|
953
|
+
exports.WorldModelScopeUnsupportedError = WorldModelScopeUnsupportedError;
|
|
243
954
|
exports._resetCustomAppManifestCacheForTest = require_react._resetCustomAppManifestCacheForTest;
|
|
244
955
|
exports.apiErrorFromResponse = require_react.apiErrorFromResponse;
|
|
956
|
+
exports.base64ToBytes = base64ToBytes;
|
|
957
|
+
exports.bytesToBase64 = bytesToBase64;
|
|
958
|
+
exports.createWorldModel = createWorldModel;
|
|
245
959
|
exports.getCustomAppDebug = getCustomAppDebug;
|
|
246
960
|
exports.getOxyAppLogger = require_react.getOxyAppLogger;
|
|
247
961
|
exports.interpretCustomAppError = require_react.interpretCustomAppError;
|
|
248
962
|
exports.loadCustomAppManifest = require_react.loadCustomAppManifest;
|
|
249
963
|
exports.readInjectedAppConfig = require_react.readInjectedAppConfig;
|
|
964
|
+
exports.readJsonSseStream = readJsonSseStream;
|
|
250
965
|
exports.setOxyAppLogger = require_react.setOxyAppLogger;
|
|
251
966
|
exports.useAgentRun = require_react.useAgentRun;
|
|
967
|
+
exports.useDistribution = useDistribution;
|
|
968
|
+
exports.useExplain = useExplain;
|
|
252
969
|
exports.useFunction = require_react.useFunction;
|
|
970
|
+
exports.useMeasureBreakdown = useMeasureBreakdown;
|
|
971
|
+
exports.useMetricTree = useMetricTree;
|
|
972
|
+
exports.useOpportunity = useOpportunity;
|
|
253
973
|
exports.useOxyApp = require_react.useOxyApp;
|
|
974
|
+
exports.usePredict = usePredict;
|
|
254
975
|
exports.useProcedureRun = require_react.useProcedureRun;
|
|
255
976
|
exports.useQuery = require_react.useQuery;
|
|
256
977
|
exports.useResolvedManifest = require_react.useResolvedManifest;
|
|
257
978
|
exports.useSemanticQuery = require_react.useSemanticQuery;
|
|
979
|
+
exports.useSensitivity = useSensitivity;
|
|
980
|
+
exports.useTimeDimensions = useTimeDimensions;
|
|
258
981
|
exports.useTrackEvent = require_react.useTrackEvent;
|
|
982
|
+
exports.useWorldModel = useWorldModel;
|
|
983
|
+
exports.useWorldModelGraph = useWorldModelGraph;
|
|
984
|
+
exports.useWorldModelInstances = useWorldModelInstances;
|
|
259
985
|
//# sourceMappingURL=index.cjs.map
|