@open-mercato/core 0.6.6-develop.6384.1.f06fc0b42c → 0.6.6-develop.6386.1.391a1afb9e

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 (42) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/bootstrap.js +6 -1
  3. package/dist/bootstrap.js.map +2 -2
  4. package/dist/modules/attachments/lib/scope.js +26 -0
  5. package/dist/modules/attachments/lib/scope.js.map +7 -0
  6. package/dist/modules/catalog/commands/variants.js +4 -2
  7. package/dist/modules/catalog/commands/variants.js.map +2 -2
  8. package/dist/modules/configs/api/module-telemetry/route.js +75 -0
  9. package/dist/modules/configs/api/module-telemetry/route.js.map +7 -0
  10. package/dist/modules/configs/api/openapi.js +110 -0
  11. package/dist/modules/configs/api/openapi.js.map +2 -2
  12. package/dist/modules/configs/backend/config/cache/page.meta.js +1 -1
  13. package/dist/modules/configs/backend/config/cache/page.meta.js.map +1 -1
  14. package/dist/modules/configs/backend/config/module-telemetry/page.js +10 -0
  15. package/dist/modules/configs/backend/config/module-telemetry/page.js.map +7 -0
  16. package/dist/modules/configs/backend/config/module-telemetry/page.meta.js +36 -0
  17. package/dist/modules/configs/backend/config/module-telemetry/page.meta.js.map +7 -0
  18. package/dist/modules/configs/components/ModuleTelemetryPanel.js +801 -0
  19. package/dist/modules/configs/components/ModuleTelemetryPanel.js.map +7 -0
  20. package/dist/modules/messages/commands/messages.js +0 -1
  21. package/dist/modules/messages/commands/messages.js.map +2 -2
  22. package/dist/modules/messages/lib/attachments.js +13 -7
  23. package/dist/modules/messages/lib/attachments.js.map +2 -2
  24. package/dist/modules/query_index/di.js +3 -3
  25. package/dist/modules/query_index/di.js.map +2 -2
  26. package/package.json +7 -7
  27. package/src/bootstrap.ts +7 -1
  28. package/src/modules/attachments/lib/scope.ts +42 -0
  29. package/src/modules/catalog/commands/variants.ts +7 -2
  30. package/src/modules/configs/api/module-telemetry/route.ts +77 -0
  31. package/src/modules/configs/api/openapi.ts +117 -0
  32. package/src/modules/configs/backend/config/cache/page.meta.ts +1 -1
  33. package/src/modules/configs/backend/config/module-telemetry/page.meta.ts +34 -0
  34. package/src/modules/configs/backend/config/module-telemetry/page.tsx +12 -0
  35. package/src/modules/configs/components/ModuleTelemetryPanel.tsx +1109 -0
  36. package/src/modules/configs/i18n/de.json +72 -0
  37. package/src/modules/configs/i18n/en.json +72 -0
  38. package/src/modules/configs/i18n/es.json +72 -0
  39. package/src/modules/configs/i18n/pl.json +72 -0
  40. package/src/modules/messages/commands/messages.ts +0 -1
  41. package/src/modules/messages/lib/attachments.ts +15 -7
  42. package/src/modules/query_index/di.ts +3 -3
