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,576 @@
1
+ // RunnerDetail — the runner inspector, opened as a `modal.detail` from the
2
+ // fleet strip and the runners page. Port of web-mojo RunnerDetailsView.js
3
+ // (1181 lines), rebuilt on portal-mojo's DetailView + detail primitives.
4
+ //
5
+ // Carried from the source: the StatusPanel-led Overview with four KPIs
6
+ // (uptime / processed / failure rate / active jobs), the sysinfo flat-row
7
+ // groups with meters and error highlighting, the per-channel active counts,
8
+ // the active-jobs / history / logs tables, the control section, and the 15s
9
+ // live poll for everything EXCEPT sysinfo.
10
+ //
11
+ // NOT carried, because the backend cannot support them:
12
+ // · Drain and Restart — the source's buttons only ever toasted "backend
13
+ // integration pending"; there is no per-runner endpoint. An unsupported
14
+ // operation stays ABSENT rather than shipping disabled.
15
+ // · the `version` chip — the heartbeat payload has no version field.
16
+ // · trusted-HTML interpolation throughout — every slot here is a ReactNode.
17
+ import { useState, type ReactNode } from 'react';
18
+ import {
19
+ Badge,
20
+ DetailView,
21
+ Eyebrow,
22
+ FlatRow,
23
+ JsonBlock,
24
+ KnownFieldsCard,
25
+ StatusPanel,
26
+ ArmedButton,
27
+ fmt,
28
+ modal,
29
+ toast,
30
+ type Tone,
31
+ } from '../../ui';
32
+ import { downloadBlob } from '../../client';
33
+ import {
34
+ BROADCAST_COMMANDS,
35
+ broadcastCommand,
36
+ pingRunner,
37
+ shutdownRunner,
38
+ type BroadcastCommand,
39
+ } from './control';
40
+ import {
41
+ JOBS_MANAGE_PERMS,
42
+ formatHeartbeatAge,
43
+ formatUptime,
44
+ heartbeatAgeSeconds,
45
+ jobStatusTone,
46
+ runnerFailureRate,
47
+ runnerHealth,
48
+ runnerUptimeSeconds,
49
+ type JobRow,
50
+ type RunnerHostInfo,
51
+ type RunnerRow,
52
+ } from './models';
53
+ import {
54
+ useRunnerActiveJobs,
55
+ useRunnerJobHistory,
56
+ useRunnerJobLogs,
57
+ useRunnerSysinfo,
58
+ } from './queries';
59
+ import { useCan } from '../../client';
60
+
61
+ // ── Small presentational pieces ───────────────────────────────────────
62
+
63
+ function Kpi({ label, value, tone }: { label: string; value: ReactNode; tone?: Tone | null }) {
64
+ return (
65
+ <div className={`jobs-kpi${tone ? ` jobs-kpi-${tone}` : ''}`}>
66
+ <div className="jobs-kpi-label">{label}</div>
67
+ <div className="jobs-kpi-value">{value}</div>
68
+ </div>
69
+ );
70
+ }
71
+
72
+ /** Utilization bar. Null percent renders an explicit unknown, never a blank. */
73
+ function Meter({ percent, caption }: { percent: number | null | undefined; caption?: ReactNode }) {
74
+ const pct = typeof percent === 'number' && Number.isFinite(percent)
75
+ ? Math.max(0, Math.min(100, percent))
76
+ : null;
77
+ const tone = pct == null ? 'muted' : pct >= 80 ? 'danger' : pct >= 60 ? 'warning' : 'success';
78
+ return (
79
+ <div className="jobs-meter">
80
+ <div className="jobs-meter-head">
81
+ <span className="dim">{caption}</span>
82
+ <strong>{pct == null ? '—' : `${pct.toFixed(0)}%`}</strong>
83
+ </div>
84
+ <div className="jobs-meter-track" role="presentation">
85
+ <div className={`jobs-meter-fill jobs-meter-${tone}`} style={{ width: `${pct ?? 0}%` }} />
86
+ </div>
87
+ </div>
88
+ );
89
+ }
90
+
91
+ function JobIdCell({ job }: { job: JobRow }) {
92
+ return (
93
+ <>
94
+ <code className="jobs-id">{fmt.truncateMiddle(job.id, 6)}</code>
95
+ <div className="dim jobs-func">{job.func || '—'}</div>
96
+ </>
97
+ );
98
+ }
99
+
100
+ function TablePane({ pending, error, empty, children }: {
101
+ pending: boolean;
102
+ error: unknown;
103
+ empty: boolean;
104
+ children: ReactNode;
105
+ }) {
106
+ if (pending) return <p className="dim">Loading…</p>;
107
+ if (error) return <p className="jobs-text-danger">{error instanceof Error ? error.message : 'Request failed'}</p>;
108
+ if (empty) return <p className="dim-italic">Nothing to show.</p>;
109
+ return <div className="jobs-pane-scroll">{children}</div>;
110
+ }
111
+
112
+ // ── System section ────────────────────────────────────────────────────
113
+
114
+ function SystemSection({ runnerId }: { runnerId: string }) {
115
+ // Enabled on first activation only — DetailView lazily mounts a section
116
+ // and keeps it alive afterwards, so this fires exactly one broadcast.
117
+ const query = useRunnerSysinfo(runnerId);
118
+ const reply = query.data;
119
+ const host: RunnerHostInfo | undefined = reply?.status === 'success' ? reply.result : undefined;
120
+ const replyError = reply?.status === 'error' ? (reply.error || 'The runner reported an error collecting system info.') : null;
121
+ const requestError = query.error instanceof Error ? query.error.message : query.error ? 'Could not load system info.' : null;
122
+
123
+ return (
124
+ <>
125
+ <div className="jobs-section-head">
126
+ <Eyebrow>{host?.datetime ? `Collected ${fmt.datetime(host.datetime)}` : 'System info'}</Eyebrow>
127
+ <button
128
+ type="button"
129
+ className="btn btn-compact"
130
+ disabled={query.isFetching}
131
+ onClick={() => void query.refetch()}
132
+ >
133
+ <i className={`bi bi-arrow-clockwise${query.isFetching ? ' spin' : ''}`} /> Refresh
134
+ </button>
135
+ </div>
136
+
137
+ {query.isFetching && !reply && <p className="dim">Broadcasting to the runner…</p>}
138
+ {/* Three DISTINCT failures, never one blank pane: the request
139
+ failed (a runner that never answered 404s), the runner answered
140
+ with an error (psutil missing, permissions), or nothing has been
141
+ collected yet. */}
142
+ {requestError && <p className="jobs-note jobs-note-bad"><i className="bi bi-exclamation-triangle" /> {requestError}</p>}
143
+ {replyError && <p className="jobs-note jobs-note-warn"><i className="bi bi-exclamation-triangle" /> {replyError}</p>}
144
+ {!query.isFetching && !reply && !requestError && <p className="dim">No system info collected yet.</p>}
145
+
146
+ {host && (
147
+ <>
148
+ <Eyebrow>Operating system</Eyebrow>
149
+ <FlatRow label="Hostname"><code>{host.os?.hostname ?? '—'}</code></FlatRow>
150
+ <FlatRow label="System">{host.os?.system ?? '—'}</FlatRow>
151
+ <FlatRow label="Release"><code>{host.os?.release ?? '—'}</code></FlatRow>
152
+ <FlatRow label="Machine"><code>{host.os?.machine ?? '—'}</code></FlatRow>
153
+ {host.boot_time != null && <FlatRow label="Boot time">{fmt.datetime(host.boot_time)}</FlatRow>}
154
+
155
+ <Eyebrow>CPU</Eyebrow>
156
+ <FlatRow label="Load"><Meter percent={host.cpu_load} caption={`${host.cpus_load?.length ?? host.cpu?.count ?? '—'} cores reporting`} /></FlatRow>
157
+ <FlatRow label="Cores">{host.cpu?.count ?? '—'}</FlatRow>
158
+ {host.cpu?.freq && (
159
+ <FlatRow label="Frequency">
160
+ {fmt.number(host.cpu.freq.current)} MHz current · {fmt.number(host.cpu.freq.max)} MHz max
161
+ </FlatRow>
162
+ )}
163
+
164
+ {host.memory && (
165
+ <>
166
+ <Eyebrow>Memory</Eyebrow>
167
+ <FlatRow label="Usage">
168
+ <Meter percent={host.memory.percent} caption={`${fmt.filesize(host.memory.used)} / ${fmt.filesize(host.memory.total)}`} />
169
+ </FlatRow>
170
+ <FlatRow label="Available"><code className="jobs-text-success">{fmt.filesize(host.memory.available)}</code></FlatRow>
171
+ </>
172
+ )}
173
+
174
+ {host.disk && (
175
+ <>
176
+ <Eyebrow>Disk (root)</Eyebrow>
177
+ <FlatRow label="Usage">
178
+ <Meter percent={host.disk.percent} caption={`${fmt.filesize(host.disk.used)} / ${fmt.filesize(host.disk.total)}`} />
179
+ </FlatRow>
180
+ <FlatRow label="Free"><code className="jobs-text-success">{fmt.filesize(host.disk.free)}</code></FlatRow>
181
+ </>
182
+ )}
183
+
184
+ {host.network && (
185
+ <>
186
+ <Eyebrow>Network</Eyebrow>
187
+ <FlatRow label="Bytes recv"><code>{fmt.filesize(host.network.bytes_recv)}</code></FlatRow>
188
+ <FlatRow label="Bytes sent"><code>{fmt.filesize(host.network.bytes_sent)}</code></FlatRow>
189
+ <FlatRow label="Packets in/out">
190
+ <code>{fmt.number(host.network.packets_recv)}</code> / <code>{fmt.number(host.network.packets_sent)}</code>
191
+ </FlatRow>
192
+ <FlatRow label="Errors in/out">
193
+ {/* Non-zero interface errors are the one number
194
+ in this block worth shouting about. */}
195
+ <code className={(host.network.errin ?? 0) > 0 || (host.network.errout ?? 0) > 0 ? 'jobs-text-danger' : undefined}>
196
+ {host.network.errin ?? 0}
197
+ </code>
198
+ {' / '}
199
+ <code className={(host.network.errin ?? 0) > 0 || (host.network.errout ?? 0) > 0 ? 'jobs-text-danger' : undefined}>
200
+ {host.network.errout ?? 0}
201
+ </code>
202
+ </FlatRow>
203
+ <FlatRow label="TCP connections">{fmt.number(host.network.tcp_cons)}</FlatRow>
204
+ </>
205
+ )}
206
+
207
+ <Eyebrow>Raw sysinfo</Eyebrow>
208
+ <KnownFieldsCard data={host as unknown as Record<string, unknown>} known={[]} rawLabel="Raw sysinfo" />
209
+ </>
210
+ )}
211
+ </>
212
+ );
213
+ }
214
+
215
+ // ── Control section ───────────────────────────────────────────────────
216
+
217
+ function ControlSection({ runner, onClose }: { runner: RunnerRow; onClose: () => void }) {
218
+ const { can: canManage } = useCan(JOBS_MANAGE_PERMS);
219
+ const [ping, setPing] = useState<ReactNode>(null);
220
+ const [pinging, setPinging] = useState(false);
221
+ const [graceful, setGraceful] = useState(true);
222
+
223
+ const runPing = async () => {
224
+ setPinging(true);
225
+ try {
226
+ const result = await pingRunner(runner.runner_id);
227
+ setPing(result.responsive
228
+ ? <span className="jobs-text-success"><i className="bi bi-check-circle-fill" /> Runner is responsive</span>
229
+ : <span className="jobs-text-warning"><i className="bi bi-exclamation-triangle-fill" /> No reply within the timeout</span>);
230
+ } catch (error) {
231
+ setPing(<span className="jobs-text-danger"><i className="bi bi-x-circle-fill" /> {error instanceof Error ? error.message : 'Ping failed'}</span>);
232
+ } finally {
233
+ setPinging(false);
234
+ }
235
+ };
236
+
237
+ const runShutdown = async () => {
238
+ try {
239
+ const message = await shutdownRunner(runner.runner_id, graceful);
240
+ toast.success(message);
241
+ // Fire-and-forget on the wire: the response confirms the command
242
+ // was sent, never that the runner obeyed. Close so the operator
243
+ // re-reads the fleet rather than a stale row.
244
+ onClose();
245
+ } catch (error) {
246
+ toast.error(error instanceof Error ? error.message : 'Shutdown failed');
247
+ }
248
+ };
249
+
250
+ const openBroadcast = async () => {
251
+ const picked = await modal.open<{ command: BroadcastCommand; timeout: number } | null>((close) => (
252
+ <BroadcastForm onCancel={() => close(null)} onSubmit={(value) => close(value)} />
253
+ ), { size: 'sm' });
254
+ if (!picked) return;
255
+ try {
256
+ const result = await broadcastCommand(picked.command, { timeout: picked.timeout });
257
+ void modal.open((close) => (
258
+ <div className="modal-pad">
259
+ <h2 className="modal-title">Broadcast response — {result.command}</h2>
260
+ <p className="dim">{result.responses_count} runner(s) replied.</p>
261
+ <JsonBlock value={result.responses} label="Responses" defaultOpen />
262
+ <div className="modal-actions">
263
+ <button type="button" className="btn btn-primary" onClick={() => close(null)}>Close</button>
264
+ </div>
265
+ </div>
266
+ ), { size: 'lg' });
267
+ } catch (error) {
268
+ toast.error(error instanceof Error ? error.message : 'Broadcast failed');
269
+ }
270
+ };
271
+
272
+ const exportSnapshot = () => {
273
+ const snapshot = { runner, exported_at: new Date().toISOString() };
274
+ downloadBlob(
275
+ new Blob([JSON.stringify(snapshot, null, 2)], { type: 'application/json' }),
276
+ `runner-${runner.runner_id}-${Date.now()}.json`,
277
+ );
278
+ toast.success('Runner snapshot exported');
279
+ };
280
+
281
+ return (
282
+ <>
283
+ <Eyebrow>Operates on {runner.runner_id}</Eyebrow>
284
+ <div className="jobs-op-row">
285
+ <div className="jobs-op-label"><i className="bi bi-broadcast-pin" /> Ping</div>
286
+ <div className="jobs-op-desc">
287
+ Ask the runner to answer on its control channel. Read-only.
288
+ {ping && <div className="jobs-op-result">{ping}</div>}
289
+ </div>
290
+ <div className="jobs-op-action">
291
+ <button type="button" className="btn btn-compact" disabled={!canManage || pinging} onClick={() => void runPing()}>
292
+ {pinging ? 'Pinging…' : 'Ping now'}
293
+ </button>
294
+ </div>
295
+ </div>
296
+
297
+ <div className="jobs-op-row">
298
+ <div className="jobs-op-label"><i className="bi bi-power" /> Shutdown</div>
299
+ <div className="jobs-op-desc">
300
+ Sends a shutdown command. Fire-and-forget — the response confirms the command was queued, not that the runner exited.
301
+ <label className="jobs-op-toggle">
302
+ <input type="checkbox" checked={graceful} disabled={!canManage} onChange={(event) => setGraceful(event.target.checked)} />
303
+ <span>Graceful (finish the current job first)</span>
304
+ </label>
305
+ </div>
306
+ <div className="jobs-op-action">
307
+ <ArmedButton
308
+ className="btn-compact btn-danger-ghost"
309
+ label="Shutdown"
310
+ armedLabel={`Click again to shut down ${runner.runner_id}`}
311
+ icon="bi-power"
312
+ disabled={!canManage}
313
+ onConfirm={runShutdown}
314
+ />
315
+ </div>
316
+ </div>
317
+
318
+ <div className="jobs-op-row">
319
+ <div className="jobs-op-label"><i className="bi bi-megaphone" /> Broadcast</div>
320
+ <div className="jobs-op-desc">
321
+ Sends a command to <strong>every</strong> runner, not just this one, and collects replies within the timeout.
322
+ </div>
323
+ <div className="jobs-op-action">
324
+ <button type="button" className="btn btn-compact" disabled={!canManage} onClick={() => void openBroadcast()}>
325
+ Broadcast…
326
+ </button>
327
+ </div>
328
+ </div>
329
+
330
+ <div className="jobs-op-row">
331
+ <div className="jobs-op-label"><i className="bi bi-download" /> Export</div>
332
+ <div className="jobs-op-desc">Download this runner’s heartbeat record as JSON.</div>
333
+ <div className="jobs-op-action">
334
+ <button type="button" className="btn btn-compact" onClick={exportSnapshot}>Export snapshot</button>
335
+ </div>
336
+ </div>
337
+
338
+ {!canManage && (
339
+ <p className="jobs-note jobs-note-info">
340
+ <i className="bi bi-info-circle" /> Runner control requires a global <code>manage_jobs</code> or <code>jobs</code> grant.
341
+ </p>
342
+ )}
343
+ </>
344
+ );
345
+ }
346
+
347
+ /**
348
+ * The broadcast picker. `shutdown` is the one command that stops the whole
349
+ * fleet, so it is armed separately from the plain submit.
350
+ */
351
+ function BroadcastForm({ onCancel, onSubmit }: {
352
+ onCancel: () => void;
353
+ onSubmit: (value: { command: BroadcastCommand; timeout: number }) => void;
354
+ }) {
355
+ const [command, setCommand] = useState<BroadcastCommand>('status');
356
+ const [timeout, setTimeoutValue] = useState(2);
357
+ const destructive = command === 'shutdown';
358
+ return (
359
+ <div className="modal-pad">
360
+ <h2 className="modal-title">Broadcast to all runners</h2>
361
+ <label className="jobs-field">
362
+ <span>Command</span>
363
+ <select
364
+ className="input input-compact"
365
+ value={command}
366
+ onChange={(event) => setCommand(event.target.value as BroadcastCommand)}
367
+ >
368
+ {BROADCAST_COMMANDS.map((value) => <option key={value} value={value}>{value}</option>)}
369
+ </select>
370
+ </label>
371
+ <label className="jobs-field">
372
+ <span>Timeout (seconds)</span>
373
+ <input
374
+ type="number" className="input input-compact" min={0.5} step={0.5}
375
+ value={timeout}
376
+ onChange={(event) => setTimeoutValue(Number(event.target.value) || 2)}
377
+ />
378
+ </label>
379
+ {destructive && (
380
+ <p className="jobs-note jobs-note-bad">
381
+ <i className="bi bi-exclamation-octagon" /> <code>shutdown</code> stops every runner in the fleet. Nothing will process jobs afterwards.
382
+ </p>
383
+ )}
384
+ <div className="modal-actions">
385
+ <button type="button" className="btn" onClick={onCancel}>Cancel</button>
386
+ {destructive
387
+ ? (
388
+ <ArmedButton
389
+ label="Broadcast shutdown"
390
+ armedLabel="Click again to stop every runner"
391
+ className="btn-danger-ghost"
392
+ onConfirm={() => onSubmit({ command, timeout })}
393
+ />
394
+ )
395
+ : (
396
+ <button type="button" className="btn btn-primary" onClick={() => onSubmit({ command, timeout })}>
397
+ Broadcast
398
+ </button>
399
+ )}
400
+ </div>
401
+ </div>
402
+ );
403
+ }
404
+
405
+ // ── The inspector ─────────────────────────────────────────────────────
406
+
407
+ export function RunnerDetail({ runner, onClose }: { runner: RunnerRow; onClose: () => void }) {
408
+ const health = runnerHealth(runner);
409
+ const active = useRunnerActiveJobs(runner.runner_id);
410
+ const history = useRunnerJobHistory(runner.runner_id);
411
+ const logs = useRunnerJobLogs(runner.runner_id);
412
+
413
+ const activeRows = active.data?.rows ?? [];
414
+ const uptimeSec = runnerUptimeSeconds(runner);
415
+ const heartbeatAge = heartbeatAgeSeconds(runner.last_heartbeat);
416
+ const failureRate = runnerFailureRate(runner);
417
+ const failureTone: Tone | null = failureRate == null ? null
418
+ : failureRate >= 5 ? 'danger' : failureRate >= 1 ? 'warning' : 'success';
419
+
420
+ const headline = health.key === 'down'
421
+ ? `Down · last heartbeat ${formatHeartbeatAge(heartbeatAge)}`
422
+ : health.key === 'stale'
423
+ ? `${health.label} · last heartbeat ${formatHeartbeatAge(heartbeatAge)}`
424
+ : `Up ${uptimeSec == null ? 'unknown' : formatUptime(uptimeSec)} · heartbeat ${formatHeartbeatAge(heartbeatAge)}`;
425
+
426
+ return (
427
+ <DetailView
428
+ icon="bi-cpu"
429
+ title={runner.runner_id}
430
+ subtitle={runner.hostname}
431
+ chips={[
432
+ { icon: 'bi-broadcast-pin', text: health.label, tone: health.tone },
433
+ ...(runner.channels.length > 0
434
+ ? [{ icon: 'bi-broadcast', text: runner.channels.join(' · '), tone: 'info' as const }]
435
+ : []),
436
+ ]}
437
+ badges={{
438
+ channels: runner.channels.length || null,
439
+ active: activeRows.length || null,
440
+ history: history.data?.count || null,
441
+ }}
442
+ sections={[
443
+ {
444
+ key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => (
445
+ <>
446
+ <StatusPanel
447
+ tone={health.tone}
448
+ state={health.label.toUpperCase()}
449
+ headline={headline}
450
+ meta={
451
+ <>
452
+ Channels: {runner.channels.length > 0
453
+ ? runner.channels.map((channel, index) => (
454
+ <span key={channel}>{index > 0 && ', '}<code>{channel}</code></span>
455
+ ))
456
+ : <span className="dim">none</span>}
457
+ {' · '}{fmt.number(runner.jobs_processed)} processed
458
+ {runner.jobs_failed > 0 && <> · {fmt.number(runner.jobs_failed)} failed</>}
459
+ </>
460
+ }
461
+ />
462
+ <div className="jobs-kpi-grid">
463
+ <Kpi label="Uptime" value={uptimeSec == null ? 'unknown' : formatUptime(uptimeSec)} />
464
+ <Kpi label="Jobs processed" value={fmt.number(runner.jobs_processed)} tone={runner.jobs_processed > 0 ? 'success' : null} />
465
+ <Kpi label="Failure rate" value={failureRate == null ? '—' : `${failureRate.toFixed(2)}%`} tone={failureTone} />
466
+ <Kpi label="Active jobs" value={fmt.number(activeRows.length)} tone={activeRows.length > 0 ? 'info' : null} />
467
+ </div>
468
+ <Eyebrow>Heartbeat</Eyebrow>
469
+ <FlatRow label="Last heartbeat">{fmt.datetime(runner.last_heartbeat)}</FlatRow>
470
+ <FlatRow label="Started">{fmt.datetime(runner.started)}</FlatRow>
471
+ <FlatRow label="Host"><code>{runner.hostname}</code></FlatRow>
472
+ </>
473
+ ),
474
+ },
475
+ { key: 'system', label: 'System', icon: 'bi-cpu', render: () => <SystemSection runnerId={runner.runner_id} /> },
476
+ {
477
+ key: 'channels', label: 'Channels', icon: 'bi-broadcast', render: () => (
478
+ runner.channels.length === 0
479
+ ? <p className="dim-italic">This runner serves no channels — it will never receive a job.</p>
480
+ : (
481
+ <>
482
+ <Eyebrow>{runner.channels.length} channel{runner.channels.length === 1 ? '' : 's'}</Eyebrow>
483
+ {runner.channels.map((channel) => {
484
+ const count = activeRows.filter((job) => job.channel === channel).length;
485
+ return (
486
+ <FlatRow key={channel} label="Channel">
487
+ <code>{channel}</code>
488
+ <Badge tone={count > 0 ? 'info' : 'muted'}>{count} active</Badge>
489
+ </FlatRow>
490
+ );
491
+ })}
492
+ </>
493
+ )
494
+ ),
495
+ },
496
+ {
497
+ key: 'active', label: 'Active jobs', icon: 'bi-hourglass-split', render: () => (
498
+ <TablePane pending={active.isPending} error={active.error} empty={activeRows.length === 0}>
499
+ <table className="jobs-table">
500
+ <thead><tr><th>Job</th><th>Channel</th><th>Started</th><th>Attempt</th></tr></thead>
501
+ <tbody>
502
+ {activeRows.map((job) => (
503
+ <tr key={job.id}>
504
+ <td><JobIdCell job={job} /></td>
505
+ <td><Badge tone="muted">{job.channel}</Badge></td>
506
+ <td>{fmt.relative(job.started_at)}</td>
507
+ <td>{job.attempt}</td>
508
+ </tr>
509
+ ))}
510
+ </tbody>
511
+ </table>
512
+ </TablePane>
513
+ ),
514
+ },
515
+ { divider: 'History' },
516
+ {
517
+ key: 'history', label: 'Job history', icon: 'bi-clock-history', render: () => (
518
+ <TablePane pending={history.isPending} error={history.error} empty={(history.data?.rows.length ?? 0) === 0}>
519
+ <table className="jobs-table">
520
+ <thead><tr><th>Job</th><th>Channel</th><th>Status</th><th>Finished</th><th>Duration</th></tr></thead>
521
+ <tbody>
522
+ {(history.data?.rows ?? []).map((job) => (
523
+ <tr key={job.id}>
524
+ <td><JobIdCell job={job} /></td>
525
+ <td><Badge tone="muted">{job.channel}</Badge></td>
526
+ <td><Badge tone={jobStatusTone(job.status)}>{job.status}</Badge></td>
527
+ <td>{fmt.relative(job.finished_at ?? job.created)}</td>
528
+ <td>{job.duration_ms > 0 ? fmt.duration(job.duration_ms) : '—'}</td>
529
+ </tr>
530
+ ))}
531
+ </tbody>
532
+ </table>
533
+ </TablePane>
534
+ ),
535
+ },
536
+ {
537
+ key: 'logs', label: 'Logs', icon: 'bi-code-square', render: () => (
538
+ <>
539
+ <p className="jobs-note jobs-note-info">
540
+ <i className="bi bi-info-circle" /> JobLog has no runner field. These are the log lines of this
541
+ runner’s most recent jobs, resolved by job id.
542
+ </p>
543
+ <TablePane pending={logs.isPending} error={logs.error} empty={(logs.data?.rows.length ?? 0) === 0}>
544
+ <table className="jobs-table">
545
+ <thead><tr><th>Time</th><th>Kind</th><th>Job</th><th>Message</th></tr></thead>
546
+ <tbody>
547
+ {(logs.data?.rows ?? []).map((log) => (
548
+ <tr key={log.id}>
549
+ <td title={fmt.datetime(log.created)}>{fmt.relative(log.created)}</td>
550
+ <td><Badge tone={log.kind === 'error' ? 'danger' : log.kind === 'warn' ? 'warning' : 'muted'}>{log.kind}</Badge></td>
551
+ <td><code className="jobs-id">{fmt.truncateMiddle(log.job_id, 5)}</code></td>
552
+ <td>{log.message}</td>
553
+ </tr>
554
+ ))}
555
+ </tbody>
556
+ </table>
557
+ </TablePane>
558
+ </>
559
+ ),
560
+ },
561
+ { divider: 'Control' },
562
+ {
563
+ key: 'control', label: 'Control', icon: 'bi-power',
564
+ render: () => <ControlSection runner={runner} onClose={onClose} />,
565
+ },
566
+ ]}
567
+ initialSection="overview"
568
+ onClose={onClose}
569
+ />
570
+ );
571
+ }
572
+
573
+ /** Open the inspector as a KISS detail modal — no record-detail route. */
574
+ export function showRunnerDetail(runner: RunnerRow): void {
575
+ void modal.detail((close) => <RunnerDetail runner={runner} onClose={() => close(null)} />);
576
+ }