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,166 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { TagInput, modal, toast } from '../../ui';
|
|
4
|
+
import {
|
|
5
|
+
clearMetricsPermissions,
|
|
6
|
+
listMetricsPermissions,
|
|
7
|
+
metricsPermissionKeys,
|
|
8
|
+
normalizeMetricPermissions,
|
|
9
|
+
saveMetricsPermissions,
|
|
10
|
+
type MetricsPermissionRow,
|
|
11
|
+
} from './models';
|
|
12
|
+
|
|
13
|
+
function samePermissions(a: string[], b: string[]): boolean {
|
|
14
|
+
return a.length === b.length && a.every((value, index) => value === b[index]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function PermissionRow({ row }: { row: MetricsPermissionRow }) {
|
|
18
|
+
const queryClient = useQueryClient();
|
|
19
|
+
const [view, setView] = useState(row.view_permissions);
|
|
20
|
+
const [write, setWrite] = useState(row.write_permissions);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
setView(row.view_permissions);
|
|
24
|
+
setWrite(row.write_permissions);
|
|
25
|
+
}, [row.view_permissions, row.write_permissions]);
|
|
26
|
+
|
|
27
|
+
const refresh = () => queryClient.refetchQueries({ queryKey: metricsPermissionKeys.all });
|
|
28
|
+
const save = useMutation({
|
|
29
|
+
mutationFn: saveMetricsPermissions,
|
|
30
|
+
onSuccess: async () => {
|
|
31
|
+
await refresh();
|
|
32
|
+
toast.success(`Permissions for ${row.account} saved`);
|
|
33
|
+
},
|
|
34
|
+
onError: (error) => toast.error(error instanceof Error ? error.message : 'Save failed'),
|
|
35
|
+
});
|
|
36
|
+
const clear = useMutation({
|
|
37
|
+
mutationFn: clearMetricsPermissions,
|
|
38
|
+
onSuccess: async () => {
|
|
39
|
+
// DELETE clears the two permission keys, not the account registry.
|
|
40
|
+
// Refetch the authoritative row rather than removing it locally.
|
|
41
|
+
await refresh();
|
|
42
|
+
toast.success(`Permissions for ${row.account} cleared`);
|
|
43
|
+
},
|
|
44
|
+
onError: (error) => toast.error(error instanceof Error ? error.message : 'Clear failed'),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const normalizedView = normalizeMetricPermissions(view);
|
|
48
|
+
const normalizedWrite = normalizeMetricPermissions(write);
|
|
49
|
+
const dirty = !samePermissions(normalizedView, row.view_permissions)
|
|
50
|
+
|| !samePermissions(normalizedWrite, row.write_permissions);
|
|
51
|
+
const busy = save.isPending || clear.isPending;
|
|
52
|
+
|
|
53
|
+
const confirmClear = async () => {
|
|
54
|
+
const confirmed = await modal.confirm({
|
|
55
|
+
title: 'Clear permissions',
|
|
56
|
+
message: <>Clear all metric view and write permissions for <code>{row.account}</code>? The account remains listed.</>,
|
|
57
|
+
confirmText: 'Clear permissions',
|
|
58
|
+
danger: true,
|
|
59
|
+
});
|
|
60
|
+
if (confirmed) clear.mutate(row.account);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<tr>
|
|
65
|
+
<th scope="row"><code>{row.account}</code></th>
|
|
66
|
+
<td>
|
|
67
|
+
<TagInput
|
|
68
|
+
value={view}
|
|
69
|
+
onChange={(_, tags) => setView(tags)}
|
|
70
|
+
placeholder="Add view permission…"
|
|
71
|
+
disabled={busy}
|
|
72
|
+
/>
|
|
73
|
+
</td>
|
|
74
|
+
<td>
|
|
75
|
+
<TagInput
|
|
76
|
+
value={write}
|
|
77
|
+
onChange={(_, tags) => setWrite(tags)}
|
|
78
|
+
placeholder="Add write permission…"
|
|
79
|
+
disabled={busy}
|
|
80
|
+
/>
|
|
81
|
+
</td>
|
|
82
|
+
<td>
|
|
83
|
+
<div className="monitoring-row-actions">
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
className="btn btn-primary btn-compact"
|
|
87
|
+
disabled={!dirty || busy}
|
|
88
|
+
onClick={() => save.mutate({
|
|
89
|
+
account: row.account,
|
|
90
|
+
view_permissions: normalizedView,
|
|
91
|
+
write_permissions: normalizedWrite,
|
|
92
|
+
})}
|
|
93
|
+
>
|
|
94
|
+
{save.isPending ? 'Saving…' : 'Save'}
|
|
95
|
+
</button>
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
className="btn btn-danger btn-compact"
|
|
99
|
+
disabled={busy || (row.view_permissions.length === 0 && row.write_permissions.length === 0)}
|
|
100
|
+
onClick={() => void confirmClear()}
|
|
101
|
+
>
|
|
102
|
+
{clear.isPending ? 'Clearing…' : 'Clear permissions'}
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
</td>
|
|
106
|
+
</tr>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function MetricsPermissionsPage() {
|
|
111
|
+
const query = useQuery({
|
|
112
|
+
queryKey: metricsPermissionKeys.all,
|
|
113
|
+
queryFn: listMetricsPermissions,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div className="monitoring-page">
|
|
118
|
+
<header className="monitoring-page-head">
|
|
119
|
+
<div>
|
|
120
|
+
<div className="eyebrow">System</div>
|
|
121
|
+
<h1>Metrics permissions</h1>
|
|
122
|
+
<p>Per-account slug-family gates for reading and writing metrics.</p>
|
|
123
|
+
</div>
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
className="btn btn-compact"
|
|
127
|
+
disabled={query.isFetching}
|
|
128
|
+
onClick={() => void query.refetch()}
|
|
129
|
+
>
|
|
130
|
+
<i className={`bi bi-arrow-clockwise${query.isFetching ? ' spin' : ''}`} /> Refresh
|
|
131
|
+
</button>
|
|
132
|
+
</header>
|
|
133
|
+
|
|
134
|
+
<div className="panel monitoring-table-panel">
|
|
135
|
+
{query.isPending && <div className="monitoring-state">Loading metrics permissions…</div>}
|
|
136
|
+
{query.isError && (
|
|
137
|
+
<div className="monitoring-state monitoring-state-error">
|
|
138
|
+
<p>{query.error instanceof Error ? query.error.message : 'Failed to load metrics permissions.'}</p>
|
|
139
|
+
<button type="button" className="btn" onClick={() => void query.refetch()}>Retry</button>
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
{!query.isPending && !query.isError && query.data.length === 0 && (
|
|
143
|
+
<div className="monitoring-state">No metrics permission accounts found.</div>
|
|
144
|
+
)}
|
|
145
|
+
{!query.isPending && !query.isError && query.data.length > 0 && (
|
|
146
|
+
<div className="monitoring-table-scroll">
|
|
147
|
+
<table className="monitoring-table">
|
|
148
|
+
<caption className="sr-only">Metrics permissions by immutable account</caption>
|
|
149
|
+
<thead>
|
|
150
|
+
<tr>
|
|
151
|
+
<th scope="col">Account</th>
|
|
152
|
+
<th scope="col">View permissions</th>
|
|
153
|
+
<th scope="col">Write permissions</th>
|
|
154
|
+
<th scope="col">Actions</th>
|
|
155
|
+
</tr>
|
|
156
|
+
</thead>
|
|
157
|
+
<tbody>
|
|
158
|
+
{query.data.map((row) => <PermissionRow key={row.account} row={row} />)}
|
|
159
|
+
</tbody>
|
|
160
|
+
</table>
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { useCan } from '../../client';
|
|
3
|
+
import { CollectionSelect } from '../../ui';
|
|
4
|
+
import type { MetricAccount, MetricsDiscoveryPage } from './metrics-explorer-data';
|
|
5
|
+
|
|
6
|
+
const GROUP_DIRECTORY_PERMISSIONS = ['sys.view_groups', 'sys.manage_groups', 'sys.groups'];
|
|
7
|
+
const USER_DIRECTORY_PERMISSIONS = ['sys.view_users', 'sys.manage_users', 'sys.users'];
|
|
8
|
+
|
|
9
|
+
interface DirectoryGroup { id: number; name: string }
|
|
10
|
+
interface DirectoryUser { id: number; display_name?: string; username?: string }
|
|
11
|
+
|
|
12
|
+
export interface MetricsSourcePickerProps {
|
|
13
|
+
account: string;
|
|
14
|
+
parsedAccount: MetricAccount | null;
|
|
15
|
+
accountError: string | null;
|
|
16
|
+
accountsPage?: MetricsDiscoveryPage;
|
|
17
|
+
accountsPending: boolean;
|
|
18
|
+
accountsError: string | null;
|
|
19
|
+
accountSearch: string;
|
|
20
|
+
onAccountSearch: (value: string) => void;
|
|
21
|
+
onAccountPage: (start: number) => void;
|
|
22
|
+
onRetryAccounts: () => void;
|
|
23
|
+
onChange: (account: string) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function MetricsSourcePicker({
|
|
27
|
+
account, parsedAccount, accountError, accountsPage, accountsPending, accountsError,
|
|
28
|
+
accountSearch, onAccountSearch, onAccountPage, onRetryAccounts, onChange,
|
|
29
|
+
}: MetricsSourcePickerProps) {
|
|
30
|
+
const [draft, setDraft] = useState(account);
|
|
31
|
+
const groupDirectory = useCan(GROUP_DIRECTORY_PERMISSIONS).can;
|
|
32
|
+
const userDirectory = useCan(USER_DIRECTORY_PERMISSIONS).can;
|
|
33
|
+
|
|
34
|
+
useEffect(() => setDraft(account), [account]);
|
|
35
|
+
|
|
36
|
+
const apply = () => onChange(draft);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<section className="panel panel-pad metrics-source-card" aria-labelledby="metrics-source-heading">
|
|
40
|
+
<div className="metrics-section-head">
|
|
41
|
+
<div>
|
|
42
|
+
<div className="eyebrow">Source</div>
|
|
43
|
+
<h2 id="metrics-source-heading">Metric account</h2>
|
|
44
|
+
<p>Use a full public, global, group, user, or configured custom account. The backend authorizes every request.</p>
|
|
45
|
+
</div>
|
|
46
|
+
{parsedAccount && <code className="metrics-account-chip">{parsedAccount.value}</code>}
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<div className="metrics-source-grid">
|
|
50
|
+
<div className="metrics-source-manual">
|
|
51
|
+
<label className="field">
|
|
52
|
+
<span className="field-label">Exact account</span>
|
|
53
|
+
<div className="metrics-inline-control">
|
|
54
|
+
<input
|
|
55
|
+
id="metrics-account-input"
|
|
56
|
+
className={`input${accountError ? ' input-error' : ''}`}
|
|
57
|
+
value={draft}
|
|
58
|
+
maxLength={256}
|
|
59
|
+
spellCheck={false}
|
|
60
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
61
|
+
onKeyDown={(event) => { if (event.key === 'Enter') apply(); }}
|
|
62
|
+
placeholder="global or group-42"
|
|
63
|
+
/>
|
|
64
|
+
<button type="button" className="btn btn-primary" onClick={apply}>Use account</button>
|
|
65
|
+
</div>
|
|
66
|
+
{accountError && <span className="field-error">{accountError}</span>}
|
|
67
|
+
{!accountError && <span className="field-help">Exact entry remains available for older unindexed accounts.</span>}
|
|
68
|
+
</label>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<div className="metrics-source-browse">
|
|
72
|
+
<label className="field">
|
|
73
|
+
<span className="field-label">Authorized accounts</span>
|
|
74
|
+
<input
|
|
75
|
+
className="input"
|
|
76
|
+
value={accountSearch}
|
|
77
|
+
maxLength={128}
|
|
78
|
+
onChange={(event) => onAccountSearch(event.target.value)}
|
|
79
|
+
placeholder="Search account registry…"
|
|
80
|
+
/>
|
|
81
|
+
</label>
|
|
82
|
+
{accountsPending && <div className="metrics-compact-state">Loading accounts…</div>}
|
|
83
|
+
{accountsError && (
|
|
84
|
+
<div className="metrics-compact-state metrics-compact-error">
|
|
85
|
+
<span>{accountsError}</span>
|
|
86
|
+
<button type="button" className="btn btn-compact" onClick={onRetryAccounts}>Retry</button>
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
{accountsPage && !accountsError && (
|
|
90
|
+
<>
|
|
91
|
+
<select
|
|
92
|
+
className="input"
|
|
93
|
+
value={accountsPage.data.includes(account) ? account : ''}
|
|
94
|
+
onChange={(event) => { if (event.target.value) onChange(event.target.value); }}
|
|
95
|
+
aria-label="Authorized metric account"
|
|
96
|
+
>
|
|
97
|
+
<option value="">Choose an authorized account…</option>
|
|
98
|
+
{accountsPage.data.map((value) => <option key={value} value={value}>{value}</option>)}
|
|
99
|
+
</select>
|
|
100
|
+
<div className="metrics-pager">
|
|
101
|
+
<span>{accountsPage.count === 0 ? 'No accounts' : `${accountsPage.start + 1}–${accountsPage.start + accountsPage.pageCount} of ${accountsPage.count}`}</span>
|
|
102
|
+
<button type="button" className="btn btn-compact" disabled={accountsPage.start === 0} onClick={() => onAccountPage(Math.max(0, accountsPage.start - accountsPage.size))}>Previous</button>
|
|
103
|
+
<button type="button" className="btn btn-compact" disabled={accountsPage.nextStart == null} onClick={() => onAccountPage(accountsPage.nextStart ?? accountsPage.start)}>Next</button>
|
|
104
|
+
</div>
|
|
105
|
+
</>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
{(groupDirectory || userDirectory) && (
|
|
111
|
+
<div className="metrics-directory-grid">
|
|
112
|
+
{groupDirectory && (
|
|
113
|
+
<CollectionSelect<DirectoryGroup>
|
|
114
|
+
endpoint="/api/group"
|
|
115
|
+
value={parsedAccount?.kind === 'group' ? parsedAccount.id : null}
|
|
116
|
+
onChange={(id) => { if (id != null) onChange(`group-${id}`); }}
|
|
117
|
+
label="Group directory convenience"
|
|
118
|
+
labelField="name"
|
|
119
|
+
placeholder="Search groups…"
|
|
120
|
+
/>
|
|
121
|
+
)}
|
|
122
|
+
{userDirectory && (
|
|
123
|
+
<CollectionSelect<DirectoryUser>
|
|
124
|
+
endpoint="/api/user"
|
|
125
|
+
value={parsedAccount?.kind === 'user' ? parsedAccount.id : null}
|
|
126
|
+
onChange={(id) => { if (id != null) onChange(`user-${id}`); }}
|
|
127
|
+
label="User directory convenience"
|
|
128
|
+
labelField="display_name"
|
|
129
|
+
placeholder="Search users…"
|
|
130
|
+
/>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
</section>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { AdminSection } from '../index';
|
|
2
|
+
import { LogsPage } from './LogsPage';
|
|
3
|
+
import { MetricsPermissionsPage } from './MetricsPermissionsPage';
|
|
4
|
+
import { MetricsExplorerPage } from './MetricsExplorerPage';
|
|
5
|
+
import {
|
|
6
|
+
LOGS_ADMIN_PERMISSIONS,
|
|
7
|
+
METRICS_EXPLORER_PERMISSIONS,
|
|
8
|
+
METRICS_PERMISSIONS_ADMIN_PERMISSIONS,
|
|
9
|
+
} from './models';
|
|
10
|
+
|
|
11
|
+
export * from './models';
|
|
12
|
+
export * from './LogInspector';
|
|
13
|
+
export * from './LogsPage';
|
|
14
|
+
export * from './MetricsPermissionsPage';
|
|
15
|
+
export * from './MetricsExplorerPage';
|
|
16
|
+
export * from './MetricsSourcePicker';
|
|
17
|
+
export * from './metrics-explorer-data';
|
|
18
|
+
export * from './metrics-explorer-client';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Reusable registry contribution. The section gate is the union needed to see
|
|
22
|
+
* at least one child; each direct route then applies its exact backend gate.
|
|
23
|
+
*/
|
|
24
|
+
export const MONITORING_ADMIN_SECTION: AdminSection = {
|
|
25
|
+
id: 'monitoring',
|
|
26
|
+
basePath: '',
|
|
27
|
+
title: 'Monitoring',
|
|
28
|
+
icon: 'bi-activity',
|
|
29
|
+
navigationGroup: 'observability',
|
|
30
|
+
permissions: [...new Set([
|
|
31
|
+
...LOGS_ADMIN_PERMISSIONS,
|
|
32
|
+
...METRICS_EXPLORER_PERMISSIONS,
|
|
33
|
+
...METRICS_PERMISSIONS_ADMIN_PERMISSIONS,
|
|
34
|
+
])],
|
|
35
|
+
routes: [
|
|
36
|
+
{
|
|
37
|
+
path: 'logs',
|
|
38
|
+
label: 'Logs',
|
|
39
|
+
component: LogsPage,
|
|
40
|
+
permissions: LOGS_ADMIN_PERMISSIONS,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
path: 'metrics/explorer',
|
|
44
|
+
label: 'Metrics Explorer',
|
|
45
|
+
component: MetricsExplorerPage,
|
|
46
|
+
permissions: METRICS_EXPLORER_PERMISSIONS,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
path: 'metrics/permissions',
|
|
50
|
+
label: 'Metrics Permissions',
|
|
51
|
+
component: MetricsPermissionsPage,
|
|
52
|
+
permissions: METRICS_PERMISSIONS_ADMIN_PERMISSIONS,
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
};
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { mojoCall, type Envelope, type MetricsResponse } from '../../client';
|
|
2
|
+
import type { Params } from '../../client';
|
|
3
|
+
import type { MetricsFetchParams } from '../../charts';
|
|
4
|
+
import {
|
|
5
|
+
METRIC_DISCOVERY_PAGE_SIZE,
|
|
6
|
+
METRIC_SEARCH_MAX_LENGTH,
|
|
7
|
+
assertAddressableMetricSlug,
|
|
8
|
+
dedupeMetricSlugs,
|
|
9
|
+
metricSlugTail,
|
|
10
|
+
parseMetricAccount,
|
|
11
|
+
planMetricHistoryRequests,
|
|
12
|
+
type MetricGaugeValue,
|
|
13
|
+
type MetricPoint,
|
|
14
|
+
type MetricsDiscoveryFilters,
|
|
15
|
+
type MetricsDiscoveryPage,
|
|
16
|
+
type MetricsDiscoveryRequest,
|
|
17
|
+
type MetricsDiscoveryResource,
|
|
18
|
+
} from './metrics-explorer-data';
|
|
19
|
+
|
|
20
|
+
type UnknownRecord = Record<string, unknown>;
|
|
21
|
+
|
|
22
|
+
export interface MetricsBreakdownResponse extends MetricsResponse {
|
|
23
|
+
groups: Record<string, number>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function record(value: unknown, label: string): UnknownRecord {
|
|
27
|
+
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
|
|
28
|
+
throw new Error(`Malformed metrics response: ${label} must be an object.`);
|
|
29
|
+
}
|
|
30
|
+
return value as UnknownRecord;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function stringArray(value: unknown, label: string): string[] {
|
|
34
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
|
|
35
|
+
throw new Error(`Malformed metrics response: ${label} must be a string array.`);
|
|
36
|
+
}
|
|
37
|
+
return value as string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function integer(value: unknown, label: string, minimum = 0): number {
|
|
41
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < minimum) {
|
|
42
|
+
throw new Error(`Malformed metrics response: ${label} must be an integer >= ${minimum}.`);
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function finiteNumber(value: unknown, label: string): number {
|
|
48
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
49
|
+
throw new Error(`Malformed metrics response: ${label} must be a finite number.`);
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sameKeys(value: UnknownRecord, expected: readonly string[], label: string): void {
|
|
55
|
+
const actual = Object.keys(value).sort();
|
|
56
|
+
const wanted = [...expected].sort();
|
|
57
|
+
if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) {
|
|
58
|
+
throw new Error(`Malformed metrics response: ${label} keys do not match the request.`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function validateDiscoveryRequest(request: MetricsDiscoveryRequest): Required<Pick<MetricsDiscoveryRequest, 'resource' | 'search' | 'start' | 'size'>> & Pick<MetricsDiscoveryRequest, 'account' | 'category'> {
|
|
63
|
+
if (!['accounts', 'categories', 'slugs'].includes(request.resource)) throw new Error('Unknown metrics discovery resource.');
|
|
64
|
+
const search = request.search ?? '';
|
|
65
|
+
if (search.length > METRIC_SEARCH_MAX_LENGTH) throw new Error(`Metrics search must be ${METRIC_SEARCH_MAX_LENGTH} characters or fewer.`);
|
|
66
|
+
const start = request.start ?? 0;
|
|
67
|
+
const size = request.size ?? METRIC_DISCOVERY_PAGE_SIZE;
|
|
68
|
+
if (!Number.isInteger(start) || start < 0) throw new Error('Metrics discovery start must be a non-negative integer.');
|
|
69
|
+
if (!Number.isInteger(size) || size < 1 || size > 500) throw new Error('Metrics discovery size must be between 1 and 500.');
|
|
70
|
+
if (request.resource === 'accounts') {
|
|
71
|
+
if (request.account !== undefined || request.category !== undefined) throw new Error('Account discovery does not accept account or category.');
|
|
72
|
+
return { resource: request.resource, search, start, size };
|
|
73
|
+
}
|
|
74
|
+
if (!request.account) throw new Error(`${request.resource === 'categories' ? 'Category' : 'Slug'} discovery requires an account.`);
|
|
75
|
+
if (request.resource === 'categories' && request.category !== undefined) throw new Error('Category discovery does not accept category.');
|
|
76
|
+
return { resource: request.resource, account: request.account, ...(request.category !== undefined ? { category: request.category } : {}), search, start, size };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function expectedFilters(resource: MetricsDiscoveryResource, request: ReturnType<typeof validateDiscoveryRequest>): MetricsDiscoveryFilters {
|
|
80
|
+
if (resource === 'accounts') return { search: request.search };
|
|
81
|
+
if (resource === 'categories') return { account: request.account, search: request.search };
|
|
82
|
+
return { account: request.account, category: request.category ?? null, search: request.search };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Exact #1438 envelope. `mojoCall` is required because list normalization drops its metadata. */
|
|
86
|
+
export async function discoverMetrics(input: MetricsDiscoveryRequest): Promise<MetricsDiscoveryPage> {
|
|
87
|
+
const request = validateDiscoveryRequest(input);
|
|
88
|
+
const response = await mojoCall('/api/metrics/discover', { params: request as Params }) as Envelope & UnknownRecord;
|
|
89
|
+
if (response.resource !== request.resource) throw new Error('Malformed metrics discovery response: resource echo mismatch.');
|
|
90
|
+
const filters = record(response.filters, 'filters');
|
|
91
|
+
const expected = expectedFilters(request.resource, request);
|
|
92
|
+
sameKeys(filters, Object.keys(expected), 'filters');
|
|
93
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
94
|
+
if (filters[key] !== value) throw new Error(`Malformed metrics discovery response: filters.${key} echo mismatch.`);
|
|
95
|
+
}
|
|
96
|
+
const data = stringArray(response.data, 'data');
|
|
97
|
+
const start = integer(response.start, 'start');
|
|
98
|
+
const size = integer(response.size, 'size', 1);
|
|
99
|
+
const count = integer(response.count, 'count');
|
|
100
|
+
const pageCount = integer(response.page_count, 'page_count');
|
|
101
|
+
if (pageCount !== data.length || start !== request.start || size !== request.size || pageCount > size || count < pageCount) {
|
|
102
|
+
throw new Error('Malformed metrics discovery response: paging metadata is inconsistent.');
|
|
103
|
+
}
|
|
104
|
+
const nextStart = response.next_start === null ? null : integer(response.next_start, 'next_start');
|
|
105
|
+
if ((nextStart == null) !== (start + pageCount >= count)) {
|
|
106
|
+
throw new Error('Malformed metrics discovery response: next_start is inconsistent.');
|
|
107
|
+
}
|
|
108
|
+
return { resource: request.resource, filters: expected, data, start, size, count, pageCount, nextStart };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function historyPayload(response: Envelope, expectedTails: readonly string[]): { labels: string[]; data: Record<string, number[]>; groups?: Record<string, number> } {
|
|
112
|
+
const payload = record(response.data, 'data');
|
|
113
|
+
const labels = stringArray(payload.labels, 'data.labels');
|
|
114
|
+
const values = record(payload.data, 'data.data');
|
|
115
|
+
sameKeys(values, expectedTails, 'data.data');
|
|
116
|
+
const data: Record<string, number[]> = {};
|
|
117
|
+
for (const tail of expectedTails) {
|
|
118
|
+
if (!Array.isArray(values[tail])) throw new Error(`Malformed metrics response: series ${tail} must be an array.`);
|
|
119
|
+
data[tail] = (values[tail] as unknown[]).map((entry, index) => finiteNumber(entry, `${tail}[${index}]`));
|
|
120
|
+
if (data[tail]!.length !== labels.length) throw new Error(`Malformed metrics response: series ${tail} length does not match labels.`);
|
|
121
|
+
}
|
|
122
|
+
let groups: Record<string, number> | undefined;
|
|
123
|
+
if (payload.groups !== undefined) {
|
|
124
|
+
const rawGroups = record(payload.groups, 'data.groups');
|
|
125
|
+
sameKeys(rawGroups, expectedTails, 'data.groups');
|
|
126
|
+
groups = Object.fromEntries(Object.entries(rawGroups).map(([key, value]) => [key, integer(value, `groups.${key}`, 1)]));
|
|
127
|
+
}
|
|
128
|
+
return { labels, data, groups };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function requestHistory(params: MetricsFetchParams, slugs: readonly string[]) {
|
|
132
|
+
const wire: Params = { ...params, slugs: slugs.join(',') };
|
|
133
|
+
const response = await mojoCall('/api/metrics/fetch', { params: wire });
|
|
134
|
+
return historyPayload(response, slugs.map(metricSlugTail));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Safe `MetricsChart.loadSeries` implementation. It uses the request as the
|
|
139
|
+
* identity source, splits duplicate tails, and rejects every ambiguous echo.
|
|
140
|
+
*/
|
|
141
|
+
export async function loadExactMetricSeries(params: MetricsFetchParams): Promise<MetricsResponse> {
|
|
142
|
+
const slugs = dedupeMetricSlugs(params.slugs.split(','));
|
|
143
|
+
if (!slugs.length) throw new Error('Choose at least one metric slug.');
|
|
144
|
+
if (params.child_kind) {
|
|
145
|
+
if (parseMetricAccount(params.account).kind !== 'group') {
|
|
146
|
+
throw new Error('Group fan-out requires account=group-<parent-id>.');
|
|
147
|
+
}
|
|
148
|
+
if (!params.child_kind.trim()) throw new Error('Group fan-out requires a child kind.');
|
|
149
|
+
}
|
|
150
|
+
if (params.breakdown) {
|
|
151
|
+
if (slugs.length !== 1) throw new Error('Fan-out breakdown requires exactly one metric slug.');
|
|
152
|
+
if (!params.child_kind) throw new Error('Fan-out breakdown requires a child kind.');
|
|
153
|
+
const response = await mojoCall('/api/metrics/fetch', { params: { ...params, slugs: slugs[0] } });
|
|
154
|
+
const payload = record(response.data, 'data');
|
|
155
|
+
const childData = record(payload.data, 'data.data');
|
|
156
|
+
const result = historyPayload(response, Object.keys(childData));
|
|
157
|
+
if (!result.groups) throw new Error('Malformed fan-out breakdown: groups map is required.');
|
|
158
|
+
return {
|
|
159
|
+
labels: result.labels,
|
|
160
|
+
datasets: Object.keys(result.data).map((label) => ({ label, data: result.data[label]! })),
|
|
161
|
+
groups: result.groups,
|
|
162
|
+
} as MetricsBreakdownResponse;
|
|
163
|
+
}
|
|
164
|
+
const plans = planMetricHistoryRequests(slugs);
|
|
165
|
+
const bySlug = new Map<string, number[]>();
|
|
166
|
+
let labels: string[] | null = null;
|
|
167
|
+
for (const batch of plans) {
|
|
168
|
+
const result = await requestHistory(params, batch);
|
|
169
|
+
if (labels == null) labels = result.labels;
|
|
170
|
+
else if (labels.length !== result.labels.length || labels.some((label, index) => label !== result.labels[index])) {
|
|
171
|
+
throw new Error('Malformed metrics response: collision-split requests returned incompatible labels.');
|
|
172
|
+
}
|
|
173
|
+
for (const slug of batch) bySlug.set(slug, result.data[metricSlugTail(slug)]!);
|
|
174
|
+
}
|
|
175
|
+
if (bySlug.size !== slugs.length) throw new Error('Malformed metrics response: one or more requested series are missing.');
|
|
176
|
+
return { labels: labels ?? [], datasets: slugs.map((label) => ({ label, data: bySlug.get(label)! })) };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface FetchMetricPointsInput {
|
|
180
|
+
account: string;
|
|
181
|
+
slugs: string[];
|
|
182
|
+
when: number;
|
|
183
|
+
granularity: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** `/series?with_delta=true` preserves full keys, so validate them directly. */
|
|
187
|
+
export async function fetchMetricPoints(input: FetchMetricPointsInput): Promise<MetricPoint[]> {
|
|
188
|
+
const slugs = dedupeMetricSlugs(input.slugs);
|
|
189
|
+
if (!slugs.length) return [];
|
|
190
|
+
const response = await mojoCall('/api/metrics/series', {
|
|
191
|
+
params: { account: input.account, slugs: slugs.join(','), when: input.when, granularity: input.granularity, with_delta: true },
|
|
192
|
+
}) as Envelope & UnknownRecord;
|
|
193
|
+
const data = record(response.data, 'data');
|
|
194
|
+
const previous = record(response.prev_data, 'prev_data');
|
|
195
|
+
const deltas = record(response.deltas, 'deltas');
|
|
196
|
+
sameKeys(data, slugs, 'data');
|
|
197
|
+
sameKeys(previous, slugs, 'prev_data');
|
|
198
|
+
sameKeys(deltas, slugs, 'deltas');
|
|
199
|
+
return slugs.map((slug) => {
|
|
200
|
+
const delta = record(deltas[slug], `deltas.${slug}`);
|
|
201
|
+
const keys = Object.keys(delta);
|
|
202
|
+
if (!keys.includes('delta') || keys.some((key) => key !== 'delta' && key !== 'delta_pct')) {
|
|
203
|
+
throw new Error(`Malformed metrics response: delta keys for ${slug} are invalid.`);
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
slug,
|
|
207
|
+
value: finiteNumber(data[slug], `data.${slug}`),
|
|
208
|
+
previous: finiteNumber(previous[slug], `prev_data.${slug}`),
|
|
209
|
+
delta: finiteNumber(delta.delta, `deltas.${slug}.delta`),
|
|
210
|
+
...(delta.delta_pct === undefined ? {} : { deltaPct: finiteNumber(delta.delta_pct, `deltas.${slug}.delta_pct`) }),
|
|
211
|
+
};
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** `/value/get` truncates keys, so one exact full slug is read per call. */
|
|
216
|
+
export async function readMetricValue(account: string, slug: string): Promise<MetricGaugeValue> {
|
|
217
|
+
const exact = slug.trim();
|
|
218
|
+
if (!exact) throw new Error('Scalar slug is required.');
|
|
219
|
+
assertAddressableMetricSlug(exact);
|
|
220
|
+
const response = await mojoCall('/api/metrics/value/get', { params: { account, slugs: exact } }) as Envelope & UnknownRecord;
|
|
221
|
+
const data = record(response.data, 'data');
|
|
222
|
+
const tail = metricSlugTail(exact);
|
|
223
|
+
sameKeys(data, [tail], 'data');
|
|
224
|
+
const echoedSlugs = stringArray(response.slugs, 'slugs');
|
|
225
|
+
if (echoedSlugs.length !== 1 || echoedSlugs[0] !== exact || response.account !== account) {
|
|
226
|
+
throw new Error('Malformed scalar response: request echo mismatch.');
|
|
227
|
+
}
|
|
228
|
+
return { account, slug: exact, value: data[tail] };
|
|
229
|
+
}
|