@@ -0,0 +1,1109 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { usePathname, useRouter, useSearchParams } from 'next/navigation'
5
+ import { Button } from '@open-mercato/ui/primitives/button'
6
+ import {
7
+ SegmentedControl,
8
+ SegmentedControlItem,
9
+ } from '@open-mercato/ui/primitives/segmented-control'
10
+ import { Spinner } from '@open-mercato/ui/primitives/spinner'
11
+ import { StatusBadge } from '@open-mercato/ui/primitives/status-badge'
12
+ import { SimpleTooltip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@open-mercato/ui/primitives/tooltip'
13
+ import { IconButton } from '@open-mercato/ui/primitives/icon-button'
14
+ import { formatAttachmentFileSize as formatBytes } from '@open-mercato/ui/backend/detail/AttachmentVisualPreview'
15
+ import { ErrorMessage } from '@open-mercato/ui/backend/detail'
16
+ import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
17
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
18
+ import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
19
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
20
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
21
+ import { Copy, Info, RefreshCw, Trash2 } from 'lucide-react'
22
+ import type {
23
+ ModuleResourceUsageEntry,
24
+ ModuleResourceUsageReport,
25
+ ModuleResourceUsageTimeBucket,
26
+ ModuleResourceUsageTimeBucketModule,
27
+ } from '@open-mercato/shared/lib/modules/resource-usage'
28
+
29
+ const API_PATH = '/api/configs/module-telemetry'
30
+
31
+ type FetchState = {
32
+ loading: boolean
33
+ error: string | null
34
+ report: ModuleTelemetryApiResponse | null
35
+ }
36
+
37
+ type ModuleTelemetryApiResponse = ModuleResourceUsageReport & {
38
+ canClearTelemetry?: boolean
39
+ }
40
+
41
+ type ModuleTelemetryClearResponse = {
42
+ cleared: boolean
43
+ }
44
+
45
+ const HOUR_MS = 60 * 60 * 1000
46
+
47
+ const REASON_LABEL_KEYS: Record<string, string> = {
48
+ p95_duration: 'configs.moduleTelemetry.reason.p95Duration',
49
+ cpu: 'configs.moduleTelemetry.reason.cpu',
50
+ heap_allocations: 'configs.moduleTelemetry.reason.heapAllocations',
51
+ rss_growth: 'configs.moduleTelemetry.reason.rssGrowth',
52
+ errors: 'configs.moduleTelemetry.reason.errors',
53
+ }
54
+
55
+ const REASON_FALLBACKS: Record<string, string> = {
56
+ p95_duration: 'Slow p95',
57
+ cpu: 'CPU',
58
+ heap_allocations: 'Heap',
59
+ rss_growth: 'RSS growth',
60
+ errors: 'Errors',
61
+ }
62
+
63
+ type UsageRangePreset = 'today' | 'last_1h' | 'last_6h' | 'last_24h' | 'all'
64
+
65
+ type UsageRange = {
66
+ preset: UsageRangePreset
67
+ startMs: number
68
+ endMs: number
69
+ }
70
+
71
+ type RangeModuleSummary = ModuleResourceUsageTimeBucketModule & {
72
+ stage: ModuleResourceUsageTimeBucket['stage']
73
+ }
74
+
75
+ type RangeModuleGroup = {
76
+ moduleId: string
77
+ stages: RangeModuleSummary[]
78
+ candidateReasons: string[]
79
+ calls: number
80
+ totalCpuMs: number
81
+ positiveRssDeltaBytes: number
82
+ }
83
+
84
+ const USAGE_RANGE_PRESETS: UsageRangePreset[] = ['today', 'last_1h', 'last_6h', 'last_24h', 'all']
85
+
86
+ function formatMs(ms: number): string {
87
+ if (!Number.isFinite(ms) || ms <= 0) return '0 ms'
88
+ if (ms >= 1000) return `${(ms / 1000).toFixed(ms >= 10_000 ? 1 : 2)} s`
89
+ return `${Math.round(ms)} ms`
90
+ }
91
+
92
+ function formatCount(value: number): string {
93
+ return new Intl.NumberFormat().format(value)
94
+ }
95
+
96
+ function formatBucketInterval(ms: number | undefined): string {
97
+ if (!ms || !Number.isFinite(ms)) return 'interval'
98
+ if (ms >= 60 * 60 * 1000) {
99
+ const hours = Math.round(ms / (60 * 60 * 1000))
100
+ return `${hours}h`
101
+ }
102
+ const minutes = Math.round(ms / (60 * 1000))
103
+ return `${minutes}m`
104
+ }
105
+
106
+ function bucketIntervalLabel(
107
+ buckets: ModuleResourceUsageTimeBucket[],
108
+ fallbackMs: number | undefined,
109
+ translate: (key: string, fallback?: string, values?: Record<string, string | number>) => string,
110
+ ): string {
111
+ const intervals = new Set(
112
+ buckets
113
+ .map((bucket) => bucket.bucketIntervalMs)
114
+ .filter((value) => Number.isFinite(value) && value > 0),
115
+ )
116
+ if (intervals.size === 1) return formatBucketInterval(Array.from(intervals)[0])
117
+ return formatBucketInterval(fallbackMs)
118
+ }
119
+
120
+ function bucketCountLabel(
121
+ buckets: ModuleResourceUsageTimeBucket[],
122
+ fallbackMs: number | undefined,
123
+ translate: (key: string, fallback?: string, values?: Record<string, string | number>) => string,
124
+ ): string {
125
+ const intervals = new Set(
126
+ buckets
127
+ .map((bucket) => bucket.bucketIntervalMs)
128
+ .filter((value) => Number.isFinite(value) && value > 0),
129
+ )
130
+ if (intervals.size > 1) {
131
+ return translate(
132
+ 'configs.moduleTelemetry.overview.mixedBucketCount',
133
+ '{{count}} buckets · mixed intervals',
134
+ { count: buckets.length },
135
+ )
136
+ }
137
+ return translate(
138
+ 'configs.moduleTelemetry.overview.bucketCount',
139
+ '{{count}} {{bucketWord}} ({{interval}} interval)',
140
+ {
141
+ count: buckets.length,
142
+ bucketWord: buckets.length === 1
143
+ ? translate('configs.moduleTelemetry.overview.bucketSingular', 'bucket')
144
+ : translate('configs.moduleTelemetry.overview.bucketPlural', 'buckets'),
145
+ interval: bucketIntervalLabel(buckets, fallbackMs, translate),
146
+ },
147
+ )
148
+ }
149
+
150
+ function formatRangeBoundary(ms: number): string {
151
+ return new Date(ms).toLocaleString(undefined, {
152
+ month: 'short',
153
+ day: 'numeric',
154
+ hour: '2-digit',
155
+ minute: '2-digit',
156
+ })
157
+ }
158
+
159
+ function MetricTitle({
160
+ label,
161
+ tooltip,
162
+ }: {
163
+ label: string
164
+ tooltip: string
165
+ }) {
166
+ return (
167
+ <div className="inline-flex min-w-0 items-center gap-1.5 text-xs uppercase tracking-wide text-muted-foreground">
168
+ <span className="truncate">{label}</span>
169
+ <SimpleTooltip content={tooltip} side="top" align="center" variant="light" size="lg">
170
+ <span
171
+ className="inline-flex size-4 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground"
172
+ aria-label={tooltip}
173
+ >
174
+ <Info className="h-3.5 w-3.5" aria-hidden="true" />
175
+ </span>
176
+ </SimpleTooltip>
177
+ </div>
178
+ )
179
+ }
180
+
181
+ function MetricCard({
182
+ label,
183
+ tooltip,
184
+ value,
185
+ subValue,
186
+ }: {
187
+ label: string
188
+ tooltip: string
189
+ value: React.ReactNode
190
+ subValue?: React.ReactNode
191
+ }) {
192
+ return (
193
+ <div className="rounded-lg border bg-card p-4">
194
+ <MetricTitle label={label} tooltip={tooltip} />
195
+ <div className="mt-2 text-2xl font-semibold">{value}</div>
196
+ {subValue ? <div className="mt-1 text-xs text-muted-foreground">{subValue}</div> : null}
197
+ </div>
198
+ )
199
+ }
200
+
201
+ function firstBucketStartMs(buckets: ModuleResourceUsageTimeBucket[]): number | null {
202
+ const starts = buckets
203
+ .map((bucket) => Date.parse(bucket.bucketStart))
204
+ .filter((value) => Number.isFinite(value))
205
+ return starts.length ? Math.min(...starts) : null
206
+ }
207
+
208
+ function firstBucketEndMs(buckets: ModuleResourceUsageTimeBucket[]): number | null {
209
+ const ends = buckets
210
+ .map((bucket) => Date.parse(bucket.bucketEnd))
211
+ .filter((value) => Number.isFinite(value))
212
+ return ends.length ? Math.min(...ends) : null
213
+ }
214
+
215
+ function firstAvailableTelemetryMs(buckets: ModuleResourceUsageTimeBucket[], startedAtMs: number, nowMs: number): number {
216
+ const firstBucketMs = firstBucketStartMs(buckets)
217
+ if (firstBucketMs === null) return Number.isFinite(startedAtMs) ? startedAtMs : nowMs
218
+ if (!Number.isFinite(startedAtMs)) return firstBucketMs
219
+
220
+ const firstBucketEnd = firstBucketEndMs(buckets)
221
+ if (firstBucketEnd !== null && firstBucketEnd > startedAtMs) return startedAtMs
222
+ return firstBucketMs
223
+ }
224
+
225
+ function startOfTodayMs(nowMs: number): number {
226
+ const date = new Date(nowMs)
227
+ date.setHours(0, 0, 0, 0)
228
+ return date.getTime()
229
+ }
230
+
231
+ function resolveUsageRange(
232
+ preset: UsageRangePreset,
233
+ buckets: ModuleResourceUsageTimeBucket[],
234
+ startedAt: string,
235
+ nowMs = Date.now(),
236
+ ): UsageRange {
237
+ const startedAtMs = Date.parse(startedAt)
238
+ const availableStartMs = firstAvailableTelemetryMs(buckets, startedAtMs, nowMs)
239
+ const rawStartMs = preset === 'today'
240
+ ? startOfTodayMs(nowMs)
241
+ : preset === 'last_1h'
242
+ ? nowMs - HOUR_MS
243
+ : preset === 'last_6h'
244
+ ? nowMs - 6 * HOUR_MS
245
+ : preset === 'last_24h'
246
+ ? nowMs - 24 * HOUR_MS
247
+ : availableStartMs
248
+ const boundedStartMs = preset === 'all' ? rawStartMs : Math.max(rawStartMs, availableStartMs)
249
+ return {
250
+ preset,
251
+ startMs: Math.min(boundedStartMs, nowMs),
252
+ endMs: nowMs,
253
+ }
254
+ }
255
+
256
+ function bucketsInRange(buckets: ModuleResourceUsageTimeBucket[], range: UsageRange): ModuleResourceUsageTimeBucket[] {
257
+ return buckets.filter((bucket) => {
258
+ const bucketStartMs = Date.parse(bucket.bucketStart)
259
+ const bucketEndMs = Date.parse(bucket.bucketEnd)
260
+ if (!Number.isFinite(bucketStartMs) || !Number.isFinite(bucketEndMs)) return false
261
+ return bucketEndMs > range.startMs && bucketStartMs <= range.endMs
262
+ })
263
+ }
264
+
265
+ // Hours of telemetry actually observed in the range (sum of the returned buckets' own
266
+ // intervals), not the wall-clock span of the range. Buckets are only created when an
267
+ // operation runs, so idle stretches with no tracked calls would otherwise dilute the
268
+ // wall-clock denominator and make "per hour" rates incomparable across range presets
269
+ // (e.g. "Today" diluted by idle daytime vs. "Last hour" right after a burst).
270
+ function activeRangeHours(buckets: ModuleResourceUsageTimeBucket[], bucketIntervalMs: number | undefined): number {
271
+ const fallbackIntervalMs = bucketIntervalMs && Number.isFinite(bucketIntervalMs) && bucketIntervalMs > 0
272
+ ? bucketIntervalMs
273
+ : HOUR_MS
274
+ if (!buckets.length) return fallbackIntervalMs / HOUR_MS
275
+ const totalMs = buckets.reduce((sum, bucket) => {
276
+ const interval = Number.isFinite(bucket.bucketIntervalMs) && bucket.bucketIntervalMs > 0
277
+ ? bucket.bucketIntervalMs
278
+ : fallbackIntervalMs
279
+ return sum + interval
280
+ }, 0)
281
+ return Math.max(fallbackIntervalMs / HOUR_MS, totalMs / HOUR_MS)
282
+ }
283
+
284
+ function formatActiveDuration(hours: number): string {
285
+ if (!Number.isFinite(hours) || hours <= 0) return '0m'
286
+ if (hours < 1) return `${Math.max(Math.round(hours * 60), 1)}m`
287
+ if (hours < 10) return `${hours.toFixed(1)}h`
288
+ return `${Math.round(hours)}h`
289
+ }
290
+
291
+ function usageRangeLabel(preset: UsageRangePreset, translate: (key: string, fallback?: string) => string): string {
292
+ if (preset === 'today') return translate('configs.moduleTelemetry.range.today', 'Today')
293
+ if (preset === 'last_1h') return translate('configs.moduleTelemetry.range.last1h', 'Last hour')
294
+ if (preset === 'last_6h') return translate('configs.moduleTelemetry.range.last6h', 'Last 6 hours')
295
+ if (preset === 'last_24h') return translate('configs.moduleTelemetry.range.last24h', 'Last 24 hours')
296
+ return translate('configs.moduleTelemetry.range.all', 'All available')
297
+ }
298
+
299
+ function isUsageRangePreset(value: string | null): value is UsageRangePreset {
300
+ return !!value && USAGE_RANGE_PRESETS.includes(value as UsageRangePreset)
301
+ }
302
+
303
+ function readUsageRangePreset(searchParams: { get(name: string): string | null } | null): UsageRangePreset {
304
+ const value = searchParams?.get('range') ?? null
305
+ return isUsageRangePreset(value) ? value : 'today'
306
+ }
307
+
308
+ function usageStageLabel(stage: ModuleResourceUsageTimeBucket['stage'], translate: (key: string, fallback?: string) => string): string {
309
+ if (stage === 'startup') return translate('configs.moduleTelemetry.stage.startup', 'Startup')
310
+ return translate('configs.moduleTelemetry.stage.running', 'Running')
311
+ }
312
+
313
+ function sortStageRows(a: RangeModuleSummary, b: RangeModuleSummary): number {
314
+ if (a.stage !== b.stage) return a.stage === 'startup' ? -1 : 1
315
+ return b.totalCpuMs - a.totalCpuMs
316
+ }
317
+
318
+ function groupRangeModules(modules: RangeModuleSummary[]): RangeModuleGroup[] {
319
+ const groups = new Map<string, RangeModuleSummary[]>()
320
+ for (const module of modules) {
321
+ const group = groups.get(module.moduleId) ?? []
322
+ group.push(module)
323
+ groups.set(module.moduleId, group)
324
+ }
325
+
326
+ return Array.from(groups.entries()).map(([moduleId, stages]) => {
327
+ const candidateReasons = Array.from(new Set(stages.flatMap((stage) => stage.candidateReasons)))
328
+ return {
329
+ moduleId,
330
+ stages: [...stages].sort(sortStageRows),
331
+ candidateReasons,
332
+ calls: stages.reduce((sum, stage) => sum + stage.calls, 0),
333
+ totalCpuMs: stages.reduce((sum, stage) => sum + stage.totalCpuMs, 0),
334
+ positiveRssDeltaBytes: stages.reduce((sum, stage) => sum + stage.positiveRssDeltaBytes, 0),
335
+ }
336
+ }).sort((a, b) =>
337
+ b.candidateReasons.length - a.candidateReasons.length
338
+ || b.totalCpuMs - a.totalCpuMs
339
+ || b.positiveRssDeltaBytes - a.positiveRssDeltaBytes
340
+ || b.calls - a.calls
341
+ || a.moduleId.localeCompare(b.moduleId)
342
+ )
343
+ }
344
+
345
+ // Thresholds are sized as "per 5-minute bucket" heavy-usage limits (see
346
+ // getModuleResourceUsageThresholds()). A range can span anywhere from one bucket ("Last hour")
347
+ // to hundreds ("Today"/"All available"), so comparing a raw range-wide sum against a per-bucket
348
+ // threshold makes longer ranges strictly more likely to flag a signal regardless of actual
349
+ // per-window intensity. Normalize CPU/heap/RSS/errors to "average per bucket this module was
350
+ // active in" before comparing, so the same sustained workload reads the same way at any range.
351
+ // p95DurationMs is already a max across buckets (not a sum), so it needs no normalization.
352
+ function buildCandidateReasonsForModule(
353
+ module: Pick<ModuleResourceUsageTimeBucketModule, 'p95DurationMs' | 'totalCpuMs' | 'positiveHeapDeltaBytes' | 'positiveRssDeltaBytes' | 'errors'>,
354
+ thresholds: ModuleResourceUsageReport['thresholds'],
355
+ bucketCount: number,
356
+ ): string[] {
357
+ const divisor = Math.max(1, bucketCount)
358
+ const reasons: string[] = []
359
+ if (module.p95DurationMs >= thresholds.p95DurationMs) reasons.push('p95_duration')
360
+ if (module.totalCpuMs / divisor >= thresholds.cpuMs) reasons.push('cpu')
361
+ if (module.positiveHeapDeltaBytes / divisor >= thresholds.positiveHeapDeltaBytes) reasons.push('heap_allocations')
362
+ if (module.positiveRssDeltaBytes / divisor >= thresholds.positiveRssDeltaBytes) reasons.push('rss_growth')
363
+ if (module.errors / divisor >= thresholds.errors) reasons.push('errors')
364
+ return reasons
365
+ }
366
+
367
+ function mergeRangeOperations(modules: ModuleResourceUsageTimeBucketModule[]) {
368
+ const operations = new Map<string, ModuleResourceUsageTimeBucketModule['topOperations'][number]>()
369
+ for (const module of modules) {
370
+ for (const operation of module.topOperations) {
371
+ const key = `${operation.surface}\u0000${operation.operation}\u0000${operation.resourceId ?? ''}`
372
+ const existing = operations.get(key)
373
+ if (!existing) {
374
+ operations.set(key, { ...operation })
375
+ continue
376
+ }
377
+ existing.calls += operation.calls
378
+ existing.errors += operation.errors
379
+ existing.totalDurationMs += operation.totalDurationMs
380
+ existing.maxDurationMs = Math.max(existing.maxDurationMs, operation.maxDurationMs)
381
+ existing.p95DurationMs = Math.max(existing.p95DurationMs, operation.p95DurationMs)
382
+ existing.totalCpuUserMs += operation.totalCpuUserMs
383
+ existing.totalCpuSystemMs += operation.totalCpuSystemMs
384
+ existing.maxCpuMs = Math.max(existing.maxCpuMs, operation.maxCpuMs)
385
+ existing.totalHeapDeltaBytes += operation.totalHeapDeltaBytes
386
+ existing.positiveHeapDeltaBytes += operation.positiveHeapDeltaBytes
387
+ existing.maxHeapDeltaBytes = Math.max(existing.maxHeapDeltaBytes, operation.maxHeapDeltaBytes)
388
+ existing.totalRssDeltaBytes += operation.totalRssDeltaBytes
389
+ existing.positiveRssDeltaBytes += operation.positiveRssDeltaBytes
390
+ existing.maxRssDeltaBytes = Math.max(existing.maxRssDeltaBytes, operation.maxRssDeltaBytes)
391
+ existing.firstSeenAt = existing.firstSeenAt < operation.firstSeenAt ? existing.firstSeenAt : operation.firstSeenAt
392
+ existing.lastSeenAt = existing.lastSeenAt > operation.lastSeenAt ? existing.lastSeenAt : operation.lastSeenAt
393
+ }
394
+ }
395
+ return Array.from(operations.values())
396
+ .sort((a, b) =>
397
+ (b.totalCpuUserMs + b.totalCpuSystemMs) - (a.totalCpuUserMs + a.totalCpuSystemMs)
398
+ || b.totalDurationMs - a.totalDurationMs
399
+ || b.calls - a.calls
400
+ )
401
+ .slice(0, 5)
402
+ }
403
+
404
+ function mergeRangeSurfaces(modules: ModuleResourceUsageTimeBucketModule[]): ModuleResourceUsageTimeBucketModule['surfaces'] {
405
+ const surfaces = new Map<string, ModuleResourceUsageTimeBucketModule['surfaces'][number]>()
406
+ for (const module of modules) {
407
+ for (const surface of module.surfaces) {
408
+ const existing = surfaces.get(surface.surface)
409
+ if (!existing) {
410
+ surfaces.set(surface.surface, { ...surface })
411
+ continue
412
+ }
413
+ existing.calls += surface.calls
414
+ existing.errors += surface.errors
415
+ existing.totalDurationMs += surface.totalDurationMs
416
+ existing.p95DurationMs = Math.max(existing.p95DurationMs, surface.p95DurationMs)
417
+ existing.totalCpuMs += surface.totalCpuMs
418
+ existing.positiveHeapDeltaBytes += surface.positiveHeapDeltaBytes
419
+ existing.positiveRssDeltaBytes += surface.positiveRssDeltaBytes
420
+ }
421
+ }
422
+ return Array.from(surfaces.values()).sort((a, b) => b.totalCpuMs - a.totalCpuMs || b.totalDurationMs - a.totalDurationMs)
423
+ }
424
+
425
+ function aggregateRangeModules(
426
+ buckets: ModuleResourceUsageTimeBucket[],
427
+ thresholds: ModuleResourceUsageReport['thresholds'],
428
+ ): RangeModuleSummary[] {
429
+ const grouped = new Map<string, {
430
+ moduleId: string
431
+ stage: ModuleResourceUsageTimeBucket['stage']
432
+ modules: ModuleResourceUsageTimeBucketModule[]
433
+ // Distinct bucketStart values this module+stage appeared in. Tracked explicitly (rather than
434
+ // relying on modules.length) so the "per-bucket average" candidate-signal divisor stays
435
+ // correct even if a future change ever pushes more/fewer than one module entry per bucket.
436
+ bucketKeys: Set<string>
437
+ }>()
438
+ for (const bucket of buckets) {
439
+ for (const module of bucket.modules) {
440
+ const key = `${module.moduleId}\u0000${bucket.stage}`
441
+ const group = grouped.get(key) ?? { moduleId: module.moduleId, stage: bucket.stage, modules: [], bucketKeys: new Set<string>() }
442
+ group.modules.push(module)
443
+ group.bucketKeys.add(bucket.bucketStart)
444
+ grouped.set(key, group)
445
+ }
446
+ }
447
+
448
+ return Array.from(grouped.values()).map(({ moduleId, stage, modules, bucketKeys }) => {
449
+ const summaryBase = {
450
+ moduleId,
451
+ stage,
452
+ calls: modules.reduce((sum, module) => sum + module.calls, 0),
453
+ errors: modules.reduce((sum, module) => sum + module.errors, 0),
454
+ totalDurationMs: modules.reduce((sum, module) => sum + module.totalDurationMs, 0),
455
+ p95DurationMs: Math.max(...modules.map((module) => module.p95DurationMs), 0),
456
+ totalCpuMs: modules.reduce((sum, module) => sum + module.totalCpuMs, 0),
457
+ positiveHeapDeltaBytes: modules.reduce((sum, module) => sum + module.positiveHeapDeltaBytes, 0),
458
+ positiveRssDeltaBytes: modules.reduce((sum, module) => sum + module.positiveRssDeltaBytes, 0),
459
+ surfaces: mergeRangeSurfaces(modules),
460
+ topOperations: mergeRangeOperations(modules),
461
+ }
462
+ return {
463
+ ...summaryBase,
464
+ candidateReasons: buildCandidateReasonsForModule(summaryBase, thresholds, bucketKeys.size),
465
+ }
466
+ }).sort((a, b) =>
467
+ b.candidateReasons.length - a.candidateReasons.length
468
+ || b.totalCpuMs - a.totalCpuMs
469
+ || b.positiveRssDeltaBytes - a.positiveRssDeltaBytes
470
+ || b.calls - a.calls
471
+ )
472
+ }
473
+
474
+ function RangeOverview({
475
+ buckets,
476
+ modules,
477
+ range,
478
+ rangePreset,
479
+ onRangePresetChange,
480
+ bucketIntervalMs,
481
+ translate,
482
+ }: {
483
+ buckets: ModuleResourceUsageTimeBucket[]
484
+ modules: RangeModuleSummary[]
485
+ range: UsageRange
486
+ rangePreset: UsageRangePreset
487
+ onRangePresetChange: (value: UsageRangePreset) => void
488
+ bucketIntervalMs?: number
489
+ translate: (key: string, fallback?: string, values?: Record<string, string | number>) => string
490
+ }) {
491
+ const selectedHours = activeRangeHours(buckets, bucketIntervalMs)
492
+ const activeDurationLabel = formatActiveDuration(selectedHours)
493
+ const bucketsLabel = bucketCountLabel(buckets, bucketIntervalMs, translate)
494
+ const totalHeapGrowthBytes = modules.reduce((sum, module) => sum + module.positiveHeapDeltaBytes, 0)
495
+ const totalRssGrowthBytes = modules.reduce((sum, module) => sum + module.positiveRssDeltaBytes, 0)
496
+ const totals = {
497
+ modules: new Set(modules.map((module) => module.moduleId)).size,
498
+ calls: modules.reduce((sum, module) => sum + module.calls, 0),
499
+ totalCpuMs: modules.reduce((sum, module) => sum + module.totalCpuMs, 0),
500
+ avgHeapGrowthPerHour: totalHeapGrowthBytes / selectedHours,
501
+ avgRssGrowthPerHour: totalRssGrowthBytes / selectedHours,
502
+ }
503
+ const growthTooltip = translate(
504
+ 'configs.moduleTelemetry.overview.growthTooltip',
505
+ 'Average positive growth per hour of measured activity in the selected range: total growth divided by the hours actually covered by tracked buckets, not the full wall-clock span. Idle stretches with no tracked calls do not dilute this rate, so it stays comparable across range presets. This is allocation pressure over time, not the current live process memory.',
506
+ )
507
+ const heapSubValue = translate(
508
+ 'configs.moduleTelemetry.overview.growthSubValue',
509
+ '{{total}} total over {{duration}}',
510
+ { total: formatBytes(totalHeapGrowthBytes), duration: activeDurationLabel },
511
+ )
512
+ const rssSubValue = translate(
513
+ 'configs.moduleTelemetry.overview.growthSubValue',
514
+ '{{total}} total over {{duration}}',
515
+ { total: formatBytes(totalRssGrowthBytes), duration: activeDurationLabel },
516
+ )
517
+
518
+ return (
519
+ <div className="space-y-4 rounded-lg border bg-card p-4">
520
+ <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
521
+ <div className="space-y-1">
522
+ <h3 className="text-base font-semibold">{translate('configs.moduleTelemetry.overview.title', 'Usage overview')}</h3>
523
+ <p className="text-sm text-muted-foreground">
524
+ {translate(
525
+ 'configs.moduleTelemetry.overview.description',
526
+ 'From {{from}} to now · {{buckets}}',
527
+ {
528
+ from: formatRangeBoundary(range.startMs),
529
+ buckets: bucketsLabel,
530
+ },
531
+ )}
532
+ </p>
533
+ </div>
534
+ <div className="flex flex-col gap-2 sm:flex-row">
535
+ <SegmentedControl
536
+ value={rangePreset}
537
+ onValueChange={(value) => onRangePresetChange(value as UsageRangePreset)}
538
+ aria-label={translate('configs.moduleTelemetry.range.label', 'Time range')}
539
+ size="sm"
540
+ className="max-w-full flex-wrap overflow-x-auto rounded-lg"
541
+ >
542
+ {USAGE_RANGE_PRESETS.map((option) => (
543
+ <SegmentedControlItem key={option} value={option}>
544
+ {usageRangeLabel(option, translate)}
545
+ </SegmentedControlItem>
546
+ ))}
547
+ </SegmentedControl>
548
+ </div>
549
+ </div>
550
+ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
551
+ <MetricCard
552
+ label={translate('configs.moduleTelemetry.overview.modules', 'Modules')}
553
+ tooltip={translate('configs.moduleTelemetry.overview.modulesTooltip', 'Unique modules with tracked activity in the selected range. Startup and running are shown as separate rows inside each module group.')}
554
+ value={formatCount(totals.modules)}
555
+ />
556
+ <MetricCard
557
+ label={translate('configs.moduleTelemetry.overview.calls', 'Calls')}
558
+ tooltip={translate('configs.moduleTelemetry.overview.callsTooltip', 'Total tracked operation calls in the selected range.')}
559
+ value={formatCount(totals.calls)}
560
+ />
561
+ <MetricCard
562
+ label={translate('configs.moduleTelemetry.overview.cpu', 'CPU')}
563
+ tooltip={translate('configs.moduleTelemetry.overview.cpuTooltip', 'Total CPU time attributed to tracked module operations in the selected range.')}
564
+ value={formatMs(totals.totalCpuMs)}
565
+ />
566
+ <MetricCard
567
+ label={translate('configs.moduleTelemetry.overview.heapPerHour', 'Heap / hour')}
568
+ tooltip={growthTooltip}
569
+ value={formatBytes(totals.avgHeapGrowthPerHour)}
570
+ subValue={heapSubValue}
571
+ />
572
+ <MetricCard
573
+ label={translate('configs.moduleTelemetry.overview.rssPerHour', 'RSS / hour')}
574
+ tooltip={growthTooltip}
575
+ value={formatBytes(totals.avgRssGrowthPerHour)}
576
+ subValue={rssSubValue}
577
+ />
578
+ </div>
579
+ </div>
580
+ )
581
+ }
582
+
583
+ function CandidateBadges({
584
+ module,
585
+ translate,
586
+ }: {
587
+ module: ModuleResourceUsageTimeBucketModule
588
+ translate: (key: string, fallback?: string, values?: Record<string, string | number>) => string
589
+ }) {
590
+ if (!module.candidateReasons.length) {
591
+ return (
592
+ <StatusBadge variant="neutral" dot>
593
+ {translate('configs.moduleTelemetry.table.normal', 'Normal')}
594
+ </StatusBadge>
595
+ )
596
+ }
597
+
598
+ return (
599
+ <div className="flex flex-wrap gap-1.5">
600
+ {module.candidateReasons.map((reason) => (
601
+ <StatusBadge key={reason} variant="warning" dot>
602
+ {translate(REASON_LABEL_KEYS[reason] ?? `configs.moduleTelemetry.reason.${reason}`, REASON_FALLBACKS[reason] ?? reason)}
603
+ </StatusBadge>
604
+ ))}
605
+ </div>
606
+ )
607
+ }
608
+
609
+ function StageBadge({
610
+ stage,
611
+ translate,
612
+ }: {
613
+ stage: ModuleResourceUsageTimeBucket['stage']
614
+ translate: (key: string, fallback?: string, values?: Record<string, string | number>) => string
615
+ }) {
616
+ return (
617
+ <StatusBadge variant={stage === 'startup' ? 'info' : 'neutral'} dot>
618
+ {usageStageLabel(stage, translate)}
619
+ </StatusBadge>
620
+ )
621
+ }
622
+
623
+ function copyRowStats(module: RangeModuleSummary, translate: (key: string, fallback?: string) => string): void {
624
+ const json = JSON.stringify(module, null, 2)
625
+ navigator.clipboard
626
+ .writeText(json)
627
+ .then(() => {
628
+ flash(translate('configs.moduleTelemetry.table.copySuccess', 'Row stats copied to clipboard.'), 'success')
629
+ })
630
+ .catch((err) => {
631
+ console.warn('[ModuleTelemetryPanel] clipboard write failed', err)
632
+ flash(translate('configs.moduleTelemetry.table.copyError', 'Failed to copy row stats.'), 'error')
633
+ })
634
+ }
635
+
636
+ function TopOperationsBreakdown({
637
+ operations,
638
+ translate,
639
+ }: {
640
+ operations: ModuleResourceUsageEntry[]
641
+ translate: (key: string, fallback?: string, values?: Record<string, string | number>) => string
642
+ }) {
643
+ return (
644
+ <div className="w-[26rem] max-w-[80vw] space-y-2">
645
+ <div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
646
+ {translate('configs.moduleTelemetry.table.topOperationsBreakdown', 'Top operations in this range')}
647
+ </div>
648
+ <table className="w-full text-xs">
649
+ <thead>
650
+ <tr className="text-muted-foreground">
651
+ <th className="pb-1 pr-2 text-left font-medium">{translate('configs.moduleTelemetry.table.topOperation', 'Top operation')}</th>
652
+ <th className="pb-1 pr-2 text-right font-medium">{translate('configs.moduleTelemetry.table.calls', 'Calls')}</th>
653
+ <th className="pb-1 pr-2 text-right font-medium">{translate('configs.moduleTelemetry.table.worstP95', 'Worst p95')}</th>
654
+ <th className="pb-1 pr-2 text-right font-medium">{translate('configs.moduleTelemetry.table.cpu', 'CPU')}</th>
655
+ <th className="pb-1 pr-2 text-right font-medium">{translate('configs.moduleTelemetry.table.heap', 'Heap growth')}</th>
656
+ <th className="pb-1 text-right font-medium">{translate('configs.moduleTelemetry.table.rss', 'RSS growth')}</th>
657
+ </tr>
658
+ </thead>
659
+ <tbody>
660
+ {operations.map((operation, index) => (
661
+ <tr key={`${operation.surface}${operation.operation}${index}`} className="border-t border-border/50">
662
+ <td className="py-1 pr-2 align-top">
663
+ <div className="font-medium text-foreground">{operation.operation}</div>
664
+ <div className="text-muted-foreground">
665
+ {operation.surface}
666
+ {operation.errors > 0
667
+ ? ` · ${translate('configs.moduleTelemetry.table.errorCount', '{{count}} errors', { count: operation.errors })}`
668
+ : ''}
669
+ </div>
670
+ {operation.concurrentCalls > 0 ? (
671
+ <div className="text-muted-foreground">
672
+ {translate(
673
+ 'configs.moduleTelemetry.table.concurrentOverlap',
674
+ '{{percent}}% overlapped other calls — CPU/heap/RSS less certain',
675
+ { percent: Math.round((operation.concurrentCalls / Math.max(operation.calls, 1)) * 100) },
676
+ )}
677
+ </div>
678
+ ) : null}
679
+ </td>
680
+ <td className="py-1 pr-2 text-right align-top">{formatCount(operation.calls)}</td>
681
+ <td className="py-1 pr-2 text-right align-top">{formatMs(operation.p95DurationMs)}</td>
682
+ <td className="py-1 pr-2 text-right align-top">{formatMs(operation.totalCpuUserMs + operation.totalCpuSystemMs)}</td>
683
+ <td className="py-1 pr-2 text-right align-top">{formatBytes(operation.positiveHeapDeltaBytes)}</td>
684
+ <td className="py-1 text-right align-top">{formatBytes(operation.positiveRssDeltaBytes)}</td>
685
+ </tr>
686
+ ))}
687
+ </tbody>
688
+ </table>
689
+ </div>
690
+ )
691
+ }
692
+
693
+ function RangeModuleTable({
694
+ modules,
695
+ translate,
696
+ }: {
697
+ modules: RangeModuleSummary[]
698
+ translate: (key: string, fallback?: string, values?: Record<string, string | number>) => string
699
+ }) {
700
+ if (!modules.length) {
701
+ return (
702
+ <p className="text-sm text-muted-foreground">
703
+ {translate('configs.moduleTelemetry.modules.empty', 'No module activity has been recorded for this range yet.')}
704
+ </p>
705
+ )
706
+ }
707
+
708
+ const groups = groupRangeModules(modules)
709
+
710
+ return (
711
+ <TooltipProvider delayDuration={200}>
712
+ <div className="overflow-x-auto">
713
+ <table className="w-full min-w-[980px] text-sm">
714
+ <thead>
715
+ <tr className="text-xs uppercase tracking-wide text-muted-foreground">
716
+ <th className="px-3 py-2 text-left">{translate('configs.moduleTelemetry.table.module', 'Module')}</th>
717
+ <th className="px-3 py-2 text-left">{translate('configs.moduleTelemetry.table.stage', 'Stage')}</th>
718
+ <th className="px-3 py-2 text-left">
719
+ <MetricTitle
720
+ label={translate('configs.moduleTelemetry.table.signals', 'Signals')}
721
+ tooltip={translate(
722
+ 'configs.moduleTelemetry.table.signalsTooltip',
723
+ 'Flags a module whose CPU, heap growth, RSS growth, or errors average at or above a heavy-usage threshold per 5-minute bucket it was active in during this range. This is a per-bucket average, not a raw range-wide total, so the same sustained workload looks the same regardless of the selected time range.',
724
+ )}
725
+ />
726
+ </th>
727
+ <th className="px-3 py-2 text-right">{translate('configs.moduleTelemetry.table.calls', 'Calls')}</th>
728
+ <th className="px-3 py-2 text-right">{translate('configs.moduleTelemetry.table.worstP95', 'Worst p95')}</th>
729
+ <th className="px-3 py-2 text-right">{translate('configs.moduleTelemetry.table.cpu', 'CPU')}</th>
730
+ <th className="px-3 py-2 text-right">{translate('configs.moduleTelemetry.table.heap', 'Heap growth')}</th>
731
+ <th className="px-3 py-2 text-right">{translate('configs.moduleTelemetry.table.rss', 'RSS growth')}</th>
732
+ <th className="px-3 py-2 text-left">{translate('configs.moduleTelemetry.table.topOperation', 'Top operation')}</th>
733
+ <th className="px-3 py-2 text-right">{translate('configs.moduleTelemetry.table.actions', 'Actions')}</th>
734
+ </tr>
735
+ </thead>
736
+ <tbody>
737
+ {groups.map((group) => (
738
+ <React.Fragment key={group.moduleId}>
739
+ {group.stages.map((module, stageIndex) => {
740
+ const topOperation = module.topOperations[0]
741
+ const topSurface = module.surfaces[0]
742
+ return (
743
+ <tr
744
+ key={`${module.moduleId}:${module.stage}`}
745
+ className={stageIndex === 0 ? 'border-t' : 'border-t border-border/50'}
746
+ >
747
+ {stageIndex === 0 ? (
748
+ <td className="w-56 px-3 py-3 align-top" rowSpan={group.stages.length}>
749
+ <div className="flex flex-col gap-1">
750
+ <span className="font-medium">{group.moduleId}</span>
751
+ <span className="text-xs text-muted-foreground">
752
+ {translate(
753
+ 'configs.moduleTelemetry.table.stageCount',
754
+ '{{count}} stage rows',
755
+ { count: group.stages.length },
756
+ )}
757
+ </span>
758
+ </div>
759
+ </td>
760
+ ) : null}
761
+ <td className="px-3 py-3 align-top">
762
+ <div className="flex flex-col gap-1">
763
+ <StageBadge stage={module.stage} translate={translate} />
764
+ <span className="text-xs text-muted-foreground">
765
+ {topSurface
766
+ ? translate(
767
+ 'configs.moduleTelemetry.table.surfaceSummary',
768
+ '{{surface}} · {{calls}} calls',
769
+ { surface: topSurface.surface, calls: topSurface.calls },
770
+ )
771
+ : translate('configs.moduleTelemetry.table.noSurface', 'No surface data')}
772
+ </span>
773
+ </div>
774
+ </td>
775
+ <td className="px-3 py-3 align-top">
776
+ <CandidateBadges module={module} translate={translate} />
777
+ </td>
778
+ <td className="px-3 py-3 align-top text-right">
779
+ <div className="flex flex-col gap-1">
780
+ <span>{formatCount(module.calls)}</span>
781
+ {module.errors > 0 ? (
782
+ <span className="text-xs text-destructive">
783
+ {translate('configs.moduleTelemetry.table.errorCount', '{{count}} errors', { count: module.errors })}
784
+ </span>
785
+ ) : null}
786
+ </div>
787
+ </td>
788
+ <td className="px-3 py-3 align-top text-right">{formatMs(module.p95DurationMs)}</td>
789
+ <td className="px-3 py-3 align-top text-right">{formatMs(module.totalCpuMs)}</td>
790
+ <td className="px-3 py-3 align-top text-right">{formatBytes(module.positiveHeapDeltaBytes)}</td>
791
+ <td className="px-3 py-3 align-top text-right">{formatBytes(module.positiveRssDeltaBytes)}</td>
792
+ <td className="px-3 py-3 align-top">
793
+ {topOperation ? (
794
+ <Tooltip delayDuration={200}>
795
+ <TooltipTrigger asChild>
796
+ <div className="flex cursor-default flex-col gap-1">
797
+ <span className="font-medium underline decoration-dotted decoration-muted-foreground underline-offset-2">
798
+ {topOperation.operation}
799
+ </span>
800
+ <span className="text-xs text-muted-foreground">
801
+ {translate(
802
+ 'configs.moduleTelemetry.table.topOperationMeta',
803
+ '{{surface}} · {{cpu}} CPU · {{calls}} calls',
804
+ {
805
+ surface: topOperation.surface,
806
+ cpu: formatMs(topOperation.totalCpuUserMs + topOperation.totalCpuSystemMs),
807
+ calls: topOperation.calls,
808
+ },
809
+ )}
810
+ </span>
811
+ </div>
812
+ </TooltipTrigger>
813
+ <TooltipContent side="left" align="start" variant="light" className="max-w-none p-3">
814
+ <TopOperationsBreakdown operations={module.topOperations} translate={translate} />
815
+ </TooltipContent>
816
+ </Tooltip>
817
+ ) : (
818
+ <span className="text-muted-foreground">{translate('configs.moduleTelemetry.table.none', 'None')}</span>
819
+ )}
820
+ </td>
821
+ <td className="px-3 py-3 align-top text-right">
822
+ <SimpleTooltip
823
+ content={translate('configs.moduleTelemetry.table.copyStats', 'Copy row stats for debugging')}
824
+ side="top"
825
+ align="center"
826
+ variant="light"
827
+ >
828
+ <IconButton
829
+ type="button"
830
+ variant="ghost"
831
+ size="sm"
832
+ aria-label={translate('configs.moduleTelemetry.table.copyStats', 'Copy row stats for debugging')}
833
+ onClick={() => copyRowStats(module, translate)}
834
+ >
835
+ <Copy className="h-4 w-4" aria-hidden="true" />
836
+ </IconButton>
837
+ </SimpleTooltip>
838
+ </td>
839
+ </tr>
840
+ )
841
+ })}
842
+ </React.Fragment>
843
+ ))}
844
+ </tbody>
845
+ </table>
846
+ </div>
847
+ </TooltipProvider>
848
+ )
849
+ }
850
+
851
+ function RangeModuleSection({
852
+ modules,
853
+ range,
854
+ bucketIntervalMs,
855
+ bucketsLabel,
856
+ translate,
857
+ }: {
858
+ modules: RangeModuleSummary[]
859
+ range: UsageRange
860
+ bucketIntervalMs?: number
861
+ bucketsLabel: string
862
+ translate: (key: string, fallback?: string, values?: Record<string, string | number>) => string
863
+ }) {
864
+ return (
865
+ <div className="space-y-4 rounded-lg border bg-card p-4">
866
+ <div className="space-y-1">
867
+ <h3 className="text-base font-semibold">{translate('configs.moduleTelemetry.modules.title', 'Modules')}</h3>
868
+ <p className="text-sm text-muted-foreground">
869
+ {translate(
870
+ 'configs.moduleTelemetry.modules.description',
871
+ 'Aggregated module stats from {{from}} to now across {{buckets}}. Startup and running rows are separated.',
872
+ {
873
+ from: formatRangeBoundary(range.startMs),
874
+ buckets: bucketsLabel,
875
+ },
876
+ )}
877
+ </p>
878
+ <p className="text-xs text-muted-foreground">
879
+ {translate(
880
+ 'configs.moduleTelemetry.modules.interval',
881
+ 'Startup is the first {{interval}} bucket after telemetry starts. Later buckets are running.',
882
+ { interval: formatBucketInterval(bucketIntervalMs) },
883
+ )}
884
+ </p>
885
+ </div>
886
+ <RangeModuleTable modules={modules} translate={translate} />
887
+ </div>
888
+ )
889
+ }
890
+
891
+ export function ModuleTelemetryPanel() {
892
+ const t = useT()
893
+ const router = useRouter()
894
+ const pathname = usePathname()
895
+ const searchParams = useSearchParams()
896
+ const [state, setState] = React.useState<FetchState>({ loading: true, error: null, report: null })
897
+ const [clearingTelemetry, setClearingTelemetry] = React.useState(false)
898
+ const rangeParam = searchParams.get('range')
899
+ const [usageRangePreset, setUsageRangePreset] = React.useState<UsageRangePreset>(() => readUsageRangePreset(searchParams))
900
+ const { confirm, ConfirmDialogElement } = useConfirmDialog()
901
+
902
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
903
+ formId: string
904
+ resourceKind: string
905
+ retryLastMutation: () => Promise<boolean>
906
+ }>({
907
+ contextId: 'configs-module-telemetry',
908
+ blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
909
+ })
910
+
911
+ const clearMutationContext = React.useMemo(
912
+ () => ({
913
+ formId: 'configs-module-telemetry',
914
+ resourceKind: 'configs.moduleTelemetry',
915
+ retryLastMutation,
916
+ }),
917
+ [retryLastMutation],
918
+ )
919
+
920
+ const loadReport = React.useCallback(async () => {
921
+ setState((current) => ({ ...current, loading: true, error: null }))
922
+ try {
923
+ const report = await readApiResultOrThrow<ModuleTelemetryApiResponse>(API_PATH, undefined, {
924
+ errorMessage: t('configs.moduleTelemetry.loadError', 'Failed to load module telemetry.'),
925
+ })
926
+ setState({ loading: false, error: null, report })
927
+ } catch (error) {
928
+ const message =
929
+ error instanceof Error && error.message
930
+ ? error.message
931
+ : t('configs.moduleTelemetry.loadError', 'Failed to load module telemetry.')
932
+ setState({ loading: false, error: message, report: null })
933
+ }
934
+ }, [t])
935
+
936
+ React.useEffect(() => {
937
+ loadReport().catch(() => {})
938
+ }, [loadReport])
939
+
940
+ React.useEffect(() => {
941
+ const nextRange = readUsageRangePreset(searchParams)
942
+ setUsageRangePreset((current) => current === nextRange ? current : nextRange)
943
+ }, [rangeParam, searchParams])
944
+
945
+ const handleUsageRangeChange = React.useCallback((value: UsageRangePreset) => {
946
+ setUsageRangePreset(value)
947
+ const params = new URLSearchParams(searchParams.toString())
948
+ params.set('range', value)
949
+ const query = params.toString()
950
+ router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false })
951
+ }, [pathname, router, searchParams])
952
+
953
+ const handleRefresh = React.useCallback(() => {
954
+ loadReport().catch(() => {})
955
+ }, [loadReport])
956
+
957
+ const handleClearTelemetry = React.useCallback(async () => {
958
+ if (clearingTelemetry) return
959
+ const confirmed = await confirm({
960
+ title: t('configs.moduleTelemetry.clear.confirmTitle', 'Clear all telemetry data?'),
961
+ text: t(
962
+ 'configs.moduleTelemetry.clear.confirmText',
963
+ 'This removes current module telemetry and local process telemetry files for this development instance.',
964
+ ),
965
+ confirmText: t('configs.moduleTelemetry.clear.confirmButton', 'Clear telemetry'),
966
+ variant: 'destructive',
967
+ })
968
+ if (!confirmed) return
969
+
970
+ setClearingTelemetry(true)
971
+ try {
972
+ await runMutation({
973
+ // optimistic-lock-exempt: clears in-process/dev-only telemetry buckets and
974
+ // local process telemetry files — there is no versioned entity (no updated_at)
975
+ // to lock against, so no expected-version header applies.
976
+ operation: () =>
977
+ readApiResultOrThrow<ModuleTelemetryClearResponse>(
978
+ API_PATH,
979
+ { method: 'DELETE' },
980
+ {
981
+ errorMessage: t('configs.moduleTelemetry.clear.error', 'Failed to clear module telemetry.'),
982
+ allowNullResult: true,
983
+ },
984
+ ),
985
+ context: clearMutationContext,
986
+ mutationPayload: {},
987
+ })
988
+ await loadReport()
989
+ flash(t('configs.moduleTelemetry.clear.success', 'Module telemetry data cleared.'), 'success')
990
+ } catch (error) {
991
+ const message =
992
+ error instanceof Error && error.message
993
+ ? error.message
994
+ : t('configs.moduleTelemetry.clear.error', 'Failed to clear module telemetry.')
995
+ flash(message, 'error')
996
+ } finally {
997
+ setClearingTelemetry(false)
998
+ }
999
+ }, [clearMutationContext, clearingTelemetry, confirm, loadReport, runMutation, t])
1000
+
1001
+ if (state.loading) {
1002
+ return (
1003
+ <section className="space-y-3 rounded-lg border bg-background p-6">
1004
+ <header className="space-y-1">
1005
+ <h2 className="text-lg font-semibold">{t('configs.moduleTelemetry.title', 'Module telemetry')}</h2>
1006
+ <p className="text-sm text-muted-foreground">
1007
+ {t('configs.moduleTelemetry.description', 'Preview module-level resource attribution collected in this process.')}
1008
+ </p>
1009
+ </header>
1010
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
1011
+ <Spinner className="h-4 w-4" />
1012
+ {t('configs.moduleTelemetry.loading', 'Loading module telemetry…')}
1013
+ </div>
1014
+ </section>
1015
+ )
1016
+ }
1017
+
1018
+ if (state.error) {
1019
+ return (
1020
+ <section className="space-y-3 rounded-lg border bg-background p-6">
1021
+ <header className="space-y-1">
1022
+ <h2 className="text-lg font-semibold">{t('configs.moduleTelemetry.title', 'Module telemetry')}</h2>
1023
+ <p className="text-sm text-muted-foreground">
1024
+ {t('configs.moduleTelemetry.description', 'Preview module-level resource attribution collected in this process.')}
1025
+ </p>
1026
+ </header>
1027
+ <ErrorMessage label={state.error} />
1028
+ <Button type="button" variant="outline" onClick={() => loadReport().catch(() => {})}>
1029
+ {t('configs.moduleTelemetry.retry', 'Retry')}
1030
+ </Button>
1031
+ </section>
1032
+ )
1033
+ }
1034
+
1035
+ const report = state.report
1036
+ if (!report) return null
1037
+ const usageRange = resolveUsageRange(usageRangePreset, report.buckets ?? [], report.startedAt)
1038
+ const rangeBuckets = bucketsInRange(report.buckets ?? [], usageRange)
1039
+ const rangeModules = aggregateRangeModules(rangeBuckets, report.thresholds)
1040
+ const bucketsLabel = bucketCountLabel(rangeBuckets, report.bucketIntervalMs, t)
1041
+ const telemetryStartedAtMs = firstAvailableTelemetryMs(report.buckets ?? [], Date.parse(report.startedAt), Date.now())
1042
+
1043
+ return (
1044
+ <section className="space-y-6 rounded-lg border bg-background p-6">
1045
+ <header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
1046
+ <div className="space-y-1">
1047
+ <h2 className="text-lg font-semibold">{t('configs.moduleTelemetry.title', 'Module telemetry')}</h2>
1048
+ <p className="text-sm text-muted-foreground">
1049
+ {t('configs.moduleTelemetry.description', 'Preview module-level resource attribution collected in this process.')}
1050
+ </p>
1051
+ <p className="text-xs text-muted-foreground">
1052
+ {t(
1053
+ 'configs.moduleTelemetry.generatedAt',
1054
+ 'Report generated {{timestamp}}',
1055
+ { timestamp: new Date(report.generatedAt).toLocaleString() },
1056
+ )}
1057
+ </p>
1058
+ <p className="text-xs text-muted-foreground">
1059
+ {t(
1060
+ 'configs.moduleTelemetry.startedAt',
1061
+ 'Collecting since {{timestamp}}',
1062
+ { timestamp: new Date(telemetryStartedAtMs).toLocaleString() },
1063
+ )}
1064
+ </p>
1065
+ </div>
1066
+ <div className="flex flex-wrap gap-2">
1067
+ {report.canClearTelemetry ? (
1068
+ <Button
1069
+ type="button"
1070
+ variant="destructive-outline"
1071
+ onClick={() => { void handleClearTelemetry() }}
1072
+ disabled={clearingTelemetry}
1073
+ >
1074
+ <Trash2 className="h-4 w-4" aria-hidden="true" />
1075
+ {clearingTelemetry
1076
+ ? t('configs.moduleTelemetry.clear.clearing', 'Clearing...')
1077
+ : t('configs.moduleTelemetry.clear.button', 'Clear all telemetry data')}
1078
+ </Button>
1079
+ ) : null}
1080
+ <Button type="button" variant="outline" onClick={handleRefresh}>
1081
+ <RefreshCw className="h-4 w-4" aria-hidden="true" />
1082
+ {t('configs.moduleTelemetry.refresh', 'Refresh')}
1083
+ </Button>
1084
+ </div>
1085
+ </header>
1086
+
1087
+ <RangeOverview
1088
+ buckets={rangeBuckets}
1089
+ modules={rangeModules}
1090
+ range={usageRange}
1091
+ rangePreset={usageRangePreset}
1092
+ onRangePresetChange={handleUsageRangeChange}
1093
+ bucketIntervalMs={report.bucketIntervalMs}
1094
+ translate={t}
1095
+ />
1096
+ <RangeModuleSection
1097
+ modules={rangeModules}
1098
+ range={usageRange}
1099
+ bucketIntervalMs={report.bucketIntervalMs}
1100
+ bucketsLabel={bucketsLabel}
1101
+ translate={t}
1102
+ />
1103
+
1104
+ {ConfirmDialogElement}
1105
+ </section>
1106
+ )
1107
+ }
1108
+
1109
+ export default ModuleTelemetryPanel