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,80 @@
|
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { CollectionSelect, modal } from '../../ui';
|
|
4
|
+
import { useCan } from '../../client';
|
|
5
|
+
import {
|
|
6
|
+
GROUP_DIRECTORY_PERMS, USER_DIRECTORY_PERMS, SUPPORTED_FILE_MANAGER_BACKENDS,
|
|
7
|
+
saveFileManagerAtomic, storageRefreshFailure, storageRelationId, type FileManagerRow, type RelationRow,
|
|
8
|
+
} from './models';
|
|
9
|
+
|
|
10
|
+
type ManagerEditorMode = 'create' | 'general' | 'credentials' | 'owner';
|
|
11
|
+
|
|
12
|
+
function ManagerEditor({ row, mode, close }: { row: FileManagerRow | null; mode: ManagerEditorMode; close: (value: FileManagerRow | null) => void }) {
|
|
13
|
+
const queryClient = useQueryClient();
|
|
14
|
+
const groupDirectory = useCan(GROUP_DIRECTORY_PERMS).can;
|
|
15
|
+
const userDirectory = useCan(USER_DIRECTORY_PERMS).can;
|
|
16
|
+
const me = useCan([]).me;
|
|
17
|
+
const inFlight = useRef(false);
|
|
18
|
+
const [busy, setBusy] = useState(false);
|
|
19
|
+
const [error, setError] = useState('');
|
|
20
|
+
const [name, setName] = useState(row?.name ?? '');
|
|
21
|
+
const [use, setUse] = useState(row?.use ?? 'uploads');
|
|
22
|
+
const [backendType, setBackendType] = useState(row?.backend_type && ['file', 's3'].includes(row.backend_type) ? row.backend_type : 'file');
|
|
23
|
+
const [backendUrl, setBackendUrl] = useState(row?.backend_url ?? '');
|
|
24
|
+
const [active, setActive] = useState(row?.is_active ?? true);
|
|
25
|
+
const [isDefault, setDefault] = useState(row?.is_default ?? false);
|
|
26
|
+
const [isPublic, setPublic] = useState(row?.is_public ?? false);
|
|
27
|
+
const [region, setRegion] = useState(row?.aws_region ?? '');
|
|
28
|
+
const [key, setKey] = useState('');
|
|
29
|
+
const [secret, setSecret] = useState('');
|
|
30
|
+
const [origins, setOrigins] = useState((row?.allowed_origins ?? []).join('\n'));
|
|
31
|
+
const [group, setGroup] = useState<number | null>(storageRelationId(row?.group));
|
|
32
|
+
const [user, setUser] = useState<number | null>(storageRelationId(row?.user));
|
|
33
|
+
const canSystem = Boolean(me?.is_superuser);
|
|
34
|
+
|
|
35
|
+
const submit = async (event: React.FormEvent) => {
|
|
36
|
+
event.preventDefault();
|
|
37
|
+
if (inFlight.current) return;
|
|
38
|
+
setError('');
|
|
39
|
+
const ownerMode = mode === 'create' || mode === 'owner';
|
|
40
|
+
if (ownerMode && !canSystem && group == null && user == null) { setError('Choose an authorized group or user owner.'); return; }
|
|
41
|
+
if ((mode === 'create' || mode === 'general') && row && row.is_public !== isPublic) {
|
|
42
|
+
const ok = await modal.confirm({ title: isPublic ? 'Make backend public?' : 'Make backend private?', message: 'This changes prefix policy on the backing storage provider. Continue only after reviewing the blast radius.', confirmText: isPublic ? 'Make public' : 'Make private', danger: isPublic });
|
|
43
|
+
if (!ok) return;
|
|
44
|
+
}
|
|
45
|
+
const changes: Record<string, unknown> = mode === 'credentials'
|
|
46
|
+
? { aws_region: region || null, aws_key: key, aws_secret: secret, allowed_origins: origins.split(/\r?\n/).map((value) => value.trim()).filter(Boolean) }
|
|
47
|
+
: mode === 'owner'
|
|
48
|
+
? { group, user }
|
|
49
|
+
: { name, use, backend_type: backendType, backend_url: backendUrl, is_active: active, is_default: isDefault, is_public: mode === 'create' ? false : isPublic, allowed_origins: origins.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), ...(ownerMode ? { group, user } : {}) };
|
|
50
|
+
inFlight.current = true; setBusy(true);
|
|
51
|
+
try {
|
|
52
|
+
const saved = await saveFileManagerAtomic({ queryClient, id: row?.id ?? null, changes, ...(ownerMode ? { expectedOwner: { group, user } } : {}) });
|
|
53
|
+
close(saved);
|
|
54
|
+
} catch (cause) {
|
|
55
|
+
const primary = cause instanceof Error ? cause.message : 'Backend save failed';
|
|
56
|
+
setError(storageRefreshFailure(cause) === undefined
|
|
57
|
+
? primary
|
|
58
|
+
: `${primary} Authoritative refresh also failed; refresh before another action.`);
|
|
59
|
+
} finally { inFlight.current = false; setBusy(false); }
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return <div className="modal-pad storage-manager-editor"><h2 className="modal-title">{mode === 'create' ? 'Create storage backend' : mode === 'credentials' ? 'Update credentials' : mode === 'owner' ? 'Change owner scope' : 'Edit storage backend'}</h2>{error && <div className="form-alert">{error}</div>}<form onSubmit={(event) => void submit(event)}>
|
|
63
|
+
{(mode === 'create' || mode === 'general') && <div className="form-grid">
|
|
64
|
+
<label className="field"><span className="field-label">Name</span><input value={name} onChange={(event) => setName(event.target.value)} required /></label>
|
|
65
|
+
<label className="field"><span className="field-label">Use</span><input value={use} onChange={(event) => setUse(event.target.value)} required /></label>
|
|
66
|
+
<label className="field"><span className="field-label">Backend</span><select value={backendType} disabled={mode !== 'create'} onChange={(event) => setBackendType(event.target.value)}>{SUPPORTED_FILE_MANAGER_BACKENDS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>
|
|
67
|
+
<label className="field"><span className="field-label">Backend URL</span><input value={backendUrl} onChange={(event) => setBackendUrl(event.target.value)} required /></label>
|
|
68
|
+
<label className="field switch-field"><input type="checkbox" checked={active} onChange={(event) => setActive(event.target.checked)} /> Active</label>
|
|
69
|
+
<label className="field switch-field"><input type="checkbox" checked={isDefault} onChange={(event) => setDefault(event.target.checked)} /> Default</label>
|
|
70
|
+
{mode === 'general' && <label className="field switch-field"><input type="checkbox" checked={isPublic} onChange={(event) => setPublic(event.target.checked)} /> Public prefix</label>}
|
|
71
|
+
</div>}
|
|
72
|
+
{mode === 'credentials' && <div className="form-grid"><p className="storage-span"><b>Current key:</b> {row?.aws_key_masked || 'Not configured'} · <b>Current secret:</b> {row?.aws_secret_masked || 'Not configured'}. Blank values preserve the existing secret.</p><label className="field"><span className="field-label">AWS region</span><input value={region} onChange={(event) => setRegion(event.target.value)} /></label><label className="field"><span className="field-label">New access key</span><input value={key} onChange={(event) => setKey(event.target.value)} autoComplete="off" /></label><label className="field"><span className="field-label">New secret key</span><input type="password" value={secret} onChange={(event) => setSecret(event.target.value)} autoComplete="new-password" /></label><label className="field storage-span"><span className="field-label">Allowed origins, one per line</span><textarea value={origins} onChange={(event) => setOrigins(event.target.value)} rows={4} /></label></div>}
|
|
73
|
+
{(mode === 'create' || mode === 'owner') && <div className="storage-owner-grid">{groupDirectory && <CollectionSelect<RelationRow> endpoint="/api/group" value={group} onChange={(id) => { setGroup(id == null ? null : Number(id)); if (id != null) setUser(null); }} label="Group owner" placeholder="Search authorized groups" />}{userDirectory && <CollectionSelect<RelationRow> endpoint="/api/user" value={user} onChange={(id) => { setUser(id == null ? null : Number(id)); if (id != null) setGroup(null); }} label="User owner" labelField="display_name" placeholder="Search authorized users" />}{canSystem && <button type="button" className="btn" onClick={() => { setGroup(null); setUser(null); }}>Use System scope</button>}</div>}
|
|
74
|
+
<div className="modal-actions"><button type="button" className="btn" disabled={busy} onClick={() => close(null)}>Cancel</button><button className="btn btn-primary" disabled={busy}>{busy ? 'Saving…' : 'Save'}</button></div>
|
|
75
|
+
</form></div>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function openFileManagerEditor(mode: ManagerEditorMode, row: FileManagerRow | null = null): Promise<FileManagerRow | null> {
|
|
79
|
+
return modal.open<FileManagerRow>((close) => <ManagerEditor row={row} mode={mode} close={(value) => close(value as FileManagerRow)} />).then((value) => value ?? null);
|
|
80
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// CircularProgress — SVG ring progress, ported from web-mojo
|
|
2
|
+
// src/extensions/charts/CircularProgress.js (740 lines, read in full).
|
|
3
|
+
// Carries: value/min/max, size presets (xs–xl) or explicit px with
|
|
4
|
+
// auto-scaled stroke width, variant colors, partial arcs (gap), rotation,
|
|
5
|
+
// rounded caps, gradient stroke, center value/label/icon with the
|
|
6
|
+
// percentage/fraction/value formats, multi-segment mode with segment gaps,
|
|
7
|
+
// and the dash-offset animation (value changes glide via CSS transition).
|
|
8
|
+
//
|
|
9
|
+
// Deviations, deliberate:
|
|
10
|
+
// · colors are theme tokens (variant → var(--ok)/--bad/--warn/--info/
|
|
11
|
+
// --accent; track → var(--surface2)) instead of hardcoded Bootstrap
|
|
12
|
+
// hexes; the 'dark'/'light' THEME presets are dropped — data-theme
|
|
13
|
+
// tokens make them meaningless here;
|
|
14
|
+
// · the Bootstrap-popover tooltip is dropped (no Bootstrap); pass `title`
|
|
15
|
+
// for a native tooltip or wrap the component;
|
|
16
|
+
// · the imperative API (setValue/animateTo/pulse/complete) is the `value`
|
|
17
|
+
// prop — the CSS transition animates every change;
|
|
18
|
+
// · valueFormat falls back to 'percentage' WITH a console.warn on unknown
|
|
19
|
+
// values (the source fell back silently through DataFormatter).
|
|
20
|
+
import { useEffect, useId, useState } from 'react';
|
|
21
|
+
|
|
22
|
+
export type CircularProgressSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | number;
|
|
23
|
+
|
|
24
|
+
const SIZE_PRESETS: Record<string, number> = { xs: 40, sm: 60, md: 80, lg: 120, xl: 180 };
|
|
25
|
+
const STROKE_PRESETS: Record<string, number> = { xs: 4, sm: 6, md: 8, lg: 12, xl: 16 };
|
|
26
|
+
|
|
27
|
+
function resolveSize(size: CircularProgressSize): { px: number; preset: string | null } {
|
|
28
|
+
if (typeof size === 'number' && Number.isFinite(size) && size > 0) return { px: size, preset: null };
|
|
29
|
+
if (typeof size === 'string' && SIZE_PRESETS[size] != null) return { px: SIZE_PRESETS[size]!, preset: size };
|
|
30
|
+
console.warn(`CircularProgress: unknown size "${String(size)}" — falling back to "md"`);
|
|
31
|
+
return { px: SIZE_PRESETS.md!, preset: 'md' };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function autoStrokeWidth(size: CircularProgressSize, px: number): number {
|
|
35
|
+
if (typeof size === 'string' && STROKE_PRESETS[size] != null) return STROKE_PRESETS[size]!;
|
|
36
|
+
if (px <= 40) return 4;
|
|
37
|
+
if (px <= 60) return 6;
|
|
38
|
+
if (px <= 80) return 8;
|
|
39
|
+
if (px <= 120) return 12;
|
|
40
|
+
return 16;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type CircularProgressVariant = 'default' | 'success' | 'danger' | 'warning' | 'info';
|
|
44
|
+
|
|
45
|
+
const VARIANT_COLOR: Record<CircularProgressVariant, string> = {
|
|
46
|
+
default: 'var(--accent)',
|
|
47
|
+
success: 'var(--ok)',
|
|
48
|
+
danger: 'var(--bad)',
|
|
49
|
+
warning: 'var(--warn)',
|
|
50
|
+
info: 'var(--info)',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export interface CircularProgressSegment {
|
|
54
|
+
value: number;
|
|
55
|
+
color?: string;
|
|
56
|
+
label?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type CircularProgressFormat = 'percentage' | 'fraction' | 'value';
|
|
60
|
+
|
|
61
|
+
export interface CircularProgressProps {
|
|
62
|
+
value?: number;
|
|
63
|
+
min?: number;
|
|
64
|
+
max?: number;
|
|
65
|
+
size?: CircularProgressSize;
|
|
66
|
+
/** 'auto' scales with size (preset table); a number pins it. */
|
|
67
|
+
strokeWidth?: number | 'auto';
|
|
68
|
+
/** Progress color; overrides `variant`. Tokens welcome. */
|
|
69
|
+
color?: string;
|
|
70
|
+
trackColor?: string;
|
|
71
|
+
variant?: CircularProgressVariant;
|
|
72
|
+
/** Two+ stops render the stroke through an SVG linearGradient. */
|
|
73
|
+
gradientColors?: string[];
|
|
74
|
+
/** Arc start in degrees; -90 = 12 o'clock (source default). */
|
|
75
|
+
rotation?: number;
|
|
76
|
+
/** Degrees left open (0 = full circle; 90 = three-quarter gauge). */
|
|
77
|
+
gap?: number;
|
|
78
|
+
rounded?: boolean;
|
|
79
|
+
showValue?: boolean;
|
|
80
|
+
/** 'percentage' | 'fraction' | 'value'. Unknown → warn + percentage. */
|
|
81
|
+
valueFormat?: CircularProgressFormat;
|
|
82
|
+
/** Wins over valueFormat. */
|
|
83
|
+
valueFormatter?: (value: number, min: number, max: number) => string;
|
|
84
|
+
/** Small line under the center value. */
|
|
85
|
+
label?: string;
|
|
86
|
+
/** Icon class replaces the center value ('bi bi-check-lg'). */
|
|
87
|
+
icon?: string;
|
|
88
|
+
/** Multi-segment mode — replaces `value`. Total > max warns + clamps visually. */
|
|
89
|
+
segments?: CircularProgressSegment[];
|
|
90
|
+
/** Degrees between segments. */
|
|
91
|
+
segmentGap?: number;
|
|
92
|
+
animate?: boolean;
|
|
93
|
+
animationDuration?: number;
|
|
94
|
+
onClick?: () => void;
|
|
95
|
+
title?: string;
|
|
96
|
+
className?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function CircularProgress({
|
|
100
|
+
value = 0,
|
|
101
|
+
min = 0,
|
|
102
|
+
max = 100,
|
|
103
|
+
size = 'md',
|
|
104
|
+
strokeWidth = 'auto',
|
|
105
|
+
color,
|
|
106
|
+
trackColor = 'var(--surface2)',
|
|
107
|
+
variant = 'default',
|
|
108
|
+
gradientColors,
|
|
109
|
+
rotation = -90,
|
|
110
|
+
gap = 0,
|
|
111
|
+
rounded = true,
|
|
112
|
+
showValue = true,
|
|
113
|
+
valueFormat = 'percentage',
|
|
114
|
+
valueFormatter,
|
|
115
|
+
label,
|
|
116
|
+
icon,
|
|
117
|
+
segments,
|
|
118
|
+
segmentGap = 2,
|
|
119
|
+
animate = true,
|
|
120
|
+
animationDuration = 600,
|
|
121
|
+
onClick,
|
|
122
|
+
title,
|
|
123
|
+
className = '',
|
|
124
|
+
}: CircularProgressProps) {
|
|
125
|
+
const uid = useId();
|
|
126
|
+
const gradientId = `cprog-grad-${uid}`;
|
|
127
|
+
|
|
128
|
+
const { px, preset } = resolveSize(size);
|
|
129
|
+
const sw = strokeWidth === 'auto' || strokeWidth == null ? autoStrokeWidth(size, px) : strokeWidth;
|
|
130
|
+
|
|
131
|
+
let mainColor = color ?? VARIANT_COLOR[variant];
|
|
132
|
+
if (color == null && VARIANT_COLOR[variant] == null) {
|
|
133
|
+
console.warn(`CircularProgress: unknown variant "${String(variant)}" — falling back to "default"`);
|
|
134
|
+
mainColor = VARIANT_COLOR.default;
|
|
135
|
+
}
|
|
136
|
+
const useGradient = Array.isArray(gradientColors) && gradientColors.length > 1;
|
|
137
|
+
const strokePaint = useGradient ? `url(#${gradientId})` : mainColor;
|
|
138
|
+
|
|
139
|
+
const center = px / 2;
|
|
140
|
+
const radius = (px - sw) / 2;
|
|
141
|
+
const circumference = 2 * Math.PI * radius;
|
|
142
|
+
const arcDeg = gap > 0 ? 360 - gap : 360;
|
|
143
|
+
const arcLen = (arcDeg / 360) * circumference;
|
|
144
|
+
|
|
145
|
+
const range = max - min;
|
|
146
|
+
const pctOf = (v: number): number => (range === 0 ? 0 : ((v - min) / range) * 100);
|
|
147
|
+
const clamped = Math.max(min, Math.min(max, value));
|
|
148
|
+
const percentage = pctOf(clamped);
|
|
149
|
+
|
|
150
|
+
// Mount-from-empty so the first paint animates like the source did:
|
|
151
|
+
// render offset=arcLen for one frame, then transition to the target.
|
|
152
|
+
const [mounted, setMounted] = useState(!animate);
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (!animate) return;
|
|
155
|
+
const raf = requestAnimationFrame(() => setMounted(true));
|
|
156
|
+
return () => cancelAnimationFrame(raf);
|
|
157
|
+
}, [animate]);
|
|
158
|
+
|
|
159
|
+
const transition = animate ? `stroke-dashoffset ${animationDuration}ms ease-out` : undefined;
|
|
160
|
+
|
|
161
|
+
const segList = Array.isArray(segments) && segments.length > 0 ? segments : null;
|
|
162
|
+
if (segList) {
|
|
163
|
+
const totalSeg = segList.reduce((s, seg) => s + (seg.value || 0), 0);
|
|
164
|
+
if (totalSeg > max) {
|
|
165
|
+
console.warn('CircularProgress: segment total exceeds max — the overflow is clipped to the arc');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const formatCenter = (): string => {
|
|
170
|
+
if (valueFormatter) return valueFormatter(clamped, min, max);
|
|
171
|
+
switch (valueFormat) {
|
|
172
|
+
case 'percentage': return `${Math.round(percentage)}%`;
|
|
173
|
+
case 'fraction': return `${clamped}/${max}`;
|
|
174
|
+
case 'value': return String(clamped);
|
|
175
|
+
default:
|
|
176
|
+
console.warn(`CircularProgress: unknown valueFormat "${String(valueFormat)}" — falling back to "percentage"`);
|
|
177
|
+
return `${Math.round(percentage)}%`;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// Segment geometry: running dash offsets along the arc (source verbatim,
|
|
182
|
+
// including the gapLen between painted segments).
|
|
183
|
+
let runningOffset = 0;
|
|
184
|
+
const segmentEls = (segList ?? []).map((seg, i) => {
|
|
185
|
+
const segLen = (pctOf(min + (seg.value || 0)) / 100) * arcLen;
|
|
186
|
+
const gapLen = (segmentGap / 360) * circumference;
|
|
187
|
+
if (segLen <= 0) return null;
|
|
188
|
+
const offset = -runningOffset;
|
|
189
|
+
runningOffset += segLen + gapLen;
|
|
190
|
+
return (
|
|
191
|
+
<circle
|
|
192
|
+
key={i}
|
|
193
|
+
cx={center} cy={center} r={radius}
|
|
194
|
+
fill="none"
|
|
195
|
+
stroke={seg.color ?? mainColor}
|
|
196
|
+
strokeWidth={sw}
|
|
197
|
+
strokeLinecap={rounded ? 'round' : 'butt'}
|
|
198
|
+
strokeDasharray={`${segLen} ${circumference}`}
|
|
199
|
+
strokeDashoffset={mounted ? offset : arcLen}
|
|
200
|
+
transform={`rotate(${rotation} ${center} ${center})`}
|
|
201
|
+
style={{ transition, transitionDelay: animate ? `${i * 100}ms` : undefined }}
|
|
202
|
+
className="cprog-segment"
|
|
203
|
+
>
|
|
204
|
+
{seg.label != null && <title>{seg.label}</title>}
|
|
205
|
+
</circle>
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const progressLen = (percentage / 100) * arcLen;
|
|
210
|
+
const dashOffset = arcLen - progressLen;
|
|
211
|
+
|
|
212
|
+
const clickable = onClick != null;
|
|
213
|
+
const rootClass = [
|
|
214
|
+
'cprog',
|
|
215
|
+
preset ? `cprog-${preset}` : '',
|
|
216
|
+
clickable ? 'cprog-clickable' : '',
|
|
217
|
+
className,
|
|
218
|
+
].filter(Boolean).join(' ');
|
|
219
|
+
const rootStyle = { width: px, height: px };
|
|
220
|
+
|
|
221
|
+
const body = (
|
|
222
|
+
<>
|
|
223
|
+
<svg width={px} height={px} viewBox={`0 0 ${px} ${px}`} className="cprog-svg" role="img">
|
|
224
|
+
{useGradient && (
|
|
225
|
+
<defs>
|
|
226
|
+
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="100%">
|
|
227
|
+
{gradientColors!.map((c, i) => (
|
|
228
|
+
<stop key={i} offset={`${(i / (gradientColors!.length - 1)) * 100}%`} stopColor={c} />
|
|
229
|
+
))}
|
|
230
|
+
</linearGradient>
|
|
231
|
+
</defs>
|
|
232
|
+
)}
|
|
233
|
+
<circle
|
|
234
|
+
cx={center} cy={center} r={radius}
|
|
235
|
+
fill="none"
|
|
236
|
+
stroke={trackColor}
|
|
237
|
+
strokeWidth={sw}
|
|
238
|
+
strokeLinecap={rounded ? 'round' : 'butt'}
|
|
239
|
+
strokeDasharray={gap > 0 ? `${arcLen} ${circumference}` : undefined}
|
|
240
|
+
transform={`rotate(${rotation} ${center} ${center})`}
|
|
241
|
+
/>
|
|
242
|
+
{segList ? segmentEls : (
|
|
243
|
+
<circle
|
|
244
|
+
cx={center} cy={center} r={radius}
|
|
245
|
+
fill="none"
|
|
246
|
+
stroke={strokePaint}
|
|
247
|
+
strokeWidth={sw}
|
|
248
|
+
strokeLinecap={rounded ? 'round' : 'butt'}
|
|
249
|
+
strokeDasharray={`${arcLen} ${circumference}`}
|
|
250
|
+
strokeDashoffset={mounted ? dashOffset : arcLen}
|
|
251
|
+
transform={`rotate(${rotation} ${center} ${center})`}
|
|
252
|
+
style={{ transition }}
|
|
253
|
+
className="cprog-bar"
|
|
254
|
+
/>
|
|
255
|
+
)}
|
|
256
|
+
</svg>
|
|
257
|
+
<div className="cprog-center">
|
|
258
|
+
{icon ? (
|
|
259
|
+
<i className={icon} aria-hidden="true" />
|
|
260
|
+
) : showValue ? (
|
|
261
|
+
<>
|
|
262
|
+
<div className="cprog-value">{formatCenter()}</div>
|
|
263
|
+
{label && <div className="cprog-label">{label}</div>}
|
|
264
|
+
</>
|
|
265
|
+
) : label ? (
|
|
266
|
+
<div className="cprog-label">{label}</div>
|
|
267
|
+
) : null}
|
|
268
|
+
</div>
|
|
269
|
+
</>
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
return clickable ? (
|
|
273
|
+
<button type="button" className={rootClass} style={rootStyle} onClick={onClick} title={title}>
|
|
274
|
+
{body}
|
|
275
|
+
</button>
|
|
276
|
+
) : (
|
|
277
|
+
<div className={rootClass} style={rootStyle} title={title}>
|
|
278
|
+
{body}
|
|
279
|
+
</div>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// KPIStrip — a responsive row of KPITiles fed by ONE batched metrics fetch.
|
|
2
|
+
// Ported from web-mojo src/extensions/charts/KPIStrip.js (273 lines, read in
|
|
3
|
+
// full), re-shaped for the one-cache world:
|
|
4
|
+
//
|
|
5
|
+
// · ONE /api/metrics/fetch (via mojoMetrics — the single normalize
|
|
6
|
+
// boundary) covers values, deltas AND sparklines: value = the latest
|
|
7
|
+
// bucket, delta = latest − previous, deltaPct omitted when the previous
|
|
8
|
+
// bucket was 0 (the tile then shows the absolute delta — the same
|
|
9
|
+
// convention /api/metrics/series?with_delta=true uses on the wire, so a
|
|
10
|
+
// later server-delta variant is a drop-in).
|
|
11
|
+
// · The source's parallel REST count tiles are the `value` override on a
|
|
12
|
+
// spec: pages already have count queries (size:0) — pass the number in
|
|
13
|
+
// rather than teaching the strip a second fetch path. `sparklineSlug`
|
|
14
|
+
// still lets a count tile borrow a metric trail + delta (source
|
|
15
|
+
// feature).
|
|
16
|
+
//
|
|
17
|
+
// A slug the response does not carry renders '—' and warns once — a missing
|
|
18
|
+
// metric is a config bug, not a silent blank.
|
|
19
|
+
import { useEffect, useRef, useState } from 'react';
|
|
20
|
+
import { useQuery } from '@tanstack/react-query';
|
|
21
|
+
import { mojoMetrics } from '../client/client';
|
|
22
|
+
import type { Params } from '../client/types';
|
|
23
|
+
import { KPITile, type KpiSeverity, type KpiTone } from './KPITile';
|
|
24
|
+
import { quickRangeWindow } from './stats';
|
|
25
|
+
|
|
26
|
+
export interface KpiTileSpec {
|
|
27
|
+
/** Metric slug — drives value/delta/sparkline from the batched fetch. */
|
|
28
|
+
slug?: string;
|
|
29
|
+
/** Identity for onTileClick when there is no slug. */
|
|
30
|
+
key?: string;
|
|
31
|
+
label: string;
|
|
32
|
+
tone?: KpiTone | null;
|
|
33
|
+
severity?: KpiSeverity | null;
|
|
34
|
+
formatter?: (v: number) => string;
|
|
35
|
+
/**
|
|
36
|
+
* Caller-supplied value (count tiles — the source's REST tiles). Wins
|
|
37
|
+
* over the metric value; undefined means "metric tile".
|
|
38
|
+
*/
|
|
39
|
+
value?: number | string | null;
|
|
40
|
+
/** Borrow a metric's trail (and its delta) for a caller-value tile. */
|
|
41
|
+
sparklineSlug?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface KPIStripProps {
|
|
45
|
+
tiles: KpiTileSpec[];
|
|
46
|
+
account?: string;
|
|
47
|
+
/** Bucket size for the window — 'days' gives day-over-day deltas. */
|
|
48
|
+
granularity?: string;
|
|
49
|
+
/** Quick range fetched for the sparklines ('7d' default, source parity). */
|
|
50
|
+
range?: string;
|
|
51
|
+
sparklineHeight?: number;
|
|
52
|
+
/** Extra wire params merged under the built-ins. */
|
|
53
|
+
apiParams?: Params;
|
|
54
|
+
onTileClick?: (e: { slug: string | null; key: string }) => void;
|
|
55
|
+
className?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function KPIStrip({
|
|
59
|
+
tiles,
|
|
60
|
+
account = 'global',
|
|
61
|
+
granularity = 'days',
|
|
62
|
+
range = '7d',
|
|
63
|
+
sparklineHeight = 36,
|
|
64
|
+
apiParams,
|
|
65
|
+
onTileClick,
|
|
66
|
+
className = '',
|
|
67
|
+
}: KPIStripProps) {
|
|
68
|
+
const slugs = Array.from(new Set([
|
|
69
|
+
...tiles.map((t) => t.slug).filter((s): s is string => !!s),
|
|
70
|
+
...tiles.map((t) => t.sparklineSlug).filter((s): s is string => !!s),
|
|
71
|
+
]));
|
|
72
|
+
|
|
73
|
+
// Window anchored once per mount — a stable key; web-mojo stamped its
|
|
74
|
+
// window at construction the same way.
|
|
75
|
+
const [anchor] = useState(() => Date.now());
|
|
76
|
+
const { startMs, endMs } = quickRangeWindow(range, anchor);
|
|
77
|
+
const wire: Params = {
|
|
78
|
+
...apiParams,
|
|
79
|
+
slugs: slugs.join(','),
|
|
80
|
+
granularity,
|
|
81
|
+
account,
|
|
82
|
+
// `range` is the mock's window param; dt_start/dt_end (epoch seconds)
|
|
83
|
+
// are the real backend's. Send both — each side ignores the other's.
|
|
84
|
+
range,
|
|
85
|
+
dt_start: Math.floor(startMs / 1000),
|
|
86
|
+
dt_end: Math.floor(endMs / 1000),
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const query = useQuery({
|
|
90
|
+
queryKey: ['metrics', wire],
|
|
91
|
+
queryFn: () => mojoMetrics(wire),
|
|
92
|
+
enabled: slugs.length > 0,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const warnedRef = useRef<Set<string>>(new Set());
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!query.data) return;
|
|
98
|
+
const have = new Set(query.data.datasets.map((d) => d.label));
|
|
99
|
+
for (const slug of slugs) {
|
|
100
|
+
if (!have.has(slug) && !warnedRef.current.has(slug)) {
|
|
101
|
+
warnedRef.current.add(slug);
|
|
102
|
+
console.warn(`KPIStrip: metrics response carries no series for slug "${slug}" — tile shows '—'`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// slugs is derived from tiles; the join is its identity.
|
|
106
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
107
|
+
}, [query.data, slugs.join(',')]);
|
|
108
|
+
|
|
109
|
+
const seriesFor = (slug: string | undefined): number[] | null => {
|
|
110
|
+
if (!slug || !query.data) return null;
|
|
111
|
+
const ds = query.data.datasets.find((d) => d.label === slug);
|
|
112
|
+
return ds ? ds.data : null;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className={`kpi-strip${className ? ` ${className}` : ''}`}>
|
|
117
|
+
{query.isError && (
|
|
118
|
+
<div className="kpi-strip-error" role="alert">
|
|
119
|
+
<i className="bi bi-exclamation-triangle" />
|
|
120
|
+
<span>{query.error instanceof Error ? query.error.message : 'Failed to load metrics'}</span>
|
|
121
|
+
<button className="btn btn-compact" onClick={() => void query.refetch()}>Retry</button>
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
<div className="kpi-strip-grid">
|
|
125
|
+
{tiles.map((spec, i) => {
|
|
126
|
+
const trail = seriesFor(spec.slug ?? spec.sparklineSlug);
|
|
127
|
+
const latest = trail && trail.length > 0 ? trail[trail.length - 1]! : null;
|
|
128
|
+
const prev = trail && trail.length > 1 ? trail[trail.length - 2]! : null;
|
|
129
|
+
|
|
130
|
+
let delta: number | null = null;
|
|
131
|
+
let deltaPct: number | null = null;
|
|
132
|
+
if (latest != null && prev != null) {
|
|
133
|
+
delta = latest - prev;
|
|
134
|
+
// prev 0 → pct omitted; the tile falls back to the
|
|
135
|
+
// absolute delta (never Infinity%).
|
|
136
|
+
deltaPct = prev === 0 ? null : ((latest - prev) / Math.abs(prev)) * 100;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const isMetricTile = spec.value === undefined;
|
|
140
|
+
const value = isMetricTile ? latest : spec.value;
|
|
141
|
+
const key = spec.key ?? spec.slug ?? `tile-${i}`;
|
|
142
|
+
return (
|
|
143
|
+
<KPITile
|
|
144
|
+
key={key}
|
|
145
|
+
label={spec.label}
|
|
146
|
+
value={value}
|
|
147
|
+
formatter={spec.formatter}
|
|
148
|
+
delta={delta}
|
|
149
|
+
deltaPct={deltaPct}
|
|
150
|
+
tone={spec.tone ?? null}
|
|
151
|
+
severity={spec.severity ?? null}
|
|
152
|
+
sparkline={trail}
|
|
153
|
+
sparklineHeight={sparklineHeight}
|
|
154
|
+
loading={isMetricTile && !!spec.slug && query.isPending}
|
|
155
|
+
onClick={onTileClick ? () => onTileClick({ slug: spec.slug ?? null, key }) : undefined}
|
|
156
|
+
/>
|
|
157
|
+
);
|
|
158
|
+
})}
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// KPITile — compact dashboard tile: small label, big tabular-numerals value,
|
|
2
|
+
// color-coded delta badge, embedded sparkline. Ported from web-mojo
|
|
3
|
+
// src/extensions/charts/KPITile.js (176 lines, read in full).
|
|
4
|
+
//
|
|
5
|
+
// Presentation-only, exactly like the source: no fetch, no settings — one
|
|
6
|
+
// parent (KPIStrip, or any page) fetches once and feeds many tiles. The
|
|
7
|
+
// delta logic is the source's, verbatim via deltaBadge(): finite deltaPct →
|
|
8
|
+
// "+12%"; pct absent (prev was 0) but delta present → "+4" absolute; both
|
|
9
|
+
// absent → no badge. Never renders Infinity%. `tone` decides whether rising
|
|
10
|
+
// is green or red; `severity` adds the left-stripe accent.
|
|
11
|
+
//
|
|
12
|
+
// Deviation: severity/spark colors are theme tokens ('high' shares the warn
|
|
13
|
+
// token — there is no orange in the 8-token palette), and the value
|
|
14
|
+
// formatter is a typed function.
|
|
15
|
+
import type { ReactNode } from 'react';
|
|
16
|
+
import { MiniChart } from './MiniChart';
|
|
17
|
+
import { deltaBadge } from './stats';
|
|
18
|
+
|
|
19
|
+
export type KpiSeverity = 'critical' | 'high' | 'warn' | 'info' | 'good';
|
|
20
|
+
export type KpiTone = 'good' | 'bad';
|
|
21
|
+
|
|
22
|
+
const SEVERITY_COLOR: Record<KpiSeverity, string> = {
|
|
23
|
+
critical: 'var(--bad)',
|
|
24
|
+
high: 'var(--warn)',
|
|
25
|
+
warn: 'var(--warn)',
|
|
26
|
+
info: 'var(--info)',
|
|
27
|
+
good: 'var(--ok)',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const KNOWN_SEVERITIES: readonly KpiSeverity[] = ['critical', 'high', 'warn', 'info', 'good'];
|
|
31
|
+
|
|
32
|
+
export interface KPITileProps {
|
|
33
|
+
label: string;
|
|
34
|
+
/** null → '—'. Strings render verbatim; numbers go through `formatter`. */
|
|
35
|
+
value: number | string | null | undefined;
|
|
36
|
+
formatter?: (v: number) => string;
|
|
37
|
+
/** Absolute change vs the comparison bucket. */
|
|
38
|
+
delta?: number | null;
|
|
39
|
+
/** Percent change; omit when the previous bucket was 0. */
|
|
40
|
+
deltaPct?: number | null;
|
|
41
|
+
/** 'bad' = rising is bad (errors); 'good' = rising is good. Default neutral. */
|
|
42
|
+
tone?: KpiTone | null;
|
|
43
|
+
/** Left-stripe accent for tiles that ARE the alert. */
|
|
44
|
+
severity?: KpiSeverity | null;
|
|
45
|
+
sparkline?: readonly number[] | null;
|
|
46
|
+
sparklineColor?: string;
|
|
47
|
+
sparklineHeight?: number;
|
|
48
|
+
/** Extra line under the value (the strip does not use it; pages may). */
|
|
49
|
+
hint?: ReactNode;
|
|
50
|
+
onClick?: () => void;
|
|
51
|
+
/** Skeleton state — value/badge render as shimmer placeholders. */
|
|
52
|
+
loading?: boolean;
|
|
53
|
+
className?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function KPITile({
|
|
57
|
+
label,
|
|
58
|
+
value,
|
|
59
|
+
formatter,
|
|
60
|
+
delta = null,
|
|
61
|
+
deltaPct = null,
|
|
62
|
+
tone = null,
|
|
63
|
+
severity = null,
|
|
64
|
+
sparkline = null,
|
|
65
|
+
sparklineColor,
|
|
66
|
+
sparklineHeight = 36,
|
|
67
|
+
hint,
|
|
68
|
+
onClick,
|
|
69
|
+
loading = false,
|
|
70
|
+
className = '',
|
|
71
|
+
}: KPITileProps) {
|
|
72
|
+
let sev: KpiSeverity | null = severity;
|
|
73
|
+
if (sev != null && !KNOWN_SEVERITIES.includes(sev)) {
|
|
74
|
+
console.warn(`KPITile: unknown severity "${String(severity)}" — ignoring`);
|
|
75
|
+
sev = null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const display = value == null
|
|
79
|
+
? '—'
|
|
80
|
+
: typeof value === 'number'
|
|
81
|
+
? (formatter ? formatter(value) : value.toLocaleString())
|
|
82
|
+
: String(value);
|
|
83
|
+
|
|
84
|
+
const badge = deltaBadge(delta, deltaPct, tone);
|
|
85
|
+
// Default to the info tone when no severity is set — a neutral grey line
|
|
86
|
+
// blends into the dark surface (source note carried over).
|
|
87
|
+
const sparkColor = sparklineColor ?? (sev ? SEVERITY_COLOR[sev] : 'var(--info)');
|
|
88
|
+
const hasSpark = Array.isArray(sparkline) && sparkline.length > 1;
|
|
89
|
+
|
|
90
|
+
const rootClass = `kpi-tile${sev ? ` kpi-tile-${sev}` : ''}${onClick ? ' kpi-tile-clickable' : ''}${className ? ` ${className}` : ''}`;
|
|
91
|
+
const body = (
|
|
92
|
+
<>
|
|
93
|
+
<span className="kpi-tile-label">{label}</span>
|
|
94
|
+
{loading ? (
|
|
95
|
+
<span className="kpi-tile-value"><span className="skel skel-w-60" /></span>
|
|
96
|
+
) : (
|
|
97
|
+
<span className="kpi-tile-value">{display}</span>
|
|
98
|
+
)}
|
|
99
|
+
{!loading && badge && <span className={`kpi-tile-delta kpi-tile-delta-${badge.tone}`}>{badge.text}</span>}
|
|
100
|
+
{!loading && hint != null && <span className="kpi-tile-hint">{hint}</span>}
|
|
101
|
+
<span className="kpi-tile-spark">
|
|
102
|
+
{hasSpark && (
|
|
103
|
+
<MiniChart
|
|
104
|
+
data={sparkline as number[]}
|
|
105
|
+
chartType="line"
|
|
106
|
+
height={sparklineHeight}
|
|
107
|
+
color={sparkColor}
|
|
108
|
+
fill
|
|
109
|
+
smoothing={0.3}
|
|
110
|
+
showTooltip={false}
|
|
111
|
+
showCrosshair={false}
|
|
112
|
+
padding={2}
|
|
113
|
+
animate={false}
|
|
114
|
+
/>
|
|
115
|
+
)}
|
|
116
|
+
</span>
|
|
117
|
+
</>
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
return onClick ? (
|
|
121
|
+
<button type="button" className={rootClass} onClick={onClick}>{body}</button>
|
|
122
|
+
) : (
|
|
123
|
+
<div className={rootClass}>{body}</div>
|
|
124
|
+
);
|
|
125
|
+
}
|