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,213 @@
1
+ /** Lossless editor model for django-mojo's comma-delimited handler DSL. */
2
+
3
+ export const HANDLER_SCHEMES = ['job', 'email', 'sms', 'notify', 'block', 'ticket', 'maestro', 'llm', 'resolve'] as const;
4
+ export type HandlerScheme = typeof HANDLER_SCHEMES[number];
5
+
6
+ export interface HandlerDefinition {
7
+ scheme: HandlerScheme;
8
+ label: string;
9
+ target: 'path' | 'targets' | 'none';
10
+ defaults?: Record<string, string>;
11
+ }
12
+
13
+ export const HANDLER_DEFINITIONS: readonly HandlerDefinition[] = [
14
+ { scheme: 'job', label: 'Run job', target: 'path' },
15
+ { scheme: 'email', label: 'Email', target: 'targets' },
16
+ { scheme: 'sms', label: 'SMS', target: 'targets' },
17
+ { scheme: 'notify', label: 'Push notification', target: 'targets' },
18
+ { scheme: 'block', label: 'Block source IP', target: 'none', defaults: { ttl: '3600' } },
19
+ { scheme: 'ticket', label: 'Create ticket', target: 'none', defaults: { priority: '5', status: 'open' } },
20
+ { scheme: 'maestro', label: 'Report to Maestro', target: 'none' },
21
+ { scheme: 'llm', label: 'LLM triage', target: 'none' },
22
+ { scheme: 'resolve', label: 'Resolve incident', target: 'none', defaults: { status: 'resolved' } },
23
+ ] as const;
24
+
25
+ export interface HandlerParam { key: string; value: string; raw: string }
26
+ export type RuntimeDisposition = 'effective' | 'swallowed' | 'skipped';
27
+ export interface HandlerStep {
28
+ /** Separator immediately before this step. Empty only for the first step. */
29
+ separator: string;
30
+ /** Exact bytes after separator. Untouched steps serialize byte-for-byte. */
31
+ raw: string;
32
+ scheme: string | null;
33
+ schemeRaw: string;
34
+ supported: boolean;
35
+ target: string;
36
+ targetRaw: string;
37
+ hadQuery: boolean;
38
+ params: HandlerParam[];
39
+ malformedEncoding: boolean;
40
+ runtime: RuntimeDisposition;
41
+ runtimeOwner: number | null;
42
+ }
43
+ export interface HandlerChain { source: string; steps: HandlerStep[] }
44
+ export interface HandlerIssue { step: number; level: 'error' | 'warning'; message: string }
45
+
46
+ const SUPPORTED = new Set<string>(HANDLER_SCHEMES);
47
+ const UI_BOUNDARY = /,(?=[A-Za-z][A-Za-z0-9+.-]*:\/\/)/g;
48
+ const URI = /^([A-Za-z][A-Za-z0-9+.-]*):\/\/([\s\S]*)$/;
49
+
50
+ function safeDecode(value: string): { value: string; malformed: boolean } {
51
+ try { return { value: decodeURIComponent(value.replace(/\+/g, ' ')), malformed: false }; }
52
+ catch { return { value, malformed: true }; }
53
+ }
54
+
55
+ function inspect(raw: string): Omit<HandlerStep, 'separator' | 'raw' | 'runtime' | 'runtimeOwner'> {
56
+ const content = raw.trim();
57
+ const match = content.match(URI);
58
+ if (!match) return { scheme: null, schemeRaw: '', supported: false, target: '', targetRaw: '', hadQuery: false, params: [], malformedEncoding: false };
59
+ const scheme = match[1]!.toLowerCase();
60
+ const rest = match[2]!;
61
+ const queryAt = rest.indexOf('?');
62
+ const targetRaw = queryAt < 0 ? rest : rest.slice(0, queryAt);
63
+ const query = queryAt < 0 ? '' : rest.slice(queryAt + 1);
64
+ const targetDecoded = safeDecode(targetRaw);
65
+ let malformedEncoding = targetDecoded.malformed;
66
+ const params = query === '' ? [] : query.split('&').map((rawEntry) => {
67
+ const eq = rawEntry.indexOf('=');
68
+ const keyRaw = eq < 0 ? rawEntry : rawEntry.slice(0, eq);
69
+ const valueRaw = eq < 0 ? '' : rawEntry.slice(eq + 1);
70
+ const key = safeDecode(keyRaw); const value = safeDecode(valueRaw);
71
+ malformedEncoding ||= key.malformed || value.malformed;
72
+ return { key: key.value, value: value.value, raw: rawEntry };
73
+ });
74
+ return { scheme, schemeRaw: match[1]!, supported: SUPPORTED.has(scheme), target: targetDecoded.value, targetRaw, hadQuery: queryAt >= 0, params, malformedEncoding };
75
+ }
76
+
77
+ export function parseHandlerChain(value: string | null | undefined): HandlerChain {
78
+ const source = value ?? '';
79
+ if (!source) return { source, steps: [] };
80
+ const pieces: Array<{ separator: string; raw: string }> = [];
81
+ let start = 0;
82
+ for (const match of source.matchAll(UI_BOUNDARY)) {
83
+ pieces.push({ separator: pieces.length ? ',' : '', raw: source.slice(start, match.index) });
84
+ start = (match.index ?? 0) + 1;
85
+ }
86
+ pieces.push({ separator: pieces.length ? ',' : '', raw: source.slice(start) });
87
+
88
+ let owner: number | null = null;
89
+ const steps = pieces.map((piece, index): HandlerStep => {
90
+ const parsed = inspect(piece.raw);
91
+ // Python starts a new runtime spec only at comma + an immediately
92
+ // adjacent known scheme. Whitespace and unknown schemes remain in
93
+ // the current spec, which is why the UI projection is separate.
94
+ const boundary = index === 0 || (piece.separator === ',' && HANDLER_SCHEMES.some((scheme) => piece.raw.startsWith(`${scheme}://`)));
95
+ let runtime: RuntimeDisposition;
96
+ if (boundary) {
97
+ runtime = parsed.supported ? 'effective' : 'skipped';
98
+ owner = parsed.supported ? index : null;
99
+ } else {
100
+ runtime = owner == null ? 'skipped' : 'swallowed';
101
+ }
102
+ return { ...piece, ...parsed, runtime, runtimeOwner: runtime === 'swallowed' ? owner : runtime === 'effective' ? index : null };
103
+ });
104
+ return { source, steps };
105
+ }
106
+
107
+ function encode(value: string): string { return encodeURIComponent(value).replace(/%40/g, '@').replace(/%2C/gi, ','); }
108
+
109
+ function buildRaw(step: HandlerStep, scheme: string, target: string, params: HandlerParam[]): string {
110
+ const leading = step.raw.match(/^\s*/)?.[0] ?? '';
111
+ const trailing = step.raw.match(/\s*$/)?.[0] ?? '';
112
+ const query = params.length ? `?${params.map((entry) => entry.raw || `${encode(entry.key)}=${encode(entry.value)}`).join('&')}` : (step.hadQuery && step.params.length === 0 ? '?' : '');
113
+ const schemeText = scheme === step.scheme ? step.schemeRaw : scheme;
114
+ const targetText = target === step.target ? step.targetRaw : encode(target);
115
+ return `${leading}${schemeText}://${targetText}${query}${trailing}`;
116
+ }
117
+
118
+ export function updateHandlerStep(chain: HandlerChain, index: number, patch: {
119
+ scheme?: HandlerScheme; target?: string; param?: { key: string; value: string }; removeParam?: string;
120
+ }, options: { confirmBehaviorChange?: boolean } = {}): HandlerChain {
121
+ const step = chain.steps[index];
122
+ if (!step) throw new Error('Handler step does not exist.');
123
+ if (step.runtime !== 'effective' && !options.confirmBehaviorChange) {
124
+ throw new Error('This step is skipped or swallowed by the backend. Confirm the runtime behavior change before editing it.');
125
+ }
126
+ const scheme = patch.scheme ?? (step.supported ? step.scheme as HandlerScheme : undefined);
127
+ if (!scheme || !SUPPORTED.has(scheme)) throw new Error('Choose a supported handler type.');
128
+ let params = step.params.map((entry) => ({ ...entry }));
129
+ if (patch.param) {
130
+ const first = params.findIndex((entry) => entry.key === patch.param!.key);
131
+ if (first >= 0) params[first] = { key: patch.param.key, value: patch.param.value, raw: '' };
132
+ else params.push({ key: patch.param.key, value: patch.param.value, raw: '' });
133
+ }
134
+ if (patch.removeParam != null) {
135
+ const first = params.findIndex((entry) => entry.key === patch.removeParam);
136
+ if (first >= 0) params.splice(first, 1);
137
+ }
138
+ const definition = HANDLER_DEFINITIONS.find((item) => item.scheme === scheme)!;
139
+ const nextTarget = patch.target ?? (patch.scheme && definition.target === 'none' ? '' : step.target);
140
+ const raw = buildRaw(step, scheme, nextTarget, params);
141
+ const next = chain.steps.map((candidate, i) => i === index ? { ...candidate, raw } : candidate);
142
+ return parseHandlerChain(next.map((candidate) => candidate.separator + candidate.raw).join(''));
143
+ }
144
+
145
+ export function moveHandlerStep(chain: HandlerChain, from: number, to: number, options: { confirmBehaviorChange?: boolean } = {}): HandlerChain {
146
+ if (from === to) return chain;
147
+ const source = chain.steps[from]; const target = chain.steps[to];
148
+ if (!source || !target) throw new Error('Handler step does not exist.');
149
+ const ordered = chain.steps.map((step) => step.raw);
150
+ const [moved] = ordered.splice(from, 1); ordered.splice(to, 0, moved!);
151
+ const next = parseHandlerChain(ordered.join(','));
152
+ const effectiveCount = (value: HandlerChain) => value.steps.filter((step) => step.runtime === 'effective').length;
153
+ const changesReachability = source.runtime !== 'effective' || target.runtime !== 'effective'
154
+ || effectiveCount(next) !== effectiveCount(chain)
155
+ || (source.runtime === 'effective' && next.steps[to]?.runtime !== 'effective');
156
+ if (changesReachability && !options.confirmBehaviorChange) {
157
+ throw new Error('Moving this content changes which specs the backend can dispatch. Confirm the behavior change first.');
158
+ }
159
+ return next;
160
+ }
161
+
162
+ export function removeHandlerStep(chain: HandlerChain, index: number, options: { confirmBehaviorChange?: boolean } = {}): HandlerChain {
163
+ const step = chain.steps[index];
164
+ if (!step) return chain;
165
+ if (step.runtime !== 'effective' && !options.confirmBehaviorChange) throw new Error('Confirm removal of skipped or swallowed legacy content.');
166
+ return parseHandlerChain(chain.steps.filter((_, i) => i !== index).map((candidate) => candidate.raw).join(','));
167
+ }
168
+
169
+ export function addHandlerStep(chain: HandlerChain, scheme: HandlerScheme): HandlerChain {
170
+ const definition = HANDLER_DEFINITIONS.find((item) => item.scheme === scheme)!;
171
+ const params = Object.entries(definition.defaults ?? {}).map(([key, value]) => `${encode(key)}=${encode(value)}`);
172
+ const raw = `${scheme}://${params.length ? `?${params.join('&')}` : ''}`;
173
+ return parseHandlerChain(`${serializeHandlerChain(chain)}${chain.steps.length ? ',' : ''}${raw}`);
174
+ }
175
+
176
+ export function serializeHandlerChain(chain: HandlerChain): string {
177
+ return chain.steps.map((step) => step.separator + step.raw).join('');
178
+ }
179
+
180
+ export function runtimeEffectiveHandlerChain(chain: HandlerChain): string[] {
181
+ // Match RuleSet.run_handler exactly: strip once, split at known adjacent
182
+ // schemes, strip each result, then skip an unsupported parsed scheme.
183
+ return serializeHandlerChain(chain).trim()
184
+ .split(/,(?=(?:job|email|sms|notify|ticket|maestro|block|llm|resolve):\/\/)/)
185
+ .map((spec) => spec.trim()).filter(Boolean)
186
+ .filter((spec) => SUPPORTED.has(spec.match(URI)?.[1]?.toLowerCase() ?? ''));
187
+ }
188
+
189
+ export function validateHandlerChain(chain: HandlerChain): HandlerIssue[] {
190
+ const issues: HandlerIssue[] = [];
191
+ chain.steps.forEach((step, index) => {
192
+ if (!step.scheme) issues.push({ step: index, level: 'error', message: 'Handler is not a URI-like scheme:// specification.' });
193
+ else if (!step.supported) issues.push({ step: index, level: 'warning', message: `Unsupported ${step.scheme}:// content is preserved but the backend will skip or swallow it.` });
194
+ if (step.malformedEncoding) issues.push({ step: index, level: 'error', message: 'Malformed percent encoding.' });
195
+ if (step.runtime === 'swallowed') issues.push({ step: index, level: 'warning', message: 'The backend swallows this segment into the preceding handler.' });
196
+ if (step.runtime === 'skipped') issues.push({ step: index, level: 'warning', message: 'The backend skips this segment.' });
197
+ step.params.forEach((entry) => {
198
+ if (!entry.key) issues.push({ step: index, level: 'error', message: 'Parameter keys cannot be empty.' });
199
+ if (!entry.value) issues.push({ step: index, level: 'error', message: `Parameter “${entry.key || '(blank)'}” cannot have an empty value.` });
200
+ });
201
+ if (step.supported && ['job', 'email', 'sms', 'notify'].includes(step.scheme!) && !step.target.trim()) issues.push({ step: index, level: 'error', message: `${step.scheme}:// requires a target.` });
202
+ if (step.scheme === 'job' && step.target && !/^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+$/.test(step.target)) issues.push({ step: index, level: 'error', message: 'Job target must be a Python module path.' });
203
+ const first = (key: string) => step.params.find((entry) => entry.key === key)?.value;
204
+ const integer = (key: string, min: number, max = Number.MAX_SAFE_INTEGER) => { const value = first(key); if (value != null && (!/^\d+$/.test(value) || Number(value) < min || Number(value) > max)) issues.push({ step: index, level: 'error', message: `${key} must be an integer from ${min}${Number.isFinite(max) ? ` to ${max}` : ''}.` }); };
205
+ if (step.scheme === 'block') integer('ttl', 0);
206
+ if (step.scheme === 'ticket') integer('priority', 1, 10);
207
+ if (step.scheme === 'maestro') integer('board', 1);
208
+ if (step.scheme === 'resolve') { const status = first('status'); if (status && !['resolved', 'closed', 'ignored'].includes(status)) issues.push({ step: index, level: 'error', message: 'Resolve status must be resolved, closed, or ignored.' }); }
209
+ const seen = new Set<string>();
210
+ step.params.forEach((entry) => { if (seen.has(entry.key)) issues.push({ step: index, level: 'warning', message: `Duplicate “${entry.key}”: django-mojo uses the first value.` }); seen.add(entry.key); });
211
+ });
212
+ return issues;
213
+ }
@@ -0,0 +1,13 @@
1
+ import type { AdminRoute } from '../index';
2
+ import { SECURITY_VIEW_PERMS } from '../security-permissions';
3
+ import { RuleSetsPage } from './RuleSetsPage';
4
+
5
+ // Importing the bundle also installs the namespaced SchemaForm field renderer.
6
+ export * from './models';
7
+ export * from './handler-dsl';
8
+ export * from './HandlerChainBuilder';
9
+ export * from './editors';
10
+
11
+ export const RULES_ADMIN_ROUTES: AdminRoute[] = [
12
+ { path: 'rules', label: 'Rule Engine', component: RuleSetsPage, permissions: SECURITY_VIEW_PERMS },
13
+ ];
@@ -0,0 +1,112 @@
1
+ import { defineModel, type Params } from '../../client';
2
+ import type { FieldOption, FormData } from '../../client/types';
3
+ import { SECURITY_MANAGE_PERMS, SECURITY_VIEW_PERMS } from '../security-permissions';
4
+
5
+ export const RULESET_VIEW_PERMS = SECURITY_VIEW_PERMS;
6
+ export const RULESET_MANAGE_PERMS = SECURITY_MANAGE_PERMS;
7
+
8
+ export interface RuleSetRow {
9
+ id: number; created: number; modified: number; priority: number; category: string; name: string | null;
10
+ bundle_minutes: number | null; bundle_by: number; bundle_by_rule_set: boolean; match_by: number;
11
+ handler: string | null; trigger_count: number | null; trigger_window: number | null;
12
+ retrigger_every: number | null; metadata: Record<string, unknown>; is_active: boolean;
13
+ [field: string]: unknown;
14
+ }
15
+ export interface RuleRow {
16
+ id: number; created: number; modified: number; parent: number | { id: number; name?: string | null };
17
+ name: string | null; index: number; comparator: string; field_name: string | null;
18
+ value: string; value_type: string; is_required: number; [field: string]: unknown;
19
+ }
20
+
21
+ export const BUNDLE_BY_OPTIONS: readonly FieldOption[] = [
22
+ 'Each event', 'Hostname', 'Model type', 'Specific model', 'Source IP', 'Hostname + model type',
23
+ 'Hostname + specific model', 'IP + model type', 'IP + specific model', 'IP + hostname',
24
+ 'Group', 'Group + model type', 'Group + specific model', 'Group + source IP',
25
+ ].map((label, value) => ({ value, label }));
26
+ export const MATCH_BY_OPTIONS: readonly FieldOption[] = [{ value: 0, label: 'All conditions' }, { value: 1, label: 'Any condition' }];
27
+ export const COMPARATOR_OPTIONS: readonly FieldOption[] = ['==', 'eq', '>', '>=', '<', '<=', 'contains', 'regex'].map((value) => ({ value, label: value }));
28
+ export const VALUE_TYPE_OPTIONS: readonly FieldOption[] = ['str', 'int', 'float', 'bool'].map((value) => ({ value, label: value }));
29
+
30
+ export function optionsWithUnknownValue(options: readonly FieldOption[], raw: unknown): FieldOption[] {
31
+ const out = options.map((option) => ({ ...option }));
32
+ if (raw != null && raw !== '' && !out.some((option) => String(option.value) === String(raw))) out.push({ value: String(raw), label: `Unknown (${String(raw)})` });
33
+ return out;
34
+ }
35
+
36
+ function normalize(params: Params, allowed: Set<string>, defaultSort: string): Params {
37
+ const out: Params = { graph: 'default' };
38
+ for (const [key, value] of Object.entries(params)) if (['start', 'size', 'sort', 'search'].includes(key) || allowed.has(key)) if (value != null && value !== '') out[key] = value;
39
+ out.sort = String(out.sort ?? defaultSort); return out;
40
+ }
41
+ export const normalizeRuleSetListParams = (params: Params) => normalize(params, new Set(['id', 'category', 'category__icontains', 'priority', 'is_active', 'bundle_by', 'match_by']), 'priority');
42
+ export const normalizeRuleListParams = (params: Params) => normalize(params, new Set(['id', 'parent', 'index', 'field_name', 'comparator', 'value_type', 'is_required']), 'index');
43
+
44
+ export const RuleSetModel = defineModel<RuleSetRow>({ name: 'incident_ruleset', endpoint: '/api/incident/event/ruleset', permissions: { view: RULESET_VIEW_PERMS, manage: RULESET_MANAGE_PERMS, delete: RULESET_MANAGE_PERMS }, normalizeListParams: normalizeRuleSetListParams });
45
+ export const RuleModel = defineModel<RuleRow>({ name: 'incident_rule', endpoint: '/api/incident/event/ruleset/rule', permissions: { view: RULESET_VIEW_PERMS, manage: RULESET_MANAGE_PERMS, delete: RULESET_MANAGE_PERMS }, normalizeListParams: normalizeRuleListParams });
46
+
47
+ function nullablePositive(value: unknown, label: string, allowZero = false): number | null {
48
+ if (value == null || value === '') return null;
49
+ const parsed = Number(String(value).trim());
50
+ if (!Number.isInteger(parsed) || parsed < (allowZero ? 0 : 1)) throw new Error(`${label} must be ${allowZero ? 'zero or ' : ''}a positive integer.`);
51
+ return parsed;
52
+ }
53
+ function integer(value: unknown, label: string): number { const parsed = Number(String(value).trim()); if (!Number.isInteger(parsed)) throw new Error(`${label} must be an integer.`); return parsed; }
54
+ function text(value: unknown, label: string): string { const parsed = String(value ?? '').trim(); if (!parsed) throw new Error(`${label} is required.`); return parsed; }
55
+ function present(data: FormData, key: string): boolean { return Object.prototype.hasOwnProperty.call(data, key); }
56
+
57
+ export function validateRuleSetTuple(values: { bundle_by: number; bundle_minutes: number | null; trigger_count: number | null; trigger_window: number | null; retrigger_every: number | null }): void {
58
+ const effectiveBundle = values.bundle_by !== 0 && values.bundle_minutes !== 0;
59
+ if (values.trigger_window != null && values.trigger_count == null) throw new Error('Trigger window requires a trigger count.');
60
+ if (values.retrigger_every != null && values.trigger_count == null) throw new Error('Re-trigger count requires a trigger count.');
61
+ if (values.trigger_count != null && !effectiveBundle) throw new Error('A count threshold requires effective bundling.');
62
+ if (values.trigger_window != null && values.bundle_minutes != null && values.bundle_minutes > 0 && values.trigger_window > values.bundle_minutes) throw new Error('Trigger window cannot exceed the finite bundle window.');
63
+ }
64
+
65
+ export function ruleSetChanges(data: FormData, creating: boolean, original?: Partial<RuleSetRow>): Record<string, unknown> {
66
+ const defaults = { priority: 10, category: '', name: '', bundle_minutes: 30, bundle_by: 4, bundle_by_rule_set: true, match_by: 0, handler: '', trigger_count: null, trigger_window: null, retrigger_every: null };
67
+ const source = { ...defaults, ...original, ...data } as Record<string, unknown>;
68
+ const tuple = {
69
+ bundle_by: integer(source.bundle_by, 'Bundle mode'),
70
+ bundle_minutes: nullablePositive(source.bundle_minutes, 'Bundle window', true),
71
+ trigger_count: nullablePositive(source.trigger_count, 'Trigger count'),
72
+ trigger_window: nullablePositive(source.trigger_window, 'Trigger window'),
73
+ retrigger_every: nullablePositive(source.retrigger_every, 'Re-trigger count'),
74
+ };
75
+ const changedBundleMode = creating || original?.bundle_by == null || tuple.bundle_by !== original.bundle_by;
76
+ if (changedBundleMode && (tuple.bundle_by < 0 || tuple.bundle_by > 13)) throw new Error('Bundle mode must be 0 through 13. Existing future values may be preserved unchanged.');
77
+ validateRuleSetTuple(tuple);
78
+ const out: Record<string, unknown> = {};
79
+ const write = (key: string, value: unknown) => { if (creating || present(data, key)) out[key] = value; };
80
+ write('name', text(source.name, 'Name')); write('category', text(source.category, 'Category'));
81
+ write('priority', integer(source.priority, 'Priority')); write('match_by', integer(source.match_by, 'Match mode'));
82
+ write('bundle_by', tuple.bundle_by); write('bundle_minutes', tuple.bundle_minutes);
83
+ write('bundle_by_rule_set', source.bundle_by_rule_set === true); write('trigger_count', tuple.trigger_count);
84
+ write('trigger_window', tuple.trigger_window); write('retrigger_every', tuple.retrigger_every);
85
+ write('handler', String(source.handler ?? ''));
86
+ const metadata: Record<string, unknown> = {};
87
+ for (const [key, value] of Object.entries(data)) if (key.startsWith('metadata.')) metadata[key.slice(9)] = value;
88
+ if (Object.keys(metadata).length) out.metadata = metadata;
89
+ if (creating) out.is_active = false; else if (present(data, 'is_active')) out.is_active = data.is_active === true;
90
+ return out;
91
+ }
92
+
93
+ export function ruleChanges(data: FormData, creating: boolean, original?: Partial<RuleRow>): Record<string, unknown> {
94
+ const source = { index: 0, comparator: '==', value_type: 'str', is_required: 0, value: '', ...original, ...data } as Record<string, unknown>;
95
+ const out: Record<string, unknown> = {};
96
+ const write = (key: string, value: unknown) => { if (creating || present(data, key)) out[key] = value; };
97
+ const fieldName = text(source.field_name, 'Field name');
98
+ const changedField = creating || original?.field_name == null || fieldName !== original.field_name;
99
+ if (changedField && fieldName.startsWith('_')) throw new Error('Field names beginning with “_” cannot be matched.');
100
+ const valueType = String(source.value_type ?? 'str');
101
+ const changesComparison = creating
102
+ || (present(data, 'value') && String(data.value ?? '') !== String(original?.value ?? ''))
103
+ || (present(data, 'value_type') && valueType !== String(original?.value_type ?? ''));
104
+ if (valueType === 'bool' && changesComparison) throw new Error('Boolean authoring is disabled: django-mojo treats the stored string “false” as true. Existing boolean rules remain preservable.');
105
+ const value = String(source.value ?? '');
106
+ if (changesComparison && valueType === 'int' && !/^[+-]?\d+$/.test(value.trim())) throw new Error('Comparison value must be an integer.');
107
+ if (changesComparison && valueType === 'float' && !Number.isFinite(Number(value.trim()))) throw new Error('Comparison value must be numeric.');
108
+ write('parent', integer(source.parent, 'Rule set')); write('name', text(source.name, 'Name')); write('index', integer(source.index, 'Index'));
109
+ write('field_name', fieldName); write('comparator', String(source.comparator)); write('value_type', valueType); write('value', value);
110
+ write('is_required', source.is_required === true || String(source.is_required) === '1' ? 1 : 0);
111
+ return out;
112
+ }
@@ -0,0 +1,123 @@
1
+ // LoginEventDetail — a small, strictly read-only `modal.detail`.
2
+ // `UserLoginEvent.RestMeta` sets CAN_CREATE / CAN_UPDATE / CAN_DELETE all
3
+ // False, so there are no actions here by contract: only the record, and
4
+ // cross-links to the user, the device and the IP dossier.
5
+ import { Badge, DetailView, Eyebrow, FlatRow, fmt, modal } from '../../../ui';
6
+ import { useCan } from '../../../client';
7
+ import { countryFlag, GEOIP_VIEW_PERMS } from '../geoip/models';
8
+ import { showGeoIpDossierForAddress } from '../geoip/GeoIpDossier';
9
+ import {
10
+ LoginEventModel, USER_DEVICE_VIEW_PERMS,
11
+ browserLabel, osLabel, type LoginEventRow,
12
+ } from './models';
13
+
14
+ const DASH = <span className="dim-italic">—</span>;
15
+
16
+ function whereOf(row: LoginEventRow): string {
17
+ return [row.city, row.region, row.country_code].filter(Boolean).join(', ') || 'Unknown location';
18
+ }
19
+
20
+ export function LoginEventDetail({ id, onClose, onOpenUser, onOpenDeviceByDuid }: {
21
+ id: number;
22
+ onClose: () => void;
23
+ onOpenUser?: (userId: number) => void;
24
+ onOpenDeviceByDuid?: (duid: string) => void;
25
+ }) {
26
+ const { data: row, isPending, error } = LoginEventModel.useOne(id);
27
+ const canGeoIp = useCan(GEOIP_VIEW_PERMS).can;
28
+ const canDevice = useCan(USER_DEVICE_VIEW_PERMS).can;
29
+
30
+ if (isPending) return <div className="modal-pad dim">Loading login event…</div>;
31
+ if (!row || error) return <div className="modal-pad text-bad">{error?.message ?? 'Login event not found'}</div>;
32
+
33
+ const flag = countryFlag(row.country_code);
34
+ const device = row.device ?? null;
35
+
36
+ return (
37
+ <DetailView
38
+ icon="bi-box-arrow-in-right"
39
+ title={whereOf(row)}
40
+ subtitle={`${fmt.datetime(row.created)}${row.source ? ` · via ${row.source}` : ''}`}
41
+ chips={[
42
+ ...(row.is_new_country ? [{ icon: 'bi-globe-americas', text: 'New country', tone: 'danger' as const }] : []),
43
+ ...(row.is_new_region ? [{ icon: 'bi-pin-map', text: 'New region', tone: 'warning' as const }] : []),
44
+ ...(row.source ? [{ text: row.source }] : []),
45
+ ]}
46
+ sections={[
47
+ {
48
+ key: 'event', label: 'Login', icon: 'bi-box-arrow-in-right', render: () => (
49
+ <>
50
+ <Eyebrow>When &amp; where</Eyebrow>
51
+ <FlatRow label="Timestamp"><code>{fmt.datetime(row.created)}</code> <span className="dim">· {fmt.relative(row.created)}</span></FlatRow>
52
+ <FlatRow label="City">{row.city || DASH}</FlatRow>
53
+ <FlatRow label="Region">
54
+ {row.region || DASH}
55
+ {row.region_code && <code className="dim geoip-cc">{row.region_code}</code>}
56
+ </FlatRow>
57
+ <FlatRow label="Country">
58
+ {row.country_code
59
+ ? <>{flag && <span className="geoip-flag">{flag}</span>}{row.country_code}</>
60
+ : DASH}
61
+ </FlatRow>
62
+ <FlatRow label="Coordinates">
63
+ {row.latitude != null && row.longitude != null
64
+ ? <code>{row.latitude}, {row.longitude}</code>
65
+ : <span className="dim-italic">— (private-range logins carry none)</span>}
66
+ </FlatRow>
67
+
68
+ <Eyebrow>Origin</Eyebrow>
69
+ <FlatRow label="IP address">
70
+ {row.ip_address
71
+ ? (canGeoIp
72
+ ? <button className="btn-link" onClick={() => showGeoIpDossierForAddress(row.ip_address!)}><code>{row.ip_address}</code></button>
73
+ : <code>{row.ip_address}</code>)
74
+ : DASH}
75
+ </FlatRow>
76
+ <FlatRow label="Source">{row.source || DASH}</FlatRow>
77
+ <FlatRow label="New country"><Badge tone={row.is_new_country ? 'danger' : 'muted'}>{row.is_new_country ? 'Yes' : 'No'}</Badge></FlatRow>
78
+ <FlatRow label="New region"><Badge tone={row.is_new_region ? 'warning' : 'muted'}>{row.is_new_region ? 'Yes' : 'No'}</Badge></FlatRow>
79
+
80
+ <Eyebrow>Identity</Eyebrow>
81
+ <FlatRow label="User">
82
+ {row.user
83
+ ? (onOpenUser
84
+ ? <button className="btn-link" onClick={() => onOpenUser(row.user!.id)}>{row.user.display_name || row.user.username}</button>
85
+ : (row.user.display_name || row.user.username))
86
+ : DASH}
87
+ </FlatRow>
88
+ <FlatRow label="Device">
89
+ {device
90
+ ? (
91
+ <>
92
+ {browserLabel(device.device_info)} on {osLabel(device.device_info)}
93
+ {' '}
94
+ {canDevice && onOpenDeviceByDuid
95
+ ? <button className="btn-link" onClick={() => onOpenDeviceByDuid(device.duid)}><code>{fmt.truncateMiddle(device.duid, 14)}</code></button>
96
+ : <code>{fmt.truncateMiddle(device.duid, 14)}</code>}
97
+ </>
98
+ )
99
+ : <span className="dim-italic">— (no device linked to this login)</span>}
100
+ </FlatRow>
101
+ {row.user_agent_info?.string && (
102
+ <>
103
+ <Eyebrow>User agent</Eyebrow>
104
+ <pre className="ud-ua-block">{row.user_agent_info.string}</pre>
105
+ </>
106
+ )}
107
+ </>
108
+ ),
109
+ },
110
+ ]}
111
+ initialSection="event"
112
+ // No context menu: the model is read-only by RestMeta contract.
113
+ onClose={onClose}
114
+ />
115
+ );
116
+ }
117
+
118
+ export function showLoginEventDetail(id: number, opts: {
119
+ onOpenUser?: (userId: number) => void;
120
+ onOpenDeviceByDuid?: (duid: string) => void;
121
+ } = {}): void {
122
+ void modal.detail((close) => <LoginEventDetail id={id} onClose={() => close(null)} {...opts} />);
123
+ }