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,588 @@
1
+ // MetricsMiniWidget — the compact metrics card: icon + title + computed
2
+ // subtitle, windowed trending chip, embedded MiniChart sparkline, and the
3
+ // stats / view-data / refresh / settings action row. Ported from web-mojo
4
+ // src/extensions/charts/MetricsMiniChartWidget.js (752) + MetricsMiniChart.js
5
+ // (253), both read in full, onto TanStack Query.
6
+ //
7
+ // New in this port (board #1258): the ENTITY SEARCH — a CollectionSelect
8
+ // bound to any model list, scoping the metric to the picked record via the
9
+ // `account` wire param ('group-<id>' / 'user-<id>'). Same shared cache keys
10
+ // as every other picker; never a parallel fetch path.
11
+ //
12
+ // Carried verbatim from source: windowed trending (trendRange/trendOffset/
13
+ // prevTrendOffset via computeTrend), the granularity-derived now/total
14
+ // labels, settings persistence under localStorage 'metrics-chart-<key>',
15
+ // the granularity-change window auto-adjust, and single-series semantics
16
+ // (the FIRST requested slug's series feeds the card; extra slugs ride along
17
+ // for the stats/data dialogs).
18
+ //
19
+ // Deviations, deliberate:
20
+ // · subtitle is a ReactNode or a ({total, nowValue, …}) => ReactNode —
21
+ // the source's Mustache '{{total}} Transactions' tokens become a typed
22
+ // context (no HTML strings);
23
+ // · the raw background/textColor CSS options become `tone` (token-tinted
24
+ // card variants) — hardcoded colors can't survive both themes;
25
+ // · custom dates from the settings dialog use DateRangePicker (day
26
+ // precision) instead of two bare date inputs.
27
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
28
+ import { useQuery } from '@tanstack/react-query';
29
+ import { mojoMetrics } from '../client/client';
30
+ import type { ModelDef } from '../client/model';
31
+ import type { Params } from '../client/types';
32
+ import { CollectionSelect, type CollectionSelectValue } from '../ui/CollectionSelect';
33
+ import { Popover } from '../ui/Popover';
34
+ import { DateRangePicker } from '../ui/date/DateRangePicker';
35
+ import { modal } from '../ui/modal';
36
+ import { showSeriesData, showSeriesStats } from './chart-dialogs';
37
+ import { MiniChart, type MiniChartType } from './MiniChart';
38
+ import {
39
+ computeTrend,
40
+ quickRangeWindow,
41
+ toNumber,
42
+ ymdRangeToEpochSeconds,
43
+ type TrendOptions,
44
+ } from './stats';
45
+
46
+ // ── Granularity-derived labels (source _updateGranularityLabels) ──────
47
+ const NOW_LABELS: Record<string, string> = {
48
+ hours: 'This Hour', days: 'Today', weeks: 'This Week', months: 'This Month', years: 'This Year',
49
+ };
50
+ const TOTAL_LABELS: Record<string, string> = {
51
+ hours: 'Total (24h)', days: 'Total (Period)', weeks: 'Total (Period)', months: 'Total (Period)', years: 'Total (Period)',
52
+ };
53
+
54
+ /** Subtitle / trending context — the typed twin of the Mustache tokens. */
55
+ export interface MiniWidgetContext {
56
+ total: number;
57
+ /** The latest bucket (always — the '{{now_value}} Today' value). */
58
+ nowValue: number;
59
+ /** Current trend-window sum (offset-shifted; '{{lastValue}}'). */
60
+ lastValue: number | null;
61
+ prevValue: number | null;
62
+ trendingPercent: number | null;
63
+ nowLabel: string;
64
+ totalLabel: string;
65
+ granularity: string;
66
+ }
67
+
68
+ export interface MiniWidgetSearch<T extends { id: number | string }> {
69
+ /** One of model / endpoint — the CollectionSelect binding. */
70
+ model?: ModelDef<T>;
71
+ endpoint?: string;
72
+ labelField?: string;
73
+ valueField?: string;
74
+ placeholder?: string;
75
+ /** Picked row → the `account` wire value (e.g. (id) => `group-${id}`). */
76
+ toAccount: (id: string | number, row?: T) => string;
77
+ /** Extra wire params for the search list. */
78
+ defaultParams?: Params | (() => Params | null | undefined);
79
+ requiresActiveGroup?: boolean;
80
+ }
81
+
82
+ interface StoredSettings {
83
+ granularity?: string;
84
+ chartType?: MiniChartType;
85
+ /** Canonical YYYY-MM-DD strings ('' = unset). */
86
+ dateStart?: string;
87
+ dateEnd?: string;
88
+ }
89
+
90
+ function loadSettings(key: string | null | undefined): StoredSettings | null {
91
+ if (!key) return null;
92
+ try {
93
+ const raw = localStorage.getItem(`metrics-chart-${key}`);
94
+ return raw ? (JSON.parse(raw) as StoredSettings) : null;
95
+ } catch (err) {
96
+ console.warn('MetricsMiniWidget: failed to load chart settings', err);
97
+ return null;
98
+ }
99
+ }
100
+
101
+ function saveSettings(key: string | null | undefined, settings: StoredSettings): void {
102
+ if (!key) return;
103
+ try {
104
+ localStorage.setItem(`metrics-chart-${key}`, JSON.stringify(settings));
105
+ } catch (err) {
106
+ console.warn('MetricsMiniWidget: failed to save chart settings', err);
107
+ }
108
+ }
109
+
110
+ const GRANULARITY_OPTIONS = [
111
+ { value: 'hours', label: 'Hours' },
112
+ { value: 'days', label: 'Days' },
113
+ { value: 'weeks', label: 'Weeks' },
114
+ { value: 'months', label: 'Months' },
115
+ { value: 'years', label: 'Years' },
116
+ ];
117
+
118
+ /** Granularity → auto-adjusted lookback (source _handleSettingsApply table). */
119
+ function autoWindowFor(granularity: string, now: Date): { start: Date; end: Date } {
120
+ const end = now;
121
+ const start = new Date(end);
122
+ switch (granularity) {
123
+ case 'hours': start.setTime(end.getTime() - 24 * 3600e3); break;
124
+ case 'days': start.setTime(end.getTime() - 30 * 864e5); break;
125
+ case 'weeks': start.setTime(end.getTime() - 12 * 7 * 864e5); break;
126
+ case 'months': start.setMonth(start.getMonth() - 12); break;
127
+ case 'years': start.setFullYear(start.getFullYear() - 5); break;
128
+ default: start.setTime(end.getTime() - 30 * 864e5);
129
+ }
130
+ return { start, end };
131
+ }
132
+
133
+ const ymd = (d: Date): string =>
134
+ `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
135
+
136
+ // ── Settings dialog (modal.form heritage — Apply resolves the values) ─
137
+ interface SettingsValues {
138
+ granularity: string;
139
+ chartType: MiniChartType;
140
+ dateStart: string;
141
+ dateEnd: string;
142
+ }
143
+
144
+ function SettingsDialog({ title, initial, showDateRange, onDone }: {
145
+ title: string;
146
+ initial: SettingsValues;
147
+ showDateRange: boolean;
148
+ onDone: (values: SettingsValues | null) => void;
149
+ }) {
150
+ const [values, setValues] = useState(initial);
151
+ return (
152
+ <div className="modal-pad">
153
+ <h2 className="modal-title">{title}</h2>
154
+ <div className="mmw-settings">
155
+ <label className="field">
156
+ <span className="field-label">Granularity</span>
157
+ <select
158
+ className="input"
159
+ value={values.granularity}
160
+ onChange={(e) => setValues((v) => ({ ...v, granularity: e.target.value }))}
161
+ >
162
+ {GRANULARITY_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
163
+ </select>
164
+ </label>
165
+ <label className="field">
166
+ <span className="field-label">Chart type</span>
167
+ <select
168
+ className="input"
169
+ value={values.chartType}
170
+ onChange={(e) => setValues((v) => ({ ...v, chartType: e.target.value === 'bar' ? 'bar' : 'line' }))}
171
+ >
172
+ <option value="line">Line</option>
173
+ <option value="bar">Bar</option>
174
+ </select>
175
+ </label>
176
+ {showDateRange && (
177
+ <div className="field">
178
+ <span className="field-label">Date range</span>
179
+ <DateRangePicker
180
+ start={values.dateStart}
181
+ end={values.dateEnd}
182
+ months={1}
183
+ onChange={(e) => setValues((v) => ({ ...v, dateStart: e.start, dateEnd: e.end }))}
184
+ />
185
+ </div>
186
+ )}
187
+ </div>
188
+ <div className="modal-actions">
189
+ <button className="btn" onClick={() => onDone(null)}>Cancel</button>
190
+ <button className="btn btn-primary" onClick={() => onDone(values)}>Apply</button>
191
+ </div>
192
+ </div>
193
+ );
194
+ }
195
+
196
+ export interface MetricsMiniWidgetProps<T extends { id: number | string }> {
197
+ title: string;
198
+ /** Bootstrap-icons class ('bi bi-activity'). */
199
+ icon?: string;
200
+ /** Metric slugs; the FIRST drives the card, all feed the dialogs. */
201
+ slugs: string[];
202
+ account?: string;
203
+ granularity?: string;
204
+ /** '1h' | '24h' | '7d' | '30d' — the fetch window. null = backend default. */
205
+ defaultRange?: string | null;
206
+ chartType?: MiniChartType;
207
+ height?: number;
208
+ /**
209
+ * Uniform-height cards: the card fills its grid row and the chart GROWS
210
+ * into the slack (default). `false` makes the card hug its content, so a
211
+ * row of cards ends up ragged but compact. `height` is the chart's
212
+ * minimum either way.
213
+ */
214
+ stretch?: boolean;
215
+ /** Sparkline color. Default: the tone's token, else var(--accent). */
216
+ color?: string;
217
+ /** Token-tinted card variant (replaces the source's raw background). */
218
+ tone?: 'accent' | 'ok' | 'warn' | 'bad' | 'info' | null;
219
+ /** Static node or ({total, nowValue, …}) => ReactNode. */
220
+ subtitle?: ReactNode | ((ctx: MiniWidgetContext) => ReactNode);
221
+ valueFormatter?: (v: number) => string;
222
+ /** Windowed trending chip (computeTrend options ride along). */
223
+ showTrending?: boolean;
224
+ trendRange?: number | null;
225
+ trendOffset?: number | null;
226
+ prevTrendOffset?: number | null;
227
+ showRefresh?: boolean;
228
+ showStats?: boolean;
229
+ showDataTable?: boolean;
230
+ showSettings?: boolean;
231
+ /** Include the date range in the settings dialog. */
232
+ showDateRange?: boolean;
233
+ /** localStorage persistence key for settings ('metrics-chart-<key>'). */
234
+ settingsKey?: string;
235
+ /** Entity search — scope the metric to a picked record via `account`. */
236
+ search?: MiniWidgetSearch<T>;
237
+ /** Called when the search scope changes (null = back to base account). */
238
+ onScopeChange?: (account: string | null, row?: T) => void;
239
+ /** Re-anchor + refetch every N ms. */
240
+ refreshInterval?: number;
241
+ /** Extra wire params merged under the built-ins. */
242
+ apiParams?: Params;
243
+ showXAxis?: boolean;
244
+ smoothing?: number;
245
+ fill?: boolean;
246
+ className?: string;
247
+ }
248
+
249
+ export function MetricsMiniWidget<T extends { id: number | string } = { id: number }>({
250
+ title,
251
+ icon,
252
+ slugs,
253
+ account = 'global',
254
+ granularity: granularityProp = 'hours',
255
+ defaultRange = '24h',
256
+ chartType: chartTypeProp = 'line',
257
+ height = 72,
258
+ stretch = true,
259
+ color,
260
+ tone = null,
261
+ subtitle,
262
+ valueFormatter,
263
+ showTrending = false,
264
+ trendRange = null,
265
+ trendOffset = null,
266
+ prevTrendOffset = null,
267
+ showRefresh = true,
268
+ showStats = true,
269
+ showDataTable = true,
270
+ showSettings = false,
271
+ showDateRange = false,
272
+ settingsKey,
273
+ search,
274
+ onScopeChange,
275
+ refreshInterval,
276
+ apiParams,
277
+ showXAxis = false,
278
+ smoothing = 0.3,
279
+ fill = true,
280
+ className = '',
281
+ }: MetricsMiniWidgetProps<T>) {
282
+ // ── Settings (persisted state wins over props, source parity) ─────
283
+ const stored = useMemo(() => (showSettings ? loadSettings(settingsKey) : null), [showSettings, settingsKey]);
284
+ const [granularity, setGranularity] = useState(stored?.granularity ?? granularityProp);
285
+ const [chartType, setChartType] = useState<MiniChartType>(stored?.chartType ?? chartTypeProp);
286
+ const [customRange, setCustomRange] = useState<{ start: string; end: string } | null>(
287
+ stored?.dateStart && stored?.dateEnd ? { start: stored.dateStart, end: stored.dateEnd } : null,
288
+ );
289
+
290
+ // ── Scope (the entity search) ─────────────────────────────────────
291
+ const [pickedId, setPickedId] = useState<CollectionSelectValue>(null);
292
+ // Scope shows as a tiny label; the picker opens from the header button.
293
+ const [scopeLabel, setScopeLabel] = useState<string | null>(null);
294
+ const [scopeOpen, setScopeOpen] = useState(false);
295
+ const scopeBtnRef = useRef<HTMLButtonElement>(null);
296
+ const [scopedAccount, setScopedAccount] = useState<string | null>(null);
297
+ const effectiveAccount = scopedAccount ?? account;
298
+
299
+ // ── Fetch window ──────────────────────────────────────────────────
300
+ const [anchor, setAnchor] = useState(() => Date.now());
301
+ useEffect(() => {
302
+ if (!refreshInterval || refreshInterval <= 0) return;
303
+ const id = window.setInterval(() => setAnchor(Date.now()), Math.max(5000, refreshInterval));
304
+ return () => window.clearInterval(id);
305
+ }, [refreshInterval]);
306
+
307
+ const wire: Params = { ...apiParams, slugs: slugs.join(','), granularity, account: effectiveAccount };
308
+ if (customRange) {
309
+ const epochs = ymdRangeToEpochSeconds(customRange.start, customRange.end);
310
+ if (epochs) {
311
+ wire.dt_start = epochs.dtStart;
312
+ wire.dt_end = epochs.dtEnd;
313
+ }
314
+ } else if (defaultRange) {
315
+ const { startMs, endMs } = quickRangeWindow(defaultRange, anchor);
316
+ // `range` is the mock's window param; dt_* the real backend's.
317
+ wire.range = defaultRange;
318
+ wire.dt_start = Math.floor(startMs / 1000);
319
+ wire.dt_end = Math.floor(endMs / 1000);
320
+ }
321
+
322
+ const query = useQuery({
323
+ queryKey: ['metrics', wire],
324
+ queryFn: () => mojoMetrics(wire),
325
+ enabled: slugs.length > 0,
326
+ });
327
+
328
+ // The card is single-series: the FIRST requested slug (source parity —
329
+ // MetricsMiniChart took the response's first key; keying by slug is
330
+ // deterministic when the map's order isn't).
331
+ const firstSlug = slugs[0];
332
+ const series = useMemo(() => {
333
+ if (!query.data) return null;
334
+ const ds = query.data.datasets.find((d) => d.label === firstSlug) ?? query.data.datasets[0] ?? null;
335
+ return ds ? ds.data.map(toNumber) : null;
336
+ }, [query.data, firstSlug]);
337
+ const labels = query.data?.labels ?? [];
338
+
339
+ const missingSlug = !!query.data && series == null;
340
+ const warnedRef = useRef(false);
341
+ useEffect(() => {
342
+ if (missingSlug && !warnedRef.current) {
343
+ warnedRef.current = true;
344
+ console.warn(`MetricsMiniWidget: metrics response carries no series for "${firstSlug}" — showing the empty state`);
345
+ }
346
+ }, [missingSlug, firstSlug]);
347
+
348
+ // ── Derived header values (source updateFromChartData) ────────────
349
+ const trendOpts: TrendOptions = { trendRange, trendOffset, prevTrendOffset };
350
+ const ctx = useMemo<MiniWidgetContext>(() => {
351
+ const nums = series ?? [];
352
+ const trend = nums.length ? computeTrend(nums, trendOpts) : null;
353
+ return {
354
+ total: nums.reduce((a, b) => a + b, 0),
355
+ nowValue: nums.length ? nums[nums.length - 1]! : 0,
356
+ lastValue: trend?.lastSum ?? null,
357
+ prevValue: trend?.prevSum ?? null,
358
+ trendingPercent: trend?.percent ?? null,
359
+ nowLabel: NOW_LABELS[granularity] ?? 'Current',
360
+ totalLabel: TOTAL_LABELS[granularity] ?? 'Total',
361
+ granularity,
362
+ };
363
+ // trendOpts is value-stable per prop trio.
364
+ // eslint-disable-next-line react-hooks/exhaustive-deps
365
+ }, [series, granularity, trendRange, trendOffset, prevTrendOffset]);
366
+
367
+ const fmt = valueFormatter ?? ((v: number) => v.toLocaleString());
368
+ const subtitleNode = typeof subtitle === 'function' ? subtitle(ctx) : subtitle;
369
+
370
+ const trendUp = (ctx.trendingPercent ?? 0) >= 0;
371
+ const trendLabel = ctx.trendingPercent != null
372
+ ? `${ctx.trendingPercent > 0 ? '+' : ''}${ctx.trendingPercent.toFixed(1)}%`
373
+ : null;
374
+
375
+ // ── Actions ───────────────────────────────────────────────────────
376
+ const cleanTitle = title.trim();
377
+ const dialogDatasets = query.data?.datasets ?? [];
378
+ const openStats = () => {
379
+ void showSeriesStats({
380
+ title: `${cleanTitle} — Stats`,
381
+ labels,
382
+ datasets: dialogDatasets,
383
+ granularity,
384
+ formatter: valueFormatter,
385
+ });
386
+ };
387
+ const openData = () => {
388
+ void showSeriesData({
389
+ title: cleanTitle || 'Chart data',
390
+ labels,
391
+ datasets: dialogDatasets,
392
+ granularity,
393
+ formatter: valueFormatter,
394
+ });
395
+ };
396
+ const refresh = () => {
397
+ setAnchor(Date.now());
398
+ void query.refetch();
399
+ };
400
+ const openSettings = async () => {
401
+ const initial: SettingsValues = {
402
+ granularity,
403
+ chartType,
404
+ dateStart: customRange?.start ?? '',
405
+ dateEnd: customRange?.end ?? '',
406
+ };
407
+ const next = await modal.open<SettingsValues | null>((close) => (
408
+ <SettingsDialog
409
+ title={cleanTitle ? `${cleanTitle} — Settings` : 'Chart Settings'}
410
+ initial={initial}
411
+ showDateRange={showDateRange}
412
+ onDone={close}
413
+ />
414
+ ), { size: 'sm' });
415
+ if (!next) return;
416
+
417
+ const granChanged = next.granularity !== granularity;
418
+ const datesSet = !!next.dateStart && !!next.dateEnd
419
+ && (next.dateStart !== initial.dateStart || next.dateEnd !== initial.dateEnd);
420
+
421
+ setGranularity(next.granularity);
422
+ setChartType(next.chartType);
423
+ let nextRange = customRange;
424
+ if (datesSet) {
425
+ nextRange = { start: next.dateStart, end: next.dateEnd };
426
+ } else if (granChanged && customRange) {
427
+ // Granularity changed with a custom window already in play —
428
+ // auto-adjust the window to fit (source behavior; never sets
429
+ // dates for the first time).
430
+ const w = autoWindowFor(next.granularity, new Date());
431
+ nextRange = { start: ymd(w.start), end: ymd(w.end) };
432
+ }
433
+ setCustomRange(nextRange);
434
+ saveSettings(settingsKey, {
435
+ granularity: next.granularity,
436
+ chartType: next.chartType,
437
+ dateStart: nextRange?.start ?? '',
438
+ dateEnd: nextRange?.end ?? '',
439
+ });
440
+ };
441
+
442
+ const toneColor = tone ? `var(--${tone === 'accent' ? 'accent' : tone})` : null;
443
+ const chartColor = color ?? toneColor ?? 'var(--accent)';
444
+ const anyAction = showStats || showDataTable || showRefresh || showSettings;
445
+
446
+ return (
447
+ <div className={`mmw panel${stretch ? ' mmw-stretch' : ' mmw-fit'}${tone ? ` mmw-tone-${tone}` : ''}${className ? ` ${className}` : ''}`}>
448
+ {anyAction && (
449
+ <div className="mmw-actions">
450
+ {showStats && (
451
+ <button className="btn-icon btn-icon-sm" title="Stats" aria-label="Stats" onClick={openStats}>
452
+ <i className="bi bi-info-circle" />
453
+ </button>
454
+ )}
455
+ {showDataTable && (
456
+ <button className="btn-icon btn-icon-sm" title="View data" aria-label="View data" onClick={openData}>
457
+ <i className="bi bi-table" />
458
+ </button>
459
+ )}
460
+ {showRefresh && (
461
+ <button className="btn-icon btn-icon-sm" title="Refresh" aria-label="Refresh" onClick={refresh}>
462
+ <i className={`bi bi-arrow-clockwise${query.isFetching ? ' spin' : ''}`} />
463
+ </button>
464
+ )}
465
+ {search && (
466
+ <button
467
+ ref={scopeBtnRef}
468
+ className={`btn-icon btn-icon-sm${scopeOpen ? ' is-on' : ''}`}
469
+ title={scopeLabel ? `Scope: ${scopeLabel}` : 'Scope this metric'}
470
+ aria-label="Scope this metric"
471
+ aria-expanded={scopeOpen}
472
+ onClick={() => setScopeOpen((o) => !o)}
473
+ >
474
+ <i className="bi bi-funnel" />
475
+ </button>
476
+ )}
477
+ {showSettings && (
478
+ <button className="btn-icon btn-icon-sm" title="Settings" aria-label="Settings" onClick={() => void openSettings()}>
479
+ <i className="bi bi-gear" />
480
+ </button>
481
+ )}
482
+ </div>
483
+ )}
484
+
485
+ <div className="mmw-head">
486
+ {icon && <i className={`${icon} mmw-icon`} aria-hidden="true" />}
487
+ <div className="mmw-id">
488
+ {/* The icon PREFIXES the title. Source hung it at the
489
+ right of the header, which worked there because the
490
+ card had a solid background color and the icon read
491
+ as a watermark; this port replaced that with token
492
+ tones, so on a plain surface a right-hand glyph sat
493
+ next to the action buttons and read as a stray
494
+ control. Prefixing binds it to the label and leaves
495
+ the top-right corner meaning exactly one thing. */}
496
+ <div className="mmw-title">{title}</div>
497
+ {subtitleNode != null && <div className="mmw-subtitle">{subtitleNode}</div>}
498
+ <div className="mmw-meta">
499
+ {showTrending && trendLabel != null && (
500
+ <div className={`mmw-trend ${trendUp ? 'mmw-trend-up' : 'mmw-trend-down'}`}>
501
+ <i className={`bi ${trendUp ? 'bi-arrow-up' : 'bi-arrow-down'}`} />
502
+ {trendLabel}
503
+ </div>
504
+ )}
505
+ {/* Scope reads as a tiny LABEL, not a form control — the
506
+ picker lives behind the funnel button in the header.
507
+ Global scope shows nothing: a chart with no chip is
508
+ simply the whole account. */}
509
+ {scopeLabel && (
510
+ <button
511
+ type="button"
512
+ className="mmw-scope"
513
+ title={`Scoped to ${scopeLabel} — click to clear`}
514
+ onClick={() => { setPickedId(null); setScopeLabel(null); setScopedAccount(null); onScopeChange?.(null); }}
515
+ >
516
+ <i className="bi bi-funnel-fill" />
517
+ {scopeLabel}
518
+ <i className="bi bi-x mmw-scope-x" />
519
+ </button>
520
+ )}
521
+ </div>
522
+ </div>
523
+ </div>
524
+
525
+ {/* The picker is a POPOVER off the funnel button — a metrics card
526
+ is not a form, so it carries no resting form control. */}
527
+ {search && (
528
+ <Popover
529
+ anchorRef={scopeBtnRef}
530
+ open={scopeOpen}
531
+ placement="bottom-end"
532
+ onClose={() => setScopeOpen(false)}
533
+ aria-label="Scope this metric"
534
+ >
535
+ <div className="mmw-scope-pop">
536
+ <CollectionSelect<T>
537
+ model={search.model}
538
+ endpoint={search.endpoint}
539
+ value={pickedId}
540
+ labelField={search.labelField ?? 'name'}
541
+ valueField={search.valueField ?? 'id'}
542
+ placeholder={search.placeholder ?? 'All (global)'}
543
+ defaultParams={search.defaultParams}
544
+ requiresActiveGroup={search.requiresActiveGroup ?? false}
545
+ onChange={(id, row) => {
546
+ setPickedId(id);
547
+ const label = id == null
548
+ ? null
549
+ : String((row as Record<string, unknown> | null)?.[search.labelField ?? 'name'] ?? id);
550
+ setScopeLabel(label);
551
+ const nextAccount = id == null ? null : search.toAccount(id, row);
552
+ setScopedAccount(nextAccount);
553
+ onScopeChange?.(nextAccount, row);
554
+ setScopeOpen(false);
555
+ }}
556
+ />
557
+ </div>
558
+ </Popover>
559
+ )}
560
+
561
+ <div className="mmw-chart" style={{ minHeight: height }}>
562
+ {query.isPending ? (
563
+ <span className="skel skel-block mmw-skel" style={{ height: Math.max(14, height - 20) }} />
564
+ ) : query.isError ? (
565
+ <div className="mmw-error" role="alert">
566
+ <i className="bi bi-exclamation-triangle" />
567
+ <span>{query.error instanceof Error ? query.error.message : 'Failed to load metrics'}</span>
568
+ <button className="btn btn-compact" onClick={() => void query.refetch()}>Retry</button>
569
+ </div>
570
+ ) : series == null || series.length === 0 ? (
571
+ <div className="mmw-empty">No data</div>
572
+ ) : (
573
+ <MiniChart
574
+ data={series}
575
+ labels={labels}
576
+ chartType={chartType}
577
+ height={stretch ? 'fill' : height}
578
+ color={chartColor}
579
+ fill={fill}
580
+ smoothing={smoothing}
581
+ showXAxis={showXAxis}
582
+ valueFormatter={fmt}
583
+ />
584
+ )}
585
+ </div>
586
+ </div>
587
+ );
588
+ }