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,191 @@
1
+ // IPSetEditor — the focused create/edit dialogs, ported from web-mojo
2
+ // `admin/models/IPSet.js` (IPSetForms) + `IPSetTablePage._handleAdd`'s
3
+ // country-code transform.
4
+ //
5
+ // TWO SAFETY FIXES over the source, both forced by the backend:
6
+ //
7
+ // 1. **Records are created DISABLED, and `is_enabled` is never written as a
8
+ // plain field — here or in the edit form.** `on_action_enable` is the only
9
+ // path that runs the cache-only rejection; a plain field write produces a
10
+ // row that reads "Enabled" and silently never syncs (`sync()` no-ops for
11
+ // cache-only sets without raising). Direct #1097 lineage. The source's
12
+ // create form defaulted `is_enabled` to true and its edit form carried a
13
+ // switch — both bypassed `on_action_enable` entirely.
14
+ //
15
+ // 2. **`data` is posted as a JSON LIST, never as a string.** `IPSet` defines
16
+ // `set_data(cidr_list)` and `on_rest_save_field` prefers a `set_<key>`
17
+ // method over a plain assignment, so a posted `data` runs
18
+ // `"\n".join(value)`. A string therefore gets a newline interleaved
19
+ // between every CHARACTER and `cidr_count` becomes the character count.
20
+ // The source posted the raw textarea string.
21
+ import { formModal, type Field, type FormData } from '../../ui';
22
+ import { COUNTRY_OPTIONS, countryName } from '../../charts/worldmap/countryCentroids';
23
+ import {
24
+ IPSET_COMMON_BLOCK_COUNTRIES, IPSET_KIND_BADGE_OPTIONS, IPSET_KIND_OPTIONS,
25
+ IPSET_SOURCE_OPTIONS, parseCidrLines, type IPSetRow,
26
+ } from './models';
27
+
28
+ /**
29
+ * The FULL ISO2 list with the twenty `CommonBlockCountries` pinned on top.
30
+ * The source offered ONLY those twenty, so no other country could be blocked
31
+ * from the UI at all.
32
+ */
33
+ const COUNTRY_PICKER_OPTIONS: { value: string; label: string }[] = (() => {
34
+ const common = IPSET_COMMON_BLOCK_COUNTRIES
35
+ .filter((code) => COUNTRY_OPTIONS.some((option) => option.value === code))
36
+ .map((code) => ({ value: code, label: `${countryName(code)} — commonly blocked` }));
37
+ const rest = COUNTRY_OPTIONS.filter((option) => !IPSET_COMMON_BLOCK_COUNTRIES.includes(option.value));
38
+ return [...common, ...rest.map((option) => ({ value: option.value, label: option.label }))];
39
+ })();
40
+
41
+ const CIDR_PLACEHOLDER = '# One CIDR per line\n192.0.2.0/24\n198.51.100.0/24\n203.0.113.0/24';
42
+
43
+ export const IPSET_CREATE_FIELDS: Field[] = [
44
+ {
45
+ name: 'kind', type: 'select', label: 'What do you want to block?',
46
+ required: true, options: IPSET_KIND_OPTIONS, columns: 12,
47
+ },
48
+ {
49
+ name: 'country_code', type: 'select', label: 'Country', required: true,
50
+ options: COUNTRY_PICKER_OPTIONS, columns: 12,
51
+ showWhen: { field: 'kind', value: 'country' },
52
+ help: 'CIDRs are fetched from IPDeny. The set is named country_<code>, which is exactly what the backend derives the zone-file URL from.',
53
+ },
54
+ {
55
+ name: 'source_key', type: 'text', label: 'AbuseIPDB API key', required: true,
56
+ placeholder: 'Your AbuseIPDB API key', columns: 12,
57
+ showWhen: { field: 'kind', value: 'abuse' },
58
+ help: 'Write-only — the key is excluded from every serializer graph and can never be read back.',
59
+ },
60
+ {
61
+ name: 'source_url', type: 'text', label: 'Source URL', required: true,
62
+ placeholder: 'https://example.com/datacenter-ranges.txt', columns: 12,
63
+ showWhen: { field: 'kind', value: 'datacenter' },
64
+ help: 'A plain-text file with one CIDR per line.',
65
+ },
66
+ {
67
+ name: 'data', type: 'textarea', label: 'CIDR list', columns: 12,
68
+ placeholder: CIDR_PLACEHOLDER,
69
+ showWhen: { field: 'kind', value: 'custom' },
70
+ help: 'One range per line in CIDR notation. Blank lines and # comments are dropped before the list is sent.',
71
+ },
72
+ {
73
+ name: 'name', type: 'text', label: 'Name', required: true,
74
+ placeholder: 'abuse_ips, dc_aws…', columns: 6,
75
+ showWhen: { field: 'kind', value: 'country', negate: true },
76
+ help: 'Unique — used verbatim as the kernel ipset name.',
77
+ },
78
+ {
79
+ name: 'description', type: 'text', label: 'Description',
80
+ placeholder: 'Human-readable label', columns: 6,
81
+ showWhen: { field: 'kind', value: 'country', negate: true },
82
+ },
83
+ ];
84
+
85
+ /**
86
+ * The edit form. `kind` is disabled (changing it would orphan the source
87
+ * plumbing) and `is_enabled` is ABSENT — enabling and disabling are actions.
88
+ */
89
+ export const IPSET_EDIT_FIELDS: Field[] = [
90
+ { name: 'name', type: 'text', label: 'Name', required: true, columns: 6 },
91
+ { name: 'kind', type: 'select', label: 'Kind', options: IPSET_KIND_BADGE_OPTIONS, disabled: true, columns: 6 },
92
+ { name: 'description', type: 'text', label: 'Description', columns: 12 },
93
+ { name: 'source', type: 'select', label: 'Source', options: IPSET_SOURCE_OPTIONS, columns: 6 },
94
+ { name: 'source_url', type: 'text', label: 'Source URL', columns: 6 },
95
+ {
96
+ name: 'source_key', type: 'text', label: 'API key', columns: 12,
97
+ placeholder: 'Leave blank to keep the current key',
98
+ help: 'Write-only — the stored value is never serialized, so this box is always empty.',
99
+ },
100
+ {
101
+ name: 'data', type: 'textarea', label: 'CIDR list', columns: 12,
102
+ placeholder: CIDR_PLACEHOLDER,
103
+ showWhen: { field: 'kind', value: 'custom' },
104
+ help: 'Saved as a list, so the stored CIDR count is recomputed by the server.',
105
+ },
106
+ ];
107
+
108
+ export interface IPSetCreatePayload {
109
+ name: string;
110
+ kind: string;
111
+ source: string;
112
+ description?: string;
113
+ source_url?: string;
114
+ source_key?: string;
115
+ data?: string[];
116
+ }
117
+
118
+ /**
119
+ * Reproduce the source's kind→fields transform. Returns null when the dialog
120
+ * was cancelled or the derived country code is not the `[a-z]{2}` the backend
121
+ * requires to build an ipdeny URL.
122
+ */
123
+ export function buildIPSetCreatePayload(data: FormData): IPSetCreatePayload | null {
124
+ const kind = String(data.kind ?? '');
125
+ if (kind === 'country') {
126
+ const code = String(data.country_code ?? '').toLowerCase();
127
+ // `_fetch_ipdeny` raises unless the derived code matches [a-z]{2}.
128
+ if (!/^[a-z]{2}$/.test(code)) return null;
129
+ return {
130
+ kind,
131
+ name: `country_${code}`,
132
+ source: 'ipdeny',
133
+ description: `Country block: ${countryName(code.toUpperCase())}`,
134
+ };
135
+ }
136
+ const base: IPSetCreatePayload = {
137
+ kind,
138
+ name: String(data.name ?? '').trim(),
139
+ source: kind === 'abuse' ? 'abuseipdb' : 'manual',
140
+ description: String(data.description ?? '').trim() || undefined,
141
+ };
142
+ if (kind === 'abuse') base.source_key = String(data.source_key ?? '').trim();
143
+ if (kind === 'datacenter') base.source_url = String(data.source_url ?? '').trim();
144
+ if (kind === 'custom') base.data = parseCidrLines(String(data.data ?? ''));
145
+ return base;
146
+ }
147
+
148
+ /** Open the create dialog and return the ready-to-POST body (or null). */
149
+ export async function promptCreateIPSet(): Promise<IPSetCreatePayload | null> {
150
+ const data = await formModal({
151
+ title: 'Create IP set',
152
+ fields: IPSET_CREATE_FIELDS,
153
+ submitText: 'Create (disabled)',
154
+ intro: 'The set is created DISABLED. Enabling it is a separate, armed action — that is the only path that runs the backend’s cache-only safety check and pushes CIDRs to the fleet.',
155
+ });
156
+ if (!data) return null;
157
+ return buildIPSetCreatePayload(data);
158
+ }
159
+
160
+ /** Open the edit dialog and return the changed fields (or null). */
161
+ export async function promptEditIPSet(row: IPSetRow): Promise<Record<string, unknown> | null> {
162
+ const data = await formModal({
163
+ title: `Edit ${row.name}`,
164
+ fields: IPSET_EDIT_FIELDS,
165
+ submitText: 'Save',
166
+ initial: {
167
+ name: row.name,
168
+ kind: row.kind,
169
+ description: row.description ?? '',
170
+ source: row.source,
171
+ source_url: row.source_url ?? '',
172
+ source_key: '',
173
+ data: '',
174
+ },
175
+ });
176
+ if (!data) return null;
177
+ const changes: Record<string, unknown> = {
178
+ name: String(data.name ?? '').trim(),
179
+ description: String(data.description ?? '').trim(),
180
+ source: String(data.source ?? ''),
181
+ source_url: String(data.source_url ?? '').trim(),
182
+ };
183
+ // Write-only: an empty box means "keep the stored key", never "clear it".
184
+ const key = String(data.source_key ?? '').trim();
185
+ if (key) changes.source_key = key;
186
+ // Only send `data` when the operator actually typed a list, and always as
187
+ // an array — see the file header.
188
+ const raw = String(data.data ?? '').trim();
189
+ if (raw) changes.data = parseCidrLines(raw);
190
+ return changes;
191
+ }
@@ -0,0 +1,177 @@
1
+ // IP Sets — kernel-level firewall sets synced fleet-wide via ipset + iptables.
2
+ // Port of web-mojo `admin/security/IPSetTablePage.js`.
3
+ //
4
+ // Deliberately ABSENT: batch delete. The source offered it. Multi-selecting
5
+ // kernel firewall sets and deleting them in one action is precisely the "one
6
+ // careless action changes the fleet" shape #1097 warned about, and the
7
+ // operation is irreversible. Delete stays single-record and armed, from the
8
+ // detail modal.
9
+ import { useQueryClient } from '@tanstack/react-query';
10
+ import {
11
+ Badge, ModelTable, fmt, toast,
12
+ type BatchAction, type Column, type FilterDef,
13
+ } from '../../ui';
14
+ import { useCan } from '../../client';
15
+ import {
16
+ IPSET_CACHE_ONLY_HELP, IPSET_KIND_BADGE_OPTIONS, IPSET_MANAGE_PERMS,
17
+ IPSET_SOURCE_OPTIONS, IPSetModel, isCacheOnlyIPSet, type IPSetRow,
18
+ } from './models';
19
+ import { ipSetSourceLabel, showIPSetDetail } from './IPSetDetail';
20
+ import { promptCreateIPSet } from './IPSetEditor';
21
+
22
+ const COLUMNS: Column<IPSetRow>[] = [
23
+ {
24
+ key: 'is_enabled', label: 'Active', align: 'center', sortable: true, hideable: false,
25
+ render: (row) => row.is_enabled
26
+ ? <Badge tone="success"><i className="bi bi-check-lg" /> Enabled</Badge>
27
+ : <Badge tone="muted">Disabled</Badge>,
28
+ },
29
+ {
30
+ key: 'name', label: 'Name', sortable: true, hideable: false,
31
+ render: (row) => (
32
+ <>
33
+ <code>{row.name}</code>
34
+ {isCacheOnlyIPSet(row) && (
35
+ <span className="chip chip-warning netsec-cache-chip" title={IPSET_CACHE_ONLY_HELP}>
36
+ <i className="bi bi-database-lock" /> CACHE-ONLY
37
+ </span>
38
+ )}
39
+ </>
40
+ ),
41
+ },
42
+ {
43
+ key: 'kind', label: 'Kind', sortable: true,
44
+ render: (row) => <Badge tone="info">{IPSET_KIND_BADGE_OPTIONS.find((o) => o.value === row.kind)?.label ?? row.kind}</Badge>,
45
+ },
46
+ {
47
+ key: 'description', label: 'Description',
48
+ render: (row) => row.description
49
+ ? <span title={row.description}>{fmt.truncate(row.description, 40)}</span>
50
+ : <span className="dim">—</span>,
51
+ },
52
+ {
53
+ // Labelled honestly: the column is only recomputed when the list is
54
+ // WRITTEN (a source refresh, or saving the list from the editor).
55
+ key: 'cidr_count', label: 'CIDRs', sortable: true, align: 'end',
56
+ render: (row) => <span title="Recorded when the CIDR list was last written">{row.cidr_count.toLocaleString()}</span>,
57
+ },
58
+ {
59
+ key: 'source', label: 'Source', sortable: true,
60
+ render: (row) => ipSetSourceLabel(row.source),
61
+ },
62
+ {
63
+ key: 'last_synced', label: 'Last synced', sortable: true,
64
+ render: (row) => row.last_synced == null
65
+ ? <span className="dim">Never</span>
66
+ : <span title={fmt.datetime(row.last_synced)}>{fmt.relative(row.last_synced)}</span>,
67
+ },
68
+ {
69
+ key: 'sync_error', label: 'Status', align: 'center',
70
+ render: (row) => row.sync_error
71
+ ? <span className="text-bad" title={row.sync_error}><i className="bi bi-exclamation-triangle" /> Error</span>
72
+ : <span className="text-ok"><i className="bi bi-check-circle" /></span>,
73
+ },
74
+ ];
75
+
76
+ const FILTERS: FilterDef[] = [
77
+ { key: 'kind', label: 'Kind', type: 'select', options: IPSET_KIND_BADGE_OPTIONS },
78
+ { key: 'is_enabled', label: 'Enabled', type: 'boolean', trueLabel: 'Enabled', falseLabel: 'Disabled' },
79
+ { key: 'source', label: 'Source', type: 'select', options: IPSET_SOURCE_OPTIONS },
80
+ { key: 'name', label: 'Name contains', type: 'text' },
81
+ { key: 'description', label: 'Description contains', type: 'text' },
82
+ { key: 'sync_error__isnull', label: 'Sync health', type: 'boolean', trueLabel: 'No error', falseLabel: 'Has an error' },
83
+ { key: 'last_synced', label: 'Last synced', type: 'daterange' },
84
+ ];
85
+
86
+ export function IPSetsPage() {
87
+ const qc = useQueryClient();
88
+ const canManage = useCan(IPSET_MANAGE_PERMS).can;
89
+ const save = IPSetModel.useSave();
90
+ const enable = IPSetModel.useAction('enable');
91
+ const disable = IPSetModel.useAction('disable');
92
+ const sync = IPSetModel.useAction('sync');
93
+ const refresh = IPSetModel.useAction('refresh_source');
94
+
95
+ const onAdd = async () => {
96
+ const payload = await promptCreateIPSet();
97
+ if (!payload) return;
98
+ try {
99
+ // No `is_enabled` in the body — a set is created disabled and
100
+ // enabling runs through the `enable` action, the only path that
101
+ // performs the backend's cache-only check.
102
+ await save.mutateAsync({ id: null, changes: payload as unknown as Record<string, unknown> });
103
+ await IPSetModel.invalidate(qc);
104
+ toast.success(`${payload.name} created — disabled. Enable it when you are ready to push it to the fleet.`);
105
+ } catch (err) {
106
+ toast.error(err instanceof Error ? err.message : 'Could not create the IP set');
107
+ }
108
+ };
109
+
110
+ const batchActions: BatchAction<IPSetRow>[] = [
111
+ {
112
+ key: 'enable',
113
+ label: 'Enable',
114
+ icon: 'bi-toggle-on',
115
+ // The backend refuses cache-only sets outright; excluding them here
116
+ // means a mixed selection still succeeds for the rest.
117
+ eligible: (row) => !row.is_enabled && !isCacheOnlyIPSet(row),
118
+ confirm: 'Enable the selected sets? Their CIDRs are loaded into iptables on every fleet instance immediately.',
119
+ run: (row) => enable.mutateAsync({ id: row.id }),
120
+ },
121
+ {
122
+ key: 'disable',
123
+ label: 'Disable',
124
+ icon: 'bi-toggle-off',
125
+ eligible: (row) => row.is_enabled,
126
+ confirm: 'Disable the selected sets? Every fleet instance drops them from iptables.',
127
+ run: (row) => disable.mutateAsync({ id: row.id }),
128
+ },
129
+ {
130
+ key: 'sync',
131
+ label: 'Sync to fleet',
132
+ icon: 'bi-broadcast',
133
+ // `sync()` is a silent no-op for a disabled or cache-only set — it
134
+ // does not raise, so a "success" toast would be a lie.
135
+ eligible: (row) => row.is_enabled && !isCacheOnlyIPSet(row),
136
+ confirm: 'Push the selected sets to every fleet instance now?',
137
+ run: (row) => sync.mutateAsync({ id: row.id }),
138
+ },
139
+ {
140
+ key: 'refresh',
141
+ label: 'Refresh source',
142
+ icon: 'bi-arrow-clockwise',
143
+ // `refresh_from_source()` returns False immediately for
144
+ // source=manual — nothing is fetched and nothing changes.
145
+ eligible: (row) => row.source !== 'manual',
146
+ confirm: 'Refetch the provider list for the selected sets and replace their stored CIDRs?',
147
+ run: (row) => refresh.mutateAsync({ id: row.id }),
148
+ },
149
+ ];
150
+
151
+ return (
152
+ <ModelTable<IPSetRow>
153
+ model={IPSetModel}
154
+ eyebrow="Security · Network"
155
+ title="IP Sets"
156
+ searchPlaceholder="Search name or description"
157
+ columns={COLUMNS}
158
+ filters={FILTERS}
159
+ presets={[
160
+ { key: 'all', label: 'All', params: {} },
161
+ { key: 'enabled', label: 'Enforcing', params: { is_enabled: 'true' } },
162
+ { key: 'staged', label: 'Staged', params: { is_enabled: 'false' } },
163
+ { key: 'errors', label: 'Sync errors', params: { sync_error__isnull: 'false' } },
164
+ ]}
165
+ defaultSort="name"
166
+ selectable={canManage}
167
+ batchActions={canManage ? batchActions : []}
168
+ columnChooser
169
+ persistState
170
+ persistKey="admin:network:ip-sets"
171
+ exportFormats={['csv', 'json']}
172
+ rowTone={(row) => row.sync_error ? 'danger' : isCacheOnlyIPSet(row) ? 'warning' : null}
173
+ onRowClick={(row) => showIPSetDetail(row.id)}
174
+ {...(canManage ? { addLabel: 'Create IP set', onAdd: () => void onAdd() } : {})}
175
+ />
176
+ );
177
+ }
@@ -0,0 +1,289 @@
1
+ // BlocksTab — the evidence surface. Port of web-mojo `GeofenceBlocksView.js`.
2
+ //
3
+ // Activity strip: the two KPI tiles the backend really records
4
+ // (`geofence:blocks`, `geofence:exempt`, both dual-written to `global` and
5
+ // `group-<pk>`), a chart over both with PNG export for compliance evidence,
6
+ // and a server-derived "top blocked countries" list from the
7
+ // `geofence:blocks:country:*` slug family. Every metrics read is gated on
8
+ // METRICS_GLOBAL_VIEW_PERMS — `check_view_permissions` demands a GLOBAL
9
+ // view_metrics/metrics grant for `account="global"`, so a geofence-only
10
+ // operator would otherwise fire three requests that all 403.
11
+ //
12
+ // TWO CORRECTIONS over the source:
13
+ // · the table offered ONLY `geofence_block` while the second KPI tile
14
+ // counted exemptions, so the table could never explain the tile. A
15
+ // category segment now covers Blocks · Exemptions used · Both.
16
+ // · the Endpoint column read `metadata.scope`, which the reporter never
17
+ // writes (`scope` is the top-level Event column and stays "global" for
18
+ // every geofence row). It is `metadata.geofence_scope`, and the column
19
+ // rendered "—" on every deployment until now.
20
+ import { useRef, useState } from 'react';
21
+ import {
22
+ Badge, ModelTable, fmt, groupByDay,
23
+ type Column, type FilterDef, type Tone,
24
+ } from '../../../ui';
25
+ import { useCan } from '../../../client';
26
+ import { MetricsChart, MetricsMiniWidget, exportChartPng } from '../../../charts';
27
+ import { COUNTRY_OPTIONS, countryName } from '../../../charts/worldmap/countryCentroids';
28
+ import { countryFlag } from '../../security/geoip';
29
+ import { showEventDetail, type EventRow } from '../../incidents';
30
+ import {
31
+ GEOFENCE_EVENT_CATEGORIES, GEOFENCE_LEVEL_OPTIONS, GeofenceEventModel,
32
+ METRICS_GLOBAL_VIEW_PERMS, useGeofenceCountryTotals,
33
+ } from '../models';
34
+ import { SECURITY_EVENTS_PERMS, describeDecision, regionName } from './geofence-data';
35
+
36
+ const SLUG_BLOCKS = 'geofence:blocks';
37
+ const SLUG_EXEMPT = 'geofence:exempt';
38
+
39
+ function metaString(row: EventRow, key: string): string {
40
+ const value = row.metadata?.[key];
41
+ return typeof value === 'string' ? value : value == null ? '' : String(value);
42
+ }
43
+
44
+ /**
45
+ * Plain-language cell text for a block event's reason code. `lookup_failed`
46
+ * gets its own copy: the log genuinely cannot distinguish a fail-open
47
+ * pass-through (level 6) from a fail-closed block (level 5) — the level column
48
+ * carries that, and pretending otherwise here would be a guess.
49
+ */
50
+ export function blockReasonText(reason: string): string {
51
+ if (!reason) return '—';
52
+ if (reason === 'lookup_failed') return 'Location lookups were unavailable — the fail posture applied.';
53
+ return describeDecision({ reason, allowed: false });
54
+ }
55
+
56
+ const COLUMNS: Column<EventRow>[] = [
57
+ {
58
+ key: 'created', label: 'When', sortable: true, hideable: false,
59
+ render: (row) => <span title={fmt.datetime(row.created)}>{fmt.relative(row.created)}</span>,
60
+ },
61
+ {
62
+ key: 'category', label: 'Kind',
63
+ render: (row) => row.category === GEOFENCE_EVENT_CATEGORIES.exempt
64
+ ? <Badge tone="info"><i className="bi bi-shield-check" /> Exempt</Badge>
65
+ : <Badge tone="danger"><i className="bi bi-slash-circle" /> Block</Badge>,
66
+ },
67
+ {
68
+ key: 'metadata__reason', label: 'What happened', hideable: false,
69
+ render: (row) => {
70
+ const text = blockReasonText(metaString(row, 'reason'));
71
+ return <span title={text}>{fmt.truncate(text, 72)}</span>;
72
+ },
73
+ },
74
+ {
75
+ // The INDEXED top-level column, populated by Event.sync_metadata from
76
+ // the geolocated source IP — not the metadata copy.
77
+ key: 'country_code', label: 'Country',
78
+ render: (row) => {
79
+ const code = row.country_code || metaString(row, 'country_code');
80
+ if (!code) return <span className="dim">—</span>;
81
+ const flag = countryFlag(code);
82
+ return <span title={countryName(code)}>{flag && <span className="geoip-flag">{flag}</span>}{code}</span>;
83
+ },
84
+ },
85
+ {
86
+ key: 'metadata__region_code', label: 'Region',
87
+ render: (row) => {
88
+ const code = metaString(row, 'region_code');
89
+ return code ? regionName(code) : <span className="dim">—</span>;
90
+ },
91
+ },
92
+ {
93
+ key: 'source_ip', label: 'Source IP',
94
+ render: (row) => row.source_ip ? <code>{row.source_ip}</code> : <span className="dim">—</span>,
95
+ },
96
+ {
97
+ key: 'metadata__geofence_scope', label: 'Endpoint scope',
98
+ render: (row) => {
99
+ const scope = metaString(row, 'geofence_scope');
100
+ return scope ? <code className="dim">{scope}</code> : <span className="dim">—</span>;
101
+ },
102
+ },
103
+ {
104
+ key: 'level', label: 'Level', sortable: true, align: 'center',
105
+ render: (row) => <Badge tone={levelTone(row.level)}>L{row.level}</Badge>,
106
+ },
107
+ ];
108
+
109
+ function levelTone(level: number): Tone {
110
+ return level >= 7 ? 'danger' : level >= 5 ? 'warning' : 'muted';
111
+ }
112
+
113
+ const FILTERS: FilterDef[] = [
114
+ { key: 'created', label: 'When', type: 'daterange' },
115
+ { key: 'level', label: 'Level', type: 'select', options: GEOFENCE_LEVEL_OPTIONS },
116
+ { key: 'country_code', label: 'Country', type: 'select', options: [...COUNTRY_OPTIONS] },
117
+ { key: 'source_ip', label: 'Source IP contains', type: 'text' },
118
+ { key: 'metadata__geofence_scope', label: 'Endpoint scope', type: 'text', lookup: 'exact' },
119
+ { key: 'metadata__reason', label: 'Reason code', type: 'text', lookup: 'exact' },
120
+ ];
121
+
122
+ function TopCountries({ days }: { days: number }) {
123
+ const query = useGeofenceCountryTotals(days);
124
+ if (query.isPending) return <p className="dim">Loading country totals…</p>;
125
+ if (query.error) return <p className="text-bad">{query.error.message}</p>;
126
+ const rows = query.data ?? [];
127
+ if (rows.length === 0) return <p className="dim">No per-country block counters recorded in this window.</p>;
128
+ const max = rows[0]!.total || 1;
129
+ return (
130
+ <ol className="geo-country-bars">
131
+ {rows.slice(0, 12).map((row) => (
132
+ <li key={row.country_code}>
133
+ <span className="geo-country-label">
134
+ {countryFlag(row.country_code) && <span className="geoip-flag">{countryFlag(row.country_code)}</span>}
135
+ {countryName(row.country_code)}
136
+ </span>
137
+ <span className="geo-country-bar">
138
+ <span className="geo-country-fill" style={{ width: `${Math.max(3, (row.total / max) * 100)}%` }} />
139
+ </span>
140
+ <b>{row.total.toLocaleString()}</b>
141
+ </li>
142
+ ))}
143
+ </ol>
144
+ );
145
+ }
146
+
147
+ export function BlocksTab() {
148
+ const { can: canMetrics } = useCan(METRICS_GLOBAL_VIEW_PERMS);
149
+ const { can: canEvents } = useCan(SECURITY_EVENTS_PERMS);
150
+ const chartRef = useRef<HTMLDivElement>(null);
151
+ const [category, setCategory] = useState<'block' | 'exempt' | 'both'>('block');
152
+
153
+ const categoryParams: Record<string, string> = category === 'both'
154
+ ? { category__in: `${GEOFENCE_EVENT_CATEGORIES.block},${GEOFENCE_EVENT_CATEGORIES.exempt}` }
155
+ : { category: GEOFENCE_EVENT_CATEGORIES[category] };
156
+
157
+ return (
158
+ <>
159
+ {canMetrics ? (
160
+ <>
161
+ <div className="geo-kpis">
162
+ <MetricsMiniWidget
163
+ title="Geofence blocks"
164
+ icon="bi bi-slash-circle"
165
+ slugs={[SLUG_BLOCKS]}
166
+ account="global"
167
+ granularity="days"
168
+ defaultRange="30d"
169
+ chartType="bar"
170
+ tone="bad"
171
+ height={72}
172
+ showTrending
173
+ subtitle={(ctx) => <><b>{ctx.total.toLocaleString()}</b> in 30d</>}
174
+ />
175
+ <MetricsMiniWidget
176
+ title="Exempt passes"
177
+ icon="bi bi-shield-check"
178
+ slugs={[SLUG_EXEMPT]}
179
+ account="global"
180
+ granularity="days"
181
+ defaultRange="30d"
182
+ chartType="bar"
183
+ tone="info"
184
+ height={72}
185
+ showTrending
186
+ subtitle={(ctx) => <><b>{ctx.total.toLocaleString()}</b> in 30d</>}
187
+ />
188
+ </div>
189
+
190
+ <div className="geo-chart-head">
191
+ <span className="dim">
192
+ Counters increment on every block, including the repeats that are deduped
193
+ in the log below — so the tiles will read higher than the row count.
194
+ </span>
195
+ <button
196
+ type="button"
197
+ className="btn btn-compact"
198
+ onClick={() => exportChartPng(chartRef.current, { filename: 'geofence-blocks.png' })}
199
+ >
200
+ <i className="bi bi-download" /> Export PNG
201
+ </button>
202
+ </div>
203
+ <div ref={chartRef} className="geo-chart">
204
+ <MetricsChart
205
+ title="Blocks over time"
206
+ slugs={[SLUG_BLOCKS, SLUG_EXEMPT]}
207
+ seriesLabels={{ [SLUG_BLOCKS]: 'Blocks', [SLUG_EXEMPT]: 'Exempt passes' }}
208
+ account="global"
209
+ defaultRange="30d"
210
+ defaultGranularity="days"
211
+ defaultType="bar"
212
+ height={220}
213
+ />
214
+ </div>
215
+
216
+ <div className="panel netsec-card">
217
+ <div className="netsec-card-head">
218
+ <span>Top blocked countries</span>
219
+ <span className="eyebrow">server-derived · 30 days</span>
220
+ </div>
221
+ <div className="netsec-card-body">
222
+ <TopCountries days={30} />
223
+ <p className="dim">
224
+ Totals come from the per-country metric counters, not from the rows below —
225
+ a page of events could never account for a whole window.
226
+ </p>
227
+ </div>
228
+ </div>
229
+ </>
230
+ ) : (
231
+ <div className="netsec-note netsec-note-info">
232
+ <i className="bi bi-bar-chart" />
233
+ <div>
234
+ Block counters and the country breakdown read global metrics, which need the
235
+ <code> view_metrics</code> or <code>metrics</code> grant. No request was issued.
236
+ </div>
237
+ </div>
238
+ )}
239
+
240
+ {canEvents ? (
241
+ <>
242
+ <div className="geo-cat-seg">
243
+ <div className="seg">
244
+ {([
245
+ ['block', 'Blocks'],
246
+ ['exempt', 'Exemptions used'],
247
+ ['both', 'Both'],
248
+ ] as const).map(([value, label]) => (
249
+ <button
250
+ key={value}
251
+ type="button"
252
+ className={`seg-btn${category === value ? ' seg-active' : ''}`}
253
+ onClick={() => setCategory(value)}
254
+ >
255
+ {label}
256
+ </button>
257
+ ))}
258
+ </div>
259
+ </div>
260
+ <ModelTable<EventRow>
261
+ key={category}
262
+ model={GeofenceEventModel}
263
+ eyebrow="Geofencing"
264
+ title="Blocks log"
265
+ searchable={false}
266
+ columns={COLUMNS}
267
+ filters={FILTERS}
268
+ defaultParams={categoryParams}
269
+ defaultSort="-created"
270
+ columnChooser
271
+ persistState
272
+ persistKey={`admin:network:geofence-blocks:${category}`}
273
+ rowTone={(row) => row.level >= 7 ? 'danger' : row.level >= 5 ? 'warning' : null}
274
+ onRowClick={(row) => showEventDetail(row.id)}
275
+ {...groupByDay<EventRow>('created')}
276
+ />
277
+ </>
278
+ ) : (
279
+ <div className="netsec-note netsec-note-info">
280
+ <i className="bi bi-eye-slash" />
281
+ <div>
282
+ The block log reads security events — it requires security-events access
283
+ (<code>view_security</code>). No request was issued.
284
+ </div>
285
+ </div>
286
+ )}
287
+ </>
288
+ );
289
+ }