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.
- package/LICENSE +201 -0
- package/README.md +55 -0
- package/docs/DetailView.md +173 -0
- package/docs/ModelTable.md +128 -0
- package/docs/README.md +98 -0
- package/docs/admin-assistant.md +72 -0
- package/docs/admin-bouncer.md +120 -0
- package/docs/admin-cloudwatch.md +38 -0
- package/docs/admin-credentials.md +116 -0
- package/docs/admin-dashboard.md +15 -0
- package/docs/admin-devices-geoip.md +284 -0
- package/docs/admin-dns-certificates.md +122 -0
- package/docs/admin-dns-records.md +65 -0
- package/docs/admin-dns-registrar.md +33 -0
- package/docs/admin-dns.md +80 -0
- package/docs/admin-identity-users.md +103 -0
- package/docs/admin-incidents.md +71 -0
- package/docs/admin-jobs.md +426 -0
- package/docs/admin-members.md +112 -0
- package/docs/admin-messaging.md +56 -0
- package/docs/admin-metrics-explorer.md +63 -0
- package/docs/admin-monitoring.md +99 -0
- package/docs/admin-network-security.md +393 -0
- package/docs/admin-phonehub.md +27 -0
- package/docs/admin-push.md +36 -0
- package/docs/admin-rules.md +87 -0
- package/docs/admin-security-tickets.md +40 -0
- package/docs/admin-settings.md +78 -0
- package/docs/admin-shortlinks.md +32 -0
- package/docs/admin-storage.md +141 -0
- package/docs/auth-pages.md +212 -0
- package/docs/auth.md +58 -0
- package/docs/calendar.md +122 -0
- package/docs/charts.md +303 -0
- package/docs/client.md +206 -0
- package/docs/collection-multiselect.md +117 -0
- package/docs/collection-select.md +132 -0
- package/docs/combobox.md +105 -0
- package/docs/dataview.md +226 -0
- package/docs/datepicker.md +151 -0
- package/docs/daterange.md +170 -0
- package/docs/datetimepicker.md +249 -0
- package/docs/defineModel.md +72 -0
- package/docs/detail-primitives.md +174 -0
- package/docs/feedback.md +85 -0
- package/docs/file-upload-ux.md +146 -0
- package/docs/forms.md +435 -0
- package/docs/grouping-and-fmt.md +98 -0
- package/docs/idioms.md +112 -0
- package/docs/image-editor.md +101 -0
- package/docs/loading.md +242 -0
- package/docs/location-address.md +76 -0
- package/docs/markdown.md +203 -0
- package/docs/menus-and-access.md +89 -0
- package/docs/multiselect-dropdown.md +153 -0
- package/docs/params.md +73 -0
- package/docs/popover.md +130 -0
- package/docs/realtime.md +61 -0
- package/docs/record-feed.md +238 -0
- package/docs/right-panel.md +42 -0
- package/docs/tabs-and-form-wizard.md +60 -0
- package/docs/taginput.md +120 -0
- package/docs/timepicker.md +173 -0
- package/docs/timezone-select.md +147 -0
- package/docs/worldmap.md +302 -0
- package/package.json +55 -0
- package/src/admin/assistant/AssistantFeed.tsx +29 -0
- package/src/admin/assistant/AssistantPanel.tsx +65 -0
- package/src/admin/assistant/api.ts +17 -0
- package/src/admin/assistant/data.ts +63 -0
- package/src/admin/assistant/index.ts +21 -0
- package/src/admin/assistant/launchers.tsx +33 -0
- package/src/admin/assistant/pages.tsx +78 -0
- package/src/admin/assistant/streaming.ts +238 -0
- package/src/admin/assistant/types.ts +30 -0
- package/src/admin/bouncer/devices.tsx +230 -0
- package/src/admin/bouncer/index.ts +34 -0
- package/src/admin/bouncer/models.ts +199 -0
- package/src/admin/bouncer/signals.tsx +182 -0
- package/src/admin/bouncer/signatures.tsx +220 -0
- package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
- package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
- package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
- package/src/admin/cloudwatch/data.ts +163 -0
- package/src/admin/cloudwatch/index.ts +16 -0
- package/src/admin/credentials/group-api-keys.tsx +433 -0
- package/src/admin/credentials/index.ts +4 -0
- package/src/admin/credentials/models.ts +244 -0
- package/src/admin/credentials/secret-dialog.tsx +77 -0
- package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
- package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
- package/src/admin/dashboard/data.ts +60 -0
- package/src/admin/dashboard/index.ts +2 -0
- package/src/admin/dns/CertificateDetail.tsx +115 -0
- package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
- package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
- package/src/admin/dns/CertificatesPage.tsx +85 -0
- package/src/admin/dns/DnsRecordEditor.tsx +110 -0
- package/src/admin/dns/DnsRecordsPage.tsx +36 -0
- package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
- package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
- package/src/admin/dns/DomainDetail.tsx +41 -0
- package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
- package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
- package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
- package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
- package/src/admin/dns/DomainsPage.tsx +27 -0
- package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
- package/src/admin/dns/RegistrantContactPage.tsx +42 -0
- package/src/admin/dns/api.ts +515 -0
- package/src/admin/dns/certificate-data.ts +221 -0
- package/src/admin/dns/data.ts +32 -0
- package/src/admin/dns/dns-data.ts +266 -0
- package/src/admin/dns/dns-integration.ts +24 -0
- package/src/admin/dns/domain-links.ts +37 -0
- package/src/admin/dns/index.ts +86 -0
- package/src/admin/dns/models.ts +418 -0
- package/src/admin/dns/purchase-data.ts +67 -0
- package/src/admin/dns/registrant-data.ts +52 -0
- package/src/admin/identity/index.ts +5 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
- package/src/admin/identity/members/MemberDetail.tsx +226 -0
- package/src/admin/identity/members/MembersPage.tsx +144 -0
- package/src/admin/identity/members/index.ts +24 -0
- package/src/admin/identity/members/member-flows.tsx +132 -0
- package/src/admin/identity/members/models.ts +204 -0
- package/src/admin/identity/users/UserDetail.tsx +257 -0
- package/src/admin/identity/users/UsersPage.tsx +163 -0
- package/src/admin/identity/users/index.ts +27 -0
- package/src/admin/identity/users/models.ts +232 -0
- package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
- package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
- package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
- package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
- package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
- package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
- package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
- package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
- package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
- package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
- package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
- package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
- package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
- package/src/admin/identity/users/sections/actions.tsx +752 -0
- package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
- package/src/admin/identity/users/sections/queries.ts +74 -0
- package/src/admin/identity/users/sections/shared.tsx +299 -0
- package/src/admin/incidents/EventDetail.tsx +43 -0
- package/src/admin/incidents/EventsPage.tsx +52 -0
- package/src/admin/incidents/IncidentDetail.tsx +68 -0
- package/src/admin/incidents/IncidentsPage.tsx +102 -0
- package/src/admin/incidents/forensics.tsx +73 -0
- package/src/admin/incidents/index.ts +16 -0
- package/src/admin/incidents/models.ts +113 -0
- package/src/admin/incidents/sanitize.ts +71 -0
- package/src/admin/index.ts +383 -0
- package/src/admin/jobs/JobDashboardPage.tsx +111 -0
- package/src/admin/jobs/JobDetail.tsx +721 -0
- package/src/admin/jobs/JobRunnersPage.tsx +216 -0
- package/src/admin/jobs/JobsTablePage.tsx +122 -0
- package/src/admin/jobs/RunnerDetail.tsx +576 -0
- package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
- package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
- package/src/admin/jobs/columns.tsx +297 -0
- package/src/admin/jobs/control.ts +488 -0
- package/src/admin/jobs/index.ts +85 -0
- package/src/admin/jobs/models.ts +599 -0
- package/src/admin/jobs/queries.ts +272 -0
- package/src/admin/jobs/scheduled-task-form.tsx +493 -0
- package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
- package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
- package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
- package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
- package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
- package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
- package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
- package/src/admin/messaging/MailboxesPage.tsx +15 -0
- package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
- package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
- package/src/admin/messaging/SentMessagesPage.tsx +11 -0
- package/src/admin/messaging/api.ts +17 -0
- package/src/admin/messaging/data.ts +10 -0
- package/src/admin/messaging/index.ts +23 -0
- package/src/admin/messaging/models.ts +76 -0
- package/src/admin/messaging/push/PushPage.tsx +44 -0
- package/src/admin/messaging/push/api.ts +17 -0
- package/src/admin/messaging/push/index.ts +9 -0
- package/src/admin/messaging/push/models.ts +43 -0
- package/src/admin/messaging/sanitize.ts +44 -0
- package/src/admin/monitoring/LogInspector.tsx +160 -0
- package/src/admin/monitoring/LogsPage.tsx +135 -0
- package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
- package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
- package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
- package/src/admin/monitoring/index.ts +55 -0
- package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
- package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
- package/src/admin/monitoring/models.ts +155 -0
- package/src/admin/network/BlockedIPsPage.tsx +194 -0
- package/src/admin/network/BlockedIpDetail.tsx +262 -0
- package/src/admin/network/FirewallLogPage.tsx +115 -0
- package/src/admin/network/IPSetDetail.tsx +280 -0
- package/src/admin/network/IPSetEditor.tsx +191 -0
- package/src/admin/network/IPSetsPage.tsx +177 -0
- package/src/admin/network/geofence/BlocksTab.tsx +289 -0
- package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
- package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
- package/src/admin/network/geofence/PostureHeader.tsx +184 -0
- package/src/admin/network/geofence/RuleEditor.tsx +176 -0
- package/src/admin/network/geofence/RulesTab.tsx +254 -0
- package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
- package/src/admin/network/geofence/geofence-data.ts +674 -0
- package/src/admin/network/index.ts +68 -0
- package/src/admin/network/models.ts +607 -0
- package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
- package/src/admin/phonehub/api.ts +47 -0
- package/src/admin/phonehub/data.ts +6 -0
- package/src/admin/phonehub/index.ts +10 -0
- package/src/admin/phonehub/models.ts +68 -0
- package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
- package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
- package/src/admin/rules/RuleSetsPage.tsx +33 -0
- package/src/admin/rules/editors.tsx +54 -0
- package/src/admin/rules/handler-dsl.ts +213 -0
- package/src/admin/rules/index.ts +13 -0
- package/src/admin/rules/models.ts +112 -0
- package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
- package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
- package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
- package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
- package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
- package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
- package/src/admin/security/devices/index.ts +40 -0
- package/src/admin/security/devices/models.ts +470 -0
- package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
- package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
- package/src/admin/security/geoip/geoip-forms.ts +102 -0
- package/src/admin/security/geoip/index.ts +27 -0
- package/src/admin/security/geoip/models.ts +397 -0
- package/src/admin/security/index.ts +31 -0
- package/src/admin/security/models.ts +137 -0
- package/src/admin/security/tickets.tsx +562 -0
- package/src/admin/security-permissions.ts +5 -0
- package/src/admin/settings/SettingDetail.tsx +57 -0
- package/src/admin/settings/SettingEditor.tsx +157 -0
- package/src/admin/settings/SettingsPage.tsx +72 -0
- package/src/admin/settings/index.ts +19 -0
- package/src/admin/settings/model.ts +143 -0
- package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
- package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
- package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
- package/src/admin/shortlinks/index.ts +18 -0
- package/src/admin/shortlinks/models.ts +246 -0
- package/src/admin/storage/BackendsPage.tsx +43 -0
- package/src/admin/storage/BucketDetail.tsx +77 -0
- package/src/admin/storage/BucketsPage.tsx +64 -0
- package/src/admin/storage/FileManagerDetail.tsx +36 -0
- package/src/admin/storage/FilePreview.tsx +54 -0
- package/src/admin/storage/FileUploadSurface.tsx +250 -0
- package/src/admin/storage/FileView.tsx +109 -0
- package/src/admin/storage/FilesPage.tsx +53 -0
- package/src/admin/storage/file-renditions.ts +99 -0
- package/src/admin/storage/index.ts +31 -0
- package/src/admin/storage/models.ts +585 -0
- package/src/admin/storage/storage-dialogs.tsx +80 -0
- package/src/charts/CircularProgress.tsx +281 -0
- package/src/charts/KPIStrip.tsx +162 -0
- package/src/charts/KPITile.tsx +125 -0
- package/src/charts/MetricsChart.tsx +459 -0
- package/src/charts/MetricsMiniWidget.tsx +588 -0
- package/src/charts/MiniChart.tsx +283 -0
- package/src/charts/PieChart.tsx +354 -0
- package/src/charts/SeriesChart.tsx +378 -0
- package/src/charts/chart-dialogs.tsx +149 -0
- package/src/charts/exportChart.ts +144 -0
- package/src/charts/index.ts +28 -0
- package/src/charts/pie-math.ts +127 -0
- package/src/charts/stats.ts +265 -0
- package/src/charts/worldmap/WorldMap.tsx +649 -0
- package/src/charts/worldmap/countryCentroids.ts +304 -0
- package/src/charts/worldmap/geo.ts +293 -0
- package/src/charts/worldmap/use-world-land.ts +47 -0
- package/src/charts/worldmap/world-land.ts +18 -0
- package/src/charts/worldmap/worldmap-data.ts +285 -0
- package/src/client/auth.ts +737 -0
- package/src/client/client.ts +268 -0
- package/src/client/duid.ts +31 -0
- package/src/client/errors.ts +33 -0
- package/src/client/group-context.ts +28 -0
- package/src/client/group.tsx +126 -0
- package/src/client/hooks.ts +50 -0
- package/src/client/index.ts +37 -0
- package/src/client/jwt.ts +75 -0
- package/src/client/location.ts +285 -0
- package/src/client/lookups.ts +41 -0
- package/src/client/markdown-fallback.tsx +115 -0
- package/src/client/markdown-parse.ts +386 -0
- package/src/client/markdown-sanitize.ts +217 -0
- package/src/client/markdown.ts +122 -0
- package/src/client/me.ts +188 -0
- package/src/client/mock.ts +9984 -0
- package/src/client/model.ts +227 -0
- package/src/client/params.ts +292 -0
- package/src/client/realtime-mock.ts +221 -0
- package/src/client/realtime.ts +561 -0
- package/src/client/record-feed.ts +329 -0
- package/src/client/safe-export.ts +71 -0
- package/src/client/types.ts +278 -0
- package/src/client/upload.ts +421 -0
- package/src/ui/AddressField.tsx +273 -0
- package/src/ui/AttachmentQueue.tsx +92 -0
- package/src/ui/CollectionMultiSelect.tsx +622 -0
- package/src/ui/CollectionSelect.tsx +517 -0
- package/src/ui/ComboBox.tsx +336 -0
- package/src/ui/DetailView.tsx +438 -0
- package/src/ui/FileDrop.tsx +194 -0
- package/src/ui/FileField.tsx +369 -0
- package/src/ui/FilterBar.tsx +269 -0
- package/src/ui/FormFields.tsx +77 -0
- package/src/ui/FormView.tsx +360 -0
- package/src/ui/FormWizard.tsx +336 -0
- package/src/ui/GroupSwitcher.tsx +202 -0
- package/src/ui/Guarded.tsx +16 -0
- package/src/ui/MarkdownView.tsx +89 -0
- package/src/ui/ModelTable.tsx +848 -0
- package/src/ui/MultiSelectDropdown.tsx +390 -0
- package/src/ui/Popover.tsx +204 -0
- package/src/ui/RecordFeed.tsx +627 -0
- package/src/ui/RequiresGroup.tsx +32 -0
- package/src/ui/RightPanel.tsx +107 -0
- package/src/ui/SidebarNav.tsx +330 -0
- package/src/ui/Tabs.tsx +161 -0
- package/src/ui/TagInput.tsx +381 -0
- package/src/ui/ThemeProvider.tsx +52 -0
- package/src/ui/UploadQueue.tsx +603 -0
- package/src/ui/armed-button.tsx +91 -0
- package/src/ui/date/Calendar.tsx +498 -0
- package/src/ui/date/DatePicker.tsx +289 -0
- package/src/ui/date/DateRangePicker.tsx +280 -0
- package/src/ui/date/DateTimePicker.tsx +587 -0
- package/src/ui/date/PresetRail.tsx +165 -0
- package/src/ui/date/TimePicker.tsx +624 -0
- package/src/ui/date/TimezoneSelect.tsx +267 -0
- package/src/ui/date/fns.ts +679 -0
- package/src/ui/detail/DataView.tsx +692 -0
- package/src/ui/detail/FlowStrip.tsx +80 -0
- package/src/ui/detail/KnownFieldsCard.tsx +121 -0
- package/src/ui/detail/MetadataSection.tsx +219 -0
- package/src/ui/detail/StackTraceView.tsx +150 -0
- package/src/ui/detail/StatusPanel.tsx +73 -0
- package/src/ui/detail/Timeline.tsx +52 -0
- package/src/ui/detail/index.ts +12 -0
- package/src/ui/field-registry.tsx +428 -0
- package/src/ui/field-wire.ts +322 -0
- package/src/ui/form-autosave.ts +583 -0
- package/src/ui/format.ts +444 -0
- package/src/ui/grouping.ts +150 -0
- package/src/ui/image-editor/ImageEditor.tsx +484 -0
- package/src/ui/image-editor/canvas.ts +254 -0
- package/src/ui/image-editor/index.ts +3 -0
- package/src/ui/image-editor/math.ts +416 -0
- package/src/ui/index.ts +58 -0
- package/src/ui/loading.tsx +604 -0
- package/src/ui/menu-registry.ts +213 -0
- package/src/ui/modal.tsx +297 -0
- package/src/ui/password.tsx +302 -0
- package/src/ui/schema-form-core.tsx +315 -0
- package/src/ui/toast.tsx +219 -0
- 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
|
+
}
|