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,262 @@
|
|
|
1
|
+
// BlockedIpDetail — the KISS `modal.detail` for one enforcement record.
|
|
2
|
+
//
|
|
3
|
+
// Deliberately NOT the full IP dossier: #1291 owns that
|
|
4
|
+
// (`showGeoIpDossier`), and this modal links to it rather than forking it.
|
|
5
|
+
// What lives here is the perimeter story — why this address is blocked, until
|
|
6
|
+
// when, and the four relaxing/refreshing actions an operator reaches for from
|
|
7
|
+
// the Blocked IPs table.
|
|
8
|
+
//
|
|
9
|
+
// Creating a block is deliberately absent everywhere in this module: an admin
|
|
10
|
+
// table that turns a typed IP into a fleet-wide iptables entry in two clicks
|
|
11
|
+
// is the wrong default. Unblock and whitelist — both RELAXING — are exposed.
|
|
12
|
+
import {
|
|
13
|
+
ArmedButton, Badge, DetailView, Eyebrow, FlatRow, StatusPanel,
|
|
14
|
+
fmt, formModal, modal, toast, type Field, type Tone,
|
|
15
|
+
} from '../../ui';
|
|
16
|
+
import { useCan } from '../../client';
|
|
17
|
+
import { countryName } from '../../charts/worldmap/countryCentroids';
|
|
18
|
+
import {
|
|
19
|
+
GEOIP_MANAGE_PERMS, GeoLocatedIPModel, blockActive, blockExpired,
|
|
20
|
+
countryFlag, riskScoreOf, showGeoIpDossier, threatTone, whitelistActive, whitelistExpired,
|
|
21
|
+
type GeoLocatedIPRow, type GeoTone,
|
|
22
|
+
} from '../security/geoip';
|
|
23
|
+
|
|
24
|
+
const DASH = <span className="dim-italic">—</span>;
|
|
25
|
+
|
|
26
|
+
/** `blocked_until`/`whitelisted_until` null means PERMANENT, never "unknown". */
|
|
27
|
+
function untilText(value: number | null | undefined): string {
|
|
28
|
+
return value == null ? 'Never' : fmt.datetime(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* #1291 ships `GEOIP_WHITELIST_FIELDS` with a `ttl` SELECT. This module needs a
|
|
33
|
+
* precise expiry instead: `on_action_whitelist`'s dict branch parses `until`
|
|
34
|
+
* through `dates.parse_datetime`, and an exemption an auditor has to justify
|
|
35
|
+
* wants a date, not "30 days from whenever the button was pressed". Hence a
|
|
36
|
+
* second, differently-named field set rather than a redefinition of theirs —
|
|
37
|
+
* `GEOIP_UNBLOCK_FIELDS` IS imported from them unchanged.
|
|
38
|
+
*
|
|
39
|
+
* `until` is serialized as an ISO string (`outputFormat: 'iso'`), not the field
|
|
40
|
+
* registry's default epoch seconds, because `parse_datetime` is what reads it.
|
|
41
|
+
*/
|
|
42
|
+
export const NETWORK_WHITELIST_FIELDS: Field[] = [
|
|
43
|
+
{
|
|
44
|
+
name: 'reason', type: 'text', label: 'Reason', required: true, columns: 12,
|
|
45
|
+
placeholder: 'Office egress — Seattle HQ',
|
|
46
|
+
help: 'Recorded on the record and in the firewall log.',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'until', type: 'datetimepicker', label: 'Expires (optional)', columns: 12,
|
|
50
|
+
outputFormat: 'iso',
|
|
51
|
+
help: 'Leave empty for a permanent exemption.',
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
export function enforcementBadge(row: GeoLocatedIPRow) {
|
|
56
|
+
if (blockActive(row)) return <Badge tone="danger"><i className="bi bi-slash-circle" /> Blocked</Badge>;
|
|
57
|
+
if (whitelistActive(row)) return <Badge tone="info"><i className="bi bi-shield-check" /> Whitelisted</Badge>;
|
|
58
|
+
if (blockExpired(row)) return <Badge tone="warning">Block expired</Badge>;
|
|
59
|
+
if (whitelistExpired(row)) return <Badge tone="warning">Whitelist expired</Badge>;
|
|
60
|
+
return <span className="dim">—</span>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function BlockedIpDetail({ id, onClose }: { id: number; onClose: () => void }) {
|
|
64
|
+
const { data: row, isPending, error } = GeoLocatedIPModel.useOne(id);
|
|
65
|
+
const canManage = useCan(GEOIP_MANAGE_PERMS).can;
|
|
66
|
+
const unblock = GeoLocatedIPModel.useAction('unblock');
|
|
67
|
+
const whitelist = GeoLocatedIPModel.useAction('whitelist');
|
|
68
|
+
const unwhitelist = GeoLocatedIPModel.useAction('unwhitelist');
|
|
69
|
+
const analyze = GeoLocatedIPModel.useAction('threat_analysis');
|
|
70
|
+
|
|
71
|
+
if (isPending) return <div className="modal-pad dim">Loading enforcement record…</div>;
|
|
72
|
+
if (!row || error) return <div className="modal-pad text-bad">{error?.message ?? 'IP record not found'}</div>;
|
|
73
|
+
|
|
74
|
+
const flag = countryFlag(row.country_code);
|
|
75
|
+
const active = blockActive(row);
|
|
76
|
+
const risk = riskScoreOf(row);
|
|
77
|
+
|
|
78
|
+
const runUnblock = async () => {
|
|
79
|
+
try {
|
|
80
|
+
await unblock.mutateAsync({ id, payload: 'Unblocked from the admin portal' });
|
|
81
|
+
toast.success(`${row.ip_address} unblocked`);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
toast.error(err instanceof Error ? err.message : 'Unblock failed');
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const runWhitelist = async () => {
|
|
88
|
+
const data = await formModal({
|
|
89
|
+
title: `Whitelist ${row.ip_address}`,
|
|
90
|
+
fields: NETWORK_WHITELIST_FIELDS,
|
|
91
|
+
submitText: 'Whitelist',
|
|
92
|
+
intro: 'A whitelisted address passes EVERY rule, including the VPN and Tor checks, and an active block is cleared immediately.',
|
|
93
|
+
});
|
|
94
|
+
if (!data) return;
|
|
95
|
+
const until = typeof data.until === 'string' && data.until ? data.until : null;
|
|
96
|
+
try {
|
|
97
|
+
await whitelist.mutateAsync({
|
|
98
|
+
id,
|
|
99
|
+
payload: until ? { reason: String(data.reason), until } : { reason: String(data.reason) },
|
|
100
|
+
});
|
|
101
|
+
toast.success(`${row.ip_address} whitelisted`);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
toast.error(err instanceof Error ? err.message : 'Whitelist failed');
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const runUnwhitelist = async () => {
|
|
108
|
+
try {
|
|
109
|
+
await unwhitelist.mutateAsync({ id, payload: 1 });
|
|
110
|
+
toast.success(`Whitelist removed for ${row.ip_address}`);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
toast.error(err instanceof Error ? err.message : 'Could not remove the whitelist');
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const runAnalyze = async () => {
|
|
117
|
+
try {
|
|
118
|
+
await analyze.mutateAsync({ id });
|
|
119
|
+
toast.success('Threat analysis re-run');
|
|
120
|
+
} catch (err) {
|
|
121
|
+
toast.error(err instanceof Error ? err.message : 'Threat analysis failed');
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<DetailView
|
|
127
|
+
icon="bi-slash-circle"
|
|
128
|
+
title={row.ip_address}
|
|
129
|
+
subtitle={[row.city, row.region, row.country_name ?? countryName(row.country_code)].filter(Boolean).join(', ') || 'Unknown location'}
|
|
130
|
+
chips={[
|
|
131
|
+
{ text: active ? 'Block active' : blockExpired(row) ? 'Block expired' : 'Not blocked', tone: active ? 'danger' : blockExpired(row) ? 'warning' : 'muted' },
|
|
132
|
+
...(whitelistActive(row) ? [{ icon: 'bi-shield-check', text: 'Whitelisted', tone: 'info' as const }] : []),
|
|
133
|
+
{ text: row.threat_level || 'no threat level', tone: threatTone(row.threat_level) as Tone },
|
|
134
|
+
]}
|
|
135
|
+
sections={[
|
|
136
|
+
{
|
|
137
|
+
key: 'enforcement', label: 'Enforcement', icon: 'bi-shield-slash', render: () => (
|
|
138
|
+
<>
|
|
139
|
+
<StatusPanel
|
|
140
|
+
tone={active ? 'danger' : whitelistActive(row) ? 'info' : 'warning'}
|
|
141
|
+
state={active ? 'BLOCKED' : whitelistActive(row) ? 'WHITELISTED' : 'NOT ENFORCED'}
|
|
142
|
+
headline={row.blocked_reason || 'No reason recorded on the block.'}
|
|
143
|
+
meta={active
|
|
144
|
+
? `Expires ${untilText(row.blocked_until)} · blocked ${row.block_count} time${row.block_count === 1 ? '' : 's'}`
|
|
145
|
+
: 'A whitelist beats a block, and an expired block stops enforcing — the raw is_blocked flag stays set either way.'}
|
|
146
|
+
actions={canManage ? (
|
|
147
|
+
<div className="netsec-action-row">
|
|
148
|
+
{row.is_blocked && (
|
|
149
|
+
<ArmedButton
|
|
150
|
+
className="btn-compact"
|
|
151
|
+
icon="bi-unlock"
|
|
152
|
+
label="Unblock"
|
|
153
|
+
armedLabel="Click again — traffic from this address is allowed fleet-wide"
|
|
154
|
+
onConfirm={runUnblock}
|
|
155
|
+
/>
|
|
156
|
+
)}
|
|
157
|
+
<button className="btn btn-compact" onClick={() => void runWhitelist()}>
|
|
158
|
+
<i className="bi bi-shield-check" /> Whitelist…
|
|
159
|
+
</button>
|
|
160
|
+
{row.is_whitelisted && (
|
|
161
|
+
<ArmedButton
|
|
162
|
+
className="btn-compact"
|
|
163
|
+
icon="bi-shield-x"
|
|
164
|
+
label="Remove whitelist"
|
|
165
|
+
armedLabel="Click again — this address is evaluated against the rules again immediately"
|
|
166
|
+
onConfirm={runUnwhitelist}
|
|
167
|
+
/>
|
|
168
|
+
)}
|
|
169
|
+
<button className="btn btn-compact" onClick={() => void runAnalyze()}>
|
|
170
|
+
<i className="bi bi-arrow-repeat" /> Re-run threat analysis
|
|
171
|
+
</button>
|
|
172
|
+
</div>
|
|
173
|
+
) : null}
|
|
174
|
+
/>
|
|
175
|
+
|
|
176
|
+
<Eyebrow>Block lifecycle</Eyebrow>
|
|
177
|
+
<FlatRow label="State">{enforcementBadge(row)}</FlatRow>
|
|
178
|
+
<FlatRow label="Blocked at">{row.blocked_at != null ? fmt.datetime(row.blocked_at) : DASH}</FlatRow>
|
|
179
|
+
<FlatRow label="Blocked until">
|
|
180
|
+
{row.blocked_until == null
|
|
181
|
+
? <><b>Never</b> <span className="dim">— permanent</span></>
|
|
182
|
+
: <>{fmt.datetime(row.blocked_until)} <span className="dim">· {fmt.relative(row.blocked_until)}</span></>}
|
|
183
|
+
</FlatRow>
|
|
184
|
+
<FlatRow label="Reason">{row.blocked_reason || DASH}</FlatRow>
|
|
185
|
+
<FlatRow label="Block count">{row.block_count}</FlatRow>
|
|
186
|
+
|
|
187
|
+
<Eyebrow>Whitelist</Eyebrow>
|
|
188
|
+
<FlatRow label="Whitelisted"><Badge tone={whitelistActive(row) ? 'info' : 'muted'}>{whitelistActive(row) ? 'Yes' : row.is_whitelisted ? 'Expired' : 'No'}</Badge></FlatRow>
|
|
189
|
+
<FlatRow label="Reason">{row.whitelisted_reason || DASH}</FlatRow>
|
|
190
|
+
<FlatRow label="Until">{row.is_whitelisted ? untilText(row.whitelisted_until) : DASH}</FlatRow>
|
|
191
|
+
</>
|
|
192
|
+
),
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
key: 'origin', label: 'Origin', icon: 'bi-globe2', render: () => (
|
|
196
|
+
<>
|
|
197
|
+
<Eyebrow>Location</Eyebrow>
|
|
198
|
+
<FlatRow label="Country">
|
|
199
|
+
{row.country_code
|
|
200
|
+
? <>{flag && <span className="geoip-flag">{flag}</span>}{row.country_name || countryName(row.country_code)} <code className="dim geoip-cc">{row.country_code}</code></>
|
|
201
|
+
: DASH}
|
|
202
|
+
</FlatRow>
|
|
203
|
+
<FlatRow label="Region">{row.region || DASH}{row.region_code && <code className="dim geoip-cc">{row.region_code}</code>}</FlatRow>
|
|
204
|
+
<FlatRow label="City">{row.city || DASH}</FlatRow>
|
|
205
|
+
<FlatRow label="Postal code">{row.postal_code || DASH}</FlatRow>
|
|
206
|
+
<FlatRow label="Timezone">{row.timezone || DASH}</FlatRow>
|
|
207
|
+
|
|
208
|
+
<Eyebrow>Network</Eyebrow>
|
|
209
|
+
<FlatRow label="Subnet"><code>{row.subnet || '—'}</code></FlatRow>
|
|
210
|
+
<FlatRow label="ASN">{row.asn || DASH}</FlatRow>
|
|
211
|
+
<FlatRow label="ASN org">{row.asn_org || DASH}</FlatRow>
|
|
212
|
+
<FlatRow label="ISP">{row.isp || DASH}</FlatRow>
|
|
213
|
+
<FlatRow label="Connection">{row.connection_type || DASH}</FlatRow>
|
|
214
|
+
<FlatRow label="Last seen">{fmt.datetime(row.last_seen)} <span className="dim">· {fmt.relative(row.last_seen)}</span></FlatRow>
|
|
215
|
+
</>
|
|
216
|
+
),
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
key: 'threat', label: 'Threat', icon: 'bi-exclamation-triangle', render: () => (
|
|
220
|
+
<>
|
|
221
|
+
<Eyebrow>Assessment</Eyebrow>
|
|
222
|
+
<FlatRow label="Threat level">
|
|
223
|
+
<Badge tone={threatTone(row.threat_level) as GeoTone as Tone}>{row.threat_level || 'none'}</Badge>
|
|
224
|
+
</FlatRow>
|
|
225
|
+
<FlatRow label="Risk score">{risk == null ? DASH : risk}</FlatRow>
|
|
226
|
+
<FlatRow label="Is threat"><Badge tone={row.is_threat ? 'danger' : 'muted'}>{row.is_threat ? 'Yes' : 'No'}</Badge></FlatRow>
|
|
227
|
+
<FlatRow label="Is suspicious"><Badge tone={row.is_suspicious ? 'warning' : 'muted'}>{row.is_suspicious ? 'Yes' : 'No'}</Badge></FlatRow>
|
|
228
|
+
|
|
229
|
+
<Eyebrow>Signals</Eyebrow>
|
|
230
|
+
<div className="netsec-flags">
|
|
231
|
+
{([
|
|
232
|
+
['Tor', row.is_tor], ['VPN', row.is_vpn], ['Proxy', row.is_proxy],
|
|
233
|
+
['Cloud', row.is_cloud], ['Datacenter', row.is_datacenter], ['Mobile', row.is_mobile],
|
|
234
|
+
['Known attacker', row.is_known_attacker], ['Known abuser', row.is_known_abuser],
|
|
235
|
+
] as const).map(([label, on]) => (
|
|
236
|
+
<span key={label} className={`chip ${on ? 'chip-danger' : 'chip-muted'}`}>
|
|
237
|
+
<i className={`bi ${on ? 'bi-check-lg' : 'bi-dash'}`} /> {label}
|
|
238
|
+
</span>
|
|
239
|
+
))}
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<Eyebrow>Full record</Eyebrow>
|
|
243
|
+
<p className="dim">
|
|
244
|
+
Location, network and security editing — plus the raw provider record —
|
|
245
|
+
live in the IP Intelligence dossier.
|
|
246
|
+
</p>
|
|
247
|
+
<button className="btn btn-compact" onClick={() => { onClose(); showGeoIpDossier(row.id); }}>
|
|
248
|
+
<i className="bi bi-box-arrow-up-right" /> Open the full IP dossier
|
|
249
|
+
</button>
|
|
250
|
+
</>
|
|
251
|
+
),
|
|
252
|
+
},
|
|
253
|
+
]}
|
|
254
|
+
initialSection="enforcement"
|
|
255
|
+
onClose={onClose}
|
|
256
|
+
/>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function showBlockedIpDetail(id: number): void {
|
|
261
|
+
void modal.detail((close) => <BlockedIpDetail id={id} onClose={() => close(null)} />);
|
|
262
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Firewall Log — `logit.Log` filtered to `kind__startswith=firewall:`.
|
|
2
|
+
// Port of web-mojo `admin/security/FirewallLogTablePage.js`.
|
|
3
|
+
//
|
|
4
|
+
// COLUMN HONESTY (the one substantive correction). `GeoLocatedIP` writes these
|
|
5
|
+
// rows through `MojoModel.log`, which fills `path`, `ip`, `username` and `uid`
|
|
6
|
+
// from the AMBIENT REQUEST — i.e. the ADMIN's endpoint and the ADMIN's IP. The
|
|
7
|
+
// address that was blocked lives only in the JSON `payload`. The source
|
|
8
|
+
// labelled `path` as "IP / Path", which was never either of those things; here
|
|
9
|
+
// Request path, Admin, Admin IP and Target IP are four separate columns.
|
|
10
|
+
//
|
|
11
|
+
// Everything else is the shipped monitoring surface: LOG_COLUMNS' renderers,
|
|
12
|
+
// LOG_FILTERS minus the `kind` text box (replaced by the preset segment over
|
|
13
|
+
// the four exact kinds), LogQuickLook and the full LogInspector.
|
|
14
|
+
import {
|
|
15
|
+
Badge, ModelTable, fmt, groupByDay,
|
|
16
|
+
type Column, type FilterDef, type Tone,
|
|
17
|
+
} from '../../ui';
|
|
18
|
+
import {
|
|
19
|
+
LOGS_ADMIN_PERMISSIONS, LOG_COLUMNS, LOG_FILTERS, LogModel, LogQuickLook,
|
|
20
|
+
openLogInspector, type LogRow,
|
|
21
|
+
} from '../monitoring';
|
|
22
|
+
import { FIREWALL_LOG_KINDS, firewallPayloadOf } from './models';
|
|
23
|
+
|
|
24
|
+
/** `logit.Log.RestMeta.VIEW_PERMS` — the same gate the Logs page uses. */
|
|
25
|
+
export const FIREWALL_LOG_PERMS = LOGS_ADMIN_PERMISSIONS;
|
|
26
|
+
|
|
27
|
+
const ACTION_TONE: Record<string, Tone> = {
|
|
28
|
+
[FIREWALL_LOG_KINDS.block]: 'danger',
|
|
29
|
+
[FIREWALL_LOG_KINDS.unblock]: 'success',
|
|
30
|
+
[FIREWALL_LOG_KINDS.whitelist]: 'info',
|
|
31
|
+
[FIREWALL_LOG_KINDS.unwhitelist]: 'warning',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const ACTION_LABEL: Record<string, string> = {
|
|
35
|
+
[FIREWALL_LOG_KINDS.block]: 'Block',
|
|
36
|
+
[FIREWALL_LOG_KINDS.unblock]: 'Unblock',
|
|
37
|
+
[FIREWALL_LOG_KINDS.whitelist]: 'Whitelist',
|
|
38
|
+
[FIREWALL_LOG_KINDS.unwhitelist]: 'Unwhitelist',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Rename a shipped monitoring column without re-deriving its renderer. */
|
|
42
|
+
function relabelled(key: string, label: string): Column<LogRow> {
|
|
43
|
+
const base = LOG_COLUMNS.find((column) => column.key === key);
|
|
44
|
+
if (!base) {
|
|
45
|
+
// Loud, never silent: the monitoring column set changed under us.
|
|
46
|
+
console.warn(`FirewallLogPage: monitoring LOG_COLUMNS no longer exports "${key}" — rendering a plain cell.`);
|
|
47
|
+
return { key, label };
|
|
48
|
+
}
|
|
49
|
+
return { ...base, label };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const COLUMNS: Column<LogRow>[] = [
|
|
53
|
+
relabelled('created', 'When'),
|
|
54
|
+
{
|
|
55
|
+
key: 'kind', label: 'Action', sortable: true, hideable: false,
|
|
56
|
+
render: (log) => {
|
|
57
|
+
const kind = log.kind ?? '';
|
|
58
|
+
return <Badge tone={ACTION_TONE[kind] ?? 'muted'}>{ACTION_LABEL[kind] ?? (kind || '—')}</Badge>;
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
// The blocked address. It exists ONLY here — never in `Log.ip`.
|
|
63
|
+
key: 'payload', label: 'Target IP',
|
|
64
|
+
render: (log) => {
|
|
65
|
+
const payload = firewallPayloadOf(log.payload);
|
|
66
|
+
return payload?.ip ? <code>{payload.ip}</code> : <span className="dim">—</span>;
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
key: 'log', label: 'Details',
|
|
71
|
+
render: (log) => log.log
|
|
72
|
+
? <span title={log.log}>{fmt.truncate(log.log, 70)}</span>
|
|
73
|
+
: <span className="dim">—</span>,
|
|
74
|
+
},
|
|
75
|
+
relabelled('username', 'Admin'),
|
|
76
|
+
relabelled('ip', 'Admin IP'),
|
|
77
|
+
relabelled('path', 'Request path'),
|
|
78
|
+
relabelled('level', 'Level'),
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The shipped log filters minus `kind` — the preset segment owns it here, and
|
|
83
|
+
* a free-text `kind` box beside four exact presets invites `firewall:blocked`
|
|
84
|
+
* (a value the backend never writes) and an empty table with no explanation.
|
|
85
|
+
*/
|
|
86
|
+
const FILTERS: FilterDef[] = LOG_FILTERS.filter((filter) => filter.key !== 'kind');
|
|
87
|
+
|
|
88
|
+
export function FirewallLogPage() {
|
|
89
|
+
return (
|
|
90
|
+
<ModelTable<LogRow>
|
|
91
|
+
model={LogModel}
|
|
92
|
+
eyebrow="Security · Network"
|
|
93
|
+
title="Firewall Log"
|
|
94
|
+
searchPlaceholder="Search path, message, user…"
|
|
95
|
+
columns={COLUMNS}
|
|
96
|
+
filters={FILTERS}
|
|
97
|
+
presets={[
|
|
98
|
+
{ key: 'all', label: 'All', params: {} },
|
|
99
|
+
{ key: 'blocks', label: 'Blocks', params: { kind: FIREWALL_LOG_KINDS.block } },
|
|
100
|
+
{ key: 'unblocks', label: 'Unblocks', params: { kind: FIREWALL_LOG_KINDS.unblock } },
|
|
101
|
+
{ key: 'whitelists', label: 'Whitelists', params: { kind: FIREWALL_LOG_KINDS.whitelist } },
|
|
102
|
+
{ key: 'unwhitelists', label: 'Unwhitelists', params: { kind: FIREWALL_LOG_KINDS.unwhitelist } },
|
|
103
|
+
]}
|
|
104
|
+
defaultParams={{ kind__startswith: 'firewall:' }}
|
|
105
|
+
defaultSort="-created"
|
|
106
|
+
columnChooser
|
|
107
|
+
persistState
|
|
108
|
+
persistKey="admin:network:firewall-log"
|
|
109
|
+
exportFormats={['csv', 'json']}
|
|
110
|
+
onRowClick={openLogInspector}
|
|
111
|
+
rowExpand={(log) => <LogQuickLook log={log} />}
|
|
112
|
+
{...groupByDay<LogRow>('created')}
|
|
113
|
+
/>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// IPSetDetail — the KISS `modal.detail` for one kernel firewall set.
|
|
2
|
+
// Port of web-mojo `admin/security/IPSetView.js` (TabView: Configuration +
|
|
3
|
+
// CIDR Data, with a fleet-operations kebab), rebuilt on the house detail
|
|
4
|
+
// modal with every fleet-affecting action behind an armed confirmation.
|
|
5
|
+
import { useState } from 'react';
|
|
6
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
7
|
+
import {
|
|
8
|
+
ArmedButton, Badge, DetailView, Eyebrow, FlatRow, StatusPanel,
|
|
9
|
+
fmt, modal, toast,
|
|
10
|
+
} from '../../ui';
|
|
11
|
+
import { useCan } from '../../client';
|
|
12
|
+
import {
|
|
13
|
+
IPSET_CACHE_ONLY_HELP, IPSET_DELETE_PERMS, IPSET_KIND_BADGE_OPTIONS,
|
|
14
|
+
IPSET_MANAGE_PERMS, IPSET_SOURCE_OPTIONS, IPSetModel,
|
|
15
|
+
isCacheOnlyIPSet, useIPSetCidrData, type IPSetRow,
|
|
16
|
+
} from './models';
|
|
17
|
+
import { promptEditIPSet } from './IPSetEditor';
|
|
18
|
+
|
|
19
|
+
const DASH = <span className="dim-italic">—</span>;
|
|
20
|
+
|
|
21
|
+
export function ipSetKindLabel(kind: string): string {
|
|
22
|
+
return IPSET_KIND_BADGE_OPTIONS.find((option) => option.value === kind)?.label ?? kind;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function ipSetSourceLabel(source: string): string {
|
|
26
|
+
return IPSET_SOURCE_OPTIONS.find((option) => option.value === source)?.label ?? source;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function CopyButton({ text }: { text: string }) {
|
|
30
|
+
const [copied, setCopied] = useState(false);
|
|
31
|
+
const copy = async () => {
|
|
32
|
+
const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard;
|
|
33
|
+
if (!clipboard) { toast.error('Clipboard unavailable in this context'); return; }
|
|
34
|
+
try {
|
|
35
|
+
await clipboard.writeText(text);
|
|
36
|
+
setCopied(true);
|
|
37
|
+
toast.success('CIDR list copied to clipboard');
|
|
38
|
+
setTimeout(() => setCopied(false), 1200);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
toast.error(`Copy failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
return (
|
|
44
|
+
<button type="button" className="btn btn-compact" onClick={() => void copy()}>
|
|
45
|
+
<i className={`bi ${copied ? 'bi-check2' : 'bi-clipboard'}`} /> {copied ? 'Copied' : 'Copy'}
|
|
46
|
+
</button>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The `graph=detailed` leg. Mounted only while the CIDR Data section is open,
|
|
52
|
+
* and cached with `gcTime: 0` — a country zone file is megabytes of text and
|
|
53
|
+
* has no business sitting in the persistent query cache.
|
|
54
|
+
*/
|
|
55
|
+
function CidrDataSection({ row }: { row: IPSetRow }) {
|
|
56
|
+
const query = useIPSetCidrData(row.id, true);
|
|
57
|
+
if (query.isPending) return <p className="dim">Loading CIDR data…</p>;
|
|
58
|
+
if (query.error) return <p className="text-bad">{query.error.message}</p>;
|
|
59
|
+
const data = query.data ?? { text: '', lines: [] };
|
|
60
|
+
|
|
61
|
+
if (data.lines.length === 0) {
|
|
62
|
+
return (
|
|
63
|
+
<div className="empty-state">
|
|
64
|
+
<i className="bi bi-database" />
|
|
65
|
+
<p>No CIDRs stored on this set.</p>
|
|
66
|
+
<p className="dim">
|
|
67
|
+
{row.source === 'manual'
|
|
68
|
+
? 'A manual set is only ever filled by an explicit edit — “Refresh source” returns immediately for source=manual and changes nothing.'
|
|
69
|
+
: 'Run “Refresh source” to fetch the list from the configured provider.'}
|
|
70
|
+
</p>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<>
|
|
77
|
+
<div className="netsec-cidr-head">
|
|
78
|
+
<div>
|
|
79
|
+
<div className="eyebrow">CIDR data</div>
|
|
80
|
+
<div className="dim">
|
|
81
|
+
<b>{data.lines.length.toLocaleString()}</b> entries in the stored blob
|
|
82
|
+
{data.lines.length !== row.cidr_count && (
|
|
83
|
+
<>
|
|
84
|
+
{' · '}
|
|
85
|
+
<span className="text-warn" title="cidr_count is only recomputed when the list is written — by a source refresh or by saving the list from the editor.">
|
|
86
|
+
reported count {row.cidr_count.toLocaleString()}
|
|
87
|
+
</span>
|
|
88
|
+
</>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
<CopyButton text={data.text} />
|
|
93
|
+
</div>
|
|
94
|
+
<pre className="netsec-cidr-block"><code>{data.text}</code></pre>
|
|
95
|
+
</>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function IPSetDetail({ id, onClose }: { id: number; onClose: () => void }) {
|
|
100
|
+
const qc = useQueryClient();
|
|
101
|
+
const { data: row, isPending, error } = IPSetModel.useOne(id);
|
|
102
|
+
const canManage = useCan(IPSET_MANAGE_PERMS).can;
|
|
103
|
+
const canDelete = useCan(IPSET_DELETE_PERMS).can;
|
|
104
|
+
const sync = IPSetModel.useAction('sync');
|
|
105
|
+
const enable = IPSetModel.useAction('enable');
|
|
106
|
+
const disable = IPSetModel.useAction('disable');
|
|
107
|
+
const refresh = IPSetModel.useAction('refresh_source');
|
|
108
|
+
const remove = IPSetModel.useDelete();
|
|
109
|
+
const save = IPSetModel.useSave();
|
|
110
|
+
|
|
111
|
+
if (isPending) return <div className="modal-pad dim">Loading IP set…</div>;
|
|
112
|
+
if (!row || error) return <div className="modal-pad text-bad">{error?.message ?? 'IP set not found'}</div>;
|
|
113
|
+
|
|
114
|
+
const cacheOnly = isCacheOnlyIPSet(row);
|
|
115
|
+
|
|
116
|
+
const run = async (label: string, fn: () => Promise<unknown>) => {
|
|
117
|
+
try {
|
|
118
|
+
await fn();
|
|
119
|
+
toast.success(label);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
// The backend's own message, verbatim — the cache-only rejection
|
|
122
|
+
// explains itself far better than any client copy could.
|
|
123
|
+
toast.error(err instanceof Error ? err.message : `${label} failed`);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const onEdit = async () => {
|
|
128
|
+
const changes = await promptEditIPSet(row);
|
|
129
|
+
if (!changes) return;
|
|
130
|
+
try {
|
|
131
|
+
await save.mutateAsync({ id, changes });
|
|
132
|
+
// A `data` save re-runs set_data() server-side, so the detailed
|
|
133
|
+
// read must not serve its previous blob.
|
|
134
|
+
await qc.invalidateQueries({ queryKey: [IPSetModel.endpoint] });
|
|
135
|
+
toast.success('IP set updated');
|
|
136
|
+
} catch (err) {
|
|
137
|
+
toast.error(err instanceof Error ? err.message : 'Could not save the IP set');
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<DetailView
|
|
143
|
+
icon="bi-hdd-network"
|
|
144
|
+
title={row.name}
|
|
145
|
+
subtitle={row.description || `${ipSetKindLabel(row.kind)} set · ${ipSetSourceLabel(row.source)}`}
|
|
146
|
+
chips={[
|
|
147
|
+
{ text: ipSetKindLabel(row.kind), tone: 'muted' },
|
|
148
|
+
{ text: row.is_enabled ? 'Enabled' : 'Disabled', tone: row.is_enabled ? 'success' : 'muted' },
|
|
149
|
+
...(cacheOnly ? [{ icon: 'bi-database-lock', text: 'CACHE-ONLY', tone: 'warning' as const }] : []),
|
|
150
|
+
...(row.sync_error ? [{ icon: 'bi-exclamation-triangle', text: 'Sync error', tone: 'danger' as const }] : []),
|
|
151
|
+
]}
|
|
152
|
+
sections={[
|
|
153
|
+
{
|
|
154
|
+
key: 'config', label: 'Configuration', icon: 'bi-sliders', render: () => (
|
|
155
|
+
<>
|
|
156
|
+
<StatusPanel
|
|
157
|
+
tone={row.sync_error ? 'danger' : row.is_enabled ? 'success' : 'info'}
|
|
158
|
+
state={row.is_enabled ? 'ENFORCING' : 'NOT ENFORCING'}
|
|
159
|
+
headline={row.sync_error
|
|
160
|
+
? row.sync_error
|
|
161
|
+
: row.is_enabled
|
|
162
|
+
? 'CIDRs from this set are loaded into iptables on every fleet instance.'
|
|
163
|
+
: 'This set is stored but is not loaded into any firewall.'}
|
|
164
|
+
meta={row.last_synced == null
|
|
165
|
+
? 'Never synced to the fleet.'
|
|
166
|
+
: `Last synced ${fmt.datetime(row.last_synced)} · ${fmt.relative(row.last_synced)}`}
|
|
167
|
+
actions={canManage ? (
|
|
168
|
+
<div className="netsec-action-row">
|
|
169
|
+
{row.is_enabled ? (
|
|
170
|
+
<ArmedButton
|
|
171
|
+
className="btn-compact"
|
|
172
|
+
icon="bi-toggle-off"
|
|
173
|
+
label="Disable & remove from fleet"
|
|
174
|
+
armedLabel="Click again — every fleet instance drops this set from iptables"
|
|
175
|
+
onConfirm={() => run('IP set disabled and removed from the fleet', () => disable.mutateAsync({ id }))}
|
|
176
|
+
/>
|
|
177
|
+
) : (
|
|
178
|
+
<ArmedButton
|
|
179
|
+
className="btn-compact"
|
|
180
|
+
icon="bi-toggle-on"
|
|
181
|
+
label="Enable & sync"
|
|
182
|
+
armedLabel={cacheOnly
|
|
183
|
+
? 'Click again — the server will refuse this (cache-only list)'
|
|
184
|
+
: `Click again — ${row.cidr_count.toLocaleString()} ranges are kernel-blocked fleet-wide`}
|
|
185
|
+
onConfirm={() => run('IP set enabled and synced', () => enable.mutateAsync({ id }))}
|
|
186
|
+
/>
|
|
187
|
+
)}
|
|
188
|
+
<ArmedButton
|
|
189
|
+
className="btn-compact"
|
|
190
|
+
icon="bi-broadcast"
|
|
191
|
+
label="Sync to fleet"
|
|
192
|
+
armedLabel="Click again — pushes this set to every fleet instance now"
|
|
193
|
+
onConfirm={() => run('Sync broadcast to the fleet', () => sync.mutateAsync({ id }))}
|
|
194
|
+
/>
|
|
195
|
+
<ArmedButton
|
|
196
|
+
className="btn-compact"
|
|
197
|
+
icon="bi-arrow-clockwise"
|
|
198
|
+
label="Refresh source"
|
|
199
|
+
armedLabel="Click again — refetches the provider list and replaces the stored CIDRs"
|
|
200
|
+
onConfirm={() => run('Source refresh requested', () => refresh.mutateAsync({ id }))}
|
|
201
|
+
/>
|
|
202
|
+
<button className="btn btn-compact" onClick={() => void onEdit()}>
|
|
203
|
+
<i className="bi bi-pencil" /> Edit
|
|
204
|
+
</button>
|
|
205
|
+
{canDelete && (
|
|
206
|
+
<ArmedButton
|
|
207
|
+
className="btn-compact btn-danger"
|
|
208
|
+
icon="bi-trash"
|
|
209
|
+
label="Delete"
|
|
210
|
+
armedLabel="Click again — deletes the set and removes it from every fleet instance. This cannot be undone."
|
|
211
|
+
onConfirm={() => run('IP set deleted', async () => {
|
|
212
|
+
await remove.mutateAsync({ id });
|
|
213
|
+
onClose();
|
|
214
|
+
})}
|
|
215
|
+
/>
|
|
216
|
+
)}
|
|
217
|
+
</div>
|
|
218
|
+
) : null}
|
|
219
|
+
/>
|
|
220
|
+
|
|
221
|
+
{cacheOnly && (
|
|
222
|
+
<div className="netsec-note netsec-note-warn">
|
|
223
|
+
<i className="bi bi-database-lock" />
|
|
224
|
+
<div>
|
|
225
|
+
<b>{row.name}</b> is a {IPSET_CACHE_ONLY_HELP}. The backend refuses to
|
|
226
|
+
enable it, and a sync request is a silent no-op. It is refreshed by the
|
|
227
|
+
threat-list cron and read by GeoIP detection, not by iptables.
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
)}
|
|
231
|
+
|
|
232
|
+
<Eyebrow>Identity</Eyebrow>
|
|
233
|
+
<FlatRow label="Name"><code>{row.name}</code></FlatRow>
|
|
234
|
+
<FlatRow label="Kind">{ipSetKindLabel(row.kind)}</FlatRow>
|
|
235
|
+
<FlatRow label="Description">{row.description || DASH}</FlatRow>
|
|
236
|
+
<FlatRow label="Enabled">
|
|
237
|
+
<Badge tone={row.is_enabled ? 'success' : 'muted'}>{row.is_enabled ? 'Yes' : 'No'}</Badge>
|
|
238
|
+
</FlatRow>
|
|
239
|
+
|
|
240
|
+
<Eyebrow>Source</Eyebrow>
|
|
241
|
+
<FlatRow label="Source">{ipSetSourceLabel(row.source)}</FlatRow>
|
|
242
|
+
<FlatRow label="Source URL">
|
|
243
|
+
{row.source_url ? <code className="netsec-break">{row.source_url}</code> : DASH}
|
|
244
|
+
</FlatRow>
|
|
245
|
+
<FlatRow label="API key">
|
|
246
|
+
<span className="dim-italic">Write-only — excluded from every serializer graph.</span>
|
|
247
|
+
</FlatRow>
|
|
248
|
+
<FlatRow label="CIDRs">
|
|
249
|
+
{row.cidr_count.toLocaleString()}
|
|
250
|
+
<span className="dim"> · recorded when the list was last written</span>
|
|
251
|
+
</FlatRow>
|
|
252
|
+
<FlatRow label="Last synced">
|
|
253
|
+
{row.last_synced == null ? <b>Never</b> : <>{fmt.datetime(row.last_synced)} <span className="dim">· {fmt.relative(row.last_synced)}</span></>}
|
|
254
|
+
</FlatRow>
|
|
255
|
+
<FlatRow label="Sync status">
|
|
256
|
+
{row.sync_error
|
|
257
|
+
? <span className="text-bad">{row.sync_error}</span>
|
|
258
|
+
: <span className="text-ok"><i className="bi bi-check-circle" /> OK</span>}
|
|
259
|
+
</FlatRow>
|
|
260
|
+
|
|
261
|
+
<Eyebrow>Record</Eyebrow>
|
|
262
|
+
<FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow>
|
|
263
|
+
<FlatRow label="Modified">{fmt.datetime(row.modified)}</FlatRow>
|
|
264
|
+
</>
|
|
265
|
+
),
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
key: 'cidrs', label: 'CIDR Data', icon: 'bi-list-ol',
|
|
269
|
+
render: () => <CidrDataSection row={row} />,
|
|
270
|
+
},
|
|
271
|
+
]}
|
|
272
|
+
initialSection="config"
|
|
273
|
+
onClose={onClose}
|
|
274
|
+
/>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function showIPSetDetail(id: number): void {
|
|
279
|
+
void modal.detail((close) => <IPSetDetail id={id} onClose={() => close(null)} />);
|
|
280
|
+
}
|