portal-mojo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (369) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +55 -0
  3. package/docs/DetailView.md +173 -0
  4. package/docs/ModelTable.md +128 -0
  5. package/docs/README.md +98 -0
  6. package/docs/admin-assistant.md +72 -0
  7. package/docs/admin-bouncer.md +120 -0
  8. package/docs/admin-cloudwatch.md +38 -0
  9. package/docs/admin-credentials.md +116 -0
  10. package/docs/admin-dashboard.md +15 -0
  11. package/docs/admin-devices-geoip.md +284 -0
  12. package/docs/admin-dns-certificates.md +122 -0
  13. package/docs/admin-dns-records.md +65 -0
  14. package/docs/admin-dns-registrar.md +33 -0
  15. package/docs/admin-dns.md +80 -0
  16. package/docs/admin-identity-users.md +103 -0
  17. package/docs/admin-incidents.md +71 -0
  18. package/docs/admin-jobs.md +426 -0
  19. package/docs/admin-members.md +112 -0
  20. package/docs/admin-messaging.md +56 -0
  21. package/docs/admin-metrics-explorer.md +63 -0
  22. package/docs/admin-monitoring.md +99 -0
  23. package/docs/admin-network-security.md +393 -0
  24. package/docs/admin-phonehub.md +27 -0
  25. package/docs/admin-push.md +36 -0
  26. package/docs/admin-rules.md +87 -0
  27. package/docs/admin-security-tickets.md +40 -0
  28. package/docs/admin-settings.md +78 -0
  29. package/docs/admin-shortlinks.md +32 -0
  30. package/docs/admin-storage.md +141 -0
  31. package/docs/auth-pages.md +212 -0
  32. package/docs/auth.md +58 -0
  33. package/docs/calendar.md +122 -0
  34. package/docs/charts.md +303 -0
  35. package/docs/client.md +206 -0
  36. package/docs/collection-multiselect.md +117 -0
  37. package/docs/collection-select.md +132 -0
  38. package/docs/combobox.md +105 -0
  39. package/docs/dataview.md +226 -0
  40. package/docs/datepicker.md +151 -0
  41. package/docs/daterange.md +170 -0
  42. package/docs/datetimepicker.md +249 -0
  43. package/docs/defineModel.md +72 -0
  44. package/docs/detail-primitives.md +174 -0
  45. package/docs/feedback.md +85 -0
  46. package/docs/file-upload-ux.md +146 -0
  47. package/docs/forms.md +435 -0
  48. package/docs/grouping-and-fmt.md +98 -0
  49. package/docs/idioms.md +112 -0
  50. package/docs/image-editor.md +101 -0
  51. package/docs/loading.md +242 -0
  52. package/docs/location-address.md +76 -0
  53. package/docs/markdown.md +203 -0
  54. package/docs/menus-and-access.md +89 -0
  55. package/docs/multiselect-dropdown.md +153 -0
  56. package/docs/params.md +73 -0
  57. package/docs/popover.md +130 -0
  58. package/docs/realtime.md +61 -0
  59. package/docs/record-feed.md +238 -0
  60. package/docs/right-panel.md +42 -0
  61. package/docs/tabs-and-form-wizard.md +60 -0
  62. package/docs/taginput.md +120 -0
  63. package/docs/timepicker.md +173 -0
  64. package/docs/timezone-select.md +147 -0
  65. package/docs/worldmap.md +302 -0
  66. package/package.json +55 -0
  67. package/src/admin/assistant/AssistantFeed.tsx +29 -0
  68. package/src/admin/assistant/AssistantPanel.tsx +65 -0
  69. package/src/admin/assistant/api.ts +17 -0
  70. package/src/admin/assistant/data.ts +63 -0
  71. package/src/admin/assistant/index.ts +21 -0
  72. package/src/admin/assistant/launchers.tsx +33 -0
  73. package/src/admin/assistant/pages.tsx +78 -0
  74. package/src/admin/assistant/streaming.ts +238 -0
  75. package/src/admin/assistant/types.ts +30 -0
  76. package/src/admin/bouncer/devices.tsx +230 -0
  77. package/src/admin/bouncer/index.ts +34 -0
  78. package/src/admin/bouncer/models.ts +199 -0
  79. package/src/admin/bouncer/signals.tsx +182 -0
  80. package/src/admin/bouncer/signatures.tsx +220 -0
  81. package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
  82. package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
  83. package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
  84. package/src/admin/cloudwatch/data.ts +163 -0
  85. package/src/admin/cloudwatch/index.ts +16 -0
  86. package/src/admin/credentials/group-api-keys.tsx +433 -0
  87. package/src/admin/credentials/index.ts +4 -0
  88. package/src/admin/credentials/models.ts +244 -0
  89. package/src/admin/credentials/secret-dialog.tsx +77 -0
  90. package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
  91. package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
  92. package/src/admin/dashboard/data.ts +60 -0
  93. package/src/admin/dashboard/index.ts +2 -0
  94. package/src/admin/dns/CertificateDetail.tsx +115 -0
  95. package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
  96. package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
  97. package/src/admin/dns/CertificatesPage.tsx +85 -0
  98. package/src/admin/dns/DnsRecordEditor.tsx +110 -0
  99. package/src/admin/dns/DnsRecordsPage.tsx +36 -0
  100. package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
  101. package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
  102. package/src/admin/dns/DomainDetail.tsx +41 -0
  103. package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
  104. package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
  105. package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
  106. package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
  107. package/src/admin/dns/DomainsPage.tsx +27 -0
  108. package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
  109. package/src/admin/dns/RegistrantContactPage.tsx +42 -0
  110. package/src/admin/dns/api.ts +515 -0
  111. package/src/admin/dns/certificate-data.ts +221 -0
  112. package/src/admin/dns/data.ts +32 -0
  113. package/src/admin/dns/dns-data.ts +266 -0
  114. package/src/admin/dns/dns-integration.ts +24 -0
  115. package/src/admin/dns/domain-links.ts +37 -0
  116. package/src/admin/dns/index.ts +86 -0
  117. package/src/admin/dns/models.ts +418 -0
  118. package/src/admin/dns/purchase-data.ts +67 -0
  119. package/src/admin/dns/registrant-data.ts +52 -0
  120. package/src/admin/identity/index.ts +5 -0
  121. package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
  122. package/src/admin/identity/members/MemberDetail.tsx +226 -0
  123. package/src/admin/identity/members/MembersPage.tsx +144 -0
  124. package/src/admin/identity/members/index.ts +24 -0
  125. package/src/admin/identity/members/member-flows.tsx +132 -0
  126. package/src/admin/identity/members/models.ts +204 -0
  127. package/src/admin/identity/users/UserDetail.tsx +257 -0
  128. package/src/admin/identity/users/UsersPage.tsx +163 -0
  129. package/src/admin/identity/users/index.ts +27 -0
  130. package/src/admin/identity/users/models.ts +232 -0
  131. package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
  132. package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
  133. package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
  134. package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
  135. package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
  136. package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
  137. package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
  138. package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
  139. package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
  140. package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
  141. package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
  142. package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
  143. package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
  144. package/src/admin/identity/users/sections/actions.tsx +752 -0
  145. package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
  146. package/src/admin/identity/users/sections/queries.ts +74 -0
  147. package/src/admin/identity/users/sections/shared.tsx +299 -0
  148. package/src/admin/incidents/EventDetail.tsx +43 -0
  149. package/src/admin/incidents/EventsPage.tsx +52 -0
  150. package/src/admin/incidents/IncidentDetail.tsx +68 -0
  151. package/src/admin/incidents/IncidentsPage.tsx +102 -0
  152. package/src/admin/incidents/forensics.tsx +73 -0
  153. package/src/admin/incidents/index.ts +16 -0
  154. package/src/admin/incidents/models.ts +113 -0
  155. package/src/admin/incidents/sanitize.ts +71 -0
  156. package/src/admin/index.ts +383 -0
  157. package/src/admin/jobs/JobDashboardPage.tsx +111 -0
  158. package/src/admin/jobs/JobDetail.tsx +721 -0
  159. package/src/admin/jobs/JobRunnersPage.tsx +216 -0
  160. package/src/admin/jobs/JobsTablePage.tsx +122 -0
  161. package/src/admin/jobs/RunnerDetail.tsx +576 -0
  162. package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
  163. package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
  164. package/src/admin/jobs/columns.tsx +297 -0
  165. package/src/admin/jobs/control.ts +488 -0
  166. package/src/admin/jobs/index.ts +85 -0
  167. package/src/admin/jobs/models.ts +599 -0
  168. package/src/admin/jobs/queries.ts +272 -0
  169. package/src/admin/jobs/scheduled-task-form.tsx +493 -0
  170. package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
  171. package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
  172. package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
  173. package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
  174. package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
  175. package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
  176. package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
  177. package/src/admin/messaging/MailboxesPage.tsx +15 -0
  178. package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
  179. package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
  180. package/src/admin/messaging/SentMessagesPage.tsx +11 -0
  181. package/src/admin/messaging/api.ts +17 -0
  182. package/src/admin/messaging/data.ts +10 -0
  183. package/src/admin/messaging/index.ts +23 -0
  184. package/src/admin/messaging/models.ts +76 -0
  185. package/src/admin/messaging/push/PushPage.tsx +44 -0
  186. package/src/admin/messaging/push/api.ts +17 -0
  187. package/src/admin/messaging/push/index.ts +9 -0
  188. package/src/admin/messaging/push/models.ts +43 -0
  189. package/src/admin/messaging/sanitize.ts +44 -0
  190. package/src/admin/monitoring/LogInspector.tsx +160 -0
  191. package/src/admin/monitoring/LogsPage.tsx +135 -0
  192. package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
  193. package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
  194. package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
  195. package/src/admin/monitoring/index.ts +55 -0
  196. package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
  197. package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
  198. package/src/admin/monitoring/models.ts +155 -0
  199. package/src/admin/network/BlockedIPsPage.tsx +194 -0
  200. package/src/admin/network/BlockedIpDetail.tsx +262 -0
  201. package/src/admin/network/FirewallLogPage.tsx +115 -0
  202. package/src/admin/network/IPSetDetail.tsx +280 -0
  203. package/src/admin/network/IPSetEditor.tsx +191 -0
  204. package/src/admin/network/IPSetsPage.tsx +177 -0
  205. package/src/admin/network/geofence/BlocksTab.tsx +289 -0
  206. package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
  207. package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
  208. package/src/admin/network/geofence/PostureHeader.tsx +184 -0
  209. package/src/admin/network/geofence/RuleEditor.tsx +176 -0
  210. package/src/admin/network/geofence/RulesTab.tsx +254 -0
  211. package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
  212. package/src/admin/network/geofence/geofence-data.ts +674 -0
  213. package/src/admin/network/index.ts +68 -0
  214. package/src/admin/network/models.ts +607 -0
  215. package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
  216. package/src/admin/phonehub/api.ts +47 -0
  217. package/src/admin/phonehub/data.ts +6 -0
  218. package/src/admin/phonehub/index.ts +10 -0
  219. package/src/admin/phonehub/models.ts +68 -0
  220. package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
  221. package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
  222. package/src/admin/rules/RuleSetsPage.tsx +33 -0
  223. package/src/admin/rules/editors.tsx +54 -0
  224. package/src/admin/rules/handler-dsl.ts +213 -0
  225. package/src/admin/rules/index.ts +13 -0
  226. package/src/admin/rules/models.ts +112 -0
  227. package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
  228. package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
  229. package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
  230. package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
  231. package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
  232. package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
  233. package/src/admin/security/devices/index.ts +40 -0
  234. package/src/admin/security/devices/models.ts +470 -0
  235. package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
  236. package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
  237. package/src/admin/security/geoip/geoip-forms.ts +102 -0
  238. package/src/admin/security/geoip/index.ts +27 -0
  239. package/src/admin/security/geoip/models.ts +397 -0
  240. package/src/admin/security/index.ts +31 -0
  241. package/src/admin/security/models.ts +137 -0
  242. package/src/admin/security/tickets.tsx +562 -0
  243. package/src/admin/security-permissions.ts +5 -0
  244. package/src/admin/settings/SettingDetail.tsx +57 -0
  245. package/src/admin/settings/SettingEditor.tsx +157 -0
  246. package/src/admin/settings/SettingsPage.tsx +72 -0
  247. package/src/admin/settings/index.ts +19 -0
  248. package/src/admin/settings/model.ts +143 -0
  249. package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
  250. package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
  251. package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
  252. package/src/admin/shortlinks/index.ts +18 -0
  253. package/src/admin/shortlinks/models.ts +246 -0
  254. package/src/admin/storage/BackendsPage.tsx +43 -0
  255. package/src/admin/storage/BucketDetail.tsx +77 -0
  256. package/src/admin/storage/BucketsPage.tsx +64 -0
  257. package/src/admin/storage/FileManagerDetail.tsx +36 -0
  258. package/src/admin/storage/FilePreview.tsx +54 -0
  259. package/src/admin/storage/FileUploadSurface.tsx +250 -0
  260. package/src/admin/storage/FileView.tsx +109 -0
  261. package/src/admin/storage/FilesPage.tsx +53 -0
  262. package/src/admin/storage/file-renditions.ts +99 -0
  263. package/src/admin/storage/index.ts +31 -0
  264. package/src/admin/storage/models.ts +585 -0
  265. package/src/admin/storage/storage-dialogs.tsx +80 -0
  266. package/src/charts/CircularProgress.tsx +281 -0
  267. package/src/charts/KPIStrip.tsx +162 -0
  268. package/src/charts/KPITile.tsx +125 -0
  269. package/src/charts/MetricsChart.tsx +459 -0
  270. package/src/charts/MetricsMiniWidget.tsx +588 -0
  271. package/src/charts/MiniChart.tsx +283 -0
  272. package/src/charts/PieChart.tsx +354 -0
  273. package/src/charts/SeriesChart.tsx +378 -0
  274. package/src/charts/chart-dialogs.tsx +149 -0
  275. package/src/charts/exportChart.ts +144 -0
  276. package/src/charts/index.ts +28 -0
  277. package/src/charts/pie-math.ts +127 -0
  278. package/src/charts/stats.ts +265 -0
  279. package/src/charts/worldmap/WorldMap.tsx +649 -0
  280. package/src/charts/worldmap/countryCentroids.ts +304 -0
  281. package/src/charts/worldmap/geo.ts +293 -0
  282. package/src/charts/worldmap/use-world-land.ts +47 -0
  283. package/src/charts/worldmap/world-land.ts +18 -0
  284. package/src/charts/worldmap/worldmap-data.ts +285 -0
  285. package/src/client/auth.ts +737 -0
  286. package/src/client/client.ts +268 -0
  287. package/src/client/duid.ts +31 -0
  288. package/src/client/errors.ts +33 -0
  289. package/src/client/group-context.ts +28 -0
  290. package/src/client/group.tsx +126 -0
  291. package/src/client/hooks.ts +50 -0
  292. package/src/client/index.ts +37 -0
  293. package/src/client/jwt.ts +75 -0
  294. package/src/client/location.ts +285 -0
  295. package/src/client/lookups.ts +41 -0
  296. package/src/client/markdown-fallback.tsx +115 -0
  297. package/src/client/markdown-parse.ts +386 -0
  298. package/src/client/markdown-sanitize.ts +217 -0
  299. package/src/client/markdown.ts +122 -0
  300. package/src/client/me.ts +188 -0
  301. package/src/client/mock.ts +9984 -0
  302. package/src/client/model.ts +227 -0
  303. package/src/client/params.ts +292 -0
  304. package/src/client/realtime-mock.ts +221 -0
  305. package/src/client/realtime.ts +561 -0
  306. package/src/client/record-feed.ts +329 -0
  307. package/src/client/safe-export.ts +71 -0
  308. package/src/client/types.ts +278 -0
  309. package/src/client/upload.ts +421 -0
  310. package/src/ui/AddressField.tsx +273 -0
  311. package/src/ui/AttachmentQueue.tsx +92 -0
  312. package/src/ui/CollectionMultiSelect.tsx +622 -0
  313. package/src/ui/CollectionSelect.tsx +517 -0
  314. package/src/ui/ComboBox.tsx +336 -0
  315. package/src/ui/DetailView.tsx +438 -0
  316. package/src/ui/FileDrop.tsx +194 -0
  317. package/src/ui/FileField.tsx +369 -0
  318. package/src/ui/FilterBar.tsx +269 -0
  319. package/src/ui/FormFields.tsx +77 -0
  320. package/src/ui/FormView.tsx +360 -0
  321. package/src/ui/FormWizard.tsx +336 -0
  322. package/src/ui/GroupSwitcher.tsx +202 -0
  323. package/src/ui/Guarded.tsx +16 -0
  324. package/src/ui/MarkdownView.tsx +89 -0
  325. package/src/ui/ModelTable.tsx +848 -0
  326. package/src/ui/MultiSelectDropdown.tsx +390 -0
  327. package/src/ui/Popover.tsx +204 -0
  328. package/src/ui/RecordFeed.tsx +627 -0
  329. package/src/ui/RequiresGroup.tsx +32 -0
  330. package/src/ui/RightPanel.tsx +107 -0
  331. package/src/ui/SidebarNav.tsx +330 -0
  332. package/src/ui/Tabs.tsx +161 -0
  333. package/src/ui/TagInput.tsx +381 -0
  334. package/src/ui/ThemeProvider.tsx +52 -0
  335. package/src/ui/UploadQueue.tsx +603 -0
  336. package/src/ui/armed-button.tsx +91 -0
  337. package/src/ui/date/Calendar.tsx +498 -0
  338. package/src/ui/date/DatePicker.tsx +289 -0
  339. package/src/ui/date/DateRangePicker.tsx +280 -0
  340. package/src/ui/date/DateTimePicker.tsx +587 -0
  341. package/src/ui/date/PresetRail.tsx +165 -0
  342. package/src/ui/date/TimePicker.tsx +624 -0
  343. package/src/ui/date/TimezoneSelect.tsx +267 -0
  344. package/src/ui/date/fns.ts +679 -0
  345. package/src/ui/detail/DataView.tsx +692 -0
  346. package/src/ui/detail/FlowStrip.tsx +80 -0
  347. package/src/ui/detail/KnownFieldsCard.tsx +121 -0
  348. package/src/ui/detail/MetadataSection.tsx +219 -0
  349. package/src/ui/detail/StackTraceView.tsx +150 -0
  350. package/src/ui/detail/StatusPanel.tsx +73 -0
  351. package/src/ui/detail/Timeline.tsx +52 -0
  352. package/src/ui/detail/index.ts +12 -0
  353. package/src/ui/field-registry.tsx +428 -0
  354. package/src/ui/field-wire.ts +322 -0
  355. package/src/ui/form-autosave.ts +583 -0
  356. package/src/ui/format.ts +444 -0
  357. package/src/ui/grouping.ts +150 -0
  358. package/src/ui/image-editor/ImageEditor.tsx +484 -0
  359. package/src/ui/image-editor/canvas.ts +254 -0
  360. package/src/ui/image-editor/index.ts +3 -0
  361. package/src/ui/image-editor/math.ts +416 -0
  362. package/src/ui/index.ts +58 -0
  363. package/src/ui/loading.tsx +604 -0
  364. package/src/ui/menu-registry.ts +213 -0
  365. package/src/ui/modal.tsx +297 -0
  366. package/src/ui/password.tsx +302 -0
  367. package/src/ui/schema-form-core.tsx +315 -0
  368. package/src/ui/toast.tsx +219 -0
  369. package/src/ui/ui.tsx +45 -0
