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,103 @@
1
+ # Admin Users
2
+
3
+ Reusable global User administration for django-mojo. Consumers must include
4
+ the semantic `user-admin.css` stylesheet used by the portal and showcase.
5
+
6
+ ```tsx
7
+ import {
8
+ UsersPage, UserDetail, UserModel, ApiKeyModel,
9
+ USERS_ADMIN_SECTION, USER_VIEW_PERMISSIONS, USER_MANAGE_PERMISSIONS,
10
+ } from 'portal-mojo/admin';
11
+ ```
12
+
13
+ `USERS_ADMIN_SECTION` is root-relative: `/users` in standalone Admin and
14
+ `/system/users` when embedded. Both the section and route are system-pinned:
15
+ view requires `sys.users | sys.view_users | sys.manage_users`; mutations
16
+ require `sys.users | sys.manage_users`. Active-group membership never satisfies
17
+ either clause, and Admin remains a no-group workspace.
18
+
19
+ ## Table and detail inventory
20
+
21
+ `UsersPage` keeps server-owned URL params, search, filters, presets, sorting,
22
+ paging, chooser, persistence, export, expansion, recency grouping, selection,
23
+ create, and state-aware batch disable/reactivate. Disable collects one required
24
+ reason per batch; rows already in the target state are ineligible.
25
+
26
+ The detail preserves all 14 sections: Overview, Profile, Personal, Security,
27
+ OAuth, Groups, Sys Perms, conditional App Perms, API Keys, Devices, Logins,
28
+ Audit, Notifications, and Metadata. Subsidiary count/list queries are enabled
29
+ only when their exact system permission is present. Logs use
30
+ `sys.view_logs | sys.manage_logs | sys.security`; incident events use
31
+ `sys.view_security | sys.security`. Permission revocation therefore removes
32
+ the section and prevents background requests.
33
+
34
+ Pass `onOpenGroup(groupId)` to `UsersPage` or `UserDetail` to connect an
35
+ app-owned Group detail. Without it, organization and membership identities are
36
+ plain text—never false links.
37
+
38
+ **Logins ships a Map tab (#1291, shipped).** The section is `Map | Logins`;
39
+ the Map renders `<LoginLocationMap userId={user.id} height={280} />` over
40
+ `/api/account/logins/user?user_id=`, and it is present only when
41
+ `useCan(LOGIN_SUMMARY_PERMS)` passes — absent, it issues no request at all.
42
+ Login rows are clickable and open `showLoginEventDetail`. The dead
43
+ `loginTone(event_type)` map is gone: `UserLoginEvent` has never had an
44
+ `event_type` field, so every dot rendered muted. Tone now comes from
45
+ `loginRiskTone(row)` (`is_new_country` / `is_new_region`). Device rows are
46
+ clickable too and open the full device dossier. See
47
+ [admin-devices-geoip.md](admin-devices-geoip.md).
48
+
49
+ The device / device-location / login-event models are DEFINED in
50
+ `admin/security/devices/models.ts` and re-exported here — one `defineModel`
51
+ per endpoint, so `UserDetail` and the fleet-wide tables share one cache.
52
+
53
+ ## Action contracts
54
+
55
+ - Username changes use the `change_username` POST_SAVE_ACTION, not a direct
56
+ field save. Model saves/actions retry one fresh-auth 440 automatically;
57
+ combined `disable_totp` writes explicitly use `withFreshAuth`.
58
+ - Send invite, password reset, magic link, and verification email controls all
59
+ require the User mutation tier even though public send responses
60
+ anti-enumerate recipients.
61
+ - Disable rejects inactive users; reactivate rejects active users. There is no
62
+ inactivity-warning reset action for an active account.
63
+ - API-key inventory/revoke uses `/api/account/api_keys`. Generation appears
64
+ only for the signed-in caller and calls `/api/auth/generate_api_key`.
65
+ `/api/auth/manage/generate_api_key` does not exist.
66
+ - Key generation splits the raw token inside the pending mutation, invokes a
67
+ transient reveal callback, deletes `token`, `jti`, `auth_key`, and secret
68
+ aliases, and resolves MutationCache with non-secret receipt metadata only.
69
+ - Notification preferences are interactive only for the signed-in caller;
70
+ django-mojo ignores a target `user` parameter. Other-user details render an
71
+ explicit unavailable state and make no request.
72
+
73
+ ## Avatar uploads
74
+
75
+ Manage avatar uses the shared `ImageField`: it uploads into the acting admin's
76
+ personal File scope, then saves that completed positive numeric File id (or
77
+ null to clear) on the target User. This matches django-mojo #1488's
78
+ admin-on-behalf rule: the File remains owned by the uploader, and replace/clear
79
+ detach without deleting it. Success requires the authoritative returned avatar
80
+ relation to match the requested id/null exactly. A failed or mismatched owner
81
+ save retains the uploaded candidate for attachment-only retry and reports an
82
+ abandoned candidate without deleting it.
83
+
84
+ Avatar selection opts into the shared editor before that upload begins. It
85
+ starts on a square crop and emits an exact alpha-preserving 200×200 PNG. Cancel
86
+ or edit failure leaves the original local selection available for retry or
87
+ discard; this required editor path does not offer Use original. Only an editor save converts the Blob to the File passed
88
+ into the existing personal-scope queue, so editing cannot create a File id or
89
+ trigger owner attachment early.
90
+
91
+ UserModel and `useMe` independently reduce expanded avatar relations to `{id}`
92
+ before their separate Query caches. The modal resolves a stored preview only
93
+ imperatively while mounted; capability URLs do not enter Query/Mutation cache,
94
+ storage, logs, or errors. Transfer and attach-save intervals both block modal
95
+ dismissal, while auth/permission loss closes the modal and cancels its queue.
96
+
97
+ ## Known seams
98
+
99
+ The login map and the richer device/GeoIP dossiers SHIPPED with #1291 and are documented
100
+ in [admin-devices-geoip.md](admin-devices-geoip.md). Arbitrary-user key
101
+ creation, arbitrary-user notification administration, and active-user
102
+ inactivity-warning clear require new backend contracts and are intentionally
103
+ absent.
@@ -0,0 +1,71 @@
1
+ # Admin incidents and events
2
+
3
+ `IncidentsPage` and `EventsPage` extend the shared Security Operations section
4
+ at `security/incidents` and `security/events`; Tickets remain at
5
+ `security/tickets`.
6
+
7
+ ## Permissions and contracts
8
+
9
+ Routes are global and system-pinned. View is `sys.view_security | sys.security`;
10
+ incident save, merge, protection, and history create use
11
+ `sys.manage_security | sys.security`; delete uses `sys.manage_security`.
12
+ Events remain immutable in this forensic UI, and event/history deletes are
13
+ denied by Django.
14
+
15
+ Incident comments require text and may atomically add one completed existing
16
+ File id as `media`. The upload queue is capacity one and derives its immutable
17
+ group from the loaded parent incident, never ambient or request-supplied group
18
+ context. The completed File and manager must match that parent group; groupless
19
+ parents use personal storage with no selector. Removing a candidate only
20
+ detaches it from the composer and never deletes the File or history row.
21
+
22
+ Lists allowlist parameters and force `graph=default`; graph-qualified detail
23
+ keys request `graph=detailed`. Both models search only `details`, hence “Search
24
+ incident details” and “Search event details.” Incident lifecycle is `pending`,
25
+ `new`, `open`, `investigating`, `resolved`, `closed`, `ignored`; `state` remains
26
+ separate and legacy `qa` is omitted. Events stay sorted by created time for
27
+ contiguous day groups and use the backend severity bands (8+ danger, 4+ warning).
28
+
29
+ ## Merge and sanitization
30
+
31
+ Merge is one selection-wide call: choose one selected target and send every
32
+ other ID once. Django moves source events/tickets, writes target history, and
33
+ deletes sources. Source history is not reparented. Conflicting Maestro links
34
+ surface Django's 409 unchanged, and every non-cancelled attempt refetches.
35
+
36
+ Rows, history, optimistic notes, mutation variables, curated copy surfaces, and
37
+ exports pass through a recursive non-mutating sanitizer. It redacts normalized
38
+ secret keys, auth/header blocks, JWT/Bearer values, sensitive URL/form params,
39
+ and parsed bodies; evidence/traces are explicitly bounded. Heuristic redaction
40
+ cannot guarantee arbitrary opaque prose, so raw JSON is not rendered or
41
+ exported. `createSafeExporter` sanitizes before accumulation and projects only
42
+ declared fields.
43
+
44
+ Before that recursive sanitizer runs, every history `media` value is positively
45
+ rebuilt to exactly `id`, `filename`, `content_type`, and `category`; URLs,
46
+ tokens, provider/manager details, transfer state, and browser File objects are
47
+ discarded rather than redacted.
48
+
49
+ Incident and event rows open the shared KISS `modal.detail` surface, composing
50
+ DetailView, StatusPanel, KnownFieldsCard, StackTraceView, and RecordFeed.
51
+ Incident→event and event→incident drill-ins stack a child detail modal; closing
52
+ the child reveals the parent. Ticket/rule/AI/network-response/dashboard controls
53
+ remain excluded. Bouncer discovery stays category-prefix plus MUID search
54
+ because its reporter writes MUID into incident details.
55
+
56
+ ## Backend evidence
57
+
58
+ The contract was checked against django-mojo source rather than inferred from
59
+ the mock:
60
+
61
+ - `mojo/apps/incident/models/incident.py`: `Incident.RestMeta` search,
62
+ permissions and graphs; `POST_SAVE_ACTIONS`; `Incident.on_action_merge`.
63
+ - `mojo/apps/incident/models/event.py`: `Event.RestMeta` search, permissions,
64
+ graph, severity definition, and unsafe CSV metadata fields.
65
+ - `mojo/apps/incident/models/history.py`: `IncidentHistory.RestMeta`, including
66
+ view/save clauses, JSON replacement and `CAN_DELETE = False`.
67
+ - `mojo/apps/incident/rest/event.py`: incident, history and event route mounts.
68
+
69
+ No disposable live Django records were available during this batch build, so
70
+ mutation evidence is source contract plus the focused mock verifier. A closing
71
+ live pass should remain read-only unless disposable incidents exist.
@@ -0,0 +1,426 @@
1
+ # Admin: jobs engine
2
+
3
+ ```ts
4
+ import {
5
+ // section (registered in ADMIN_SECTIONS — no app wiring needed)
6
+ JOBS_ADMIN_SECTION,
7
+ // pages
8
+ JobDashboardPage, JobRunnersPage, JobsTablePage, ScheduledTasksPage,
9
+ // dashboard sections
10
+ JobRunnersStrip, JobChannelsPanel, JobThroughputSection, JobOperationsSection,
11
+ // KISS detail modals (#1425)
12
+ JobDetail, showJobDetail,
13
+ RunnerDetail, showRunnerDetail,
14
+ ScheduledTaskDetail, showScheduledTaskDetail,
15
+ openScheduledTaskEditor,
16
+ // models — ONE defineModel per endpoint
17
+ JobModel, JobEventModel, JobLogModel, ScheduledTaskModel, TaskResultModel,
18
+ // permission clauses
19
+ JOBS_VIEW_PERMS, JOBS_MANAGE_PERMS,
20
+ SCHEDULED_TASK_VIEW_PERMS, SCHEDULED_TASK_MANAGE_PERMS,
21
+ // control plane (one typed function per route)
22
+ fetchJobStats, fetchRunners, fetchAllRunnerSysinfo, fetchRunnerSysinfo,
23
+ fetchChannels, fetchQueueSizes, fetchJobsConfig,
24
+ pingRunner, shutdownRunner, broadcastCommand, BROADCAST_COMMANDS,
25
+ clearStuck, manualReclaim, purgeJobs, isPurgeDryRun, resetFailed,
26
+ clearQueue, cleanupConsumers, rebuildScheduled, forceSchedulerLead,
27
+ publishTestJob, publishTestSuite,
28
+ forEachChannel, summarizeChannelOutcomes,
29
+ // query hooks (every one `enabled`-gated on the matching useCan)
30
+ jobsKeys, useJobStats, useRunners, useDiscoveredChannels, useChannelOptions,
31
+ useQueueSizes, useJobsConfig, useRunnerSysinfo,
32
+ useJobTimeline, useJobLogs, useRunnerActiveJobs, useRunnerJobHistory, useRunnerJobLogs,
33
+ useTaskResults,
34
+ // derived helpers
35
+ jobStatusTone, jobStatusIcon, isTerminalJob, isScheduledJob, isOverdueJob,
36
+ canCancelJob, canRetryJob,
37
+ runnerHealth, heartbeatAgeSeconds, formatHeartbeatAge, formatUptime, formatUptimeShort,
38
+ runnerUptimeSeconds, runnerFailureRate,
39
+ channelSeverity, channelSeverityTone,
40
+ formatRunDays, formatRunTimes, formatSchedule, taskTypeTone,
41
+ // scheduled-task form helpers (the client mirror of the model's _validate)
42
+ emptyScheduledTaskDraft, scheduledTaskDraft,
43
+ validateScheduledTaskDraft, scheduledTaskChanges,
44
+ // table pieces
45
+ JOB_SEGMENTS, jobSegmentOf, jobColumns, JOB_EXPORTER, sanitizeJobRowForExport,
46
+ } from 'portal-mojo/admin';
47
+ ```
48
+
49
+ Queue operations: is the runner fleet alive, which channel is backed up and
50
+ why, what is this job doing and why did it fail, retry or cancel it, clear
51
+ stuck work, purge history — plus the cron-style scheduled tasks whose backend
52
+ django-mojo has always shipped and whose admin page web-mojo never wired up.
53
+
54
+ Demo: showcase → Admin → **Jobs engine**. Styles:
55
+ `apps/{portal,showcase}/src/theme/admin-jobs.css` (byte-identical).
56
+ Verifier: `npm run verify:admin-jobs`.
57
+
58
+ Wire facts below come from django-mojo `mojo/apps/jobs/` (`models/job.py`,
59
+ `models/scheduled_task.py`, `models/task_result.py`, `rest/jobs.py`,
60
+ `rest/control.py`, `manager.py`, `job_engine.py`), which is authoritative
61
+ wherever web-mojo disagreed — and it disagreed a lot. See
62
+ [Backend corrections](#backend-corrections).
63
+
64
+ ## Registration
65
+
66
+ `JOBS_ADMIN_SECTION` is in `ADMIN_SECTIONS`, so routes and the sidebar entry
67
+ are generated. **A mounting app edits nothing** — neither
68
+ `apps/portal/src/menus.ts` nor `apps/portal/src/pages/admin-routes.tsx`.
69
+
70
+ | | |
71
+ |---|---|
72
+ | `id` / `basePath` | `jobs` / `jobs` (basePath defaults to id) |
73
+ | `navigationGroup` | `operations` |
74
+ | `icon` | `bi-cpu` |
75
+ | `permissions` | `['sys.manage_jobs', 'sys.view_jobs', 'sys.jobs', 'sys.view_scheduled_tasks']` — the ANY-OF union of the route gates |
76
+
77
+ Standalone mount → `#/jobs`, `#/jobs/runners`, `#/jobs/list`,
78
+ `#/jobs/scheduled-tasks`. Embedded (`mount: '/system'`) → the same paths under
79
+ `#/system/…`. The dashboard registers `path: ''`, so the section gets no
80
+ generated landing redirect: `#/jobs` **is** the dashboard.
81
+
82
+ ## Permissions — and why the gates are NOT uniform
83
+
84
+ Every clause is `sys.`-pinned, so an identically named ACTIVE-GROUP member
85
+ grant can never light an operator-global surface, and every clause is
86
+ fail-closed while `me` is loading.
87
+
88
+ | Route | Page | Route gate | Backend source |
89
+ |---|---|---|---|
90
+ | `jobs` | `JobDashboardPage` | `JOBS_VIEW_PERMS` | control plane `requires_global_perms('view_jobs','manage_jobs','jobs')` |
91
+ | `jobs/runners` | `JobRunnersPage` (+ `RunnerDetail`) | `JOBS_VIEW_PERMS` | same |
92
+ | `jobs/list` | `JobsTablePage` (+ `JobDetail`) | `JOBS_VIEW_PERMS` | `Job.VIEW_PERMS = ["view_jobs","manage_jobs","jobs"]` |
93
+ | `jobs/scheduled-tasks` | `ScheduledTasksPage` (+ detail + editor) | **`SCHEDULED_TASK_VIEW_PERMS`** | `ScheduledTask.VIEW_PERMS = ["jobs","view_scheduled_tasks","owner"]` |
94
+
95
+ ```ts
96
+ JOBS_VIEW_PERMS = ['sys.manage_jobs', 'sys.view_jobs', 'sys.jobs'];
97
+ JOBS_MANAGE_PERMS = ['sys.manage_jobs', 'sys.jobs'];
98
+ SCHEDULED_TASK_VIEW_PERMS = ['sys.jobs', 'sys.view_scheduled_tasks'];
99
+ SCHEDULED_TASK_MANAGE_PERMS= ['sys.jobs', 'sys.manage_scheduled_tasks'];
100
+ ```
101
+
102
+ **The scheduled-tasks difference is load-bearing, not tidiness.**
103
+ `ScheduledTask.VIEW_PERMS` contains *neither* manage grant and *not*
104
+ `view_jobs`. Three consequences:
105
+
106
+ 1. A `jobs.viewer` (holding only `view_jobs`) **cannot** read the system task
107
+ list. If the sidenav entry and the route reused `JOBS_VIEW_PERMS`, that
108
+ operator would get a menu entry that opens a page rendering a permission
109
+ notice. The route gate matches the page's own gate, so the entry is simply
110
+ absent for them.
111
+ 2. `manage_scheduled_tasks` does **not** imply view (correction 13 below), so
112
+ `SCHEDULED_TASK_MANAGE_PERMS` alone opens nothing either — the page checks
113
+ view first and says so explicitly.
114
+ 3. `owner` is deliberately **dropped** from both clauses. Keeping it would be
115
+ worse than useless: see correction 10.
116
+
117
+ Mutations gate separately — `JOBS_MANAGE_PERMS` for the jobs control plane and
118
+ job actions, `SCHEDULED_TASK_MANAGE_PERMS` for task writes. Every
119
+ permission-disabled query is `enabled: false`, so a view-only operator issues
120
+ **zero** denied requests rather than firing and catching 403s.
121
+
122
+ ## The wire
123
+
124
+ ### Model endpoints
125
+
126
+ `uses_model_security`, global-only — Job/JobEvent/JobLog have no group field.
127
+
128
+ | Endpoint | Verbs | Request params this module sends | Response |
129
+ |---|---|---|---|
130
+ | `/api/jobs/job` | GET list | `status`, `status__in`, `channel`, `func__icontains`, `runner_id`, `run_at__isnull`, `dr_field/dr_start/dr_end`, `search`, `start`, `size`, **always `sort`** | `{status, count, start, size, data: JobRow[], graph}` |
131
+ | `/api/jobs/job/<id>` | GET | `graph` (`default` · `detail` · `admin` · `status`) | `{status, data: JobRow, graph}` |
132
+ | `/api/jobs/job/<id>` | POST | `{cancel_request: true}` / `{retry_request: true}` / `{retry_request:{retry:true, delay:N}}` | the handler dict **verbatim** — see correction 4 |
133
+ | `/api/jobs/job/<id>` | DELETE | — | `{status:'deleted'}` (gate `manage_jobs\|jobs`) |
134
+ | `/api/jobs/event` | GET list | `job`, `job__in`, `event`, `graph=timeline`, `sort=at`, `size` | `{status, count, data: JobTimelineEntry[]}` |
135
+ | `/api/jobs/logs` | GET list | `job`, `job__in`, `kind`, `sort=-created`, `size` | `{status, count, data: JobLogRow[]}` |
136
+ | `/api/jobs/scheduled_task` | GET/POST/DELETE | `enabled`, `task_type`, `name__icontains`, `search`, paging, `sort=-created`; `graph` `list` \| `default` | `{status, count, data: ScheduledTaskRow[]}` |
137
+ | `/api/jobs/task_result` | GET (read-only), DELETE | `task`, `sort=-created`, `size` | `{status, count, data: TaskResultRow[]}` |
138
+
139
+ `JobEvent`/`JobLog` `SAVE_PERMS` is empty — rows are system-created; POST is a
140
+ 403. `TaskResult` is read-only over REST; its DELETE_PERMS drops `owner`
141
+ (`jobs|manage_scheduled_tasks` only).
142
+
143
+ Graph field sets that matter:
144
+
145
+ - `event?graph=timeline` → exactly `{event, at, runner_id, details}` — **no
146
+ `id`**. That is why `JobTimelineEntry` is a separate type from
147
+ `JobEventRow`: a timeline entry cannot be fed to anything that keys on a row
148
+ id.
149
+ - `job?graph=admin` → `__all__` **minus** `stack_trace`, and no `duration_ms`
150
+ (the admin graph declares no `extra`).
151
+ - `scheduled_task?graph=list` carries neither `description` nor `job_config`;
152
+ the table must not read them.
153
+ - `task_result?graph=list` is `{id, task_id, status, created}` — `output` and
154
+ `error` only exist on `default`.
155
+
156
+ ### Control plane
157
+
158
+ `requires_global_perms` — member grants never satisfy it. Read gate
159
+ `view_jobs|manage_jobs|jobs`; **every write** gate `manage_jobs|jobs`.
160
+
161
+ | Route | Body / params | Response shape |
162
+ |---|---|---|
163
+ | `GET stats` | — | `{status, data:{channels, runners, totals, scheduler}}` |
164
+ | `GET runners` | `channel?` **only** | `{status, count, data:[…]}` — complete list, paging ignored |
165
+ | `GET runners/sysinfo` | `timeout` | `{status, count, data: RunnerSysinfo[]}` |
166
+ | `GET runners/sysinfo/<id>` | `timeout` | `{status, data: RunnerSysinfo}`; no reply → **404 envelope** |
167
+ | `GET control/channels` | — | `{status, data: string[]}` (Redis stream scan) |
168
+ | `GET control/queue-sizes` | — | `{status, data:{<channel>: {stream, scheduled, db_*}}}` |
169
+ | `GET control/config` | — | `{status, data: JobsConfig}` — **manage-gated**, unlike every other read |
170
+ | `POST runners/ping` | `{runner_id, timeout}` | **top-level** `{status, runner_id, responsive}` |
171
+ | `POST runners/shutdown` | `{runner_id, graceful}` | `{status, message}` — fire-and-forget |
172
+ | `POST runners/broadcast` | `{command, data, timeout}` | **top-level** `{status, command, responses_count, responses}` |
173
+ | `POST control/clear-stuck` | `{channel*, idle_threshold_ms}` | `{status, message, data:{channel, cleared, details[], errors[]}}` |
174
+ | `POST control/manual-reclaim` | `{channel*}` | same shape (`idle_threshold_ms: 0` — reclaims everything in flight) |
175
+ | `POST control/purge` | `{days_old*, status?, dry_run?}` | dry run `{dry_run:true, count, cutoff, status_filter}` · real run `{deleted, details, cutoff, status_filter}` |
176
+ | `POST control/reset-failed` | `{channel?, since?, limit}` | **top-level** `{status, message, reset_count, requeue[]}` |
177
+ | `POST control/clear-queue` | `{channel*, confirm:"yes"*}` | `{status, message, data:{channel, deleted{}, db_pending_canceled, errors[]}}` |
178
+ | `POST control/cleanup-consumers` | `{channel?, destroy_empty_groups}` | `{status, data}` |
179
+ | `POST control/rebuild-scheduled` | `{channel?, limit?}` | `{status, data}` |
180
+ | `POST control/force-scheduler-lead` | `{}` | **top-level** `{status, message, previous_holder}` |
181
+ | `POST control/test` | `{channel, delay?}` | **top-level** `{status, message, job_id, channel, delayed}` |
182
+ | `POST tests` | `{}` | `{status, message}` — publishes a whole sample suite (load-generating; armed) |
183
+
184
+ `*` = enforced server-side; omitting it is an HTTP 400.
185
+
186
+ Result keys are **not uniform**: some handlers nest under `data`, others answer
187
+ with top-level fields. `mojoCall` returns the unwrapped envelope and each
188
+ `control.ts` function reads from whichever level its endpoint actually uses —
189
+ never a `data ?? body` heuristic.
190
+
191
+ ### Metrics slugs
192
+
193
+ `jobs.published`, `jobs.completed`, `jobs.failed`, `jobs.retried`,
194
+ `jobs.expired`, `jobs.local.completed|failed|duration_ms`.
195
+
196
+ Per-channel slugs are **asymmetric**, and the throughput switch honours it:
197
+
198
+ ```
199
+ jobs.published.<channel> # publishes
200
+ jobs.channel.<channel>.completed # terminal outcomes
201
+ jobs.channel.<channel>.failed
202
+ ```
203
+
204
+ ## Backend corrections
205
+
206
+ Thirteen places where the backend does not do what web-mojo (or a reasonable
207
+ reading of the model) assumes. The backend wins in all of them; the mock
208
+ encodes every one, which is why they are testable.
209
+
210
+ 1. **`GET /api/jobs/health` and `health/<channel>` are DEAD at runtime.**
211
+ `JobManager.get_channel_health` reads `state['stream_length']` and
212
+ `state['pending_count']`, which the Plan-B `get_queue_state` no longer
213
+ returns → `KeyError` → HTTP 400 `{status:false,error:"'stream_length'"}`.
214
+ Nothing here calls them; every dashboard number comes from
215
+ `GET /api/jobs/stats`, which uses `.get()` fallbacks throughout and already
216
+ carries `channels`, `runners`, `totals` and `scheduler`.
217
+ 2. **`clear-stuck` and `manual-reclaim` are `@requires_params('channel')`** —
218
+ there is **no** all-channel form. `purge` requires `days_old`.
219
+ `clear-queue` requires `channel` **and** `confirm:"yes"`. Sending
220
+ `channel: null` (web-mojo's "All Channels") is an instant 400.
221
+ 3. **Result keys are the backend's, not the obvious ones.** `clear-stuck` →
222
+ `data:{cleared, details[], errors[], message}` — there is **no `count`**
223
+ (web-mojo read one and always reported 0). `purge` real run → `deleted`;
224
+ dry run → `count`. `reset-failed` → top-level `{reset_count, requeue[]}`.
225
+ `ping` → top-level `{runner_id, responsive}`. `broadcast` → top-level
226
+ `{command, responses_count, responses}`.
227
+ 4. **`POST_SAVE_ACTIONS` return the handler dict VERBATIM as the response
228
+ body** (`rest.py on_rest_save_and_respond → JsonResponse(resp)`) — not the
229
+ row, not nested under `data`, and at **HTTP 200 even on failure**. The
230
+ client's `status === false` unwrap is what turns a refusal into a rejection,
231
+ so both actions declare `response: 'payload'`.
232
+ `cancel_request` must be sent as `true`: Python treats `{}` as falsy, so an
233
+ argument-less call answers `"cancel_request must be true"`.
234
+ `retry_request` is `true` or `{retry:true, delay:N}` and **always
235
+ republishes**: `{original_job_id, new_job_id, delayed}`. The UI must never
236
+ imply the same row resumed.
237
+ 5. **Job has no `recent_events` in any graph** (only inside the `get_status`
238
+ action payload) and no `is_retriable`/`stack_trace` in any graph. The
239
+ lifecycle timeline is `/api/jobs/event?job=<id>&graph=timeline`;
240
+ retriability is computed client-side from the *service* rule
241
+ (`status ∈ {failed, canceled, expired}`), which is deliberately wider than
242
+ the model's `is_retriable` property.
243
+ 6. **Unknown filter keys are silently dropped** by `build_rest_filters`
244
+ (`hasattr(cls, field_name)`), so a bad key returns the **unfiltered** list
245
+ rather than an error. JobLog has no `runner_id` field at all — web-mojo
246
+ shipped `/api/jobs/logs?runner_id=…` and presented the whole log table as
247
+ one runner's logs. Always filter by the **relation** name (`job=`,
248
+ `job__in=`). See correction 12 for the sharper version of this.
249
+ 7. **The default list sort is `-id`**, and Job ids are 32-char uuid hex, so an
250
+ unsorted job list is lexicographic noise. `normalizeJobListParams` (and its
251
+ four siblings) therefore **always** emit an explicit `sort`. The `ordering`
252
+ param web-mojo sent is not read by the backend at all.
253
+ 8. **The runner heartbeat payload is
254
+ `{runner_id, hostname, channels[], jobs_processed, jobs_failed, started,
255
+ last_heartbeat}`** plus `alive` and `id` injected by the view. There is **no
256
+ `version` field** (web-mojo rendered a chip for one). `GET runners` ignores
257
+ `start/size/sort/search` and returns the complete list — it is not a paged
258
+ model endpoint, which is why `JobRunnersPage` is a bespoke panel table.
259
+ Heartbeat/sysinfo timestamps are **ISO strings** while model rows carry
260
+ **epoch seconds**: a real asymmetry, preserved in the types.
261
+ 9. **`/api/jobs/publish` does not exist.** web-mojo's `Job.publish` was dead
262
+ code. Republishing happens through `retry_request` (or `control/test` for a
263
+ sample job).
264
+ 10. **ScheduledTask/TaskResult are not globally pinned.** `VIEW_PERMS`
265
+ includes `owner`, so a caller with **no** global jobs grant gets HTTP 200
266
+ with only their **own** tasks. An admin page that did not gate on the
267
+ global grant would silently render a personal list as if it were the
268
+ system's. Gating fail-closed on `sys.jobs | sys.view_scheduled_tasks`
269
+ closes that hole; `owner` is dropped from the client clause on purpose.
270
+ 11. **ScheduledTask has no REST "run now"** — only the owner-scoped assistant
271
+ tool `_tool_run_scheduled_task_now`. See [The run-now gap](#the-run-now-gap).
272
+
273
+ Two more corrections found while building, refining 6 and the gate table:
274
+
275
+ 12. **`/api/jobs/logs?job_id=` is an HTTP 500, not an unfiltered list.**
276
+ Django installs a descriptor for a FK's *attname*, so `hasattr(cls,
277
+ 'job_id')` is `True` and `build_rest_filters` accepts the key — then
278
+ `get_model_field('job_id')` returns `None` and `normalize_rest_value`
279
+ dereferences it: `AttributeError` → 500
280
+ (`"'NoneType' object has no attribute 'get_internal_type'"`). By contrast
281
+ `?runner_id=` (a name JobLog does not have in any form) **is** silently
282
+ dropped and returns the whole table. Same-looking mistake, two completely
283
+ different failures — the mock reproduces both, and the same split applies
284
+ to `task_result?task_id=` vs the relation name `task=`.
285
+ 13. **`manage_scheduled_tasks` does not imply view.**
286
+ `ScheduledTask.VIEW_PERMS` is `["jobs","view_scheduled_tasks","owner"]` and
287
+ `SAVE_PERMS` is `["jobs","manage_scheduled_tasks","owner"]` — the manage
288
+ grant appears in **neither** view clause. An operator holding only
289
+ `manage_scheduled_tasks` falls through to the `owner` branch and sees just
290
+ their own tasks. A real jobs operator needs the `jobs` catch-all, or
291
+ `view_scheduled_tasks` alongside the manage grant (the mock's
292
+ `jobs.operator` identity is seeded exactly that way, deliberately).
293
+
294
+ ## Design decisions
295
+
296
+ - **Stats, not health.** Correction 1. web-mojo's `JobHealthView` — the only
297
+ consumer of `/health` — was never mounted, so nothing is lost.
298
+ - **Runners is not a ModelTable.** The endpoint has no paging/sort/search
299
+ contract; rendering a pager and sort headers that silently do nothing would
300
+ lie about the wire. Client-side sort/filter over the full list, the same
301
+ precedent as `MetricsPermissionsPage`.
302
+ - **Queued vs Scheduled by `run_at__isnull`,** not by comparing a datetime on
303
+ the wire. `__isnull` is an explicitly supported operator and needs no date
304
+ coercion. A pending job with a *past* `run_at` shows up under Scheduled
305
+ flagged **overdue**, which is its true state.
306
+ - **All-channel operations fan out client-side** (`forEachChannel` →
307
+ `Promise.allSettled`), because correction 2 leaves no server-side form.
308
+ Partial failures are reported per channel with a `warning` toast — never a
309
+ blanket success.
310
+ - **Purge is dry-run first.** The dialog runs `dry_run:true` and reports
311
+ "N jobs would be deleted before `<cutoff>`"; only the armed confirm performs
312
+ the real run, which is read from `deleted`. Fixes the source's wrong
313
+ `data.count` read.
314
+ - **Armed confirmations** for purge, clear-queue, clear-stuck,
315
+ cleanup-consumers, shutdown, broadcast `shutdown`, force-scheduler-lead and
316
+ task delete. `clear-queue` sends `confirm:"yes"` **only after** arming: the
317
+ token is a server-side safety gate, and pre-satisfying it (as web-mojo did on
318
+ every call) removes the safety rather than honouring it.
319
+ - **KISS modals.** Job, runner, scheduled-task and task-result inspection are
320
+ `modal.detail`; the editor and confirmations are stacked native `<dialog>`s.
321
+ No RightPanel, no record-detail routes — the jobs package imports neither.
322
+ - **Bounded export.** `JOB_EXPORTER` is a *client* projection (id, channel,
323
+ func, status, timing, attempt, runner). The server's `download_format` would
324
+ ship `payload`, `metadata` and `last_error`.
325
+
326
+ ### ScheduledTask: PORT (decision recorded)
327
+
328
+ The backend is complete and live — model, REST, hourly cron dispatcher,
329
+ `TaskResult`, three task types including LLM. The only reason the page never
330
+ shipped is that web-mojo's `ScheduledTaskTablePage` was imported by
331
+ `src/admin.js` and then never `registerPage`d.
332
+
333
+ It is also the one jobs surface with a real security subtlety worth owning:
334
+ the `owner` fallback (correction 10) means an ungated page degrades into a
335
+ personal task list wearing an admin page's chrome. Porting it *with a
336
+ global-only gate* turns a dead page into a first-class admin feature and closes
337
+ that hole in one move — which is why the gate difference is called out at the
338
+ top of this page rather than buried.
339
+
340
+ ### The run-now gap (django-mojo #1309)
341
+
342
+ There is **no REST route** to run a scheduled task immediately. The capability
343
+ exists only as the owner-scoped assistant tool `_tool_run_scheduled_task_now`,
344
+ and there is no `/api/jobs/publish` (correction 9) to synthesize one from.
345
+
346
+ Per the wave-7A precedent, an unsupported operation stays **absent** rather
347
+ than shipping disabled, so **no "Run now" control ships** — not greyed out, not
348
+ hidden behind a permission. When django-mojo **#1309** lands a REST route, the
349
+ control belongs in `ScheduledTaskDetail`'s action kebab and in the editor's
350
+ footer; nothing else needs to change.
351
+
352
+ ### Immutable task type / the JSONField merge
353
+
354
+ `task_type` is **fixed after creation** — the select is disabled while editing.
355
+ django-mojo merges a JSON body into an existing `JSONField` rather than
356
+ replacing it, so switching a task's type would leave the previous type's keys
357
+ behind inside `job_config` (an LLM task carrying a stale `url`, a webhook task
358
+ carrying a stale `user_prompt`). The dispatcher reads by type and would ignore
359
+ them, but the record would be quietly wrong and the editor would show a config
360
+ the server does not use. Creating a new task is the honest operation; the field
361
+ help says so in place.
362
+
363
+ For the same reason the config editor is keyed on `task_type`
364
+ (`<ConfigEditor key={draft.task_type} …>`) and a type change on a *new* task
365
+ resets `job_config` to `{}`.
366
+
367
+ ### Deliberately absent
368
+
369
+ Carried over from web-mojo only as *decisions not to carry*:
370
+
371
+ | Not shipped | Why |
372
+ |---|---|
373
+ | Per-runner **Drain** and **Restart** | The source's buttons only toasted "backend integration pending". No endpoint exists. |
374
+ | The runner **`version` chip** | No such field in the heartbeat payload (correction 8). |
375
+ | **`Job.publish`** | `/api/jobs/publish` does not exist (correction 9). |
376
+ | **`JobHealthView`** | Reads a broken endpoint (correction 1) and was never mounted anyway. |
377
+ | **Batch task delete** | Delete cascades over `TaskResult`; arming once for a whole selection is not a safety story. Enable/disable *is* offered as a batch — reversible, one field. |
378
+ | **Run Now** | django-mojo #1309, above. |
379
+ | `data.count` reads, `ordering=` params, `job_id=`/`runner_id=` log filters | Corrections 3, 7, 6/12. |
380
+ | HTML-string interpolation | ReactNode slots only (architecture rule 6). |
381
+
382
+ ## Edge cases the pages handle
383
+
384
+ - Zero runners with queued work → critical strip **and** critical channel rows.
385
+ - Heartbeat tiers computed from the ISO `last_heartbeat`: ≥30s slow, ≥120s
386
+ stale. The roster row and the detail header always agree.
387
+ - Running job whose runner is dead → cancel force-cancels server-side and
388
+ returns `forced:true`; the toast says "canceled", not "cancellation
389
+ requested".
390
+ - Terminal job → the service refuses cancel, so the control is **not offered**.
391
+ - Retry of a canceled/expired job is allowed and always produces a new id.
392
+ - Sysinfo has three distinct states: a healthy host, a successful envelope with
393
+ an inner `status:'error'` (psutil missing), and a runner that never answers
394
+ (404 envelope). None of them renders blank.
395
+ - Empty/absent channel list → operations needing a channel are disabled with an
396
+ explanation, not a broken select.
397
+ - Huge `payload`/`last_error` → bounded rendering (`JsonBlock` + truncation with
398
+ full text on demand). Stack traces are unavailable by graph and are not
399
+ implied.
400
+ - Scheduled task at the per-user cap, or with a `run_times` value that slipped
401
+ past client validation → the server's message surfaces **verbatim** through
402
+ the rejecting save path.
403
+ - A viewer-only operator sees every read surface, zero mutation controls, and
404
+ issues no denied request.
405
+
406
+ ## Showcase & styling
407
+
408
+ `apps/showcase/src/pages/components/demos-admin-jobs.tsx` reaches every state
409
+ the browser pass must see, in both themes: the dashboard (dead runner, a
410
+ channel with zero alive runners), the runner roster and its three sysinfo
411
+ states, the jobs table across all five segments, the job inspector (failed with
412
+ a multi-line error, running on a dead runner, overdue, canceled, expired), the
413
+ operations panel including a purge dry run, the scheduled-task table + detail +
414
+ editor, and the permission-gate story.
415
+
416
+ Two states cannot be produced by calling the mock, because the seeded fixture
417
+ is a *populated* deployment: **no runners registered at all** and **no channels
418
+ configured**. The demo's "Fleet edge cases" tab drives the shipped components
419
+ from hand-built `JobsStats` objects (and, for `JobRunnersStrip`, a demo-only
420
+ `QueryClient` pinned to an empty fleet). Nothing in the package is mocked or
421
+ branched for the demo.
422
+
423
+ Styles are semantic `jobs-*` classes, tokens only, both themes:
424
+ `apps/{portal,showcase}/src/theme/admin-jobs.css`, imported from each app's
425
+ `theme.css`. The verifier asserts the two copies stay byte-identical and that
426
+ neither contains a raw hex colour.