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,721 @@
1
+ // JobDetail — the job inspector, opened as a `modal.detail` from the jobs
2
+ // table (and from the runner inspector's job lists). Port of web-mojo
3
+ // JobDetailsView.js (912 lines) onto portal-mojo's DetailView + the detail
4
+ // primitives.
5
+ //
6
+ // Carried from the source: the StatusPanel-led Overview with its per-state
7
+ // narrative, the four KPIs (attempt / runtime / retries left / next), the
8
+ // Execution flat rows with an error block, the Lifecycle timeline, and the
9
+ // Payload · Events · Logs · Retry history · Similar sections behind the
10
+ // Activity/Related dividers, plus the header chips and the state-conditional
11
+ // kebab.
12
+ //
13
+ // Re-wired, because the backend does not serve what the source assumed:
14
+ // · `recent_events` exists in NO graph — only inside the `get_status` action
15
+ // payload. The lifecycle timeline is `/api/jobs/event?job=&graph=timeline`
16
+ // (useJobTimeline), and those entries carry no `id` by design.
17
+ // · logs filter by the RELATION name (`job=`). The source sent `job_id=`,
18
+ // which django-mojo accepts as an FK attname and then dies on — HTTP 500,
19
+ // not an unfiltered list.
20
+ // · `retry_request` ALWAYS republishes a NEW job. The source toasted "Retry
21
+ // scheduled" and left the operator looking at a row that did not resume;
22
+ // here the new id is named and offered.
23
+ // · `cancel_request` must be sent as `true` — `{}` is falsy in Python.
24
+ // · there is no `stack_trace` and no `is_retriable` in any graph.
25
+ // Retriability is the SERVICE rule (canRetryJob), computed client-side.
26
+ // · every trusted-HTML slot (`meta`, `detail`, `auxFn`) is a ReactNode.
27
+ import { useState, type ReactNode } from 'react';
28
+ import { useQueryClient } from '@tanstack/react-query';
29
+ import { useCan } from '../../client';
30
+ import {
31
+ ArmedButton,
32
+ Badge,
33
+ DetailView,
34
+ Eyebrow,
35
+ FlatRow,
36
+ JsonBlock,
37
+ StatusPanel,
38
+ Timeline,
39
+ fmt,
40
+ modal,
41
+ toast,
42
+ type DetailMenuEntry,
43
+ type TimelineItem,
44
+ type Tone,
45
+ } from '../../ui';
46
+ import { JobIdentity, JobStatusBadge, relativeWhen } from './columns';
47
+ import {
48
+ canCancelJob,
49
+ canRetryJob,
50
+ isScheduledJob,
51
+ isOverdueJob,
52
+ jobStatusIcon,
53
+ jobStatusTone,
54
+ JOBS_MANAGE_PERMS,
55
+ JOBS_VIEW_PERMS,
56
+ JobEventModel,
57
+ JobLogModel,
58
+ JobModel,
59
+ type JobEventKind,
60
+ type JobRow,
61
+ type JobTimelineEntry,
62
+ } from './models';
63
+ import { jobsKeys, useJobLogs, useJobTimeline } from './queries';
64
+
65
+ // ── Event vocabulary ──────────────────────────────────────────────────
66
+ // JobEvent.event is a fixed choice list on the backend; an unknown value still
67
+ // falls back WITH a warn rather than rendering nothing.
68
+
69
+ const EVENT_TONE: Record<JobEventKind, Tone> = {
70
+ created: 'muted',
71
+ queued: 'info',
72
+ scheduled: 'warning',
73
+ running: 'primary',
74
+ claimed: 'primary',
75
+ released: 'warning',
76
+ retry: 'warning',
77
+ canceled: 'muted',
78
+ completed: 'success',
79
+ failed: 'danger',
80
+ expired: 'warning',
81
+ };
82
+
83
+ const EVENT_ICON: Record<JobEventKind, string> = {
84
+ created: 'bi-plus-circle',
85
+ queued: 'bi-inbox',
86
+ scheduled: 'bi-clock',
87
+ running: 'bi-play-fill',
88
+ claimed: 'bi-hand-index',
89
+ released: 'bi-hand-index-thumb',
90
+ retry: 'bi-arrow-repeat',
91
+ canceled: 'bi-x-circle',
92
+ completed: 'bi-check-circle',
93
+ failed: 'bi-x-octagon',
94
+ expired: 'bi-clock-history',
95
+ };
96
+
97
+ function eventTone(event: string): Tone {
98
+ const tone = EVENT_TONE[event as JobEventKind];
99
+ if (tone) return tone;
100
+ console.warn(`[admin/jobs] unknown job event ${JSON.stringify(event)} — falling back to "muted".`);
101
+ return 'muted';
102
+ }
103
+
104
+ function eventIcon(event: string): string {
105
+ return EVENT_ICON[event as JobEventKind] ?? 'bi-dot';
106
+ }
107
+
108
+ /** One event row (timeline graph or default graph) → a Timeline item. */
109
+ function toTimelineItem(entry: Pick<JobTimelineEntry, 'event' | 'at' | 'runner_id' | 'details'>): TimelineItem {
110
+ const detailKeys = Object.keys(entry.details ?? {});
111
+ return {
112
+ tone: eventTone(entry.event),
113
+ title: entry.event,
114
+ meta: fmt.relative(entry.at),
115
+ body: (
116
+ <>
117
+ {entry.runner_id && <>runner <code>{entry.runner_id}</code>{detailKeys.length > 0 && ' · '}</>}
118
+ {detailKeys.length > 0 && <code className="jobs-json-inline">{JSON.stringify(entry.details)}</code>}
119
+ </>
120
+ ),
121
+ };
122
+ }
123
+
124
+ // ── Small presentational pieces ───────────────────────────────────────
125
+
126
+ function Kpi({ label, value, tone }: { label: string; value: ReactNode; tone?: Tone | null }) {
127
+ return (
128
+ <div className={`jobs-kpi${tone ? ` jobs-kpi-${tone}` : ''}`}>
129
+ <div className="jobs-kpi-label">{label}</div>
130
+ <div className="jobs-kpi-value">{value}</div>
131
+ </div>
132
+ );
133
+ }
134
+
135
+ function TablePane({ pending, error, empty, children }: {
136
+ pending: boolean;
137
+ error: unknown;
138
+ empty: boolean;
139
+ children: ReactNode;
140
+ }) {
141
+ if (pending) return <p className="dim">Loading…</p>;
142
+ if (error) return <p className="jobs-text-danger">{error instanceof Error ? error.message : 'Request failed'}</p>;
143
+ if (empty) return <p className="dim-italic">Nothing to show.</p>;
144
+ return <div className="jobs-pane-scroll">{children}</div>;
145
+ }
146
+
147
+ /**
148
+ * `last_error` is an unbounded exception string — a 200KB traceback must not
149
+ * be pasted into the DOM just because it exists. The first 2000 characters
150
+ * render immediately and the rest is one click away. There is no stack trace
151
+ * to offer beside it: no graph carries `stack_trace`, and implying otherwise
152
+ * would send the operator looking for something that is not on the wire.
153
+ */
154
+ const ERROR_PREVIEW_CHARS = 2000;
155
+
156
+ function ErrorBlock({ text }: { text: string }) {
157
+ const [full, setFull] = useState(false);
158
+ const long = text.length > ERROR_PREVIEW_CHARS;
159
+ return (
160
+ <>
161
+ <pre className="jobs-error-block">{full || !long ? text : `${text.slice(0, ERROR_PREVIEW_CHARS)}…`}</pre>
162
+ {long && (
163
+ <button type="button" className="btn btn-compact jobs-error-more" onClick={() => setFull((v) => !v)}>
164
+ {full ? 'Show less' : `Show the full error (${fmt.number(text.length)} characters)`}
165
+ </button>
166
+ )}
167
+ </>
168
+ );
169
+ }
170
+
171
+ // ── Cancel + retry dialogs ────────────────────────────────────────────
172
+
173
+ /**
174
+ * Cancel is irreversible and its effect depends on whether the runner is still
175
+ * alive, so it is ARMED rather than a plain confirm — and the dialog says which
176
+ * of the two outcomes to expect before the second click.
177
+ */
178
+ function CancelJobDialog({ job, onConfirm, onClose }: {
179
+ job: JobRow;
180
+ onConfirm: () => Promise<void>;
181
+ onClose: () => void;
182
+ }) {
183
+ return (
184
+ <div className="modal-pad">
185
+ <h2 className="modal-title">Cancel job</h2>
186
+ <p className="dim">
187
+ <code>{fmt.truncateMiddle(job.id, 16)}</code> · <code>{job.func || 'unknown function'}</code>
188
+ </p>
189
+ <p className="jobs-note jobs-note-warn">
190
+ <i className="bi bi-exclamation-triangle" />
191
+ {job.status === 'running'
192
+ ? <>A running job is canceled <strong>cooperatively</strong>: the flag is set and the runner stops at its next check. If its runner is not alive the backend force-cancels instead and the job ends immediately.</>
193
+ : <>The job is canceled outright and will never run.</>}
194
+ </p>
195
+ <div className="modal-actions">
196
+ <button type="button" className="btn" onClick={onClose}>Keep job</button>
197
+ <ArmedButton
198
+ label="Cancel job"
199
+ armedLabel="Click again to cancel this job"
200
+ icon="bi-x-circle"
201
+ className="btn-danger-ghost"
202
+ onConfirm={onConfirm}
203
+ />
204
+ </div>
205
+ </div>
206
+ );
207
+ }
208
+
209
+ /**
210
+ * Retry ALWAYS republishes a new job (`{original_job_id, new_job_id, delayed}`)
211
+ * — the row on screen does not resume. The dialog says so before the run and
212
+ * hands back the new id afterwards, because "Retry scheduled" (the source's
213
+ * toast) left an operator watching a job that was never going to move.
214
+ */
215
+ function RetryJobDialog({ job, onRun, onOpenNewJob, onClose }: {
216
+ job: JobRow;
217
+ onRun: (delay: number | null) => Promise<{ newJobId: string; delayed: boolean }>;
218
+ onOpenNewJob: (newJobId: string) => void;
219
+ onClose: () => void;
220
+ }) {
221
+ const [delay, setDelay] = useState(0);
222
+ const [busy, setBusy] = useState(false);
223
+ const [result, setResult] = useState<{ newJobId: string; delayed: boolean } | null>(null);
224
+
225
+ const run = async () => {
226
+ setBusy(true);
227
+ try {
228
+ setResult(await onRun(delay > 0 ? delay : null));
229
+ } catch (error) {
230
+ toast.error(error instanceof Error ? error.message : 'Retry failed');
231
+ } finally {
232
+ setBusy(false);
233
+ }
234
+ };
235
+
236
+ if (result) {
237
+ return (
238
+ <div className="modal-pad">
239
+ <h2 className="modal-title">Retry published</h2>
240
+ <div className="jobs-result-note">
241
+ <p className="jobs-note jobs-note-info">
242
+ <i className="bi bi-info-circle" />
243
+ A <strong>new</strong> job was published{result.delayed ? ` and runs in ${delay}s` : ''}. The original
244
+ job keeps its own history — it did not resume.
245
+ </p>
246
+ <FlatRow label="Original"><code>{fmt.truncateMiddle(job.id, 16)}</code></FlatRow>
247
+ <FlatRow label="New job"><code>{fmt.truncateMiddle(result.newJobId, 16)}</code></FlatRow>
248
+ </div>
249
+ <div className="modal-actions">
250
+ <button type="button" className="btn" onClick={onClose}>Close</button>
251
+ <button type="button" className="btn btn-primary" onClick={() => onOpenNewJob(result.newJobId)}>
252
+ <i className="bi bi-box-arrow-up-right" /> Open new job
253
+ </button>
254
+ </div>
255
+ </div>
256
+ );
257
+ }
258
+
259
+ return (
260
+ <div className="modal-pad">
261
+ <h2 className="modal-title">Retry job</h2>
262
+ <p className="dim">
263
+ <code>{fmt.truncateMiddle(job.id, 16)}</code> · <code>{job.func || 'unknown function'}</code>
264
+ </p>
265
+ <p className="jobs-note jobs-note-info">
266
+ <i className="bi bi-info-circle" /> Retrying republishes the payload as a <strong>new job</strong>. This
267
+ one stays {job.status} until it is purged.
268
+ </p>
269
+ <label className="jobs-field">
270
+ <span>Delay before it runs (seconds, 0 = immediately)</span>
271
+ <input
272
+ type="number" className="input input-compact" min={0} step={5}
273
+ value={delay}
274
+ onChange={(event) => setDelay(Math.max(0, Number(event.target.value) || 0))}
275
+ />
276
+ </label>
277
+ <div className="modal-actions">
278
+ <button type="button" className="btn" onClick={onClose}>Cancel</button>
279
+ <button type="button" className="btn btn-primary" disabled={busy} onClick={() => void run()}>
280
+ {busy ? 'Publishing…' : 'Retry'}
281
+ </button>
282
+ </div>
283
+ </div>
284
+ );
285
+ }
286
+
287
+ // ── Narrative ─────────────────────────────────────────────────────────
288
+
289
+ interface JobNarrative {
290
+ tone: Tone;
291
+ state: string;
292
+ headline: string;
293
+ meta: ReactNode;
294
+ }
295
+
296
+ /** The StatusPanel copy per lifecycle state — web-mojo's `_narrative` port. */
297
+ function jobNarrative(job: JobRow): JobNarrative {
298
+ const duration = job.duration_ms > 0 ? fmt.duration(job.duration_ms) : null;
299
+ const attempts = <>Attempt <strong>{job.attempt}</strong> of <strong>{job.max_retries || '∞'}</strong></>;
300
+
301
+ if (isScheduledJob(job)) {
302
+ const overdue = isOverdueJob(job);
303
+ return {
304
+ tone: overdue ? 'warning' : 'info',
305
+ state: overdue ? 'OVERDUE' : 'SCHEDULED',
306
+ headline: overdue
307
+ ? `Was due ${relativeWhen(job.run_at)} and has not run`
308
+ : `Runs ${relativeWhen(job.run_at)}`,
309
+ meta: <><code>{job.func || 'unknown'}</code> on channel <code>{job.channel}</code> · queued {fmt.relative(job.created)}</>,
310
+ };
311
+ }
312
+ if (job.status === 'running') {
313
+ return {
314
+ tone: 'primary',
315
+ state: job.cancel_requested ? 'CANCELING' : 'RUNNING',
316
+ headline: job.runner_id
317
+ ? `Running on ${job.runner_id} · started ${fmt.relative(job.started_at)}`
318
+ : `Running · started ${fmt.relative(job.started_at)}`,
319
+ meta: job.cancel_requested
320
+ ? <>{attempts} · a cancel has been requested; the runner stops at its next check</>
321
+ : attempts,
322
+ };
323
+ }
324
+ if (job.status === 'completed') {
325
+ return {
326
+ tone: 'success',
327
+ state: 'COMPLETED',
328
+ headline: duration ? `Completed in ${duration}` : 'Completed',
329
+ meta: <>Finished {fmt.relative(job.finished_at)}{job.runner_id ? <> on <code>{job.runner_id}</code></> : null}</>,
330
+ };
331
+ }
332
+ if (job.status === 'failed') {
333
+ const firstLine = (job.last_error || '').split('\n')[0] || 'Failed';
334
+ return {
335
+ tone: 'danger',
336
+ state: 'FAILED',
337
+ headline: duration ? `Failed after ${duration}` : 'Failed',
338
+ meta: <>{attempts}{canRetryJob(job) ? ' · retry available' : ''}<br /><code className="jobs-text-danger">{firstLine}</code></>,
339
+ };
340
+ }
341
+ if (job.status === 'canceled') {
342
+ return {
343
+ tone: 'muted',
344
+ state: 'CANCELED',
345
+ headline: 'Canceled',
346
+ meta: <>Canceled {fmt.relative(job.finished_at ?? job.modified)} · retry is still allowed</>,
347
+ };
348
+ }
349
+ if (job.status === 'expired') {
350
+ return {
351
+ tone: 'warning',
352
+ state: 'EXPIRED',
353
+ headline: 'Expired before it ran',
354
+ meta: <>Created {fmt.relative(job.created)}{job.expires_at ? <> · expiry {fmt.datetime(job.expires_at)}</> : null}</>,
355
+ };
356
+ }
357
+ return {
358
+ tone: jobStatusTone(job.status),
359
+ state: 'PENDING',
360
+ headline: 'Waiting for a runner',
361
+ meta: <>Queued on channel <code>{job.channel}</code> · {fmt.relative(job.created)}</>,
362
+ };
363
+ }
364
+
365
+ // ── The inspector ─────────────────────────────────────────────────────
366
+
367
+ const EVENT_PAGE = 50;
368
+ const SIMILAR_PAGE = 15;
369
+
370
+ export function JobDetail({ jobId, onClose }: { jobId: string; onClose: () => void }) {
371
+ const queryClient = useQueryClient();
372
+ const { can: canView } = useCan(JOBS_VIEW_PERMS);
373
+ const { can: canManage } = useCan(JOBS_MANAGE_PERMS);
374
+
375
+ // Every query is view-gated: a caller without the grant issues no request
376
+ // at all rather than firing one and catching a 403.
377
+ const query = JobModel.useOne(canView ? jobId : null);
378
+ const job = query.data ?? null;
379
+ const timeline = useJobTimeline(canView ? jobId : null);
380
+ const logs = useJobLogs(canView ? jobId : null);
381
+ const events = JobEventModel.useList(
382
+ { job: jobId, sort: '-at', size: EVENT_PAGE },
383
+ { enabled: canView },
384
+ );
385
+ const retries = JobEventModel.useList(
386
+ { job: jobId, event: 'retry', sort: '-at', size: EVENT_PAGE },
387
+ { enabled: canView },
388
+ );
389
+ const similar = JobModel.useList(
390
+ { func: job?.func ?? '', sort: '-created', size: SIMILAR_PAGE },
391
+ { enabled: canView && !!job?.func },
392
+ );
393
+
394
+ const cancelAction = JobModel.useAction('cancel_request');
395
+ const retryAction = JobModel.useAction('retry_request');
396
+
397
+ /**
398
+ * One refresh for the whole record: the job, its events (timeline + the
399
+ * events/retry lists share the endpoint root), its logs, and the jobs
400
+ * domain root so the dashboard totals follow a cancel or a retry.
401
+ */
402
+ const refresh = () => {
403
+ void queryClient.invalidateQueries({ queryKey: JobModel.keys.root });
404
+ void queryClient.invalidateQueries({ queryKey: JobEventModel.keys.root });
405
+ void queryClient.invalidateQueries({ queryKey: JobLogModel.keys.root });
406
+ void queryClient.invalidateQueries({ queryKey: jobsKeys.root });
407
+ };
408
+
409
+ if (query.isPending) return <div className="modal-pad dim">Loading job…</div>;
410
+ if (!job || query.error) {
411
+ return <div className="modal-pad text-bad">{query.error?.message ?? 'Job not found'}</div>;
412
+ }
413
+
414
+ const narrative = jobNarrative(job);
415
+ const shortId = fmt.truncateMiddle(job.id, 16);
416
+ const retriesLeft = Math.max(0, (job.max_retries ?? 0) - (job.attempt ?? 0));
417
+ const similarRows = (similar.data?.rows ?? []).filter((row) => row.id !== job.id);
418
+
419
+ const nextLabel = isScheduledJob(job) ? relativeWhen(job.run_at)
420
+ : job.status === 'running' ? 'In flight'
421
+ : canRetryJob(job) ? 'Retry available'
422
+ : '—';
423
+ const nextTone: Tone | null = isScheduledJob(job) ? (isOverdueJob(job) ? 'warning' : 'info')
424
+ : job.status === 'running' ? 'primary'
425
+ : canRetryJob(job) ? 'info'
426
+ : null;
427
+
428
+ const runCancel = async () => {
429
+ try {
430
+ const outcome = await cancelAction.mutateAsync({ id: job.id, payload: true });
431
+ const body = outcome.body as { message?: string; forced?: boolean };
432
+ // A running job on a DEAD runner is force-canceled server-side and
433
+ // is already over — saying "cancellation requested" would be a lie.
434
+ toast.success(body.forced
435
+ ? `Job ${shortId} canceled — its runner was not alive, so the cancel was forced.`
436
+ : body.message ?? 'Cancellation requested');
437
+ refresh();
438
+ } catch (error) {
439
+ toast.error(error instanceof Error ? error.message : 'Cancel failed');
440
+ }
441
+ };
442
+
443
+ const openCancel = () => {
444
+ void modal.open((close) => (
445
+ <CancelJobDialog
446
+ job={job}
447
+ onClose={() => close(null)}
448
+ // The dialog stays up while the request is in flight and closes
449
+ // on its outcome — a cancel that 400s must not vanish silently.
450
+ onConfirm={async () => { await runCancel(); close(null); }}
451
+ />
452
+ ), { size: 'sm' });
453
+ };
454
+
455
+ const openRetry = () => {
456
+ void modal.open((close) => (
457
+ <RetryJobDialog
458
+ job={job}
459
+ onClose={() => close(null)}
460
+ onRun={async (delay) => {
461
+ const outcome = await retryAction.mutateAsync({
462
+ id: job.id,
463
+ payload: delay == null ? { retry: true } : { retry: true, delay },
464
+ });
465
+ const body = outcome.body as { new_job_id?: string; delayed?: boolean };
466
+ const newJobId = body.new_job_id ?? '';
467
+ toast.success(`Retry published as job ${fmt.truncateMiddle(newJobId, 16)}`);
468
+ refresh();
469
+ return { newJobId, delayed: Boolean(body.delayed) };
470
+ }}
471
+ onOpenNewJob={(newJobId) => {
472
+ // Follow the retry: close both this dialog and the original
473
+ // job's inspector, then open the job that actually runs.
474
+ close(null);
475
+ onClose();
476
+ showJobDetail(newJobId);
477
+ }}
478
+ />
479
+ ), { size: 'sm' });
480
+ };
481
+
482
+ const contextMenu: DetailMenuEntry<JobRow>[] = [
483
+ { label: 'Refresh', icon: 'bi-arrow-clockwise', onSelect: refresh },
484
+ { divider: true },
485
+ {
486
+ label: 'Retry job…', icon: 'bi-arrow-repeat',
487
+ permissions: JOBS_MANAGE_PERMS,
488
+ when: (row) => (row ? canRetryJob(row) : false),
489
+ onSelect: openRetry,
490
+ },
491
+ {
492
+ label: 'Cancel job…', icon: 'bi-x-circle', danger: true,
493
+ permissions: JOBS_MANAGE_PERMS,
494
+ when: (row) => (row ? canCancelJob(row) : false),
495
+ onSelect: openCancel,
496
+ },
497
+ ];
498
+
499
+ return (
500
+ <DetailView<JobRow>
501
+ icon={isScheduledJob(job) ? 'bi-clock-fill' : jobStatusIcon(job.status)}
502
+ title={job.func || 'unknown.task'}
503
+ subtitle={narrative.headline}
504
+ chips={[
505
+ { icon: 'bi-broadcast', text: job.channel, tone: 'info' },
506
+ { text: `#${job.id.slice(-8)}`, tone: 'muted' },
507
+ ...(job.runner_id ? [{ icon: 'bi-cpu', text: job.runner_id, tone: 'muted' as const }] : []),
508
+ ...(job.attempt > 0 || job.max_retries > 0
509
+ ? [{ text: `attempt ${job.attempt}/${job.max_retries || '∞'}`, tone: 'muted' as const }]
510
+ : []),
511
+ ...(job.duration_ms > 0 ? [{ text: fmt.duration(job.duration_ms), tone: 'muted' as const }] : []),
512
+ ...(job.cancel_requested
513
+ ? [{ icon: 'bi-exclamation-triangle', text: 'cancel requested', tone: 'warning' as const }]
514
+ : []),
515
+ ]}
516
+ menuContext={job}
517
+ contextMenu={contextMenu}
518
+ // Keyed by section key — and `similar` only exists when `func` does,
519
+ // so the key is omitted with the section rather than left dangling
520
+ // (DetailView warns about a badge key that names no section).
521
+ badges={{
522
+ events: events.data?.count || null,
523
+ logs: logs.data?.count || null,
524
+ retries: retries.data?.count || null,
525
+ ...(job.func ? { similar: similarRows.length || null } : {}),
526
+ }}
527
+ sections={[
528
+ {
529
+ key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => (
530
+ <>
531
+ <StatusPanel
532
+ tone={narrative.tone}
533
+ state={narrative.state}
534
+ headline={narrative.headline}
535
+ meta={narrative.meta}
536
+ actions={canManage ? (
537
+ <div className="jobs-detail-actions">
538
+ {canRetryJob(job) && (
539
+ <button type="button" className="btn btn-compact btn-primary" onClick={openRetry}>
540
+ <i className="bi bi-arrow-repeat" /> Retry…
541
+ </button>
542
+ )}
543
+ {canCancelJob(job) && (
544
+ <button type="button" className="btn btn-compact btn-danger-ghost" onClick={openCancel}>
545
+ <i className="bi bi-x-circle" /> Cancel…
546
+ </button>
547
+ )}
548
+ </div>
549
+ ) : null}
550
+ />
551
+
552
+ <div className="jobs-kpi-grid">
553
+ <Kpi
554
+ label="Attempt"
555
+ value={`${job.attempt} / ${job.max_retries || '∞'}`}
556
+ tone={job.attempt > 0 && job.status === 'failed' ? 'warning' : null}
557
+ />
558
+ <Kpi label="Runtime" value={job.duration_ms > 0 ? fmt.duration(job.duration_ms) : '—'} />
559
+ <Kpi label="Retries left" value={retriesLeft} tone={retriesLeft === 0 && job.status === 'failed' ? 'danger' : null} />
560
+ <Kpi label={isScheduledJob(job) ? 'Scheduled' : 'Next'} value={nextLabel} tone={nextTone} />
561
+ </div>
562
+
563
+ <Eyebrow>Execution</Eyebrow>
564
+ <FlatRow label="Function"><code>{job.func || '—'}</code></FlatRow>
565
+ <FlatRow label="Channel"><code>{job.channel}</code></FlatRow>
566
+ <FlatRow label="Status"><JobStatusBadge status={job.status} /></FlatRow>
567
+ <FlatRow label="Runner">
568
+ {job.runner_id ? <code>{job.runner_id}</code> : <span className="dim">not claimed</span>}
569
+ </FlatRow>
570
+ <FlatRow label="Created">{fmt.datetime(job.created)}</FlatRow>
571
+ <FlatRow label="Started">
572
+ {job.started_at == null ? <span className="dim">—</span> : fmt.datetime(job.started_at)}
573
+ </FlatRow>
574
+ <FlatRow label="Finished">
575
+ {job.finished_at == null ? <span className="dim">—</span> : fmt.datetime(job.finished_at)}
576
+ </FlatRow>
577
+ {job.run_at != null && (
578
+ <FlatRow label="Runs at">{fmt.datetime(job.run_at)} · {relativeWhen(job.run_at)}</FlatRow>
579
+ )}
580
+ {job.expires_at != null && (
581
+ <FlatRow label="Expires">{fmt.datetime(job.expires_at)}</FlatRow>
582
+ )}
583
+ {job.broadcast && <FlatRow label="Broadcast"><Badge tone="info">every runner</Badge></FlatRow>}
584
+ {job.max_exec_seconds != null && (
585
+ <FlatRow label="Max execution">{fmt.duration(job.max_exec_seconds, 's')}</FlatRow>
586
+ )}
587
+ {job.cancel_requested && (
588
+ <FlatRow label="Cancel"><Badge tone="warning">requested</Badge></FlatRow>
589
+ )}
590
+ {job.last_error && <ErrorBlock text={job.last_error} />}
591
+
592
+ <Eyebrow>Lifecycle</Eyebrow>
593
+ {timeline.isPending
594
+ ? <p className="dim">Loading lifecycle…</p>
595
+ : timeline.error
596
+ ? <p className="jobs-text-danger">{timeline.error instanceof Error ? timeline.error.message : 'Could not load the lifecycle.'}</p>
597
+ : (
598
+ <Timeline
599
+ items={(timeline.data?.rows ?? []).map(toTimelineItem)}
600
+ emptyText="No events recorded yet. Lifecycle entries appear as the runner claims the job and emits events."
601
+ />
602
+ )}
603
+ </>
604
+ ),
605
+ },
606
+ {
607
+ key: 'payload', label: 'Payload', icon: 'bi-braces', render: () => (
608
+ <>
609
+ <JsonBlock value={job.payload} label="Payload" defaultOpen />
610
+ {Object.keys(job.metadata ?? {}).length > 0 && (
611
+ <JsonBlock value={job.metadata} label="Metadata" />
612
+ )}
613
+ </>
614
+ ),
615
+ },
616
+ { divider: 'Activity' },
617
+ {
618
+ key: 'events', label: 'Events', icon: 'bi-list-ul', render: () => (
619
+ <TablePane pending={events.isPending} error={events.error} empty={(events.data?.rows.length ?? 0) === 0}>
620
+ <table className="jobs-table">
621
+ <thead><tr><th>When</th><th>Event</th><th>Runner</th><th>Attempt</th><th>Details</th></tr></thead>
622
+ <tbody>
623
+ {(events.data?.rows ?? []).map((event) => (
624
+ <tr key={event.id}>
625
+ <td title={fmt.datetime(event.at)}>{fmt.relative(event.at)}</td>
626
+ <td>
627
+ <Badge tone={eventTone(event.event)}>
628
+ <i className={`bi ${eventIcon(event.event)}`} /> {event.event}
629
+ </Badge>
630
+ </td>
631
+ <td>{event.runner_id ? <code className="jobs-id">{fmt.truncateMiddle(event.runner_id, 14)}</code> : <span className="dim">—</span>}</td>
632
+ <td>{event.attempt}</td>
633
+ <td>
634
+ {Object.keys(event.details ?? {}).length > 0
635
+ ? <code className="jobs-json-inline">{JSON.stringify(event.details)}</code>
636
+ : <span className="dim">—</span>}
637
+ </td>
638
+ </tr>
639
+ ))}
640
+ </tbody>
641
+ </table>
642
+ </TablePane>
643
+ ),
644
+ },
645
+ {
646
+ key: 'logs', label: 'Logs', icon: 'bi-code-square', render: () => (
647
+ <TablePane pending={logs.isPending} error={logs.error} empty={(logs.data?.rows.length ?? 0) === 0}>
648
+ <table className="jobs-table">
649
+ <thead><tr><th>When</th><th>Kind</th><th>Message</th></tr></thead>
650
+ <tbody>
651
+ {(logs.data?.rows ?? []).map((log) => (
652
+ <tr key={log.id}>
653
+ <td title={fmt.datetime(log.created)}>{fmt.relative(log.created)}</td>
654
+ <td>
655
+ <Badge tone={log.kind === 'error' ? 'danger' : log.kind === 'warn' ? 'warning' : 'muted'}>
656
+ {log.kind}
657
+ </Badge>
658
+ </td>
659
+ <td>{log.message}</td>
660
+ </tr>
661
+ ))}
662
+ </tbody>
663
+ </table>
664
+ </TablePane>
665
+ ),
666
+ },
667
+ {
668
+ key: 'retries', label: 'Retry history', icon: 'bi-arrow-repeat', render: () => (
669
+ <>
670
+ <p className="jobs-note jobs-note-info">
671
+ <i className="bi bi-info-circle" /> Each retry republished the payload as a NEW job; the
672
+ <code>new_job_id</code> in an entry’s details is the job that actually ran.
673
+ </p>
674
+ {retries.isPending
675
+ ? <p className="dim">Loading retries…</p>
676
+ : (
677
+ <Timeline
678
+ items={(retries.data?.rows ?? []).map(toTimelineItem)}
679
+ emptyText="This job has never been retried."
680
+ />
681
+ )}
682
+ </>
683
+ ),
684
+ },
685
+ ...(job.func
686
+ ? [{
687
+ divider: 'Related' as const,
688
+ }, {
689
+ key: 'similar', label: 'Similar', icon: 'bi-files', render: () => (
690
+ <>
691
+ <Eyebrow>Recent jobs running <code>{job.func}</code></Eyebrow>
692
+ <TablePane pending={similar.isPending} error={similar.error} empty={similarRows.length === 0}>
693
+ <table className="jobs-table">
694
+ <thead><tr><th>Job</th><th>Status</th><th>Created</th><th>Duration</th></tr></thead>
695
+ <tbody>
696
+ {similarRows.map((row) => (
697
+ <tr key={row.id} className="row-click" onClick={() => showJobDetail(row.id)}>
698
+ <td><JobIdentity job={row} /></td>
699
+ <td><JobStatusBadge status={row.status} /></td>
700
+ <td title={fmt.datetime(row.created)}>{fmt.relative(row.created)}</td>
701
+ <td>{row.duration_ms > 0 ? fmt.duration(row.duration_ms) : '—'}</td>
702
+ </tr>
703
+ ))}
704
+ </tbody>
705
+ </table>
706
+ </TablePane>
707
+ </>
708
+ ),
709
+ }]
710
+ : []),
711
+ ]}
712
+ initialSection="overview"
713
+ onClose={onClose}
714
+ />
715
+ );
716
+ }
717
+
718
+ /** Open the inspector as a KISS detail modal — no record-detail route. */
719
+ export function showJobDetail(jobId: string): void {
720
+ void modal.detail((close) => <JobDetail jobId={jobId} onClose={() => close(null)} />);
721
+ }