@pyric/ui 0.1.0-alpha.11 → 0.1.0-alpha.12

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.
Files changed (152) hide show
  1. package/package.json +5 -4
  2. package/src/agents/ContextWindowUsage.tsx +514 -0
  3. package/src/agents/EmptyState.tsx +27 -0
  4. package/src/agents/Fold.tsx +64 -0
  5. package/src/agents/Modal.tsx +65 -0
  6. package/src/agents/PulsingDot.tsx +28 -0
  7. package/src/agents/inbrowser-agent-usage.d.ts +141 -0
  8. package/src/agents/index.ts +28 -0
  9. package/src/auth/authApi.ts +72 -0
  10. package/src/auth/claims.ts +63 -0
  11. package/src/auth/components/AuthProviderToggles.tsx +147 -0
  12. package/src/auth/components/AuthSignInHelper.tsx +197 -0
  13. package/src/auth/components/AuthUserForm.tsx +328 -0
  14. package/src/auth/components/AuthUserList.tsx +219 -0
  15. package/src/auth/components/ClaimsField.tsx +50 -0
  16. package/src/auth/components/confirmActions.tsx +114 -0
  17. package/src/auth/controller.ts +173 -0
  18. package/src/auth/hooks/index.ts +36 -0
  19. package/src/auth/hooks/useAuthFlowHelper.ts +55 -0
  20. package/src/auth/hooks/useAuthProviderConfig.ts +139 -0
  21. package/src/auth/hooks/useAuthUserEditor.ts +76 -0
  22. package/src/auth/hooks/useAuthUsers.ts +154 -0
  23. package/src/auth/index.ts +42 -0
  24. package/src/auth/providers.ts +28 -0
  25. package/src/auth/reducers/userEditor.ts +186 -0
  26. package/src/events/components/ActivityActionItems.tsx +136 -0
  27. package/src/events/components/ActivityGrid.tsx +197 -0
  28. package/src/events/components/ActivityGridRow.tsx +65 -0
  29. package/src/events/components/ProposedChangeDiff.tsx +175 -0
  30. package/src/events/components/format.ts +21 -0
  31. package/src/events/components/index.ts +17 -0
  32. package/src/events/digest.ts +630 -0
  33. package/src/events/hooks/index.ts +9 -0
  34. package/src/events/hooks/useActivityDigest.ts +42 -0
  35. package/src/events/hooks/useActivityStream.ts +86 -0
  36. package/src/events/index.ts +39 -0
  37. package/src/events/types.ts +152 -0
  38. package/src/firestore/components/CollectionList.tsx +78 -0
  39. package/src/firestore/components/DeleteWithConfirm.tsx +88 -0
  40. package/src/firestore/components/DocumentEditor.tsx +350 -0
  41. package/src/firestore/components/DocumentList.tsx +217 -0
  42. package/src/firestore/components/DocumentPreview.tsx +265 -0
  43. package/src/firestore/components/FieldRenderer.tsx +25 -0
  44. package/src/firestore/components/QueryBuilder.tsx +181 -0
  45. package/src/firestore/components/ReferencePicker.tsx +212 -0
  46. package/src/firestore/components/TreeEntry.tsx +58 -0
  47. package/src/firestore/components/context.ts +25 -0
  48. package/src/firestore/fieldEditors/array.tsx +30 -0
  49. package/src/firestore/fieldEditors/boolean.tsx +39 -0
  50. package/src/firestore/fieldEditors/bytes.tsx +53 -0
  51. package/src/firestore/fieldEditors/geopoint.tsx +71 -0
  52. package/src/firestore/fieldEditors/map.tsx +33 -0
  53. package/src/firestore/fieldEditors/null.tsx +27 -0
  54. package/src/firestore/fieldEditors/number.tsx +43 -0
  55. package/src/firestore/fieldEditors/reference.tsx +87 -0
  56. package/src/firestore/fieldEditors/registry.ts +45 -0
  57. package/src/firestore/fieldEditors/string.tsx +33 -0
  58. package/src/firestore/fieldEditors/timestamp.tsx +75 -0
  59. package/src/firestore/fieldEditors/types.ts +68 -0
  60. package/src/firestore/fieldEditors/vector.tsx +142 -0
  61. package/src/firestore/firestoreApi.ts +86 -0
  62. package/src/firestore/hooks/coerceError.ts +34 -0
  63. package/src/firestore/hooks/index.ts +46 -0
  64. package/src/firestore/hooks/useCollectionList.ts +102 -0
  65. package/src/firestore/hooks/useDocumentEditor.ts +161 -0
  66. package/src/firestore/hooks/useDocumentList.ts +242 -0
  67. package/src/firestore/hooks/useDocumentSubcollections.ts +86 -0
  68. package/src/firestore/hooks/useFirestoreCollection.ts +51 -0
  69. package/src/firestore/hooks/useFirestoreDoc.ts +55 -0
  70. package/src/firestore/hooks/useQueryBuilder.ts +188 -0
  71. package/src/firestore/hooks/useRecursiveDelete.ts +77 -0
  72. package/src/firestore/hooks/useReferencePicker.ts +228 -0
  73. package/src/firestore/import/parseImport.ts +137 -0
  74. package/src/firestore/index.ts +87 -0
  75. package/src/firestore/reducers/defaults.ts +38 -0
  76. package/src/firestore/reducers/documentEditor.ts +239 -0
  77. package/src/firestore/reducers/tree.ts +140 -0
  78. package/src/firestore/reducers/types.ts +92 -0
  79. package/src/firestore/reducers/validation.ts +129 -0
  80. package/src/firestore/types.ts +231 -0
  81. package/src/firestore/validation/ids.ts +45 -0
  82. package/src/firestore/valueEquality.ts +43 -0
  83. package/src/index.ts +10 -0
  84. package/src/primitives/Badge.tsx +40 -0
  85. package/src/primitives/ConfirmDialog.tsx +137 -0
  86. package/src/primitives/CopyButton.tsx +62 -0
  87. package/src/primitives/JsonView.tsx +151 -0
  88. package/src/primitives/SegmentedControl.tsx +72 -0
  89. package/src/primitives/Toast.tsx +161 -0
  90. package/src/primitives/VirtualList.tsx +104 -0
  91. package/src/primitives/hooks/useContainerSize.ts +53 -0
  92. package/src/primitives/hooks/useUpdateHighlights.ts +109 -0
  93. package/src/primitives/index.ts +39 -0
  94. package/src/primitives/useConfirm.tsx +113 -0
  95. package/src/rtdb/components/RtdbPathBar.tsx +135 -0
  96. package/src/rtdb/components/RtdbTree.tsx +409 -0
  97. package/src/rtdb/editor.ts +79 -0
  98. package/src/rtdb/hooks/useRtdbTree.ts +137 -0
  99. package/src/rtdb/index.ts +66 -0
  100. package/src/rtdb/pathInput.ts +47 -0
  101. package/src/rtdb/reducers/tree.ts +191 -0
  102. package/src/rtdb/rtdbApi.ts +23 -0
  103. package/src/rtdb/values.ts +188 -0
  104. package/src/rules/components/DenialInspector.tsx +227 -0
  105. package/src/rules/components/format.ts +171 -0
  106. package/src/rules/components/index.ts +12 -0
  107. package/src/rules/components/scope.ts +75 -0
  108. package/src/rules/hooks/index.ts +5 -0
  109. package/src/rules/hooks/useDenialTrace.ts +100 -0
  110. package/src/rules/index.ts +24 -0
  111. package/src/rules/types.ts +91 -0
  112. package/src/storage/collisionRename.ts +114 -0
  113. package/src/storage/components/DeleteSelectionWithConfirm.tsx +193 -0
  114. package/src/storage/components/ObjectBrowser.tsx +219 -0
  115. package/src/storage/components/ObjectInspector.tsx +169 -0
  116. package/src/storage/components/PathBreadcrumb.tsx +84 -0
  117. package/src/storage/components/UploadDropzone.tsx +182 -0
  118. package/src/storage/folderPlaceholder.ts +40 -0
  119. package/src/storage/hooks/index.ts +59 -0
  120. package/src/storage/hooks/useMetadataEditor.ts +329 -0
  121. package/src/storage/hooks/useObjectUpload.ts +262 -0
  122. package/src/storage/hooks/usePathState.ts +94 -0
  123. package/src/storage/hooks/useStorageDelete.ts +195 -0
  124. package/src/storage/hooks/useStorageList.ts +261 -0
  125. package/src/storage/hooks/useStorageObject.ts +162 -0
  126. package/src/storage/hooks/useStorageRulesGate.ts +270 -0
  127. package/src/storage/hooks/useStorageSelection.ts +90 -0
  128. package/src/storage/index.ts +59 -0
  129. package/src/storage/pendingPrefixes.ts +125 -0
  130. package/src/storage/previews.tsx +120 -0
  131. package/src/storage/storageApi.ts +54 -0
  132. package/src/traffic/components/RuleHeatmap.tsx +97 -0
  133. package/src/traffic/components/TrafficDetail.tsx +160 -0
  134. package/src/traffic/components/TrafficGroupRow.tsx +91 -0
  135. package/src/traffic/components/TrafficLineChart.tsx +139 -0
  136. package/src/traffic/components/TrafficLog.tsx +175 -0
  137. package/src/traffic/components/TrafficMetricCards.tsx +77 -0
  138. package/src/traffic/components/TrafficRow.tsx +69 -0
  139. package/src/traffic/components/TrafficStats.tsx +73 -0
  140. package/src/traffic/components/TrafficTimeline.tsx +289 -0
  141. package/src/traffic/components/format.ts +22 -0
  142. package/src/traffic/components/index.ts +22 -0
  143. package/src/traffic/hooks/index.ts +60 -0
  144. package/src/traffic/hooks/useRuleHeatmap.ts +92 -0
  145. package/src/traffic/hooks/useTrafficBuckets.ts +146 -0
  146. package/src/traffic/hooks/useTrafficFilter.ts +74 -0
  147. package/src/traffic/hooks/useTrafficGroups.ts +126 -0
  148. package/src/traffic/hooks/useTrafficMetrics.ts +250 -0
  149. package/src/traffic/hooks/useTrafficMonitor.ts +111 -0
  150. package/src/traffic/hooks/useTrafficStats.ts +77 -0
  151. package/src/traffic/index.ts +13 -0
  152. package/src/traffic/types.ts +85 -0
