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,423 @@
1
+ // JobOperationsSection — the queue control plane. Port of web-mojo
2
+ // sections/JobOperationsSection.js, re-wired to what django-mojo serves.
3
+ //
4
+ // Corrections to the source, all of them consequences of reading the backend:
5
+ // · "All Channels" no longer sends `channel: null` (an instant HTTP 400 —
6
+ // clear-stuck and manual-reclaim are @requires_params('channel')). It is a
7
+ // CLIENT fan-out with per-channel results.
8
+ // · results are read from the keys the handlers actually return (`cleared`,
9
+ // `deleted`, `reset_count`) instead of the `data.count` the source read,
10
+ // which never existed and always reported zero.
11
+ // · purge is dry-run-first: the dialog previews "N jobs would be deleted
12
+ // before <cutoff>" and only then offers the armed real run.
13
+ // · clear-queue's `confirm:"yes"` is sent only AFTER the armed confirmation.
14
+ // Pre-satisfying a server-side safety gate removes it.
15
+ // · every destructive control is an ArmedButton, not a plain button.
16
+ import { useState, type ReactNode } from 'react';
17
+ import { ArmedButton, JsonBlock, modal, toast } from '../../../ui';
18
+ import { useCan } from '../../../client';
19
+ import {
20
+ cleanupConsumers,
21
+ clearQueue,
22
+ clearStuck,
23
+ forEachChannel,
24
+ isPurgeDryRun,
25
+ manualReclaim,
26
+ publishTestJob,
27
+ publishTestSuite,
28
+ purgeJobs,
29
+ rebuildScheduled,
30
+ resetFailed,
31
+ summarizeChannelOutcomes,
32
+ type PurgeResult,
33
+ } from '../control';
34
+ import { JOBS_MANAGE_PERMS, JOB_STATUS_OPTIONS } from '../models';
35
+
36
+ const ALL_CHANNELS = '';
37
+
38
+ function OperationRow({ label, icon, description, children, note }: {
39
+ label: string;
40
+ icon: string;
41
+ description: ReactNode;
42
+ children: ReactNode;
43
+ note?: ReactNode;
44
+ }) {
45
+ return (
46
+ <div className="jobs-op-row">
47
+ <div className="jobs-op-label"><i className={`bi ${icon}`} /> {label}</div>
48
+ <div className="jobs-op-desc">
49
+ {description}
50
+ {note && <div className="jobs-op-note">{note}</div>}
51
+ </div>
52
+ <div className="jobs-op-action">{children}</div>
53
+ </div>
54
+ );
55
+ }
56
+
57
+ // ── Purge dialog: preview, then arm ───────────────────────────────────
58
+
59
+ function PurgeDialog({ onClose }: { onClose: () => void }) {
60
+ const [days, setDays] = useState(30);
61
+ const [status, setStatus] = useState('');
62
+ const [preview, setPreview] = useState<PurgeResult | null>(null);
63
+ const [busy, setBusy] = useState(false);
64
+
65
+ const runPreview = async () => {
66
+ setBusy(true);
67
+ setPreview(null);
68
+ try {
69
+ const result = await purgeJobs({ daysOld: days, status: status || null, dryRun: true });
70
+ setPreview(result);
71
+ } catch (error) {
72
+ toast.error(error instanceof Error ? error.message : 'Purge preview failed');
73
+ } finally {
74
+ setBusy(false);
75
+ }
76
+ };
77
+
78
+ const runPurge = async () => {
79
+ setBusy(true);
80
+ try {
81
+ const result = await purgeJobs({ daysOld: days, status: status || null });
82
+ // A REAL run reports `deleted`; only the dry run has `count`.
83
+ const deleted = isPurgeDryRun(result) ? result.count : result.deleted;
84
+ toast.success(`Purged ${deleted} record(s) older than ${days} day(s)`);
85
+ onClose();
86
+ } catch (error) {
87
+ toast.error(error instanceof Error ? error.message : 'Purge failed');
88
+ } finally {
89
+ setBusy(false);
90
+ }
91
+ };
92
+
93
+ const previewCount = preview && isPurgeDryRun(preview) ? preview.count : null;
94
+
95
+ return (
96
+ <div className="modal-pad">
97
+ <h2 className="modal-title">Purge old jobs</h2>
98
+ <p className="dim">
99
+ Deletes job rows older than the cutoff, and their events and logs with them. Preview first — the
100
+ count is the only way to know what the cutoff actually covers.
101
+ </p>
102
+ <label className="jobs-field">
103
+ <span>Older than (days)</span>
104
+ <input
105
+ type="number" className="input input-compact" min={0} step={1}
106
+ value={days}
107
+ onChange={(event) => { setDays(Math.max(0, Number(event.target.value) || 0)); setPreview(null); }}
108
+ />
109
+ </label>
110
+ <label className="jobs-field">
111
+ <span>Status filter</span>
112
+ <select
113
+ className="input input-compact"
114
+ value={status}
115
+ onChange={(event) => { setStatus(event.target.value); setPreview(null); }}
116
+ >
117
+ <option value="">Every status</option>
118
+ {JOB_STATUS_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
119
+ </select>
120
+ </label>
121
+
122
+ {preview && previewCount != null && (
123
+ <p className={`jobs-note ${previewCount > 0 ? 'jobs-note-warn' : 'jobs-note-info'}`}>
124
+ <i className="bi bi-info-circle" />
125
+ {previewCount > 0
126
+ ? <> <strong>{previewCount}</strong> job(s) would be deleted, created before {new Date(preview.cutoff).toLocaleString()}.</>
127
+ : <> Nothing matches — no job is older than that cutoff.</>}
128
+ </p>
129
+ )}
130
+
131
+ <div className="modal-actions">
132
+ <button type="button" className="btn" onClick={onClose}>Cancel</button>
133
+ <button type="button" className="btn" disabled={busy} onClick={() => void runPreview()}>
134
+ {busy && !preview ? 'Previewing…' : 'Preview'}
135
+ </button>
136
+ <ArmedButton
137
+ label="Purge"
138
+ armedLabel={previewCount != null ? `Click again to delete ${previewCount} job(s)` : 'Click again to purge'}
139
+ className="btn-danger-ghost"
140
+ // Arming before a preview would be arming a number nobody
141
+ // has seen.
142
+ disabled={busy || previewCount == null || previewCount === 0}
143
+ onConfirm={runPurge}
144
+ />
145
+ </div>
146
+ </div>
147
+ );
148
+ }
149
+
150
+ // ── Test-job dialog ───────────────────────────────────────────────────
151
+
152
+ function TestJobDialog({ channels, defaultChannel, onClose }: {
153
+ channels: string[];
154
+ defaultChannel: string;
155
+ onClose: () => void;
156
+ }) {
157
+ const [channel, setChannel] = useState(defaultChannel || channels[0] || 'default');
158
+ const [delay, setDelay] = useState(0);
159
+ const [busy, setBusy] = useState(false);
160
+
161
+ const run = async () => {
162
+ setBusy(true);
163
+ try {
164
+ const result = await publishTestJob({ channel, delay: delay > 0 ? delay : null });
165
+ toast.success(`Test job ${result.job_id.slice(0, 12)}… published to ${result.channel}${result.delayed ? ` (in ${delay}s)` : ''}`);
166
+ onClose();
167
+ } catch (error) {
168
+ toast.error(error instanceof Error ? error.message : 'Could not publish the test job');
169
+ } finally {
170
+ setBusy(false);
171
+ }
172
+ };
173
+
174
+ return (
175
+ <div className="modal-pad">
176
+ <h2 className="modal-title">Publish a test job</h2>
177
+ <p className="dim">Publishes one sample job and hands back its id so it can be followed in the jobs table.</p>
178
+ <label className="jobs-field">
179
+ <span>Channel</span>
180
+ <select className="input input-compact" value={channel} onChange={(event) => setChannel(event.target.value)}>
181
+ {(channels.length > 0 ? channels : ['default']).map((name) => <option key={name} value={name}>{name}</option>)}
182
+ </select>
183
+ </label>
184
+ <label className="jobs-field">
185
+ <span>Delay (seconds, 0 = immediate)</span>
186
+ <input
187
+ type="number" className="input input-compact" min={0} step={5}
188
+ value={delay}
189
+ onChange={(event) => setDelay(Math.max(0, Number(event.target.value) || 0))}
190
+ />
191
+ </label>
192
+ <div className="modal-actions">
193
+ <button type="button" className="btn" onClick={onClose}>Cancel</button>
194
+ <button type="button" className="btn btn-primary" disabled={busy} onClick={() => void run()}>
195
+ {busy ? 'Publishing…' : 'Publish'}
196
+ </button>
197
+ </div>
198
+ </div>
199
+ );
200
+ }
201
+
202
+ // ── The section ───────────────────────────────────────────────────────
203
+
204
+ export function JobOperationsSection({ channels, channelsPending, onChanged }: {
205
+ channels: string[];
206
+ channelsPending: boolean;
207
+ /** Refresh the dashboard after a control mutates queue state. */
208
+ onChanged?: () => void;
209
+ }) {
210
+ const { can: canManage } = useCan(JOBS_MANAGE_PERMS);
211
+ const [channel, setChannel] = useState(ALL_CHANNELS);
212
+ const noChannels = !channelsPending && channels.length === 0;
213
+ // A stale selection must never become a request for a channel that no
214
+ // longer exists.
215
+ const target = channel && channels.includes(channel) ? channel : ALL_CHANNELS;
216
+ const targets = target ? [target] : channels;
217
+ const disabled = !canManage || noChannels;
218
+
219
+ const after = (message: string) => { toast.success(message); onChanged?.(); };
220
+ const failed = (error: unknown, fallback: string) =>
221
+ toast.error(error instanceof Error ? error.message : fallback);
222
+
223
+ /** clear-stuck / manual-reclaim have no all-channel form — fan out. */
224
+ const runFanOut = async (verb: string, run: (name: string) => Promise<{ cleared: number }>) => {
225
+ try {
226
+ const outcomes = await forEachChannel(targets, run);
227
+ const cleared = outcomes.reduce((sum, outcome) => sum + (outcome.value?.cleared ?? 0), 0);
228
+ const anyFailed = outcomes.some((outcome) => !outcome.ok);
229
+ const line = `${cleared} job(s) requeued · ${summarizeChannelOutcomes(outcomes, verb)}`;
230
+ // A partial fan-out is reported as partial, never as success.
231
+ if (anyFailed) toast.warning(line); else toast.success(line);
232
+ onChanged?.();
233
+ } catch (error) {
234
+ failed(error, `${verb} failed`);
235
+ }
236
+ };
237
+
238
+ return (
239
+ <div className="panel jobs-operations">
240
+ <div className="toolbar">
241
+ <div className="toolbar-heading">
242
+ <div className="eyebrow">Control</div>
243
+ <h2 className="panel-title">Operations</h2>
244
+ </div>
245
+ <div className="toolbar-controls">
246
+ <label className="jobs-inline-field">
247
+ <span className="dim">Target</span>
248
+ <select
249
+ className="input input-compact"
250
+ value={target}
251
+ disabled={noChannels}
252
+ onChange={(event) => setChannel(event.target.value)}
253
+ aria-label="Target channel"
254
+ >
255
+ <option value={ALL_CHANNELS}>All channels</option>
256
+ {channels.map((name) => <option key={name} value={name}>{name}</option>)}
257
+ </select>
258
+ </label>
259
+ </div>
260
+ </div>
261
+
262
+ {noChannels && (
263
+ <p className="jobs-note jobs-note-warn">
264
+ <i className="bi bi-exclamation-triangle" /> No channels are configured or registered. Every
265
+ channel-scoped operation is unavailable until a channel exists.
266
+ </p>
267
+ )}
268
+ {!canManage && (
269
+ <p className="jobs-note jobs-note-info">
270
+ <i className="bi bi-info-circle" /> These controls require a global <code>manage_jobs</code> or <code>jobs</code> grant.
271
+ </p>
272
+ )}
273
+
274
+ <OperationRow
275
+ label="Test job" icon="bi-play-circle"
276
+ description="Publish one sample job to verify the pipeline end to end."
277
+ >
278
+ <button
279
+ type="button" className="btn btn-compact" disabled={disabled}
280
+ onClick={() => void modal.open((close) => (
281
+ <TestJobDialog channels={channels} defaultChannel={target} onClose={() => { close(null); onChanged?.(); }} />
282
+ ), { size: 'sm' })}
283
+ >
284
+ Publish…
285
+ </button>
286
+ </OperationRow>
287
+
288
+ <OperationRow
289
+ label="Test suite" icon="bi-robot"
290
+ description="Publishes a whole sample suite — one job per channel plus dozens of immediate and delayed jobs."
291
+ note="Load-generating. It returns no ids; watch the dashboard."
292
+ >
293
+ <ArmedButton
294
+ className="btn-compact" label="Run suite" armedLabel="Click again to flood the queues"
295
+ disabled={!canManage}
296
+ onConfirm={async () => {
297
+ try { after(await publishTestSuite()); } catch (error) { failed(error, 'Test suite failed'); }
298
+ }}
299
+ />
300
+ </OperationRow>
301
+
302
+ <OperationRow
303
+ label="Clear stuck" icon="bi-wrench"
304
+ description="Requeue in-flight jobs that have been claimed for longer than the idle threshold."
305
+ note={target ? <>Targets <code>{target}</code>.</> : <>No all-channel form exists server-side — this runs once per channel and reports each result.</>}
306
+ >
307
+ <ArmedButton
308
+ className="btn-compact" label="Clear stuck" armedLabel={`Click again to requeue in ${target || `${channels.length} channels`}`}
309
+ disabled={disabled}
310
+ onConfirm={() => runFanOut('cleared', (name) => clearStuck(name))}
311
+ />
312
+ </OperationRow>
313
+
314
+ <OperationRow
315
+ label="Reclaim all" icon="bi-arrow-counterclockwise"
316
+ description="Requeue EVERY in-flight job, not only the idle ones (idle threshold 0)."
317
+ note={target ? <>Targets <code>{target}</code>.</> : <>Runs once per channel.</>}
318
+ >
319
+ <ArmedButton
320
+ className="btn-compact" label="Reclaim" armedLabel={`Click again to reclaim in ${target || `${channels.length} channels`}`}
321
+ disabled={disabled}
322
+ onConfirm={() => runFanOut('reclaimed', (name) => manualReclaim(name))}
323
+ />
324
+ </OperationRow>
325
+
326
+ <OperationRow
327
+ label="Reset failed" icon="bi-arrow-repeat"
328
+ description="Move failed jobs back to pending and requeue them, up to 100 at a time."
329
+ note={target ? <>Targets <code>{target}</code>.</> : <>The backend handles the all-channel form itself.</>}
330
+ >
331
+ <ArmedButton
332
+ className="btn-compact" label="Reset failed" armedLabel="Click again to requeue failed jobs"
333
+ disabled={!canManage}
334
+ onConfirm={async () => {
335
+ try {
336
+ const result = await resetFailed({ channel: target || null });
337
+ after(`${result.reset_count} failed job(s) reset to pending`);
338
+ } catch (error) { failed(error, 'Reset failed'); }
339
+ }}
340
+ />
341
+ </OperationRow>
342
+
343
+ <OperationRow
344
+ label="Clear queue" icon="bi-eraser"
345
+ description="Deletes the channel's Redis keys and cancels every DB-pending job on it."
346
+ note={target
347
+ ? <>Targets <code>{target}</code>. Queued work is <strong>lost</strong>.</>
348
+ : <>Pick a single channel — clearing every queue at once is not offered.</>}
349
+ >
350
+ <ArmedButton
351
+ className="btn-compact btn-danger-ghost" label="Clear queue"
352
+ armedLabel={`Click again to wipe ${target}`}
353
+ disabled={disabled || !target}
354
+ onConfirm={async () => {
355
+ try {
356
+ const result = await clearQueue(target);
357
+ after(`Queue ${target} cleared · ${result.db_pending_canceled} pending job(s) canceled`);
358
+ } catch (error) { failed(error, 'Clear queue failed'); }
359
+ }}
360
+ />
361
+ </OperationRow>
362
+
363
+ <OperationRow
364
+ label="Purge history" icon="bi-trash"
365
+ description="Delete job rows older than a cutoff, with their events and logs."
366
+ note="Runs a dry-run preview before anything is deleted."
367
+ >
368
+ <button
369
+ type="button" className="btn btn-compact btn-danger-ghost" disabled={!canManage}
370
+ onClick={() => void modal.open((close) => (
371
+ <PurgeDialog onClose={() => { close(null); onChanged?.(); }} />
372
+ ), { size: 'sm' })}
373
+ >
374
+ Purge…
375
+ </button>
376
+ </OperationRow>
377
+
378
+ <OperationRow
379
+ label="Cleanup consumers" icon="bi-people"
380
+ description="Remove stale Redis stream consumers and, when empty, their groups."
381
+ note={target ? <>Targets <code>{target}</code>.</> : <>Cleans every channel.</>}
382
+ >
383
+ <ArmedButton
384
+ className="btn-compact" label="Cleanup" armedLabel="Click again to clean up consumers"
385
+ disabled={!canManage}
386
+ onConfirm={async () => {
387
+ try {
388
+ const result = await cleanupConsumers({ channel: target || null });
389
+ void modal.open((close) => (
390
+ <div className="modal-pad">
391
+ <h2 className="modal-title">Consumer cleanup</h2>
392
+ <JsonBlock value={result} label="Result" defaultOpen />
393
+ <div className="modal-actions">
394
+ <button type="button" className="btn btn-primary" onClick={() => close(null)}>Close</button>
395
+ </div>
396
+ </div>
397
+ ), { size: 'md' });
398
+ onChanged?.();
399
+ } catch (error) { failed(error, 'Cleanup failed'); }
400
+ }}
401
+ />
402
+ </OperationRow>
403
+
404
+ <OperationRow
405
+ label="Rebuild scheduled" icon="bi-calendar-check"
406
+ description="Rebuild the Redis scheduled sets from the database's pending future jobs."
407
+ note={target ? <>Targets <code>{target}</code>.</> : <>Rebuilds every channel.</>}
408
+ >
409
+ <button
410
+ type="button" className="btn btn-compact" disabled={!canManage}
411
+ onClick={async () => {
412
+ try {
413
+ await rebuildScheduled({ channel: target || null });
414
+ after('Scheduled sets rebuilt from the database');
415
+ } catch (error) { failed(error, 'Rebuild failed'); }
416
+ }}
417
+ >
418
+ Rebuild
419
+ </button>
420
+ </OperationRow>
421
+ </div>
422
+ );
423
+ }
@@ -0,0 +1,82 @@
1
+ // JobRunnersStrip — the fleet summary that opens the dashboard. Port of
2
+ // web-mojo JobsRunnersStrip.js.
3
+ //
4
+ // Runners are the PRIMARY alert signal: if none are alive nothing moves, no
5
+ // matter how healthy the channel numbers look. Hence the position at the top
6
+ // and the critical empty state.
7
+ import { Badge, fmt } from '../../../ui';
8
+ import { showRunnerDetail } from '../RunnerDetail';
9
+ import {
10
+ formatHeartbeatAge,
11
+ heartbeatAgeSeconds,
12
+ runnerHealth,
13
+ type RunnerRow,
14
+ } from '../models';
15
+ import { useRunners } from '../queries';
16
+
17
+ /** Worst state in the fleet drives the header tone. */
18
+ function summaryTone(runners: RunnerRow[], loaded: boolean): 'success' | 'warning' | 'danger' | 'muted' {
19
+ if (!loaded) return 'muted';
20
+ if (runners.length === 0) return 'danger';
21
+ if (runners.some((runner) => runnerHealth(runner).key === 'down')) return 'danger';
22
+ if (runners.some((runner) => runnerHealth(runner).key === 'stale')) return 'warning';
23
+ return 'success';
24
+ }
25
+
26
+ export function JobRunnersStrip() {
27
+ const query = useRunners();
28
+ const runners = query.data ?? [];
29
+ const loaded = !query.isPending && !query.isError;
30
+ const alive = runners.filter((runner) => runner.alive).length;
31
+ const processed = runners.reduce((sum, runner) => sum + (runner.jobs_processed || 0), 0);
32
+ const tone = summaryTone(runners, loaded);
33
+
34
+ const summary = query.isPending
35
+ ? 'Loading runners…'
36
+ : query.isError
37
+ ? 'Could not load runners'
38
+ : runners.length === 0
39
+ ? 'No runners registered'
40
+ : `${alive} of ${runners.length} runner${runners.length === 1 ? '' : 's'} alive · ${fmt.number(processed)} jobs processed`;
41
+
42
+ return (
43
+ <details className="panel jobs-strip" open>
44
+ <summary className="jobs-strip-head">
45
+ <span className="jobs-strip-title"><i className="bi bi-cpu" /> Runners</span>
46
+ <span className={`jobs-strip-summary jobs-text-${tone}`}>{summary}</span>
47
+ <i className="bi bi-chevron-down jobs-strip-chevron" />
48
+ </summary>
49
+ <ul className="jobs-strip-list">
50
+ {loaded && runners.length === 0 && (
51
+ <li className="jobs-strip-row jobs-strip-empty">
52
+ <i className="bi bi-exclamation-triangle" />
53
+ No runners registered — jobs will not be processed.
54
+ </li>
55
+ )}
56
+ {runners.map((runner) => {
57
+ const health = runnerHealth(runner);
58
+ return (
59
+ <li key={runner.id}>
60
+ <button type="button" className="jobs-strip-row" onClick={() => showRunnerDetail(runner)}>
61
+ <span className={`jobs-dot jobs-dot-${health.tone}`} />
62
+ <span className="jobs-strip-id">
63
+ <code>{runner.runner_id}</code>
64
+ <span className="dim">
65
+ {runner.channels.length > 0 ? runner.channels.join(', ') : 'no channels'}
66
+ {' · '}
67
+ {fmt.number(runner.jobs_processed)} done
68
+ {runner.jobs_failed > 0 && ` · ${fmt.number(runner.jobs_failed)} failed`}
69
+ </span>
70
+ </span>
71
+ <span className="dim jobs-strip-when" title={fmt.datetime(runner.last_heartbeat)}>
72
+ {formatHeartbeatAge(heartbeatAgeSeconds(runner.last_heartbeat))}
73
+ </span>
74
+ <Badge tone={health.tone}>{health.label}</Badge>
75
+ </button>
76
+ </li>
77
+ );
78
+ })}
79
+ </ul>
80
+ </details>
81
+ );
82
+ }
@@ -0,0 +1,118 @@
1
+ // JobThroughputSection — the metrics half of the dashboard. Port of web-mojo
2
+ // sections/JobOverviewSection.js (the two sparkline widgets) plus the full
3
+ // MetricsChart the source never had.
4
+ //
5
+ // The per-channel switch is the interesting part. The backend's slug naming is
6
+ // ASYMMETRIC: publishing is recorded as `jobs.published.<channel>` while
7
+ // terminal outcomes are recorded as `jobs.channel.<channel>.completed` and
8
+ // `.failed`. There is NO per-channel slug for retried or expired, so those two
9
+ // series exist only in the all-channel view and the UI says so rather than
10
+ // plotting an empty line.
11
+ import { useRef, useState } from 'react';
12
+ import { MetricsChart, MetricsMiniWidget } from '../../../charts';
13
+
14
+ const GLOBAL_SLUGS = ['jobs.completed', 'jobs.failed', 'jobs.retried', 'jobs.expired'];
15
+ const GLOBAL_LABELS: Record<string, string> = {
16
+ 'jobs.completed': 'Completed',
17
+ 'jobs.failed': 'Failed',
18
+ 'jobs.retried': 'Retried',
19
+ 'jobs.expired': 'Expired',
20
+ };
21
+
22
+ /** The three slugs a channel actually has, in the backend's own naming. */
23
+ function channelSlugs(channel: string): { slugs: string[]; labels: Record<string, string> } {
24
+ const published = `jobs.published.${channel}`;
25
+ const completed = `jobs.channel.${channel}.completed`;
26
+ const failed = `jobs.channel.${channel}.failed`;
27
+ return {
28
+ slugs: [published, completed, failed],
29
+ labels: { [published]: 'Published', [completed]: 'Completed', [failed]: 'Failed' },
30
+ };
31
+ }
32
+
33
+ export function JobThroughputSection({ channels }: { channels: string[] }) {
34
+ const [channel, setChannel] = useState('');
35
+ // A channel that disappears from the list (stream removed, settings
36
+ // change) must not leave the chart requesting slugs that no longer exist.
37
+ // The fallback is loud, but once per channel — not once per render.
38
+ const warned = useRef<Set<string>>(new Set());
39
+ const active = channel && channels.includes(channel) ? channel : '';
40
+ if (channel && !active && !warned.current.has(channel)) {
41
+ warned.current.add(channel);
42
+ console.warn(`[admin/jobs] channel ${JSON.stringify(channel)} is no longer reported — falling back to all channels.`);
43
+ }
44
+ const scoped = active ? channelSlugs(active) : null;
45
+
46
+ return (
47
+ <div className="jobs-throughput">
48
+ <div className="jobs-throughput-minis">
49
+ <MetricsMiniWidget
50
+ title="Jobs published"
51
+ icon="bi bi-upload"
52
+ slugs={['jobs.published']}
53
+ account="global"
54
+ granularity="days"
55
+ defaultRange="30d"
56
+ chartType="line"
57
+ height={90}
58
+ showTrending
59
+ showSettings
60
+ settingsKey="admin-jobs-published"
61
+ />
62
+ <MetricsMiniWidget
63
+ title="Jobs failed"
64
+ icon="bi bi-exclamation-octagon"
65
+ slugs={['jobs.failed']}
66
+ account="global"
67
+ granularity="days"
68
+ defaultRange="30d"
69
+ chartType="line"
70
+ tone="bad"
71
+ height={90}
72
+ showTrending
73
+ showSettings
74
+ settingsKey="admin-jobs-failed"
75
+ />
76
+ </div>
77
+
78
+ <div className="panel jobs-throughput-chart">
79
+ <div className="toolbar">
80
+ <div className="toolbar-heading">
81
+ <div className="eyebrow">Throughput</div>
82
+ <h2 className="panel-title">Job outcomes</h2>
83
+ </div>
84
+ <div className="toolbar-controls">
85
+ <label className="jobs-inline-field">
86
+ <span className="dim">Channel</span>
87
+ <select
88
+ className="input input-compact"
89
+ value={active}
90
+ onChange={(event) => setChannel(event.target.value)}
91
+ aria-label="Metric channel"
92
+ >
93
+ <option value="">All channels</option>
94
+ {channels.map((name) => <option key={name} value={name}>{name}</option>)}
95
+ </select>
96
+ </label>
97
+ </div>
98
+ </div>
99
+ <MetricsChart
100
+ key={active || 'all'}
101
+ title={active ? `Outcomes · ${active}` : 'Outcomes · all channels'}
102
+ slugs={scoped ? scoped.slugs : GLOBAL_SLUGS}
103
+ seriesLabels={scoped ? scoped.labels : GLOBAL_LABELS}
104
+ account="global"
105
+ defaultRange="7d"
106
+ defaultGranularity="days"
107
+ defaultType="bar"
108
+ height={260}
109
+ />
110
+ {active && (
111
+ <p className="dim jobs-throughput-note">
112
+ Retried and expired are only recorded system-wide — the backend records no per-channel slug for them.
113
+ </p>
114
+ )}
115
+ </div>
116
+ </div>
117
+ );
118
+ }
@@ -0,0 +1,24 @@
1
+ import { useEffect, useState } from 'react';
2
+ import type { QueryClient } from '@tanstack/react-query';
3
+ import { FormWizard, modal, type FormData } from '../../ui';
4
+ import { useCan } from '../../client';
5
+ import { getDnsAdminIntegration } from '../dns/dns-integration';
6
+ import { DNS_VIEW_PERMISSIONS } from '../dns/models';
7
+ import { onboardEmailDomain } from './api';
8
+ import type { DomainActionResult, EmailDomainRow } from './models';
9
+
10
+ interface WizardOutcome{result:DomainActionResult;recordsHref:string|null}
11
+ function mountedHref(relative:string):string{const clean=relative.replace(/^\/+|\/+$/g,'');const embedded=window.location.hash.startsWith('#/system/');return `#/${embedded?'system/':''}${clean}`;}
12
+ function OnboardResult({outcome,onClose,onNavigate}:{outcome:WizardOutcome;onClose:()=>void;onNavigate:()=>void}){const {result}=outcome;return <div className="modal-pad"><h2 className="modal-title">Onboarding result</h2><p><b>{result.domain}</b> · {result.region}</p>{result.provider&&<p>Provider: {result.provider}{result.applied!=null?` · ${result.applied} records applied`:''}</p>}{result.receipt_rule&&<p><b>Receipt rule:</b> <code>{result.receipt_rule}</code></p>}{result.rule_set&&<p><b>Rule set:</b> <code>{result.rule_set}</code></p>}{result.dns_records.length>0?<div className="table-wrap"><table className="tbl"><thead><tr><th>Type</th><th>Name</th><th>Value</th><th>TTL</th></tr></thead><tbody>{result.dns_records.map((record,index)=><tr key={`${record.type}:${record.name}:${index}`}><td>{record.type}</td><td><code>{record.name}</code></td><td><code>{record.value}</code></td><td>{record.ttl}</td></tr>)}</tbody></table></div>:<p className="dim">No DNS records were returned.</p>}{outcome.recordsHref&&<p><button className="btn" onClick={onNavigate}>Open DNS records</button></p>}{Object.entries(result.topic_arns??{}).map(([key,value])=><p key={key}><b>{key} topic:</b> <code>{value}</code></p>)}{result.notes.map(note=><p key={note}>{note}</p>)}<div className="modal-actions"><button className="btn" onClick={onClose}>Close</button></div></div>}
13
+
14
+ function WizardBody({domain,queryClient,onPending,onSuccess,onClose}:{domain:EmailDomainRow;queryClient:QueryClient;onPending:(value:boolean)=>void;onSuccess:(value:WizardOutcome)=>void;onClose:()=>void}){
15
+ const integration=getDnsAdminIntegration();const dnsPermission=useCan(DNS_VIEW_PERMISSIONS);const canViewDns=dnsPermission.can;const canResolve=canViewDns&&Boolean(integration?.resolveDomainByName&&integration.recordsHref);const normalizedDomain=domain.name.trim().toLowerCase().replace(/\.$/,'');const [dnsDomainId,setDnsDomainId]=useState<number|null>(null);const [resolvedFor,setResolvedFor]=useState<string|null>(null);
16
+ useEffect(()=>{let alive=true;if(dnsPermission.isLoading){setDnsDomainId(null);setResolvedFor(null);return;}if(!canResolve||!integration?.resolveDomainByName){setDnsDomainId(null);setResolvedFor(null);return;}setDnsDomainId(null);setResolvedFor(null);void integration.resolveDomainByName(normalizedDomain).then(id=>{if(alive)setDnsDomainId(id);}).catch(()=>{if(alive)setDnsDomainId(null);}).finally(()=>{if(alive)setResolvedFor(normalizedDomain);});return()=>{alive=false;};},[canResolve,dnsPermission.isLoading,integration,normalizedDomain]);
17
+ const managedReady=canViewDns&&dnsDomainId!=null&&Boolean(integration?.recordsHref);
18
+ const finish=async(data:FormData)=>{const useDns=data.use_dnsman==='managed';if(useDns&&!managedReady)throw new Error('Managed DNS is unavailable for this exact domain. Choose manual DNS.');const receiving=Boolean(data.receiving_enabled);const bucket=String(data.s3_inbound_bucket??'').trim();if(receiving&&!bucket)throw new Error('Inbound S3 bucket is required when receiving is enabled.');const mailFrom=String(data.mail_from_subdomain??'').trim();if(Boolean(data.ensure_mail_from)&&!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(mailFrom))throw new Error('MAIL FROM subdomain is invalid.');const endpoints:Record<string,string>={};for(const key of ['bounce','complaint','delivery','inbound'] as const){const value=String(data[`${key}_endpoint`]??'').trim();if(value){let url:URL;try{url=new URL(value);}catch{throw new Error(`${key} endpoint must be an HTTPS URL.`);}if(url.protocol!=='https:'||url.username||url.password)throw new Error(`${key} endpoint must be an HTTPS URL without credentials.`);endpoints[key]=value;}}
19
+ const input={use_dnsman:useDns,region:String(data.region||domain.region),receiving_enabled:receiving,...(bucket?{s3_inbound_bucket:bucket}:{}),s3_inbound_prefix:String(data.s3_inbound_prefix??''),ensure_mail_from:Boolean(data.ensure_mail_from),...(mailFrom?{mail_from_subdomain:mailFrom}:{}),...(Object.keys(endpoints).length?{endpoints}:{})};const ok=await modal.confirm({title:'Apply SES onboarding?',message:<><p>This performs external SES/SNS and receiving configuration. Managed mode may also write DNS.</p><p><b>{domain.name}</b> · {useDns?'Managed DNS':'Manual DNS'} · {input.region}</p><p>Receiving: {receiving?`enabled → ${bucket}`:'disabled'} · MAIL FROM: {input.ensure_mail_from?mailFrom:'off'}</p></>,confirmText:'Apply onboarding'});if(!ok)return;onPending(true);try{const result=await onboardEmailDomain(queryClient,domain.id,input);const recordsHref=managedReady&&integration?.recordsHref&&dnsDomainId!=null?mountedHref(integration.recordsHref(dnsDomainId)):null;onSuccess({result,recordsHref});}finally{onPending(false);}};
20
+ if(dnsPermission.isLoading||(canResolve&&resolvedFor!==normalizedDomain))return <div className="modal-pad"><h2 className="modal-title">Onboard {domain.name}</h2><p className="dim">Resolving DNS permissions and the exact managed domain…</p><div className="modal-actions"><button className="btn" onClick={onClose}>Cancel</button></div></div>;
21
+ return <div className="modal-pad"><h2 className="modal-title">Onboard {domain.name}</h2>{!managedReady&&<div className="form-alert">Managed DNS is unavailable for this exact domain or permission set. Manual mode returns complete records.</div>}<FormWizard mode="wizard" finishText="Review and onboard" busyText="Onboarding…" onCancel={onClose} onFinish={finish} initial={{region:domain.region,receiving_enabled:domain.receiving_enabled,s3_inbound_bucket:domain.s3_inbound_bucket??'',s3_inbound_prefix:domain.s3_inbound_prefix??'',use_dnsman:managedReady?'managed':'manual',ensure_mail_from:true,mail_from_subdomain:'feedback'}} sections={[{key:'domain',label:'Domain',description:'Confirm region and receiving intent.',fields:[{name:'region',label:'AWS region',type:'text',required:true},{name:'receiving_enabled',label:'Enable receiving',type:'switch'}]},{key:'receiving',label:'Receiving',fields:[{name:'s3_inbound_bucket',label:'Inbound S3 bucket',type:'text',help:'Required when receiving is enabled.'},{name:'s3_inbound_prefix',label:'Object prefix',type:'text'}]},{key:'notifications',label:'Notifications',optional:true,fields:[{name:'bounce_endpoint',label:'Bounce HTTPS endpoint',type:'text'},{name:'complaint_endpoint',label:'Complaint HTTPS endpoint',type:'text'},{name:'delivery_endpoint',label:'Delivery HTTPS endpoint',type:'text'},{name:'inbound_endpoint',label:'Inbound HTTPS endpoint',type:'text'}]},{key:'dns',label:'DNS',description:managedReady?'Managed DNS uses the exact registered DNSMan domain and its stored credential.':'Manual DNS keeps provider changes in your hands.',fields:[{name:'use_dnsman',label:'DNS method',type:'select',options:managedReady?[{value:'manual',label:'Manual DNS'},{value:'managed',label:'Managed DNS'}]:[{value:'manual',label:'Manual DNS'}]},{name:'ensure_mail_from',label:'Configure MAIL FROM',type:'switch'},{name:'mail_from_subdomain',label:'MAIL FROM subdomain',type:'text'}]},{key:'review',label:'Review',description:'Finish opens an exact confirmation before one external request.',fields:[]}]} /></div>;
22
+ }
23
+
24
+ export async function openEmailDomainWizard(domain:EmailDomainRow,queryClient:QueryClient):Promise<void>{let pending=false;let outcome:WizardOutcome|null=null;await modal.open(close=><WizardBody domain={domain} queryClient={queryClient} onPending={value=>{pending=value;}} onSuccess={value=>{outcome=value;close(null);}} onClose={()=>close(null)}/>,{size:'lg',canDismiss:()=>!pending});const completed=outcome as WizardOutcome|null;if(completed){const navigate=await modal.open<'dns'|null>(close=><OnboardResult outcome={completed} onClose={()=>close(null)} onNavigate={()=>close('dns')}/>,{size:'lg'});if(navigate==='dns'&&completed.recordsHref)window.location.hash=completed.recordsHref.replace(/^#/,'');}}