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,238 @@
1
+ import {
2
+ defineRealtimeEvent, type RealtimeClient, type RealtimeLifecycleStatus,
3
+ } from '../../client/realtime';
4
+ import { projectBlocks } from './data';
5
+ import { getAssistantConversation, listAssistantConversations } from './api';
6
+ import type { AssistantBlock, AssistantConversation, AssistantMessage } from './types';
7
+
8
+ const MAX_TEXT = 20_000;
9
+ const MAX_TOOL_NAME = 160;
10
+ const MAX_TOOLS = 32;
11
+ const MAX_PLAN_STEPS = 20;
12
+ const object = (value: unknown): Record<string, unknown> | null => value != null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null;
13
+ const text = (value: unknown, cap: number): string | null => typeof value === 'string' && value.length > 0 ? value.slice(0, cap) : null;
14
+ const conversationId = (value: unknown): number | null => typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : null;
15
+
16
+ export interface AssistantThinkingEvent { type: 'thinking'; conversationId: number }
17
+ export interface AssistantTextEvent { type: 'text'; conversationId: number; text: string; blocks: AssistantBlock[] }
18
+ export interface AssistantToolEvent { type: 'tool'; conversationId: number; name: string; status: 'running'; count: number }
19
+ export interface AssistantPlanStep { id: number; description: string; status: 'pending' | 'in_progress' | 'done' | 'skipped'; summary?: string }
20
+ export interface AssistantPlanEvent { type: 'plan'; conversationId: number; planId: string; title: string; steps: AssistantPlanStep[] }
21
+ export interface AssistantPlanUpdateEvent { type: 'plan_update'; conversationId: number; planId: string; stepId: number; status: AssistantPlanStep['status']; summary?: string }
22
+ export interface AssistantResponseEvent { type: 'response'; conversationId: number; messageId: number | string | null; created: number | string; response: string; blocks: AssistantBlock[]; toolCount: number; durationMs: number }
23
+ export interface AssistantErrorEvent { type: 'error'; conversationId: number | null; error: string }
24
+ export type AssistantProjectedEvent = AssistantThinkingEvent | AssistantTextEvent | AssistantToolEvent | AssistantPlanEvent | AssistantPlanUpdateEvent | AssistantResponseEvent | AssistantErrorEvent;
25
+
26
+ function exact(value: unknown, expectedType: string): Record<string, unknown> | null {
27
+ const raw = object(value);
28
+ return raw?.type === expectedType ? raw : null;
29
+ }
30
+
31
+ export function projectAssistantThinking(value: unknown): AssistantThinkingEvent | null {
32
+ const raw = exact(value, 'assistant_thinking'); const id = raw && conversationId(raw.conversation_id);
33
+ return id == null ? null : { type: 'thinking', conversationId: id };
34
+ }
35
+
36
+ export function projectAssistantText(value: unknown): AssistantTextEvent | null {
37
+ const raw = exact(value, 'assistant_text'); const id = raw && conversationId(raw.conversation_id); const content = raw && text(raw.text, MAX_TEXT);
38
+ return id == null || content == null ? null : { type: 'text', conversationId: id, text: content, blocks: projectBlocks(raw.blocks) };
39
+ }
40
+
41
+ export function projectAssistantToolCall(value: unknown): AssistantToolEvent | null {
42
+ const raw = exact(value, 'assistant_tool_call'); const id = raw && conversationId(raw.conversation_id); const name = raw && text(raw.tool, MAX_TOOL_NAME);
43
+ return id == null || name == null ? null : { type: 'tool', conversationId: id, name, status: 'running', count: 1 };
44
+ }
45
+
46
+ const PLAN_STATUSES = new Set<AssistantPlanStep['status']>(['pending', 'in_progress', 'done', 'skipped']);
47
+ function planStatus(value: unknown): AssistantPlanStep['status'] | null {
48
+ return typeof value === 'string' && PLAN_STATUSES.has(value as AssistantPlanStep['status']) ? value as AssistantPlanStep['status'] : null;
49
+ }
50
+
51
+ export function projectAssistantPlan(value: unknown): AssistantPlanEvent | null {
52
+ const raw = exact(value, 'assistant_plan'); const id = raw && conversationId(raw.conversation_id); const plan = raw && object(raw.plan);
53
+ const planId = plan && text(plan.plan_id, 80); const title = plan && text(plan.title, 200);
54
+ if (id == null || !plan || !planId || !title || !Array.isArray(plan.steps) || plan.steps.length < 1 || plan.steps.length > MAX_PLAN_STEPS) return null;
55
+ const steps = plan.steps.map(object).map((step) => {
56
+ const stepId = step && conversationId(step.id); const description = step && text(step.description, 500); const status = step && planStatus(step.status);
57
+ if (stepId == null || !description || !status) return null;
58
+ const summary = text(step.summary, 500);
59
+ return { id: stepId, description, status, ...(summary ? { summary } : {}) };
60
+ });
61
+ return steps.every(Boolean) ? { type: 'plan', conversationId: id, planId, title, steps: steps as AssistantPlanStep[] } : null;
62
+ }
63
+
64
+ export function projectAssistantPlanUpdate(value: unknown): AssistantPlanUpdateEvent | null {
65
+ const raw = exact(value, 'assistant_plan_update'); const id = raw && conversationId(raw.conversation_id); const planId = raw && text(raw.plan_id, 80);
66
+ const stepId = raw && conversationId(raw.step_id); const status = raw && planStatus(raw.status); const summary = raw && text(raw.summary, 500);
67
+ return id == null || !planId || stepId == null || !status ? null : { type: 'plan_update', conversationId: id, planId, stepId, status, ...(summary ? { summary } : {}) };
68
+ }
69
+
70
+ export function projectAssistantResponse(value: unknown): AssistantResponseEvent | null {
71
+ const raw = exact(value, 'assistant_response'); const id = raw && conversationId(raw.conversation_id); const response = raw && typeof raw.response === 'string' ? raw.response.slice(0, MAX_TEXT) : null;
72
+ if (id == null || response == null) return null;
73
+ const messageId = typeof raw.message_id === 'number' && Number.isSafeInteger(raw.message_id) && raw.message_id > 0
74
+ ? raw.message_id : typeof raw.message_id === 'string' && raw.message_id.length > 0 && raw.message_id.length <= 160 ? raw.message_id : null;
75
+ const created = typeof raw.created === 'number' || typeof raw.created === 'string' ? raw.created : 0;
76
+ const toolCount = Array.isArray(raw.tool_calls_made) ? Math.min(raw.tool_calls_made.length, MAX_TOOLS) : typeof raw.tool_calls_made === 'number' ? Math.min(Math.max(0, raw.tool_calls_made), MAX_TOOLS) : 0;
77
+ const durationMs = typeof raw.duration_ms === 'number' && Number.isFinite(raw.duration_ms) ? Math.max(0, raw.duration_ms) : 0;
78
+ return { type: 'response', conversationId: id, messageId, created, response, blocks: projectBlocks(raw.blocks), toolCount, durationMs };
79
+ }
80
+
81
+ export function projectAssistantError(value: unknown): AssistantErrorEvent | null {
82
+ const raw = exact(value, 'assistant_error'); const error = raw && text(raw.error, 1_000);
83
+ return !raw || !error ? null : { type: 'error', conversationId: conversationId(raw.conversation_id), error };
84
+ }
85
+
86
+ export const ASSISTANT_REALTIME_EVENTS = {
87
+ thinking: defineRealtimeEvent('assistant_thinking', projectAssistantThinking),
88
+ text: defineRealtimeEvent('assistant_text', projectAssistantText),
89
+ tool: defineRealtimeEvent('assistant_tool_call', projectAssistantToolCall),
90
+ plan: defineRealtimeEvent('assistant_plan', projectAssistantPlan),
91
+ planUpdate: defineRealtimeEvent('assistant_plan_update', projectAssistantPlanUpdate),
92
+ response: defineRealtimeEvent('assistant_response', projectAssistantResponse),
93
+ error: defineRealtimeEvent('assistant_error', projectAssistantError),
94
+ } as const;
95
+
96
+ export type AssistantTransportChoice = 'websocket' | 'rest' | 'inspect';
97
+ export function chooseAssistantTransport(input: { owner: boolean; textOnly: boolean; hasViewAdmin: boolean; realtimeStatus: RealtimeLifecycleStatus }): AssistantTransportChoice {
98
+ if (!input.owner) return 'inspect';
99
+ return input.textOnly && input.hasViewAdmin && input.realtimeStatus === 'ready' ? 'websocket' : 'rest';
100
+ }
101
+
102
+ export class AssistantOutcomeUnknownError extends Error {
103
+ constructor() {
104
+ super('Outcome unknown: the connection ended before the server assigned a conversation. The conversation list was refreshed; the message was not resent.');
105
+ this.name = 'AssistantOutcomeUnknownError';
106
+ }
107
+ }
108
+
109
+ export interface AssistantRealtimeProgress {
110
+ tools: Array<{ name: string; status: 'running'; count: number }>;
111
+ plan: AssistantPlanEvent | null;
112
+ }
113
+
114
+ export interface AssistantRealtimeTurnCallbacks {
115
+ onConversation(id: number): void;
116
+ onText(message: AssistantMessage): void;
117
+ onProgress(progress: AssistantRealtimeProgress): void;
118
+ onResponse(message: AssistantMessage): void;
119
+ onReconcile(conversation: AssistantConversation): void;
120
+ onUnknown(): void;
121
+ }
122
+
123
+ export interface AssistantRealtimeTurn {
124
+ promise: Promise<void>;
125
+ cancel(): void;
126
+ }
127
+
128
+ export function startAssistantRealtimeTurn(
129
+ client: RealtimeClient,
130
+ input: {
131
+ message: string;
132
+ conversationId?: number;
133
+ seenMessageIds: Set<number | string>;
134
+ reconcile?: (conversationId: number) => Promise<AssistantConversation>;
135
+ refreshList?: () => Promise<unknown>;
136
+ },
137
+ callbacks: AssistantRealtimeTurnCallbacks,
138
+ ): AssistantRealtimeTurn {
139
+ let expected = input.conversationId ?? null;
140
+ let active = true;
141
+ let disconnected = false;
142
+ let interimSequence = 0;
143
+ const tools = new Map<string, { name: string; status: 'running'; count: number }>();
144
+ let plan: AssistantPlanEvent | null = null;
145
+ const cleanups: Array<() => void> = [];
146
+ let resolvePromise!: () => void;
147
+ let rejectPromise!: (error: Error) => void;
148
+ const promise = new Promise<void>((resolve, reject) => { resolvePromise = resolve; rejectPromise = reject; });
149
+
150
+ const finish = (error?: Error) => {
151
+ if (!active) return;
152
+ active = false;
153
+ cleanups.splice(0).forEach((cleanup) => cleanup());
154
+ if (error) rejectPromise(error); else resolvePromise();
155
+ };
156
+ const correlated = (id: number | null): id is number => id != null && (expected == null || expected === id);
157
+ const adopt = (id: number) => {
158
+ if (expected == null) { expected = id; callbacks.onConversation(id); }
159
+ };
160
+ const progress = () => callbacks.onProgress({ tools: [...tools.values()].slice(0, MAX_TOOLS), plan });
161
+
162
+ cleanups.push(client.on(ASSISTANT_REALTIME_EVENTS.thinking, ({ data }) => {
163
+ if (!active || !correlated(data.conversationId)) return;
164
+ adopt(data.conversationId);
165
+ }));
166
+ cleanups.push(client.on(ASSISTANT_REALTIME_EVENTS.text, ({ data }) => {
167
+ if (!active || !correlated(data.conversationId)) return;
168
+ adopt(data.conversationId);
169
+ callbacks.onText({ id: `stream-${data.conversationId}-${++interimSequence}`, role: 'assistant', content: data.text, created: Math.floor(Date.now() / 1000), blocks: data.blocks });
170
+ }));
171
+ cleanups.push(client.on(ASSISTANT_REALTIME_EVENTS.tool, ({ data }) => {
172
+ if (!active || !correlated(data.conversationId)) return;
173
+ adopt(data.conversationId);
174
+ const current = tools.get(data.name);
175
+ tools.set(data.name, { name: data.name, status: 'running', count: Math.min(MAX_TOOLS, (current?.count ?? 0) + 1) });
176
+ progress();
177
+ }));
178
+ cleanups.push(client.on(ASSISTANT_REALTIME_EVENTS.plan, ({ data }) => {
179
+ if (!active || !correlated(data.conversationId)) return;
180
+ adopt(data.conversationId); plan = data; progress();
181
+ }));
182
+ cleanups.push(client.on(ASSISTANT_REALTIME_EVENTS.planUpdate, ({ data }) => {
183
+ if (!active || !correlated(data.conversationId) || !plan || plan.planId !== data.planId) return;
184
+ plan = { ...plan, steps: plan.steps.map((step) => step.id === data.stepId ? { ...step, status: data.status, ...(data.summary ? { summary: data.summary } : {}) } : step) };
185
+ progress();
186
+ }));
187
+ cleanups.push(client.on(ASSISTANT_REALTIME_EVENTS.response, ({ data }) => {
188
+ if (!active || !correlated(data.conversationId)) return;
189
+ adopt(data.conversationId);
190
+ if (data.messageId == null) {
191
+ void (input.reconcile ?? getAssistantConversation)(data.conversationId).then((conversation) => {
192
+ if (!active) return;
193
+ callbacks.onReconcile(conversation);
194
+ for (const message of conversation.messages) input.seenMessageIds.add(message.id);
195
+ finish();
196
+ }, (cause) => finish(cause instanceof Error ? cause : new Error('Assistant terminal reconciliation failed')));
197
+ return;
198
+ }
199
+ if (!input.seenMessageIds.has(data.messageId)) {
200
+ input.seenMessageIds.add(data.messageId);
201
+ callbacks.onResponse({ id: data.messageId, role: 'assistant', content: data.response, created: data.created, blocks: data.blocks });
202
+ }
203
+ finish();
204
+ }));
205
+ cleanups.push(client.on(ASSISTANT_REALTIME_EVENTS.error, ({ data }) => {
206
+ if (!active || (data.conversationId != null && !correlated(data.conversationId))) return;
207
+ if (data.conversationId != null) adopt(data.conversationId);
208
+ finish(new Error(data.error));
209
+ }));
210
+ cleanups.push(client.subscribeStatus(() => {
211
+ if (!active) return;
212
+ const status = client.getStatus().status;
213
+ if (status !== 'ready') {
214
+ if (expected == null) {
215
+ callbacks.onUnknown();
216
+ void (input.refreshList ?? listAssistantConversations)().catch(() => undefined);
217
+ finish(new AssistantOutcomeUnknownError());
218
+ } else {
219
+ disconnected = true;
220
+ }
221
+ } else if (disconnected && expected != null) {
222
+ disconnected = false;
223
+ void (input.reconcile ?? getAssistantConversation)(expected).then((conversation) => {
224
+ if (!active) return;
225
+ callbacks.onReconcile(conversation);
226
+ for (const message of conversation.messages) input.seenMessageIds.add(message.id);
227
+ finish();
228
+ }, (cause) => finish(cause instanceof Error ? cause : new Error('Assistant reconciliation failed')));
229
+ }
230
+ }));
231
+
232
+ try {
233
+ client.send({ type: 'assistant_message', message: input.message, ...(input.conversationId != null ? { conversation_id: input.conversationId } : {}) });
234
+ } catch (cause) {
235
+ finish(cause instanceof Error ? cause : new Error('Realtime Assistant send failed'));
236
+ }
237
+ return { promise, cancel: () => finish(new Error('Assistant stream cancelled')) };
238
+ }
@@ -0,0 +1,30 @@
1
+ import type { FileReference, RecordFeedId } from '../../client/record-feed';
2
+
3
+ export type AssistantScalar = string | number | boolean | null;
4
+ export type AssistantBlock =
5
+ | { type: 'table'; title?: string; columns: string[]; rows: AssistantScalar[][] }
6
+ | { type: 'chart'; title?: string; chart_type: 'line' | 'bar' | 'pie' | 'area'; labels: string[]; series: Array<{ name: string; values: number[] }> }
7
+ | { type: 'stat'; title?: string; items: Array<{ label: string; value: AssistantScalar }> }
8
+ | { type: 'list'; title?: string; items: Array<{ label: string; value: AssistantScalar }> }
9
+ | { type: 'action'; title?: string; description?: string; action_id?: string; actions: Array<{ label: string; value: string }> }
10
+ | { type: 'alert'; level: 'info' | 'success' | 'warning' | 'error'; title?: string; message: string }
11
+ | { type: 'file'; filename: string; url: string; size?: number; format?: string; row_count?: number; expires_in?: string }
12
+ | { type: 'attachment'; files: FileReference[] }
13
+ | { type: 'context'; references: Array<{ model: 'incident.Incident' | 'incident.Ticket'; pk: RecordFeedId; label?: string }> };
14
+
15
+ export interface AssistantUser { id: number; display_name: string }
16
+ export interface AssistantMessage {
17
+ id: RecordFeedId; role: 'user' | 'assistant'; content: string; created: number | string;
18
+ blocks: AssistantBlock[];
19
+ }
20
+ export interface AssistantConversation {
21
+ id: number; title: string; created: number | string; modified: number | string;
22
+ user: AssistantUser; messages: AssistantMessage[];
23
+ }
24
+ export interface AssistantConversationSummary extends Omit<AssistantConversation, 'messages'> {}
25
+ export interface AssistantSkill {
26
+ id: number; tier: 'global' | 'user' | 'group'; name: string; description: string;
27
+ auto_execute: boolean; is_active: boolean; created: number | string; modified: number | string;
28
+ user: AssistantUser | null; triggers: string[]; steps: Array<{ tool: string; description: string; condition?: string }>;
29
+ }
30
+ export interface AssistantReply { response: string; conversation_id: number; tool_calls_made: number; duration_ms: number; blocks: AssistantBlock[] }
@@ -0,0 +1,230 @@
1
+ import { useState } from 'react';
2
+ import { useQueryClient } from '@tanstack/react-query';
3
+ import {
4
+ Badge, DetailView, Eyebrow, FlatRow, ModelTable,
5
+ fmt, modal,
6
+ type Column, type FilterDef, type Tone,
7
+ } from '../../ui';
8
+ import {
9
+ BOUNCER_INCIDENT_VIEW_PERMS,
10
+ BouncerDeviceModel, BouncerIncidentModel, BouncerSignalModel,
11
+ type BouncerDeviceRow, type BouncerIncidentRow, type BouncerRiskTier,
12
+ } from './models';
13
+ import { bouncerDecisionTone, bouncerRiskTone, showBouncerSignalDetail } from './signals';
14
+ import { showIncidentDetail } from '../incidents';
15
+
16
+ function riskTierTone(tier: BouncerRiskTier): Tone {
17
+ if (tier === 'low') return 'success';
18
+ if (tier === 'medium') return 'warning';
19
+ if (tier === 'high' || tier === 'blocked') return 'danger';
20
+ return 'muted';
21
+ }
22
+
23
+ function LocalPager({ start, size, count, onStart }: {
24
+ start: number;
25
+ size: number;
26
+ count: number;
27
+ onStart: (start: number) => void;
28
+ }) {
29
+ if (count <= size) return null;
30
+ const first = count === 0 ? 0 : start + 1;
31
+ const last = Math.min(count, start + size);
32
+ return (
33
+ <div className="bouncer-local-pager">
34
+ <span className="dim">{first}–{last} of {count}</span>
35
+ <div>
36
+ <button className="btn btn-compact" disabled={start === 0} onClick={() => onStart(Math.max(0, start - size))}>
37
+ <i className="bi bi-chevron-left" /> Previous
38
+ </button>
39
+ <button className="btn btn-compact" disabled={start + size >= count} onClick={() => onStart(start + size)}>
40
+ Next <i className="bi bi-chevron-right" />
41
+ </button>
42
+ </div>
43
+ </div>
44
+ );
45
+ }
46
+
47
+ function RelatedSignals({ device }: { device: BouncerDeviceRow }) {
48
+ const size = 10;
49
+ const [start, setStart] = useState(0);
50
+ const query = BouncerSignalModel.useList({
51
+ size, start, sort: '-created', muid: device.muid,
52
+ });
53
+ const rows = query.data?.rows ?? [];
54
+ if (query.isPending) return <p className="dim">Loading related signals…</p>;
55
+ if (query.error) return <p className="text-bad">{query.error.message}</p>;
56
+ if (!rows.length) return <p className="dim-italic">No signal assessments found for this MUID.</p>;
57
+ return (
58
+ <div className="bouncer-local-table">
59
+ <table className="tbl">
60
+ <thead><tr><th>Time</th><th>IP</th><th>Decision</th><th className="align-end">Risk</th><th>Page</th></tr></thead>
61
+ <tbody>
62
+ {rows.map((row) => (
63
+ <tr key={row.id} className="row-click" onClick={() => showBouncerSignalDetail(row.id)}>
64
+ <td>{fmt.relative(row.created)}</td>
65
+ <td><code>{row.ip_address || '—'}</code></td>
66
+ <td><Badge tone={bouncerDecisionTone(row.decision)}>{row.decision.toUpperCase()}</Badge></td>
67
+ <td className="align-end"><strong className={`bouncer-risk bouncer-risk-${bouncerRiskTone(row.risk_score)}`}>{row.risk_score}</strong></td>
68
+ <td>{row.page_type || '—'}</td>
69
+ </tr>
70
+ ))}
71
+ </tbody>
72
+ </table>
73
+ <LocalPager start={start} size={size} count={query.data?.count ?? 0} onStart={setStart} />
74
+ </div>
75
+ );
76
+ }
77
+
78
+ function incidentPriorityTone(priority: number): Tone {
79
+ if (priority >= 7) return 'danger';
80
+ if (priority >= 5) return 'warning';
81
+ if (priority > 0) return 'info';
82
+ return 'muted';
83
+ }
84
+
85
+ function RelatedIncidents({ device }: { device: BouncerDeviceRow }) {
86
+ const size = 10;
87
+ const [start, setStart] = useState(0);
88
+ const query = BouncerIncidentModel.useList({
89
+ size,
90
+ start,
91
+ sort: '-created',
92
+ category__startswith: 'security:bouncer',
93
+ search: device.muid,
94
+ });
95
+ const rows: BouncerIncidentRow[] = query.data?.rows ?? [];
96
+ if (query.isPending) return <p className="dim">Loading related incidents…</p>;
97
+ if (query.error) return <p className="text-bad">{query.error.message}</p>;
98
+ if (!rows.length) return <p className="dim-italic">No Bouncer incidents are linked to this device.</p>;
99
+ return (
100
+ <div className="bouncer-local-table">
101
+ <table className="tbl">
102
+ <thead><tr><th>Created</th><th>Status</th><th>Priority</th><th>Category</th><th>Title</th></tr></thead>
103
+ <tbody>
104
+ {rows.map((row) => (
105
+ <tr key={row.id} className="row-click" onClick={() => showIncidentDetail(row.id)}>
106
+ <td>{fmt.datetime(row.created)}</td>
107
+ <td><Badge>{row.status}</Badge></td>
108
+ <td><Badge tone={incidentPriorityTone(row.priority)}>{row.priority}</Badge></td>
109
+ <td><code>{row.category}</code></td>
110
+ <td>{row.title || row.details || `Incident #${row.id}`}</td>
111
+ </tr>
112
+ ))}
113
+ </tbody>
114
+ </table>
115
+ <LocalPager start={start} size={size} count={query.data?.count ?? 0} onStart={setStart} />
116
+ </div>
117
+ );
118
+ }
119
+
120
+ export function BouncerDeviceDetail({ id, onClose }: { id: number; onClose: () => void }) {
121
+ const queryClient = useQueryClient();
122
+ const { data: device, isPending, error } = BouncerDeviceModel.useOne(id);
123
+ if (isPending) return <div className="modal-pad dim">Loading Bouncer device…</div>;
124
+ if (!device || error) return <div className="modal-pad text-bad">{error?.message ?? 'Bouncer device not found'}</div>;
125
+
126
+ return (
127
+ <DetailView
128
+ icon="bi-fingerprint"
129
+ title="Bouncer Device"
130
+ subtitle={`MUID ${device.muid}`}
131
+ chips={[
132
+ { text: device.risk_tier.toUpperCase(), tone: riskTierTone(device.risk_tier) },
133
+ { text: `${device.event_count} events`, tone: 'info' },
134
+ { text: `${device.block_count} blocks`, tone: device.block_count ? 'danger' : 'muted' },
135
+ ]}
136
+ sections={[
137
+ {
138
+ key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => (
139
+ <>
140
+ <Eyebrow>Device identity</Eyebrow>
141
+ <FlatRow label="MUID"><code>{device.muid}</code></FlatRow>
142
+ <FlatRow label="DUID"><code>{device.duid || '—'}</code></FlatRow>
143
+ <FlatRow label="MSID"><code>{device.msid || '—'}</code></FlatRow>
144
+ <FlatRow label="Fingerprint"><code>{device.fingerprint_id || '—'}</code></FlatRow>
145
+ <FlatRow label="Risk tier"><Badge tone={riskTierTone(device.risk_tier)}>{device.risk_tier.toUpperCase()}</Badge></FlatRow>
146
+ <FlatRow label="Last IP"><code>{device.last_seen_ip || '—'}</code></FlatRow>
147
+ <FlatRow label="First seen">{device.first_seen ? fmt.datetime(device.first_seen) : '—'}</FlatRow>
148
+ <FlatRow label="Last seen">{fmt.datetime(device.last_seen)}</FlatRow>
149
+ <Eyebrow>Activity</Eyebrow>
150
+ <FlatRow label="Events">{device.event_count}</FlatRow>
151
+ <FlatRow label="Blocks">{device.block_count}</FlatRow>
152
+ <FlatRow label="Linked MUIDs">
153
+ <span className="bouncer-token-list">
154
+ {device.linked_muids?.length
155
+ ? device.linked_muids.map((muid) => <code className="bouncer-code-token" key={muid}>{muid}</code>)
156
+ : <span className="dim">None</span>}
157
+ </span>
158
+ </FlatRow>
159
+ </>
160
+ ),
161
+ },
162
+ { key: 'signals', label: 'Signals', icon: 'bi-activity', render: () => <RelatedSignals device={device} /> },
163
+ {
164
+ key: 'incidents', label: 'Incidents', icon: 'bi-shield-exclamation',
165
+ permissions: BOUNCER_INCIDENT_VIEW_PERMS,
166
+ render: () => <RelatedIncidents device={device} />,
167
+ },
168
+ ]}
169
+ initialSection="overview"
170
+ contextMenu={[
171
+ {
172
+ label: 'Refresh', icon: 'bi-arrow-clockwise', onSelect: () => {
173
+ void queryClient.invalidateQueries({ queryKey: BouncerDeviceModel.keys.one(device.id) });
174
+ },
175
+ },
176
+ ]}
177
+ onClose={onClose}
178
+ />
179
+ );
180
+ }
181
+
182
+ export function showBouncerDeviceDetail(id: number): void {
183
+ void modal.detail((close) => (
184
+ <BouncerDeviceDetail id={id} onClose={() => close(null)} />
185
+ ));
186
+ }
187
+
188
+ const DEVICE_COLUMNS: Column<BouncerDeviceRow>[] = [
189
+ { key: 'muid', label: 'MUID', sortable: true, hideable: false, render: (row) => <code title={row.muid}>{fmt.truncateMiddle(row.muid, 18)}</code> },
190
+ { key: 'risk_tier', label: 'Risk Tier', sortable: true, render: (row) => <Badge tone={riskTierTone(row.risk_tier)}>{row.risk_tier.toUpperCase()}</Badge> },
191
+ { key: 'event_count', label: 'Events', sortable: true, align: 'end' },
192
+ { key: 'block_count', label: 'Blocks', sortable: true, align: 'end', render: (row) => <strong className={row.block_count ? 'text-bad' : undefined}>{row.block_count}</strong> },
193
+ { key: 'last_seen_ip', label: 'Last IP', render: (row) => <code>{row.last_seen_ip || '—'}</code> },
194
+ { key: 'last_seen', label: 'Last Seen', sortable: true, render: (row) => fmt.relative(row.last_seen) },
195
+ ];
196
+
197
+ const DEVICE_FILTERS: FilterDef[] = [
198
+ { key: 'muid', label: 'MUID', type: 'text' },
199
+ { key: 'risk_tier', label: 'Risk tier', type: 'select', options: [
200
+ { value: 'unknown', label: 'Unknown' }, { value: 'low', label: 'Low' },
201
+ { value: 'medium', label: 'Medium' }, { value: 'high', label: 'High' },
202
+ { value: 'blocked', label: 'Blocked' },
203
+ ] },
204
+ { key: 'last_seen_ip', label: 'Last IP', type: 'text' },
205
+ { key: 'last_seen', label: 'Last seen', type: 'daterange' },
206
+ ];
207
+
208
+ export function BouncerDevicesPage() {
209
+ return (
210
+ <ModelTable<BouncerDeviceRow>
211
+ model={BouncerDeviceModel}
212
+ eyebrow="Security · Bouncer"
213
+ title="Device Reputation"
214
+ searchPlaceholder="Search MUID, DUID, fingerprint, or IP"
215
+ columns={DEVICE_COLUMNS}
216
+ filters={DEVICE_FILTERS}
217
+ presets={[
218
+ { key: 'all', label: 'All', params: {} },
219
+ { key: 'high', label: 'High risk', params: { risk_tier: 'high' } },
220
+ { key: 'blocked', label: 'Blocked', params: { risk_tier: 'blocked' } },
221
+ ]}
222
+ defaultSort="-last_seen"
223
+ columnChooser
224
+ persistState
225
+ persistKey="admin:bouncer:devices"
226
+ exportFormats={['csv', 'json']}
227
+ onRowClick={(row) => showBouncerDeviceDetail(row.id)}
228
+ />
229
+ );
230
+ }
@@ -0,0 +1,34 @@
1
+ import type { AdminSection } from '../index';
2
+ import { BOUNCER_VIEW_PERMS } from './models';
3
+ import { BouncerSignalsPage } from './signals';
4
+ import { BouncerDevicesPage } from './devices';
5
+ import { BotSignaturesPage } from './signatures';
6
+
7
+ export * from './models';
8
+ export * from './signals';
9
+ export * from './devices';
10
+ export * from './signatures';
11
+
12
+ /** Mount-point-relative Bouncer contribution for standalone or embedded admin. */
13
+ export const BOUNCER_ADMIN_SECTION: AdminSection = {
14
+ id: 'bouncer',
15
+ basePath: 'security/bouncer',
16
+ title: 'Bouncer',
17
+ icon: 'bi-shield-check',
18
+ navigationGroup: 'security',
19
+ permissions: BOUNCER_VIEW_PERMS,
20
+ routes: [
21
+ {
22
+ path: 'signals', label: 'Signal Decisions',
23
+ component: BouncerSignalsPage, permissions: BOUNCER_VIEW_PERMS,
24
+ },
25
+ {
26
+ path: 'devices', label: 'Device Reputation',
27
+ component: BouncerDevicesPage, permissions: BOUNCER_VIEW_PERMS,
28
+ },
29
+ {
30
+ path: 'signatures', label: 'Bot Signatures',
31
+ component: BotSignaturesPage, permissions: BOUNCER_VIEW_PERMS,
32
+ },
33
+ ],
34
+ };