@@ -0,0 +1,74 @@
1
+ import { useMemo, useState } from 'react';
2
+ import type { TrafficEvent } from '../types.js';
3
+
4
+ /**
5
+ * `user` keeps everything that isn't a listener re-eval (user ops
6
+ * plus their transaction/batch sub-ops); `listener` keeps only
7
+ * listener re-evals; `all` keeps everything.
8
+ */
9
+ export type TrafficOriginFilter = 'user' | 'all' | 'listener';
10
+
11
+ export type TrafficResultFilter = 'all' | 'allow' | 'deny';
12
+
13
+ export interface UseTrafficFilterOptions {
14
+ events: TrafficEvent[];
15
+ /**
16
+ * Default `user` — the probe found listener traffic is 94–99.6%
17
+ * of events, so it's hidden until explicitly asked for.
18
+ */
19
+ initialOrigin?: TrafficOriginFilter;
20
+ /** Default `all` — the probe found ~75–80% allow in realistic
21
+ * sessions, so hiding either side loses diagnostic signal. */
22
+ initialResult?: TrafficResultFilter;
23
+ initialPathQuery?: string;
24
+ }
25
+
26
+ export interface TrafficFilterState {
27
+ origin: TrafficOriginFilter;
28
+ result: TrafficResultFilter;
29
+ pathQuery: string;
30
+ }
31
+
32
+ export interface UseTrafficFilterResult {
33
+ /** Events passing all three filters, in the input order. */
34
+ filtered: TrafficEvent[];
35
+ filter: TrafficFilterState;
36
+ setOrigin: (origin: TrafficOriginFilter) => void;
37
+ setResult: (result: TrafficResultFilter) => void;
38
+ setPathQuery: (pathQuery: string) => void;
39
+ }
40
+
41
+ /**
42
+ * Derives a filtered view over a traffic buffer along three
43
+ * dimensions: origin, result, and a case-insensitive path substring.
44
+ * Owns the filter state; pure derivation otherwise.
45
+ */
46
+ export function useTrafficFilter({
47
+ events,
48
+ initialOrigin = 'user',
49
+ initialResult = 'all',
50
+ initialPathQuery = '',
51
+ }: UseTrafficFilterOptions): UseTrafficFilterResult {
52
+ const [origin, setOrigin] = useState<TrafficOriginFilter>(initialOrigin);
53
+ const [result, setResult] = useState<TrafficResultFilter>(initialResult);
54
+ const [pathQuery, setPathQuery] = useState(initialPathQuery);
55
+
56
+ const filtered = useMemo(() => {
57
+ const needle = pathQuery.trim().toLowerCase();
58
+ return events.filter((e) => {
59
+ if (origin === 'user' && e.origin === 'listener') return false;
60
+ if (origin === 'listener' && e.origin !== 'listener') return false;
61
+ if (result !== 'all' && e.result !== result) return false;
62
+ if (needle && !e.path.toLowerCase().includes(needle)) return false;
63
+ return true;
64
+ });
65
+ }, [events, origin, result, pathQuery]);
66
+
67
+ return {
68
+ filtered,
69
+ filter: { origin, result, pathQuery },
70
+ setOrigin,
71
+ setResult,
72
+ setPathQuery,
73
+ };
74
+ }
@@ -0,0 +1,126 @@
1
+ import { useMemo } from 'react';
2
+ import type { TrafficEvent } from '../types.js';
3
+
4
+ export type TrafficGroupKind = 'batch' | 'transaction' | 'listener-run';
5
+
6
+ export interface TrafficGroup {
7
+ type: 'group';
8
+ kind: TrafficGroupKind;
9
+ /** `groupId` for batch/transaction; a synthetic key for listener
10
+ * runs. Stable enough for a React key. */
11
+ key: string;
12
+ events: TrafficEvent[];
13
+ count: number;
14
+ denies: number;
15
+ }
16
+
17
+ export interface TrafficSingle {
18
+ type: 'single';
19
+ event: TrafficEvent;
20
+ }
21
+
22
+ export type TrafficLogItem = TrafficGroup | TrafficSingle;
23
+
24
+ export interface UseTrafficGroupsOptions {
25
+ events: TrafficEvent[];
26
+ /** Collapse consecutive ops sharing a `groupId`. Default true. */
27
+ groupBatches?: boolean;
28
+ /**
29
+ * Collapse a consecutive run of listener re-evals from the same
30
+ * originating op into one group — the probe found a single write
31
+ * can trigger 250+ re-evals. Default true.
32
+ */
33
+ groupListenerRuns?: boolean;
34
+ }
35
+
36
+ export interface UseTrafficGroupsResult {
37
+ /** Events folded into a flat list of singles and groups, in the
38
+ * input order. */
39
+ items: TrafficLogItem[];
40
+ }
41
+
42
+ function sameTrigger(a: TrafficEvent, b: TrafficEvent): boolean {
43
+ const ta = a.triggeredBy;
44
+ const tb = b.triggeredBy;
45
+ if (!ta && !tb) return true;
46
+ if (!ta || !tb) return false;
47
+ return ta.method === tb.method && ta.path === tb.path;
48
+ }
49
+
50
+ function makeGroup(
51
+ kind: TrafficGroupKind,
52
+ key: string,
53
+ events: TrafficEvent[],
54
+ ): TrafficGroup {
55
+ let denies = 0;
56
+ for (const e of events) if (e.result === 'deny') denies++;
57
+ return { type: 'group', kind, key, events, count: events.length, denies };
58
+ }
59
+
60
+ /**
61
+ * Folds a traffic buffer into a list of singles and collapsible
62
+ * groups. Two grouping modes, both over *consecutive* events:
63
+ *
64
+ * - `groupId` — batch/transaction sub-ops sharing an id collapse
65
+ * into one `batch` / `transaction` group.
66
+ * - listener runs — a consecutive run of listener re-evals from the
67
+ * same originating op collapses into one `listener-run` group.
68
+ * A run of length 1 stays a single (no point collapsing one row).
69
+ *
70
+ * Pure derivation; the input order is preserved.
71
+ */
72
+ export function useTrafficGroups({
73
+ events,
74
+ groupBatches = true,
75
+ groupListenerRuns = true,
76
+ }: UseTrafficGroupsOptions): UseTrafficGroupsResult {
77
+ return useMemo(() => {
78
+ const items: TrafficLogItem[] = [];
79
+ let i = 0;
80
+
81
+ while (i < events.length) {
82
+ const event = events[i];
83
+
84
+ if (groupBatches && event.groupId) {
85
+ let j = i + 1;
86
+ while (j < events.length && events[j].groupId === event.groupId) j++;
87
+ const run = events.slice(i, j);
88
+ const kind: TrafficGroupKind =
89
+ event.origin === 'transaction' ? 'transaction' : 'batch';
90
+ items.push(makeGroup(kind, event.groupId, run));
91
+ i = j;
92
+ continue;
93
+ }
94
+
95
+ if (groupListenerRuns && event.origin === 'listener') {
96
+ let j = i + 1;
97
+ while (
98
+ j < events.length &&
99
+ events[j].origin === 'listener' &&
100
+ !events[j].groupId &&
101
+ sameTrigger(events[j], event)
102
+ ) {
103
+ j++;
104
+ }
105
+ const run = events.slice(i, j);
106
+ if (run.length > 1) {
107
+ const trigger = event.triggeredBy
108
+ ? `${event.triggeredBy.method}:${event.triggeredBy.path}`
109
+ : 'unknown';
110
+ items.push(
111
+ makeGroup('listener-run', `listener-run:${trigger}:${i}`, run),
112
+ );
113
+ } else {
114
+ items.push({ type: 'single', event });
115
+ }
116
+ i = j;
117
+ continue;
118
+ }
119
+
120
+ items.push({ type: 'single', event });
121
+ i++;
122
+ }
123
+
124
+ return { items };
125
+ }, [events, groupBatches, groupListenerRuns]);
126
+ }
@@ -0,0 +1,250 @@
1
+ import { useMemo } from 'react';
2
+ import type { TimeWindow } from './useTrafficBuckets.js';
3
+ import type { TrafficEvent } from '../types.js';
4
+
5
+ /**
6
+ * Billable-metrics + rules-metrics aggregation (Traffic tab: "Billable
7
+ * metrics" and "Rules"). Mirrors the Firebase Console
8
+ * Usage tab's shape (per-series totals + a bucketed time series), built
9
+ * on top of the same half-open `[window.start, window.end)` bucketing
10
+ * kernel {@link bucketTraffic} already uses.
11
+ *
12
+ * ── Billable mapping (documented; verified against the sandbox source) ──
13
+ * reads = get + list
14
+ * writes = create + update + set
15
+ * deletes = delete + remove
16
+ *
17
+ * A `list` event does NOT carry the number of documents the query
18
+ * returned — `local-environment.ts` emits ONE `allow` event per query
19
+ * before it computes/constrains the doc set, and the returned doc array
20
+ * never rides back onto the event. Real Firestore bills a list as one
21
+ * read PER RETURNED DOCUMENT; this stream can only honestly count list
22
+ * OPERATIONS, not documents. So this series is "read ops", not "billable
23
+ * reads" — an op-count proxy, not a byte-accurate bill. See the gap note
24
+ * in the Traffic feature's tracking issue.
25
+ *
26
+ * An op only bills if it actually ran against data: a `deny`/`error`/
27
+ * `unsupported`/`not-applicable` result means nothing was read or
28
+ * written, so those never count toward billable totals. An `admin`
29
+ * (rules-bypassed) op DOES run — admin reads/writes still bill in real
30
+ * Firestore — so admin ops count toward billable totals even though they
31
+ * never touch the rules-metrics series below.
32
+ *
33
+ * ── Rules metrics ──
34
+ * Allows / denies / errors are rules-ENGINE verdicts: how many times the
35
+ * rules engine actually ran and what it decided. An admin (bypassed) op
36
+ * never reaches rules, so it must never inflate "allows" — that would
37
+ * misrepresent the rules engine as having evaluated something it never
38
+ * saw. `unsupported` / `not-applicable` results aren't rules verdicts
39
+ * either (the simulator declined to evaluate) and are excluded too.
40
+ *
41
+ * ── Admin classification ──
42
+ * The base `TrafficEvent` carries only `origin` (no `detail` field — the
43
+ * sandbox-layer `RequestEvent.detail.admin` doesn't survive the
44
+ * `operation`-kind adapter path, and rides untyped through the
45
+ * `request`-kind cast). `origin === 'admin'` is the one signal declared
46
+ * on the public type, so it's the default `isAdmin` predicate here.
47
+ * Firestore's admin-lens ops don't currently set `origin: 'admin'`
48
+ * (RTDB's do) — callers that can see the richer Studio provenance
49
+ * (`authLens.mode === 'admin'`, as `verdict.ts#verdictFor` already
50
+ * checks) should pass their own `isAdmin` predicate to close that gap.
51
+ */
52
+
53
+ const READ_METHODS = new Set(['get', 'list']);
54
+ const WRITE_METHODS = new Set(['create', 'update', 'set']);
55
+ const DELETE_METHODS = new Set(['delete', 'remove']);
56
+
57
+ export type BillableSeriesKey = 'reads' | 'writes' | 'deletes';
58
+ export type RulesSeriesKey = 'allows' | 'denies' | 'errors';
59
+
60
+ export const BILLABLE_SERIES_DEFS: ReadonlyArray<{ key: BillableSeriesKey; label: string }> = [
61
+ { key: 'reads', label: 'Read ops' },
62
+ { key: 'writes', label: 'Writes' },
63
+ { key: 'deletes', label: 'Deletes' },
64
+ ];
65
+
66
+ export const RULES_SERIES_DEFS: ReadonlyArray<{ key: RulesSeriesKey; label: string }> = [
67
+ { key: 'allows', label: 'Allows' },
68
+ { key: 'denies', label: 'Denies' },
69
+ { key: 'errors', label: 'Errors' },
70
+ ];
71
+
72
+ /** Default admin predicate: the one signal the public `TrafficEvent`
73
+ * type declares. See the module doc for the known Firestore gap. */
74
+ export function isAdminEvent(event: Pick<TrafficEvent, 'origin'>): boolean {
75
+ return event.origin === 'admin';
76
+ }
77
+
78
+ /** Classify a billable op, or `null` if it isn't one / never ran. */
79
+ export function classifyBillable(
80
+ event: Pick<TrafficEvent, 'method' | 'result' | 'origin'>,
81
+ isAdmin: (event: Pick<TrafficEvent, 'origin'>) => boolean = isAdminEvent,
82
+ ): BillableSeriesKey | null {
83
+ const executed = event.result === 'allow' || isAdmin(event);
84
+ if (!executed) return null;
85
+ if (READ_METHODS.has(event.method)) return 'reads';
86
+ if (WRITE_METHODS.has(event.method)) return 'writes';
87
+ if (DELETE_METHODS.has(event.method)) return 'deletes';
88
+ return null;
89
+ }
90
+
91
+ /** Classify a rules-engine verdict, or `null` if it isn't one (bypassed,
92
+ * unsupported, or not-applicable). */
93
+ export function classifyRules(
94
+ event: Pick<TrafficEvent, 'result' | 'origin'>,
95
+ isAdmin: (event: Pick<TrafficEvent, 'origin'>) => boolean = isAdminEvent,
96
+ ): RulesSeriesKey | null {
97
+ if (isAdmin(event)) return null;
98
+ if (event.result === 'allow') return 'allows';
99
+ if (event.result === 'deny') return 'denies';
100
+ if (event.result === 'error') return 'errors';
101
+ return null;
102
+ }
103
+
104
+ export interface MetricPoint {
105
+ /** 0-based bucket index, left (oldest) to right (newest). */
106
+ index: number;
107
+ /** Half-open bounds of this bucket `[start, end)` in epoch-ms. */
108
+ start: number;
109
+ end: number;
110
+ }
111
+
112
+ export interface MetricSeries {
113
+ key: string;
114
+ label: string;
115
+ /** One count per bucket, aligned with `points`. */
116
+ values: number[];
117
+ /** Sum of `values` — the period total (the legend/card number). */
118
+ total: number;
119
+ }
120
+
121
+ export interface TrafficMetricsResult {
122
+ points: MetricPoint[];
123
+ series: MetricSeries[];
124
+ /** The largest single-bucket value across every series — the shared
125
+ * y-scale divisor a chart would use by default. */
126
+ maxValue: number;
127
+ }
128
+
129
+ const EMPTY = (
130
+ seriesDefs: ReadonlyArray<{ key: string; label: string }>,
131
+ ): TrafficMetricsResult => ({
132
+ points: [],
133
+ series: seriesDefs.map((d) => ({ key: d.key, label: d.label, values: [], total: 0 })),
134
+ maxValue: 0,
135
+ });
136
+
137
+ /** The shared bucketing kernel behind both metric hooks below. */
138
+ function bucketMetrics<K extends string>(
139
+ events: readonly TrafficEvent[],
140
+ window: TimeWindow,
141
+ bucketCount: number,
142
+ seriesDefs: ReadonlyArray<{ key: K; label: string }>,
143
+ classify: (event: TrafficEvent) => K | null,
144
+ ): TrafficMetricsResult {
145
+ const span = window.end - window.start;
146
+ if (bucketCount <= 0 || span <= 0) return EMPTY(seriesDefs);
147
+
148
+ const width = span / bucketCount;
149
+ const valuesByKey = new Map<K, number[]>(
150
+ seriesDefs.map((d) => [d.key, new Array<number>(bucketCount).fill(0)]),
151
+ );
152
+ const totals = new Map<K, number>(seriesDefs.map((d) => [d.key, 0]));
153
+
154
+ for (const event of events) {
155
+ const at = event.at;
156
+ if (at < window.start || at >= window.end) continue;
157
+ const key = classify(event);
158
+ if (key == null) continue;
159
+ let i = Math.floor((at - window.start) / width);
160
+ if (i >= bucketCount) i = bucketCount - 1;
161
+ const bucketValues = valuesByKey.get(key)!;
162
+ bucketValues[i]++;
163
+ totals.set(key, (totals.get(key) ?? 0) + 1);
164
+ }
165
+
166
+ let maxValue = 0;
167
+ for (const values of valuesByKey.values()) {
168
+ for (const v of values) if (v > maxValue) maxValue = v;
169
+ }
170
+
171
+ const points: MetricPoint[] = new Array(bucketCount);
172
+ for (let i = 0; i < bucketCount; i++) {
173
+ points[i] = { index: i, start: window.start + i * width, end: window.start + (i + 1) * width };
174
+ }
175
+
176
+ const series: MetricSeries[] = seriesDefs.map((d) => ({
177
+ key: d.key,
178
+ label: d.label,
179
+ values: valuesByKey.get(d.key)!,
180
+ total: totals.get(d.key) ?? 0,
181
+ }));
182
+
183
+ return { points, series, maxValue };
184
+ }
185
+
186
+ /** Pure kernel behind {@link useBillableMetrics} — usable outside React. */
187
+ export function bucketBillableMetrics(
188
+ events: readonly TrafficEvent[],
189
+ window: TimeWindow,
190
+ bucketCount = 24,
191
+ isAdmin: (event: Pick<TrafficEvent, 'origin'>) => boolean = isAdminEvent,
192
+ ): TrafficMetricsResult {
193
+ return bucketMetrics(events, window, bucketCount, BILLABLE_SERIES_DEFS, (e) =>
194
+ classifyBillable(e, isAdmin),
195
+ );
196
+ }
197
+
198
+ /** Pure kernel behind {@link useRulesMetrics} — usable outside React. */
199
+ export function bucketRulesMetrics(
200
+ events: readonly TrafficEvent[],
201
+ window: TimeWindow,
202
+ bucketCount = 24,
203
+ isAdmin: (event: Pick<TrafficEvent, 'origin'>) => boolean = isAdminEvent,
204
+ ): TrafficMetricsResult {
205
+ return bucketMetrics(events, window, bucketCount, RULES_SERIES_DEFS, (e) =>
206
+ classifyRules(e, isAdmin),
207
+ );
208
+ }
209
+
210
+ export interface UseTrafficMetricsOptions {
211
+ events: TrafficEvent[];
212
+ window: TimeWindow;
213
+ /** Number of buckets to divide the window into. The window itself
214
+ * should already be sized to the session (sandbox sessions run
215
+ * minutes, not days) — bucket count doesn't need to change, only the
216
+ * window a caller passes in. Default 24. */
217
+ bucketCount?: number;
218
+ /** Override admin classification (e.g. a Studio caller with
219
+ * `authLens` provenance available — see the module doc). Defaults to
220
+ * `origin === 'admin'`. */
221
+ isAdmin?: (event: Pick<TrafficEvent, 'origin'>) => boolean;
222
+ }
223
+
224
+ /** Reads / writes / deletes, bucketed over `window`. See the module doc
225
+ * for the billable mapping + the "read ops, not billable reads" caveat. */
226
+ export function useBillableMetrics({
227
+ events,
228
+ window,
229
+ bucketCount = 24,
230
+ isAdmin,
231
+ }: UseTrafficMetricsOptions): TrafficMetricsResult {
232
+ return useMemo(
233
+ () => bucketBillableMetrics(events, window, bucketCount, isAdmin),
234
+ [events, window.start, window.end, bucketCount, isAdmin],
235
+ );
236
+ }
237
+
238
+ /** Allows / denies / errors, bucketed over `window`. Excludes
239
+ * rules-bypassed (admin) ops — see the module doc. */
240
+ export function useRulesMetrics({
241
+ events,
242
+ window,
243
+ bucketCount = 24,
244
+ isAdmin,
245
+ }: UseTrafficMetricsOptions): TrafficMetricsResult {
246
+ return useMemo(
247
+ () => bucketRulesMetrics(events, window, bucketCount, isAdmin),
248
+ [events, window.start, window.end, bucketCount, isAdmin],
249
+ );
250
+ }
@@ -0,0 +1,111 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import type { TrafficEvent, TrafficSource } from '../types.js';
3
+
4
+ export interface UseTrafficMonitorOptions {
5
+ /**
6
+ * The subscription function — `sandbox.onRequest` satisfies this
7
+ * directly. Pass a stable reference; the hook re-subscribes on
8
+ * identity change.
9
+ */
10
+ source: TrafficSource;
11
+ /**
12
+ * Ring-buffer cap. Once exceeded, the oldest events are dropped.
13
+ * Default 5000 (~3 MB worst case — see the traffic-monitor probe
14
+ * findings).
15
+ */
16
+ bufferSize?: number;
17
+ /** Whether the buffer starts paused. Default false. */
18
+ paused?: boolean;
19
+ /**
20
+ * Runs per event before buffering — return a (possibly trimmed)
21
+ * event. Lets the consumer shrink oversized payloads without the
22
+ * library knowing payload semantics. Identity is read fresh on
23
+ * each event, so it need not be memoized.
24
+ */
25
+ transform?: (event: TrafficEvent) => TrafficEvent;
26
+ }
27
+
28
+ export interface TrafficCounts {
29
+ /** Events currently in the buffer. */
30
+ total: number;
31
+ /** Of those, how many were denied. */
32
+ denied: number;
33
+ /** Of those, how many are listener re-evals. */
34
+ listener: number;
35
+ }
36
+
37
+ export interface UseTrafficMonitorResult {
38
+ /** The buffered events, oldest first. */
39
+ events: TrafficEvent[];
40
+ counts: TrafficCounts;
41
+ isPaused: boolean;
42
+ /** Stop appending — incoming events are dropped while paused. */
43
+ pause: () => void;
44
+ /** Resume appending. */
45
+ resume: () => void;
46
+ /** Empty the buffer. */
47
+ clear: () => void;
48
+ }
49
+
50
+ /**
51
+ * Buffers a traffic stream into a capped ring buffer with
52
+ * pause/resume/clear. Decoupled from `pyric/sandbox` — `source` is
53
+ * just a `(cb) => unsubscribe` function.
54
+ *
55
+ * Pause is consumer-side: while paused, the subscription stays
56
+ * attached but incoming events are dropped (not queued). This
57
+ * matches the probe decision — a `load-test`-shaped session can emit
58
+ * 100k+ events, so queueing-while-paused would defeat the point.
59
+ */
60
+ export function useTrafficMonitor({
61
+ source,
62
+ bufferSize = 5000,
63
+ paused = false,
64
+ transform,
65
+ }: UseTrafficMonitorOptions): UseTrafficMonitorResult {
66
+ const [events, setEvents] = useState<TrafficEvent[]>([]);
67
+ const [isPaused, setIsPaused] = useState(paused);
68
+
69
+ // The subscription callback closes over these once. Refs keep it
70
+ // reading the live values without forcing a re-subscribe.
71
+ const pausedRef = useRef(isPaused);
72
+ pausedRef.current = isPaused;
73
+ const bufferSizeRef = useRef(bufferSize);
74
+ bufferSizeRef.current = bufferSize;
75
+ const transformRef = useRef(transform);
76
+ transformRef.current = transform;
77
+
78
+ useEffect(() => {
79
+ const unsubscribe = source((event) => {
80
+ if (pausedRef.current) return;
81
+ const shaped = transformRef.current
82
+ ? transformRef.current(event)
83
+ : event;
84
+ setEvents((prev) => {
85
+ const next = prev.length >= bufferSizeRef.current ? prev.slice(1) : prev.slice();
86
+ next.push(shaped);
87
+ return next;
88
+ });
89
+ });
90
+ return unsubscribe;
91
+ }, [source]);
92
+
93
+ const counts = useMemo<TrafficCounts>(() => {
94
+ let denied = 0;
95
+ let listener = 0;
96
+ for (const e of events) {
97
+ if (e.result === 'deny') denied++;
98
+ if (e.origin === 'listener') listener++;
99
+ }
100
+ return { total: events.length, denied, listener };
101
+ }, [events]);
102
+
103
+ return {
104
+ events,
105
+ counts,
106
+ isPaused,
107
+ pause: () => setIsPaused(true),
108
+ resume: () => setIsPaused(false),
109
+ clear: () => setEvents([]),
110
+ };
111
+ }
@@ -0,0 +1,77 @@
1
+ import { useMemo } from 'react';
2
+ import type { TrafficEvent } from '../types.js';
3
+
4
+ export interface TrafficStatBucket {
5
+ key: string;
6
+ count: number;
7
+ }
8
+
9
+ export interface TrafficStatsSummary {
10
+ total: number;
11
+ allows: number;
12
+ denies: number;
13
+ unsupported: number;
14
+ /** `denies / total` — 0 for an empty buffer. */
15
+ denyRate: number;
16
+ /** Counts by method, sorted descending. */
17
+ byMethod: TrafficStatBucket[];
18
+ /** Counts by origin, sorted descending. */
19
+ byOrigin: TrafficStatBucket[];
20
+ /** Counts by path, sorted descending, capped at `topPaths`. */
21
+ byPath: TrafficStatBucket[];
22
+ }
23
+
24
+ export interface UseTrafficStatsOptions {
25
+ events: TrafficEvent[];
26
+ /** Cap on `byPath` entries — paths are unbounded. Default 10. */
27
+ topPaths?: number;
28
+ }
29
+
30
+ function buckets(
31
+ events: TrafficEvent[],
32
+ pick: (event: TrafficEvent) => string,
33
+ limit?: number,
34
+ ): TrafficStatBucket[] {
35
+ const counts = new Map<string, number>();
36
+ for (const event of events) {
37
+ const key = pick(event);
38
+ counts.set(key, (counts.get(key) ?? 0) + 1);
39
+ }
40
+ const sorted = [...counts.entries()]
41
+ .map(([key, count]) => ({ key, count }))
42
+ .sort((a, b) => b.count - a.count || a.key.localeCompare(b.key));
43
+ return limit === undefined ? sorted : sorted.slice(0, limit);
44
+ }
45
+
46
+ /**
47
+ * Aggregations over a traffic buffer: totals, deny rate, and counts
48
+ * broken down by method, origin, and path. Pure derivation — feed it
49
+ * the filtered or full event list depending on what the panel
50
+ * should reflect.
51
+ */
52
+ export function useTrafficStats({
53
+ events,
54
+ topPaths = 10,
55
+ }: UseTrafficStatsOptions): TrafficStatsSummary {
56
+ return useMemo(() => {
57
+ let allows = 0;
58
+ let denies = 0;
59
+ let unsupported = 0;
60
+ for (const event of events) {
61
+ if (event.result === 'allow') allows++;
62
+ else if (event.result === 'deny') denies++;
63
+ else unsupported++;
64
+ }
65
+ const total = events.length;
66
+ return {
67
+ total,
68
+ allows,
69
+ denies,
70
+ unsupported,
71
+ denyRate: total === 0 ? 0 : denies / total,
72
+ byMethod: buckets(events, (e) => e.method),
73
+ byOrigin: buckets(events, (e) => e.origin),
74
+ byPath: buckets(events, (e) => e.path, topPaths),
75
+ };
76
+ }, [events, topPaths]);
77
+ }
@@ -0,0 +1,13 @@
1
+ export * from './hooks/index.js';
2
+ export * from './components/index.js';
3
+
4
+ export type {
5
+ TrafficEvent,
6
+ TrafficSource,
7
+ TrafficMethod,
8
+ TrafficResult,
9
+ TrafficOrigin,
10
+ TrafficAuthState,
11
+ TrafficResourceState,
12
+ TrafficMatchedRule,
13
+ } from './types.js';