@@ -0,0 +1,160 @@
1
+ import { useEffect, useRef, useState, type ReactNode } from 'react';
2
+ import { Badge, JsonBlock, fmt, toast, type Tone } from '../../ui';
3
+ import type { LogRow } from './models';
4
+
5
+ export type StoredLogKind = 'request' | 'response' | 'message';
6
+
7
+ const LEVEL_TONE: Record<string, Tone> = {
8
+ error: 'danger',
9
+ critical: 'danger',
10
+ warning: 'warning',
11
+ info: 'info',
12
+ };
13
+
14
+ /** Classify only explicit backend kind tokens; prose is never guessed. */
15
+ export function storedLogKind(row: Pick<LogRow, 'kind'>): StoredLogKind {
16
+ const kind = (row.kind ?? '').toLowerCase();
17
+ if (/(^|[:._-])response($|[:._-])/.test(kind)) return 'response';
18
+ if (/(^|[:._-])request($|[:._-])/.test(kind)) return 'request';
19
+ return 'message';
20
+ }
21
+
22
+ export function storedLogContentLabel(kind: StoredLogKind): string {
23
+ if (kind === 'request') return 'Stored request content';
24
+ if (kind === 'response') return 'Stored response content';
25
+ return 'Message';
26
+ }
27
+
28
+ interface StoredContent {
29
+ raw: string;
30
+ json: unknown | null;
31
+ }
32
+
33
+ function storedContent(value: string | null): StoredContent | null {
34
+ if (value == null || value === '') return null;
35
+ try {
36
+ return { raw: value, json: JSON.parse(value) as unknown };
37
+ } catch {
38
+ return { raw: value, json: null };
39
+ }
40
+ }
41
+
42
+ function CopyTextButton({ text, label }: { text: string; label: string }) {
43
+ const [copied, setCopied] = useState(false);
44
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
45
+ useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
46
+
47
+ const copy = async () => {
48
+ const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard;
49
+ if (!clipboard) {
50
+ toast.error('Clipboard unavailable in this context');
51
+ return;
52
+ }
53
+ try {
54
+ await clipboard.writeText(text);
55
+ setCopied(true);
56
+ toast.success(`${label} copied to clipboard`);
57
+ if (timer.current) clearTimeout(timer.current);
58
+ timer.current = setTimeout(() => setCopied(false), 1200);
59
+ } catch (error) {
60
+ toast.error(`Copy failed: ${error instanceof Error ? error.message : String(error)}`);
61
+ }
62
+ };
63
+
64
+ return (
65
+ <button type="button" className="btn btn-compact" onClick={() => void copy()}>
66
+ <i className={`bi ${copied ? 'bi-check2' : 'bi-clipboard'}`} /> {copied ? 'Copied' : 'Copy'}
67
+ </button>
68
+ );
69
+ }
70
+
71
+ function ContentBlock({ label, content }: { label: string; content: StoredContent | null }) {
72
+ if (!content) {
73
+ return (
74
+ <section className="monitoring-inspector-block">
75
+ <div className="eyebrow">{label}</div>
76
+ <p className="dim">Not stored on this record.</p>
77
+ </section>
78
+ );
79
+ }
80
+ return (
81
+ <section className="monitoring-inspector-block">
82
+ {content.json !== null
83
+ ? <JsonBlock value={content.json} label={label} defaultOpen />
84
+ : (
85
+ <>
86
+ <div className="monitoring-inspector-block-head">
87
+ <div className="eyebrow">{label}</div>
88
+ <CopyTextButton text={content.raw} label={label} />
89
+ </div>
90
+ <pre className="monitoring-plain"><code>{content.raw}</code></pre>
91
+ </>
92
+ )}
93
+ </section>
94
+ );
95
+ }
96
+
97
+ function Fact({ label, children }: { label: string; children: ReactNode }) {
98
+ return (
99
+ <div className="monitoring-fact">
100
+ <span>{label}</span>
101
+ <strong>{children}</strong>
102
+ </div>
103
+ );
104
+ }
105
+
106
+ /** Focused viewer for exactly one stored log row; it never invents a pair. */
107
+ export function LogInspector({ log, onClose }: { log: LogRow; onClose?: () => void }) {
108
+ const recordKind = storedLogKind(log);
109
+ const message = storedContent(log.log);
110
+ const payload = storedContent(log.payload);
111
+ const route = [log.method, log.path].filter(Boolean).join(' ') || 'No request route';
112
+
113
+ return (
114
+ <div className="monitoring-inspector">
115
+ <header className="monitoring-inspector-head">
116
+ <div>
117
+ <div className="eyebrow">Stored {recordKind} record</div>
118
+ <h2>{route}</h2>
119
+ <p>{fmt.datetime(log.created)} · log #{log.id}</p>
120
+ </div>
121
+ <div className="monitoring-inspector-actions">
122
+ <Badge tone={LEVEL_TONE[log.level.toLowerCase()] ?? 'muted'}>{log.level}</Badge>
123
+ {onClose && (
124
+ <button type="button" className="btn-icon" onClick={onClose} aria-label="Close inspector" title="Close">
125
+ <i className="bi bi-x-lg" />
126
+ </button>
127
+ )}
128
+ </div>
129
+ </header>
130
+
131
+ <div className="monitoring-facts">
132
+ <Fact label="Kind">{log.kind || 'message'}</Fact>
133
+ <Fact label="User">{log.username || (log.uid ? `#${log.uid}` : '—')}</Fact>
134
+ <Fact label="Group">{log.gid || '—'}</Fact>
135
+ <Fact label="IP">{log.ip || '—'}</Fact>
136
+ <Fact label="Browser ID"><code>{log.duid || '—'}</code></Fact>
137
+ <Fact label="Related record">{log.model_name ? `${log.model_name}#${log.model_id}` : '—'}</Fact>
138
+ </div>
139
+
140
+ <ContentBlock label={storedLogContentLabel(recordKind)} content={message} />
141
+ <ContentBlock label="Auxiliary payload" content={payload} />
142
+
143
+ {log.user_agent && (
144
+ <section className="monitoring-inspector-block">
145
+ <div className="monitoring-inspector-block-head">
146
+ <div className="eyebrow">User agent</div>
147
+ <CopyTextButton text={log.user_agent} label="User agent" />
148
+ </div>
149
+ <p className="monitoring-break">{log.user_agent}</p>
150
+ </section>
151
+ )}
152
+ {(recordKind === 'request' || recordKind === 'response') && (
153
+ <p className="monitoring-pairing-note">
154
+ <i className="bi bi-info-circle" /> This is one independently stored {recordKind} record.
155
+ The API exposes no request/response correlation key, so no paired record is claimed.
156
+ </p>
157
+ )}
158
+ </div>
159
+ );
160
+ }
@@ -0,0 +1,135 @@
1
+ import {
2
+ Badge, JsonBlock, ModelTable, fmt, groupByDay, modal,
3
+ type Column, type FilterDef, type Tone,
4
+ } from '../../ui';
5
+ import { LogInspector, storedLogContentLabel, storedLogKind } from './LogInspector';
6
+ import { LOG_LEVEL_OPTIONS, LogModel, type LogRow } from './models';
7
+
8
+ const LEVEL_TONE: Record<string, Tone> = {
9
+ error: 'danger',
10
+ critical: 'danger',
11
+ warning: 'warning',
12
+ info: 'info',
13
+ };
14
+
15
+ export const LOG_COLUMNS: Column<LogRow>[] = [
16
+ {
17
+ key: 'created', label: 'Time', sortable: true, hideable: false, render: (log) => (
18
+ <span title={fmt.datetime(log.created)}>{fmt.relative(log.created)}</span>
19
+ ),
20
+ },
21
+ {
22
+ key: 'level', label: 'Level', sortable: true, render: (log) => (
23
+ <Badge tone={LEVEL_TONE[log.level.toLowerCase()] ?? 'muted'}>{log.level}</Badge>
24
+ ),
25
+ },
26
+ { key: 'kind', label: 'Kind', sortable: true, render: (log) => log.kind ?? <span className="dim">—</span> },
27
+ {
28
+ key: 'method', label: 'Method', align: 'center', render: (log) =>
29
+ log.method ? <code className="dim">{log.method}</code> : <span className="dim">—</span>,
30
+ },
31
+ {
32
+ key: 'path', label: 'Path', render: (log) =>
33
+ log.path ? <code>{fmt.truncate(log.path, 42)}</code> : <span className="dim">—</span>,
34
+ },
35
+ {
36
+ key: 'username', label: 'User', render: (log) =>
37
+ log.username ? log.username : log.uid ? <span className="dim">#{log.uid}</span> : <span className="dim">—</span>,
38
+ },
39
+ { key: 'ip', label: 'IP', render: (log) => log.ip ? <code className="dim">{log.ip}</code> : <span className="dim">—</span> },
40
+ {
41
+ key: 'log', label: 'Message', render: (log) =>
42
+ log.log ? <span className="dim">{fmt.truncate(log.log, 70)}</span> : <span className="dim">—</span>,
43
+ },
44
+ ];
45
+
46
+ export const LOG_FILTERS: FilterDef[] = [
47
+ { key: 'level', label: 'Level', type: 'multiselect', options: LOG_LEVEL_OPTIONS },
48
+ { key: 'kind', label: 'Kind', type: 'text', placeholder: 'request, response, login…' },
49
+ {
50
+ key: 'method', label: 'Method', type: 'select', options: [
51
+ { value: 'GET', label: 'GET' },
52
+ { value: 'POST', label: 'POST' },
53
+ { value: 'PUT', label: 'PUT' },
54
+ { value: 'DELETE', label: 'DELETE' },
55
+ ],
56
+ },
57
+ { key: 'path', label: 'Path', type: 'text', placeholder: 'Contains…' },
58
+ { key: 'username', label: 'Username', type: 'text', placeholder: 'Contains…' },
59
+ { key: 'ip', label: 'IP', type: 'text', placeholder: 'Contains…' },
60
+ { key: 'uid', label: 'User ID', type: 'number', lookup: 'exact' },
61
+ { key: 'gid', label: 'Group ID', type: 'number', lookup: 'exact' },
62
+ { key: 'model_name', label: 'Model', type: 'text', placeholder: 'account.User…' },
63
+ { key: 'created', label: 'Created', type: 'daterange' },
64
+ ];
65
+
66
+ /** Chevron quick-look. Row click opens the full inspector independently. */
67
+ export function LogQuickLook({ log }: { log: LogRow }) {
68
+ const contentLabel = storedLogContentLabel(storedLogKind(log));
69
+ let payload: unknown = null;
70
+ if (log.payload) {
71
+ try { payload = JSON.parse(log.payload) as unknown; } catch { payload = log.payload; }
72
+ }
73
+ return (
74
+ <div className="expand-grid">
75
+ <div>
76
+ <div className="eyebrow">{contentLabel}</div>
77
+ <div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
78
+ {log.log ?? <span className="dim">—</span>}
79
+ </div>
80
+ {log.model_name && (
81
+ <>
82
+ <div className="eyebrow" style={{ marginTop: 10 }}>Record</div>
83
+ <div><code>{log.model_name}#{log.model_id}</code></div>
84
+ </>
85
+ )}
86
+ </div>
87
+ <div>
88
+ <div className="eyebrow">Request context</div>
89
+ <div>{log.method ?? '—'} <code>{log.path ?? '—'}</code></div>
90
+ <div className="dim">IP {log.ip ?? '—'}{log.duid && <> · DUID <code>{log.duid}</code></>}</div>
91
+ <div className="dim" style={{ wordBreak: 'break-word' }}>{log.user_agent ?? ''}</div>
92
+ <div className="dim">uid {log.uid || '—'} · gid {log.gid || '—'} · <code>#{log.id}</code> · {fmt.datetime(log.created)}</div>
93
+ </div>
94
+ <div>
95
+ <div className="eyebrow">Auxiliary payload</div>
96
+ {payload != null
97
+ ? <JsonBlock value={payload} defaultOpen />
98
+ : <span className="dim">No payload</span>}
99
+ </div>
100
+ </div>
101
+ );
102
+ }
103
+
104
+ export function openLogInspector(log: LogRow): void {
105
+ void modal.detail((close) => <LogInspector log={log} onClose={() => close(null)} />);
106
+ }
107
+
108
+ export function LogsPage() {
109
+ return (
110
+ <ModelTable<LogRow>
111
+ model={LogModel}
112
+ eyebrow="System"
113
+ title="Logs"
114
+ searchPlaceholder="Search path, message, user…"
115
+ columns={LOG_COLUMNS}
116
+ filters={LOG_FILTERS}
117
+ presets={[
118
+ { key: 'all', label: 'All', params: {} },
119
+ { key: 'errors', label: 'Errors', params: { level__in: 'error,critical' } },
120
+ { key: 'warnings', label: 'Warnings', params: { level: 'warning' } },
121
+ { key: 'requests', label: 'Requests', params: { kind: 'request' } },
122
+ { key: 'responses', label: 'Responses', params: { kind: 'response' } },
123
+ { key: 'logins', label: 'Logins', params: { kind: 'login' } },
124
+ ]}
125
+ defaultSort="-created"
126
+ columnChooser
127
+ persistState
128
+ exportFormats={['csv', 'json']}
129
+ autoRefresh={15}
130
+ onRowClick={openLogInspector}
131
+ rowExpand={(log) => <LogQuickLook log={log} />}
132
+ {...groupByDay<LogRow>('created')}
133
+ />
134
+ );
135
+ }
@@ -0,0 +1,360 @@
1
+ import { useCallback, useMemo, useState } from 'react';
2
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
3
+ import { useSearchParams } from 'react-router-dom';
4
+ import { KPITile, MetricsChart } from '../../charts';
5
+ import { useAuthSnapshot, useMe } from '../../client';
6
+ import { modal } from '../../ui';
7
+ import {
8
+ dedupeMetricSlugs,
9
+ metricsDiscoveryKey,
10
+ parseMetricAccount,
11
+ replaceMetricSlugParams,
12
+ type MetricGaugeValue,
13
+ type MetricAccount,
14
+ type MetricPoint,
15
+ } from './metrics-explorer-data';
16
+ import {
17
+ discoverMetrics,
18
+ fetchMetricPoints,
19
+ loadExactMetricSeries,
20
+ readMetricValue,
21
+ } from './metrics-explorer-client';
22
+ import { MetricsSourcePicker } from './MetricsSourcePicker';
23
+
24
+ function errorMessage(error: unknown, fallback: string): string {
25
+ return error instanceof Error ? error.message : fallback;
26
+ }
27
+
28
+ const warnedFanoutModes = new Set<string>();
29
+ function normalizeFanoutMode(value: string | null): 'off' | 'sum' | 'breakdown' {
30
+ if (value == null || value === 'off') return 'off';
31
+ if (value === 'sum' || value === 'breakdown') return value;
32
+ if (!warnedFanoutModes.has(value)) {
33
+ warnedFanoutModes.add(value);
34
+ console.warn(`MetricsExplorerPage: unknown fan-out mode "${value}" — falling back to off`);
35
+ }
36
+ return 'off';
37
+ }
38
+
39
+ function ScalarMetricDetail({ result, scale, onClose }: { result: MetricGaugeValue; scale: number; onClose: () => void }) {
40
+ const scaled = typeof result.value === 'number' && Number.isFinite(result.value)
41
+ ? result.value * scale
42
+ : null;
43
+ return (
44
+ <div className="monitoring-inspector metrics-scalar-detail">
45
+ <header className="monitoring-inspector-head">
46
+ <div>
47
+ <div className="eyebrow">Exact scalar</div>
48
+ <h2>{result.slug}</h2>
49
+ <p><code>{result.account}</code></p>
50
+ </div>
51
+ <button type="button" className="btn-icon" aria-label="Close" onClick={onClose}><i className="bi bi-x-lg" /></button>
52
+ </header>
53
+ <div className="monitoring-facts">
54
+ <div className="monitoring-fact"><span>Raw value</span><strong>{typeof result.value === 'string' ? result.value : JSON.stringify(result.value)}</strong></div>
55
+ <div className="monitoring-fact"><span>Display scale</span><strong>{scale.toLocaleString()}</strong></div>
56
+ <div className="monitoring-fact"><span>Presented value</span><strong>{scaled == null ? 'Not numeric' : scaled.toLocaleString()}</strong></div>
57
+ </div>
58
+ <div className="monitoring-inspector-block">
59
+ <p className="dim">Scale is presentation-only. This read does not record, set, classify, or discover scalar values.</p>
60
+ </div>
61
+ </div>
62
+ );
63
+ }
64
+
65
+ export function MetricsExplorerPage() {
66
+ const [params, setParams] = useSearchParams();
67
+ const auth = useAuthSnapshot();
68
+ const { data: me } = useMe();
69
+ const queryClient = useQueryClient();
70
+ // The token uid is synchronous and changes before the async `me` query.
71
+ // Hold every protected read until both identities agree, so neither boot
72
+ // nor a direct login switch can cache data under a shared placeholder.
73
+ const callerId = auth.uid ?? 'anonymous';
74
+ const identityReady = auth.authenticated && auth.uid != null
75
+ && me != null && String(me.id) === auth.uid;
76
+ const account = params.get('account') ?? 'global';
77
+ const category = params.get('category') ?? '';
78
+ let parsedAccount: MetricAccount | null = null;
79
+ let accountError: string | null = null;
80
+ try { parsedAccount = parseMetricAccount(account); } catch (error) { accountError = errorMessage(error, 'Invalid metric account.'); }
81
+ let selectedSlugs: string[] = [];
82
+ let slugUrlError: string | null = null;
83
+ try { selectedSlugs = dedupeMetricSlugs(params.getAll('slug')); } catch (error) { slugUrlError = errorMessage(error, 'Invalid metric slug selection.'); }
84
+
85
+ const [accountSearch, setAccountSearch] = useState('');
86
+ const [accountStart, setAccountStart] = useState(0);
87
+ const [categorySearch, setCategorySearch] = useState('');
88
+ const [categoryStart, setCategoryStart] = useState(0);
89
+ const [slugSearch, setSlugSearch] = useState('');
90
+ const [slugStart, setSlugStart] = useState(0);
91
+ const [pointWhen, setPointWhen] = useState(() => Math.floor(Date.now() / 1000));
92
+ const [scalarSlug, setScalarSlug] = useState('');
93
+ const [scalarScale, setScalarScale] = useState('1');
94
+ const [scalarPending, setScalarPending] = useState(false);
95
+ const [scalarError, setScalarError] = useState<string | null>(null);
96
+
97
+ const accountRequest = { resource: 'accounts' as const, search: accountSearch, start: accountStart, size: 50 };
98
+ const accountQuery = useQuery({
99
+ queryKey: metricsDiscoveryKey(callerId, accountRequest),
100
+ queryFn: () => discoverMetrics(accountRequest),
101
+ enabled: identityReady,
102
+ });
103
+
104
+ const categoryRequest = { resource: 'categories' as const, account, search: categorySearch, start: categoryStart, size: 50 };
105
+ const categoryQuery = useQuery({
106
+ queryKey: metricsDiscoveryKey(callerId, categoryRequest),
107
+ queryFn: () => discoverMetrics(categoryRequest),
108
+ enabled: identityReady && parsedAccount != null,
109
+ });
110
+
111
+ const slugRequest = { resource: 'slugs' as const, account, category, search: slugSearch, start: slugStart, size: 50 };
112
+ const slugQuery = useQuery({
113
+ queryKey: metricsDiscoveryKey(callerId, slugRequest),
114
+ queryFn: () => discoverMetrics(slugRequest),
115
+ enabled: identityReady && parsedAccount != null && category !== '',
116
+ });
117
+
118
+ const commitParams = useCallback((next: URLSearchParams) => setParams(next, { replace: true }), [setParams]);
119
+ const changeAccount = (nextAccount: string) => {
120
+ let exact: string;
121
+ try { exact = parseMetricAccount(nextAccount).value; } catch {
122
+ const next = new URLSearchParams(params);
123
+ next.set('account', nextAccount.trim());
124
+ next.delete('category');
125
+ next.delete('slug');
126
+ next.delete('fanout');
127
+ next.delete('child_kind');
128
+ setCategoryStart(0);
129
+ setSlugStart(0);
130
+ commitParams(next);
131
+ return;
132
+ }
133
+ const next = new URLSearchParams(params);
134
+ next.set('account', exact);
135
+ next.delete('category');
136
+ next.delete('slug');
137
+ next.delete('fanout');
138
+ next.delete('child_kind');
139
+ setCategoryStart(0);
140
+ setSlugStart(0);
141
+ commitParams(next);
142
+ };
143
+ const changeCategory = (nextCategory: string) => {
144
+ const next = new URLSearchParams(params);
145
+ if (nextCategory) next.set('category', nextCategory); else next.delete('category');
146
+ next.delete('slug');
147
+ setSlugStart(0);
148
+ commitParams(next);
149
+ };
150
+ const setSlugs = (slugs: string[]) => commitParams(replaceMetricSlugParams(params, slugs));
151
+ const toggleSlug = (slug: string) => setSlugs(selectedSlugs.includes(slug)
152
+ ? selectedSlugs.filter((selected) => selected !== slug)
153
+ : [...selectedSlugs, slug]);
154
+
155
+ const fanout = parsedAccount?.kind === 'group' ? normalizeFanoutMode(params.get('fanout')) : 'off';
156
+ const childKind = params.get('child_kind') ?? '';
157
+ const setFanout = (mode: string) => {
158
+ const next = new URLSearchParams(params);
159
+ if (mode === 'off') next.delete('fanout'); else next.set('fanout', mode);
160
+ if (mode === 'breakdown' && selectedSlugs.length > 1) {
161
+ next.delete('slug');
162
+ next.append('slug', selectedSlugs[0]!);
163
+ }
164
+ commitParams(next);
165
+ };
166
+ const setChildKind = (value: string) => {
167
+ const next = new URLSearchParams(params);
168
+ if (value) next.set('child_kind', value); else next.delete('child_kind');
169
+ commitParams(next);
170
+ };
171
+
172
+ const chartSlugs = fanout === 'breakdown' ? selectedSlugs.slice(0, 1) : selectedSlugs;
173
+ const chartReady = identityReady && parsedAccount != null && chartSlugs.length > 0
174
+ && (fanout === 'off' || childKind.trim() !== '');
175
+ const chartCacheKey = `metrics-explorer:${callerId}:exact-history`;
176
+ const seriesLabels = useMemo(() => Object.fromEntries(chartSlugs.map((slug) => [slug, slug])), [chartSlugs]);
177
+
178
+ const pointsQuery = useQuery({
179
+ queryKey: ['metrics-series', callerId, account, selectedSlugs, 'hours', pointWhen],
180
+ queryFn: () => fetchMetricPoints({ account, slugs: selectedSlugs, granularity: 'hours', when: pointWhen }),
181
+ enabled: identityReady && parsedAccount != null && selectedSlugs.length > 0 && fanout === 'off',
182
+ });
183
+
184
+ const readScalar = async () => {
185
+ setScalarPending(true);
186
+ setScalarError(null);
187
+ try {
188
+ const slug = scalarSlug.trim();
189
+ const result = await queryClient.fetchQuery({
190
+ queryKey: ['metrics-value', callerId, account, slug],
191
+ queryFn: () => readMetricValue(account, slug),
192
+ staleTime: 0,
193
+ });
194
+ const parsedScale = Number(scalarScale);
195
+ const scale = Number.isFinite(parsedScale) ? parsedScale : 1;
196
+ await modal.detail((close) => <ScalarMetricDetail result={result} scale={scale} onClose={() => close(null)} />);
197
+ } catch (error) {
198
+ setScalarError(errorMessage(error, 'Failed to read scalar value.'));
199
+ } finally {
200
+ setScalarPending(false);
201
+ }
202
+ };
203
+
204
+ return (
205
+ <div className="monitoring-page metrics-explorer">
206
+ <header className="monitoring-page-head">
207
+ <div>
208
+ <div className="eyebrow">Observability</div>
209
+ <h1>Metrics Explorer</h1>
210
+ <p>Browse recorded time-series metrics without changing data or permissions.</p>
211
+ </div>
212
+ </header>
213
+
214
+ <MetricsSourcePicker
215
+ account={account}
216
+ parsedAccount={parsedAccount}
217
+ accountError={accountError}
218
+ accountsPage={accountQuery.data}
219
+ accountsPending={accountQuery.isPending}
220
+ accountsError={accountQuery.isError ? errorMessage(accountQuery.error, 'Account discovery failed.') : null}
221
+ accountSearch={accountSearch}
222
+ onAccountSearch={(value) => { setAccountSearch(value); setAccountStart(0); }}
223
+ onAccountPage={setAccountStart}
224
+ onRetryAccounts={() => void accountQuery.refetch()}
225
+ onChange={changeAccount}
226
+ />
227
+
228
+ <section className="panel panel-pad metrics-discovery-card" aria-labelledby="metrics-discovery-heading">
229
+ <div className="metrics-section-head">
230
+ <div>
231
+ <div className="eyebrow">Registry</div>
232
+ <h2 id="metrics-discovery-heading">Category and full slugs</h2>
233
+ <p>Colon-bearing slugs remain exact identities. Changing source clears downstream selections; retrying does not.</p>
234
+ </div>
235
+ </div>
236
+ {accountError ? (
237
+ <div className="metrics-compact-state metrics-compact-error">Fix the exact account before browsing its registry.</div>
238
+ ) : (
239
+ <div className="metrics-discovery-grid">
240
+ <div className="metrics-discovery-column">
241
+ <label className="field">
242
+ <span className="field-label">Categories</span>
243
+ <input className="input" value={categorySearch} maxLength={128} onChange={(event) => { setCategorySearch(event.target.value); setCategoryStart(0); }} placeholder="Search categories…" />
244
+ </label>
245
+ {categoryQuery.isPending && <div className="metrics-compact-state">Loading categories…</div>}
246
+ {categoryQuery.isError && (
247
+ <div className="metrics-compact-state metrics-compact-error">
248
+ <span>{errorMessage(categoryQuery.error, 'Category discovery failed.')}</span>
249
+ <button type="button" className="btn btn-compact" onClick={() => void categoryQuery.refetch()}>Retry</button>
250
+ <button type="button" className="btn btn-compact" onClick={() => document.getElementById('metrics-account-input')?.focus()}>Change account</button>
251
+ </div>
252
+ )}
253
+ {categoryQuery.data && (
254
+ <>
255
+ <div className="metrics-choice-list" role="listbox" aria-label="Metric category">
256
+ {categoryQuery.data.data.map((value) => (
257
+ <button type="button" role="option" aria-selected={category === value} key={value} className={`metrics-choice${category === value ? ' metrics-choice-active' : ''}`} onClick={() => changeCategory(value)}><code>{value}</code></button>
258
+ ))}
259
+ {categoryQuery.data.data.length === 0 && <span className="metrics-compact-state">No categories found.</span>}
260
+ </div>
261
+ <div className="metrics-pager">
262
+ <span>{categoryQuery.data.pageCount} of {categoryQuery.data.count}</span>
263
+ <button type="button" className="btn btn-compact" disabled={categoryQuery.data.start === 0} onClick={() => setCategoryStart(Math.max(0, categoryQuery.data.start - categoryQuery.data.size))}>Previous</button>
264
+ <button type="button" className="btn btn-compact" disabled={categoryQuery.data.nextStart == null} onClick={() => setCategoryStart(categoryQuery.data.nextStart ?? categoryQuery.data.start)}>Next</button>
265
+ </div>
266
+ </>
267
+ )}
268
+ </div>
269
+
270
+ <div className="metrics-discovery-column">
271
+ <label className="field">
272
+ <span className="field-label">Full slugs</span>
273
+ <input className="input" value={slugSearch} maxLength={128} disabled={!category} onChange={(event) => { setSlugSearch(event.target.value); setSlugStart(0); }} placeholder={category ? 'Search slugs…' : 'Choose a category first'} />
274
+ </label>
275
+ {slugUrlError && <div className="metrics-compact-state metrics-compact-error">{slugUrlError}</div>}
276
+ {!category && <div className="metrics-compact-state">Choose a category to browse its exact slug registry.</div>}
277
+ {slugQuery.isPending && category && <div className="metrics-compact-state">Loading slugs…</div>}
278
+ {slugQuery.isError && (
279
+ <div className="metrics-compact-state metrics-compact-error"><span>{errorMessage(slugQuery.error, 'Slug discovery failed.')}</span><button type="button" className="btn btn-compact" onClick={() => void slugQuery.refetch()}>Retry</button></div>
280
+ )}
281
+ {slugQuery.data && (
282
+ <>
283
+ <div className="metrics-choice-list metrics-slug-list">
284
+ {slugQuery.data.data.map((slug) => (
285
+ <label className="metrics-slug-choice" key={slug}>
286
+ <input type="checkbox" checked={selectedSlugs.includes(slug)} disabled={fanout === 'breakdown' && selectedSlugs.length === 1 && !selectedSlugs.includes(slug)} onChange={() => toggleSlug(slug)} />
287
+ <code>{slug}</code>
288
+ </label>
289
+ ))}
290
+ {slugQuery.data.data.length === 0 && <span className="metrics-compact-state">No slugs found.</span>}
291
+ </div>
292
+ <div className="metrics-pager">
293
+ <span>{slugQuery.data.pageCount} of {slugQuery.data.count}</span>
294
+ <button type="button" className="btn btn-compact" disabled={slugQuery.data.start === 0} onClick={() => setSlugStart(Math.max(0, slugQuery.data.start - slugQuery.data.size))}>Previous</button>
295
+ <button type="button" className="btn btn-compact" disabled={slugQuery.data.nextStart == null} onClick={() => setSlugStart(slugQuery.data.nextStart ?? slugQuery.data.start)}>Next</button>
296
+ </div>
297
+ </>
298
+ )}
299
+ </div>
300
+ </div>
301
+ )}
302
+ </section>
303
+
304
+ {parsedAccount?.kind === 'group' && selectedSlugs.length > 0 && (
305
+ <section className="panel panel-pad metrics-fanout-card">
306
+ <div className="metrics-section-head">
307
+ <div><div className="eyebrow">Group fan-out</div><h2>Aggregate active child groups</h2><p>Sum supports every selected slug. Breakdown preserves child labels and requires one slug.</p></div>
308
+ </div>
309
+ <div className="metrics-fanout-controls">
310
+ <label className="field"><span className="field-label">Mode</span><select className="input" value={fanout} onChange={(event) => setFanout(event.target.value)}><option value="off">Off</option><option value="sum">Sum children</option><option value="breakdown">Break down one slug</option></select></label>
311
+ <label className="field"><span className="field-label">Child kind</span><input className="input" value={childKind} maxLength={64} disabled={fanout === 'off'} onChange={(event) => setChildKind(event.target.value)} placeholder="team" /></label>
312
+ </div>
313
+ </section>
314
+ )}
315
+
316
+ {chartReady ? (
317
+ <MetricsChart
318
+ title={fanout === 'breakdown' ? `${chartSlugs[0]} by ${childKind}` : `${category || 'Selected'} metrics`}
319
+ account={account}
320
+ slugs={chartSlugs}
321
+ seriesLabels={seriesLabels}
322
+ preserveSeriesLabels
323
+ loadSeries={loadExactMetricSeries}
324
+ seriesCacheKey={chartCacheKey}
325
+ childKind={fanout === 'off' ? undefined : childKind.trim()}
326
+ breakdown={fanout === 'breakdown' ? true : undefined}
327
+ />
328
+ ) : (
329
+ <div className="panel metrics-empty-chart"><i className="bi bi-graph-up" /><h2>Choose metrics to chart</h2><p>{fanout !== 'off' && !childKind.trim() ? 'Enter a child kind to run fan-out.' : 'Select one or more full slugs from an authorized category.'}</p></div>
330
+ )}
331
+
332
+ {selectedSlugs.length > 0 && fanout === 'off' && (
333
+ <section className="metrics-kpi-section" aria-labelledby="metrics-kpi-heading">
334
+ <div className="metrics-section-head">
335
+ <div><div className="eyebrow">Point comparison</div><h2 id="metrics-kpi-heading">Current hour vs previous</h2></div>
336
+ <button type="button" className="btn btn-compact" disabled={pointsQuery.isFetching} onClick={() => setPointWhen(Math.floor(Date.now() / 1000))}><i className={`bi bi-arrow-clockwise${pointsQuery.isFetching ? ' spin' : ''}`} /> Refresh</button>
337
+ </div>
338
+ {pointsQuery.isError && <div className="metrics-compact-state metrics-compact-error"><span>{errorMessage(pointsQuery.error, 'Point comparison failed.')}</span><button type="button" className="btn btn-compact" onClick={() => void pointsQuery.refetch()}>Retry</button></div>}
339
+ <div className="metrics-kpi-grid">
340
+ {(pointsQuery.data ?? selectedSlugs.map<MetricPoint>((slug) => ({ slug, value: 0, previous: 0, delta: 0 }))).map((point) => (
341
+ <KPITile key={point.slug} label={point.slug} value={pointsQuery.data ? point.value : null} delta={pointsQuery.data ? point.delta : null} deltaPct={pointsQuery.data ? point.deltaPct : null} loading={pointsQuery.isPending} />
342
+ ))}
343
+ </div>
344
+ </section>
345
+ )}
346
+
347
+ <section className="panel panel-pad metrics-scalar-card" aria-labelledby="metrics-scalar-heading">
348
+ <div className="metrics-section-head">
349
+ <div><div className="eyebrow">Known scalar</div><h2 id="metrics-scalar-heading">Read one exact slug</h2><p>Scalar browsing and health semantics are intentionally unavailable. Enter a known full slug for a raw read.</p></div>
350
+ </div>
351
+ <div className="metrics-scalar-controls">
352
+ <label className="field"><span className="field-label">Full scalar slug</span><input className="input" value={scalarSlug} onChange={(event) => setScalarSlug(event.target.value)} placeholder="limits:max_users" /></label>
353
+ <label className="field"><span className="field-label">Display scale</span><input className="input" type="number" step="any" value={scalarScale} onChange={(event) => setScalarScale(event.target.value)} /></label>
354
+ <button type="button" className="btn btn-primary" disabled={scalarPending || !identityReady || !parsedAccount || !scalarSlug.trim()} onClick={() => void readScalar()}>{scalarPending ? 'Reading…' : 'Read value'}</button>
355
+ </div>
356
+ {scalarError && <div className="metrics-compact-state metrics-compact-error">{scalarError}</div>}
357
+ </section>
358
+ </div>
359
+ );
360
+ }