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,421 @@
1
+ // ScheduledTaskDetail — the scheduled-task inspector, opened as a
2
+ // `modal.detail` from the tasks table. Port of web-mojo ScheduledTaskView.js,
3
+ // rebuilt on DetailView.
4
+ //
5
+ // The source was never wired up (`ScheduledTaskTablePage` was imported but
6
+ // never `registerPage`d in web-mojo's `src/admin.js`), so its field list is a
7
+ // design reference whose wiring was never exercised — every row below is
8
+ // checked against `mojo/apps/jobs/models/scheduled_task.py`, `task_result.py`
9
+ // and the hourly dispatcher in `cronjobs.py`.
10
+ //
11
+ // Carried from the source: the schedule / notify / execution / last-error /
12
+ // configuration blocks and the recent-results list, plus the kebab's
13
+ // Edit · Enable|Disable · Delete.
14
+ //
15
+ // Changed, with reason:
16
+ // · the results list shows STATUS AND OUTPUT. The source rendered only a
17
+ // status word and a raw datetime, which is the one thing a scheduled task
18
+ // produces that an operator actually needs to read. That requires the
19
+ // `default` graph — TaskResult's `list` graph carries no `output`/`error`.
20
+ // · type-specific configuration is rendered per task_type (llm prompts /
21
+ // job func+payload / webhook url), not dumped as one JSON blob.
22
+ // · delete is an ArmedButton in a danger zone rather than a confirm dialog
23
+ // (house idiom: arming IS the confirmation).
24
+ // · no "Run now": the capability exists only as the owner-scoped assistant
25
+ // tool `_tool_run_scheduled_task_now`. There is no REST route and no
26
+ // publish endpoint to synthesize one, so the control stays ABSENT rather
27
+ // than shipping disabled (wave-7a precedent). Tracked as django-mojo #1309.
28
+ import type { ReactNode } from 'react';
29
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
30
+ import { mojoList, useCan } from '../../client';
31
+ import {
32
+ ArmedButton,
33
+ Badge,
34
+ DetailView,
35
+ Eyebrow,
36
+ FlatRow,
37
+ JsonBlock,
38
+ StatusPanel,
39
+ fmt,
40
+ modal,
41
+ toast,
42
+ type Tone,
43
+ } from '../../ui';
44
+ import {
45
+ SCHEDULED_TASK_MANAGE_PERMS,
46
+ SCHEDULED_TASK_VIEW_PERMS,
47
+ ScheduledTaskModel,
48
+ TaskResultModel,
49
+ formatRunDays,
50
+ formatRunTimes,
51
+ type ScheduledTaskRow,
52
+ type ScheduledTaskType,
53
+ type TaskResultRow,
54
+ } from './models';
55
+ import { openScheduledTaskEditor } from './scheduled-task-form';
56
+
57
+ /** How many recent executions the detail shows. */
58
+ const RESULT_WINDOW = 10;
59
+
60
+ /**
61
+ * A task's most recent executions.
62
+ *
63
+ * `graph: 'default'` is explicit and deliberate: TaskResult's `list` graph is
64
+ * `{id, task_id, status, created}` — no `output`, no `error` — and
65
+ * `normalizeTaskResultListParams` strips a `graph` key precisely so a pasted
66
+ * URL cannot widen a serialized field set. A hook that KNOWS which fields it
67
+ * needs goes through `mojoList` directly, the same way `useJobTimeline` asks
68
+ * for the timeline graph.
69
+ *
70
+ * `task=` is the relation name. `task_id=` is a Django FK attname, which
71
+ * `build_rest_filters` accepts and then dereferences into an AttributeError —
72
+ * a 500, not a filter.
73
+ */
74
+ export function useTaskResults(taskId: string | null) {
75
+ const { can } = useCan(SCHEDULED_TASK_VIEW_PERMS);
76
+ return useQuery({
77
+ queryKey: [TaskResultModel.endpoint, 'task', taskId ?? ''],
78
+ queryFn: () => mojoList<TaskResultRow>(TaskResultModel.endpoint, {
79
+ task: taskId!,
80
+ graph: 'default',
81
+ sort: '-created',
82
+ size: RESULT_WINDOW,
83
+ }),
84
+ enabled: can && taskId != null,
85
+ });
86
+ }
87
+
88
+ function Kpi({ label, value, tone }: { label: string; value: ReactNode; tone?: Tone | null }) {
89
+ return (
90
+ <div className={`jobs-kpi${tone ? ` jobs-kpi-${tone}` : ''}`}>
91
+ <div className="jobs-kpi-label">{label}</div>
92
+ <div className="jobs-kpi-value">{value}</div>
93
+ </div>
94
+ );
95
+ }
96
+
97
+ const TYPE_TONE: Record<ScheduledTaskType, Tone> = { llm: 'primary', job: 'info', webhook: 'warning' };
98
+
99
+ /** Unknown wire values fall back WITH a warn — never to nothing. */
100
+ export function taskTypeTone(type: string): Tone {
101
+ const tone = TYPE_TONE[type as ScheduledTaskType];
102
+ if (tone) return tone;
103
+ console.warn(`[admin/jobs] unknown scheduled task type ${JSON.stringify(type)} — falling back to "muted". Valid: llm, job, webhook`);
104
+ return 'muted';
105
+ }
106
+
107
+ /** `run_times · run_days` — the schedule as one readable line. */
108
+ export function formatSchedule(task: Pick<ScheduledTaskRow, 'run_times' | 'run_days'>): string {
109
+ return `${formatRunTimes(task.run_times ?? [])} · ${formatRunDays(task.run_days ?? [])}`;
110
+ }
111
+
112
+ // ── Sections ──────────────────────────────────────────────────────────
113
+
114
+ function ConfigurationSection({ task }: { task: ScheduledTaskRow }) {
115
+ const config = task.job_config ?? {};
116
+ const text = (key: string): string => {
117
+ const value = config[key];
118
+ return typeof value === 'string' ? value : value == null ? '' : JSON.stringify(value);
119
+ };
120
+ const blob = (key: string): Record<string, unknown> => {
121
+ const value = config[key];
122
+ return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
123
+ };
124
+
125
+ return (
126
+ <>
127
+ <Eyebrow>{task.task_type} configuration</Eyebrow>
128
+ {task.task_type === 'llm' && (
129
+ <>
130
+ <FlatRow label="System prompt">
131
+ {text('system_prompt') ? <span className="jobs-task-prompt">{text('system_prompt')}</span> : <span className="dim">none</span>}
132
+ </FlatRow>
133
+ <FlatRow label="User prompt">
134
+ {text('user_prompt')
135
+ ? <span className="jobs-task-prompt">{text('user_prompt')}</span>
136
+ : <span className="jobs-text-danger">Missing — every run fails with “LLM task requires a user_prompt in job_config”.</span>}
137
+ </FlatRow>
138
+ </>
139
+ )}
140
+ {task.task_type === 'job' && (
141
+ <>
142
+ <FlatRow label="Function">
143
+ {text('func')
144
+ ? <code className="jobs-func">{text('func')}</code>
145
+ : <span className="jobs-text-danger">Missing — every run fails with “Job task requires a func in job_config”.</span>}
146
+ </FlatRow>
147
+ <FlatRow label="Payload"><JsonBlock value={blob('payload')} label="payload" /></FlatRow>
148
+ </>
149
+ )}
150
+ {task.task_type === 'webhook' && (
151
+ <>
152
+ <FlatRow label="URL">
153
+ {text('url')
154
+ ? <code className="jobs-func">{text('url')}</code>
155
+ : <span className="jobs-text-danger">Missing — every run fails with “Webhook task requires a url in job_config”.</span>}
156
+ </FlatRow>
157
+ <FlatRow label="Body"><JsonBlock value={blob('data')} label="data" /></FlatRow>
158
+ </>
159
+ )}
160
+
161
+ <Eyebrow>Raw job_config</Eyebrow>
162
+ <JsonBlock value={config} label="job_config" defaultOpen />
163
+ </>
164
+ );
165
+ }
166
+
167
+ function ResultsSection({ taskId }: { taskId: string }) {
168
+ const results = useTaskResults(taskId);
169
+ const rows = results.data?.rows ?? [];
170
+
171
+ return (
172
+ <>
173
+ <Eyebrow>Last {RESULT_WINDOW} executions</Eyebrow>
174
+ {results.isPending && <p className="dim">Loading results…</p>}
175
+ {results.error != null && (
176
+ <p className="jobs-text-danger">
177
+ {results.error instanceof Error ? results.error.message : 'Could not load results.'}
178
+ </p>
179
+ )}
180
+ {!results.isPending && results.error == null && rows.length === 0 && (
181
+ <p className="dim-italic">No results yet — this task has not produced an execution record.</p>
182
+ )}
183
+ <ul className="jobs-task-results">
184
+ {rows.map((row) => {
185
+ const ok = row.status === 'success';
186
+ return (
187
+ <li key={row.id} className={`jobs-task-result${ok ? '' : ' jobs-task-result-bad'}`}>
188
+ <div className="jobs-task-result-head">
189
+ <Badge tone={ok ? 'success' : 'danger'}>
190
+ <i className={`bi ${ok ? 'bi-check-circle-fill' : 'bi-x-circle-fill'}`} /> {row.status}
191
+ </Badge>
192
+ <span className="dim" title={fmt.datetime(row.created)}>{fmt.relative(row.created)}</span>
193
+ {row.job_id && <code className="jobs-id">job {fmt.truncateMiddle(row.job_id, 5)}</code>}
194
+ </div>
195
+ {ok
196
+ ? (row.output
197
+ ? <pre className="jobs-task-output">{row.output}</pre>
198
+ : <p className="dim-italic">No output recorded.</p>)
199
+ : <pre className="jobs-task-output jobs-text-danger">{row.error || 'No error text recorded.'}</pre>}
200
+ </li>
201
+ );
202
+ })}
203
+ </ul>
204
+ </>
205
+ );
206
+ }
207
+
208
+ // ── The inspector ─────────────────────────────────────────────────────
209
+
210
+ export function ScheduledTaskDetail({ id, onClose }: { id: string; onClose: () => void }) {
211
+ const queryClient = useQueryClient();
212
+ const query = ScheduledTaskModel.useOne(id);
213
+ const save = ScheduledTaskModel.useSave();
214
+ const remove = ScheduledTaskModel.useDelete();
215
+ const { can: canManage } = useCan(SCHEDULED_TASK_MANAGE_PERMS);
216
+
217
+ if (query.isPending) return <div className="modal-pad dim">Loading scheduled task…</div>;
218
+ if (!query.data || query.error) {
219
+ return <div className="modal-pad jobs-text-danger">{query.error?.message ?? 'Scheduled task not found'}</div>;
220
+ }
221
+ const task = query.data;
222
+
223
+ const invalidate = () => queryClient.invalidateQueries({ queryKey: ScheduledTaskModel.keys.root });
224
+
225
+ const setEnabled = async (enabled: boolean) => {
226
+ try {
227
+ await save.mutateAsync({ id, changes: { enabled } });
228
+ await invalidate();
229
+ toast.success(enabled ? 'Task enabled' : 'Task disabled');
230
+ } catch (error) {
231
+ toast.error(error instanceof Error ? error.message : 'Could not change the task state');
232
+ }
233
+ };
234
+
235
+ const edit = async () => {
236
+ const saved = await openScheduledTaskEditor({
237
+ task,
238
+ submit: (changes) => save.mutateAsync({ id, changes }),
239
+ });
240
+ if (saved) {
241
+ await invalidate();
242
+ toast.success('Task updated');
243
+ }
244
+ };
245
+
246
+ const destroy = async () => {
247
+ try {
248
+ await remove.mutateAsync({ id });
249
+ await invalidate();
250
+ toast.success(`Deleted ${task.name || 'scheduled task'}`);
251
+ onClose();
252
+ } catch (error) {
253
+ toast.error(error instanceof Error ? error.message : 'Delete failed');
254
+ }
255
+ };
256
+
257
+ const schedule = formatSchedule(task);
258
+ const stateTone: Tone = task.enabled ? 'success' : 'muted';
259
+ const headline = task.enabled
260
+ ? `Runs ${schedule}`
261
+ : `Disabled — ${schedule} is not dispatched`;
262
+
263
+ return (
264
+ <DetailView<ScheduledTaskRow>
265
+ icon="bi-clock-history"
266
+ title={task.name || 'Untitled task'}
267
+ subtitle={task.description || `${task.task_type} task`}
268
+ chips={[
269
+ { text: task.task_type.toUpperCase(), tone: taskTypeTone(task.task_type) },
270
+ { icon: task.enabled ? 'bi-play-circle' : 'bi-pause-circle', text: task.enabled ? 'Enabled' : 'Disabled', tone: stateTone },
271
+ ...(task.run_once ? [{ icon: 'bi-1-circle', text: 'Run once', tone: 'info' as const }] : []),
272
+ { icon: 'bi-broadcast', text: task.channel, tone: 'muted' },
273
+ ]}
274
+ badges={{ overview: task.last_error ? <Badge tone="danger">error</Badge> : null }}
275
+ menuContext={task}
276
+ contextMenu={[
277
+ { label: 'Refresh', icon: 'bi-arrow-clockwise', onSelect: () => { void query.refetch(); } },
278
+ {
279
+ label: 'Edit', icon: 'bi-pencil', permissions: SCHEDULED_TASK_MANAGE_PERMS,
280
+ onSelect: () => { void edit(); },
281
+ },
282
+ {
283
+ label: task.enabled ? 'Disable' : 'Enable',
284
+ icon: task.enabled ? 'bi-pause-circle' : 'bi-play-circle',
285
+ permissions: SCHEDULED_TASK_MANAGE_PERMS,
286
+ onSelect: () => { void setEnabled(!task.enabled); },
287
+ },
288
+ ]}
289
+ sections={[
290
+ {
291
+ key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => (
292
+ <>
293
+ <StatusPanel
294
+ tone={stateTone}
295
+ state={task.enabled ? 'ENABLED' : 'DISABLED'}
296
+ headline={headline}
297
+ meta={
298
+ <>
299
+ {fmt.number(task.run_count)} run{task.run_count === 1 ? '' : 's'}
300
+ {' · last '}{task.last_run == null ? 'never' : fmt.relative(task.last_run)}
301
+ {task.run_once && ' · disables itself after the next success'}
302
+ </>
303
+ }
304
+ />
305
+
306
+ <div className="jobs-kpi-grid">
307
+ <Kpi label="Runs" value={fmt.number(task.run_count)} tone={task.run_count > 0 ? 'info' : null} />
308
+ <Kpi label="Last run" value={task.last_run == null ? 'Never' : fmt.relative(task.last_run)} />
309
+ <Kpi label="Max retries" value={fmt.number(task.max_retries)} />
310
+ <Kpi label="State" value={task.enabled ? 'Enabled' : 'Disabled'} tone={stateTone} />
311
+ </div>
312
+
313
+ <Eyebrow>Schedule</Eyebrow>
314
+ <FlatRow label="Run times">
315
+ {(task.run_times ?? []).length > 0
316
+ ? (task.run_times ?? []).map((time) => <code key={time} className="jobs-channel-chip">{time}</code>)
317
+ : <span className="jobs-text-warning">No times set — this task never matches an hour.</span>}
318
+ </FlatRow>
319
+ <FlatRow label="Run days">
320
+ {formatRunDays(task.run_days ?? [])}
321
+ {(task.run_days ?? []).length === 0 && <span className="dim"> (no days selected)</span>}
322
+ </FlatRow>
323
+ <FlatRow label="Run once">{task.run_once ? 'Yes — auto-disables after a success' : 'No'}</FlatRow>
324
+ <FlatRow label="Dispatch">
325
+ <span className="dim">
326
+ Hourly cron converts each run time to the owner’s organization timezone and publishes a job.
327
+ </span>
328
+ </FlatRow>
329
+
330
+ <Eyebrow>Delivery</Eyebrow>
331
+ <FlatRow label="Channel"><code>{task.channel}</code></FlatRow>
332
+ <FlatRow label="Notify">
333
+ {(task.notify ?? []).length > 0
334
+ ? (task.notify ?? []).map((channel) => <code key={channel} className="jobs-channel-chip">{channel}</code>)
335
+ : <span className="dim">None — results are recorded but nobody is told.</span>}
336
+ </FlatRow>
337
+ <FlatRow label="Created">{fmt.datetime(task.created)}</FlatRow>
338
+ <FlatRow label="Modified">{fmt.datetime(task.modified)}</FlatRow>
339
+
340
+ {task.last_error && (
341
+ <>
342
+ <Eyebrow>Last error</Eyebrow>
343
+ <pre className="jobs-task-output jobs-text-danger">{task.last_error}</pre>
344
+ </>
345
+ )}
346
+ </>
347
+ ),
348
+ },
349
+ { key: 'config', label: 'Configuration', icon: 'bi-sliders', render: () => <ConfigurationSection task={task} /> },
350
+ { key: 'results', label: 'Results', icon: 'bi-list-check', render: () => <ResultsSection taskId={id} /> },
351
+ { divider: 'Manage' },
352
+ {
353
+ key: 'manage', label: 'Manage', icon: 'bi-gear', permissions: SCHEDULED_TASK_MANAGE_PERMS, render: () => (
354
+ <>
355
+ <Eyebrow>Operates on {task.name || 'this task'}</Eyebrow>
356
+ <div className="jobs-op-row">
357
+ <div className="jobs-op-label">
358
+ <i className={`bi ${task.enabled ? 'bi-pause-circle' : 'bi-play-circle'}`} />
359
+ {task.enabled ? 'Disable' : 'Enable'}
360
+ </div>
361
+ <div className="jobs-op-desc">
362
+ {task.enabled
363
+ ? 'The hourly dispatcher skips disabled tasks. Nothing already published is affected.'
364
+ : 'The hourly dispatcher will pick this task up again on its next matching hour.'}
365
+ </div>
366
+ <div className="jobs-op-action">
367
+ <button
368
+ type="button" className="btn btn-compact" disabled={!canManage || save.isPending}
369
+ onClick={() => void setEnabled(!task.enabled)}
370
+ >
371
+ {task.enabled ? 'Disable' : 'Enable'}
372
+ </button>
373
+ </div>
374
+ </div>
375
+
376
+ <div className="jobs-op-row">
377
+ <div className="jobs-op-label"><i className="bi bi-pencil" /> Edit</div>
378
+ <div className="jobs-op-desc">
379
+ Change the schedule, notification channels and configuration. The task type is fixed
380
+ after creation.
381
+ </div>
382
+ <div className="jobs-op-action">
383
+ <button type="button" className="btn btn-compact" disabled={!canManage} onClick={() => void edit()}>
384
+ Edit task…
385
+ </button>
386
+ </div>
387
+ </div>
388
+
389
+ <div className="danger-zone">
390
+ <div className="jobs-op-row">
391
+ <div className="jobs-op-label"><i className="bi bi-trash" /> Delete</div>
392
+ <div className="jobs-op-desc">
393
+ Removes the task and cascades to every one of its execution results. Jobs already
394
+ published stay queued.
395
+ </div>
396
+ <div className="jobs-op-action">
397
+ <ArmedButton
398
+ className="btn-compact btn-danger-ghost"
399
+ label="Delete task"
400
+ armedLabel="Click again — the task and all its results are gone"
401
+ icon="bi-trash"
402
+ disabled={!canManage || remove.isPending}
403
+ onConfirm={destroy}
404
+ />
405
+ </div>
406
+ </div>
407
+ </div>
408
+ </>
409
+ ),
410
+ },
411
+ ]}
412
+ initialSection="overview"
413
+ onClose={onClose}
414
+ />
415
+ );
416
+ }
417
+
418
+ /** Open the inspector as a KISS detail modal — no record-detail route. */
419
+ export function showScheduledTaskDetail(id: string): void {
420
+ void modal.detail((close) => <ScheduledTaskDetail id={id} onClose={() => close(null)} />);
421
+ }
@@ -0,0 +1,167 @@
1
+ // ScheduledTasksPage — the user-defined cron surface. Port of web-mojo
2
+ // ScheduledTaskTablePage.js (a page that was imported but never
3
+ // `registerPage`d, so its wiring was never exercised).
4
+ //
5
+ // THE SECURITY POINT OF THIS PAGE
6
+ //
7
+ // `ScheduledTask.RestMeta.VIEW_PERMS = ["jobs", "view_scheduled_tasks",
8
+ // "owner"]` — neither `manage_jobs` nor `manage_scheduled_tasks` appears in
9
+ // it. A caller holding only a manage grant therefore falls through to the
10
+ // OWNER branch and receives HTTP 200 with their OWN tasks, which an ungated
11
+ // admin page would render as if it were the system's task list. So the read
12
+ // is gated fail-closed on `SCHEDULED_TASK_VIEW_PERMS`
13
+ // (`sys.jobs | sys.view_scheduled_tasks`, `owner` deliberately dropped) and a
14
+ // manage grant alone never opens the page. When the gate fails nothing is
15
+ // rendered that queries — a denied caller issues zero requests.
16
+ import { useQueryClient } from '@tanstack/react-query';
17
+ import { useCan } from '../../client';
18
+ import {
19
+ Badge,
20
+ ModelTable,
21
+ fmt,
22
+ toast,
23
+ type BatchAction,
24
+ type Column,
25
+ type FilterDef,
26
+ } from '../../ui';
27
+ import {
28
+ SCHEDULED_TASK_MANAGE_PERMS,
29
+ SCHEDULED_TASK_TYPE_OPTIONS,
30
+ SCHEDULED_TASK_VIEW_PERMS,
31
+ ScheduledTaskModel,
32
+ formatRunDays,
33
+ formatRunTimes,
34
+ type ScheduledTaskRow,
35
+ } from './models';
36
+ import { showScheduledTaskDetail, taskTypeTone } from './ScheduledTaskDetail';
37
+ import { openScheduledTaskEditor } from './scheduled-task-form';
38
+
39
+ /**
40
+ * Columns read ONLY the `list` graph's fields — `{id, name, enabled, run_once,
41
+ * task_type, run_times, run_days, last_run, run_count, created}`. `description`,
42
+ * `channel`, `job_config`, `notify` and `last_error` are `default`-graph only
43
+ * and arrive when the detail modal fetches the record; rendering them here
44
+ * would show a permanent blank. `run_times`/`run_days` are JSONFields and are
45
+ * not sortable server-side.
46
+ */
47
+ const COLUMNS: Column<ScheduledTaskRow>[] = [
48
+ {
49
+ key: 'name', label: 'Name', sortable: true, hideable: false,
50
+ render: (row) => (
51
+ <>
52
+ <div className="jobs-runner-id">{row.name || <span className="dim-italic">Untitled</span>}</div>
53
+ {row.run_once && <span className="dim jobs-channel-note">Runs once, then disables itself</span>}
54
+ </>
55
+ ),
56
+ },
57
+ {
58
+ key: 'task_type', label: 'Type', sortable: true,
59
+ render: (row) => <Badge tone={taskTypeTone(row.task_type)}>{row.task_type.toUpperCase()}</Badge>,
60
+ },
61
+ {
62
+ key: 'enabled', label: 'Status', sortable: true,
63
+ render: (row) => <Badge tone={row.enabled ? 'success' : 'muted'}>{row.enabled ? 'Enabled' : 'Disabled'}</Badge>,
64
+ },
65
+ {
66
+ key: 'run_times', label: 'Schedule',
67
+ render: (row) => (
68
+ <>
69
+ <div>{formatRunTimes(row.run_times ?? [])}</div>
70
+ {/* Mon=0 — ScheduledTask.run_days uses Python's weekday(). */}
71
+ <div className="dim jobs-channel-note">{formatRunDays(row.run_days ?? [])}</div>
72
+ </>
73
+ ),
74
+ },
75
+ { key: 'run_count', label: 'Runs', sortable: true, align: 'end', render: (row) => fmt.number(row.run_count) },
76
+ {
77
+ key: 'last_run', label: 'Last run', sortable: true,
78
+ render: (row) => (row.last_run == null
79
+ ? <span className="dim">Never</span>
80
+ : <span title={fmt.datetime(row.last_run)}>{fmt.relative(row.last_run)}</span>),
81
+ },
82
+ {
83
+ key: 'created', label: 'Created', sortable: true,
84
+ render: (row) => <span title={fmt.datetime(row.created)}>{fmt.relative(row.created)}</span>,
85
+ },
86
+ ];
87
+
88
+ const FILTERS: FilterDef[] = [
89
+ { key: 'enabled', label: 'Status', type: 'boolean', trueLabel: 'Enabled', falseLabel: 'Disabled' },
90
+ { key: 'task_type', label: 'Type', type: 'select', options: SCHEDULED_TASK_TYPE_OPTIONS },
91
+ ];
92
+
93
+ export function ScheduledTasksPage() {
94
+ const { can: canView } = useCan(SCHEDULED_TASK_VIEW_PERMS);
95
+ const { can: canManage } = useCan(SCHEDULED_TASK_MANAGE_PERMS);
96
+ const queryClient = useQueryClient();
97
+ const save = ScheduledTaskModel.useSave();
98
+
99
+ // Fail-closed, and structurally so: with no view grant the table is never
100
+ // mounted, so no list request is issued to be denied. A manage grant does
101
+ // NOT open this page — see the file header.
102
+ if (!canView) {
103
+ return (
104
+ <div className="panel jobs-note jobs-note-info">
105
+ <i className="bi bi-shield-lock" />
106
+ <span>
107
+ Scheduled tasks need a system-level <code>jobs</code> or <code>view_scheduled_tasks</code> grant.
108
+ A manage grant on its own is not enough: the backend would answer with your personal tasks rather
109
+ than the system’s.
110
+ </span>
111
+ </div>
112
+ );
113
+ }
114
+
115
+ const invalidate = () => queryClient.invalidateQueries({ queryKey: ScheduledTaskModel.keys.root });
116
+
117
+ const create = async () => {
118
+ const saved = await openScheduledTaskEditor({
119
+ // Creation is ALWAYS caller-owned: django-mojo stamps `user` from
120
+ // the request, so there is no owner field to offer and no
121
+ // arbitrary-owner write to make.
122
+ submit: (changes) => save.mutateAsync({ id: null, changes }),
123
+ });
124
+ if (saved) {
125
+ await invalidate();
126
+ toast.success('Scheduled task created');
127
+ }
128
+ };
129
+
130
+ // Enable/disable are the one safe bulk edit here: reversible, one field,
131
+ // and the exact `save({enabled})` the detail performs. Delete stays a
132
+ // single-record armed action in the detail — a bulk cascade over task
133
+ // results is not something to arm once for a whole selection.
134
+ const setEnabled = (enabled: boolean): BatchAction<ScheduledTaskRow> => ({
135
+ key: enabled ? 'enable' : 'disable',
136
+ label: enabled ? 'Enable' : 'Disable',
137
+ icon: enabled ? 'bi-play-circle' : 'bi-pause-circle',
138
+ run: (row) => save.mutateAsync({ id: row.id, changes: { enabled } }),
139
+ });
140
+
141
+ return (
142
+ <ModelTable<ScheduledTaskRow>
143
+ model={ScheduledTaskModel}
144
+ title="Scheduled tasks"
145
+ eyebrow="Jobs"
146
+ columns={COLUMNS}
147
+ filters={FILTERS}
148
+ searchable
149
+ searchPlaceholder="Search name, description, channel…"
150
+ defaultSort="-created"
151
+ columnChooser
152
+ persistState
153
+ persistKey="admin-jobs-scheduled-tasks"
154
+ autoRefresh={60}
155
+ exportFormats={['csv', 'json']}
156
+ onRowClick={(row) => showScheduledTaskDetail(row.id)}
157
+ {...(canManage
158
+ ? {
159
+ addLabel: 'New task',
160
+ onAdd: () => { void create(); },
161
+ selectable: true,
162
+ batchActions: [setEnabled(true), setEnabled(false)],
163
+ }
164
+ : {})}
165
+ />
166
+ );
167
+ }