From fdee8a31e7c27c5077f7e50cabc4307075b56f5e Mon Sep 17 00:00:00 2001 From: myyrakle Date: Tue, 28 Jul 2026 14:45:55 +0900 Subject: [PATCH 1/2] feat(app): internationalize the web app Move all frontend-owned user-facing copy in packages/app into i18next translation catalogs, and add a Language selector (English / Korean) to Preferences. - `src/i18n/` owns a single i18next instance, the locale config, typed catalogs, and the provider that syncs the stored preference with the active language and ``. - English remains the default. The locale is never inferred from the browser or OS and is stored only in the existing local preferences record, so URLs, API contracts, and saved state are unchanged. - Korean catalogs are typed as partial counterparts of English, so they can be filled in one reviewed key at a time; any key without a Korean entry renders the English source copy. - `scripts/check-i18n.mjs` fails the build on new hardcoded user-facing literals, with a reviewable allowlist for technical values (ClickHouse column identifiers, SQL keywords, product names, JSON literals). It now runs as part of `yarn ci:lint`. - User-facing date and number formatting keeps its existing behavior; Mantine date components receive the selected locale. Values that must stay language-neutral keep their English form and carry a separate display label: materialized-view granularities, infrastructure correlation titles, metric type suffixes, and time-range presets (which are parsed back by chrono and persisted in the `tq` URL param). --- .changeset/web-app-internationalization.md | 12 + knip.json | 2 +- packages/app/i18n-audit-allowlist.json | 472 ++++++++++++++++ packages/app/package.json | 8 +- packages/app/pages/_app.tsx | 15 +- packages/app/pages/trace/[traceId].tsx | 4 +- .../i18n-audit/deterministic/a-second.tsx | 6 + .../i18n-audit/deterministic/z-first.tsx | 6 + .../__fixtures__/i18n-audit/invalid-ts.ts | 3 + .../__fixtures__/i18n-audit/invalid.tsx | 47 ++ .../scripts/__fixtures__/i18n-audit/valid.tsx | 37 ++ packages/app/scripts/check-i18n.mjs | 440 +++++++++++++++ packages/app/src/AlertsPage.tsx | 123 ++-- packages/app/src/AuthLoadingBlocker.tsx | 4 +- packages/app/src/AuthPage.tsx | 78 ++- packages/app/src/BenchmarkPage.tsx | 16 +- packages/app/src/CareersPage.tsx | 29 +- packages/app/src/ChartUtils.tsx | 29 +- packages/app/src/ClickhousePage.tsx | 84 +-- packages/app/src/DBChartPage.tsx | 27 +- packages/app/src/DBDashboardImportPage.tsx | 138 +++-- packages/app/src/DBDashboardPage.tsx | 236 ++++---- packages/app/src/DBSearchPage.tsx | 154 +++-- packages/app/src/DBSearchPageAlertModal.tsx | 65 ++- packages/app/src/DBServiceMapPage.tsx | 22 +- packages/app/src/DOMPlayer.tsx | 11 +- packages/app/src/DashboardFilters.tsx | 18 +- packages/app/src/DashboardFiltersModal.tsx | 68 +-- packages/app/src/EditablePageName.tsx | 8 +- packages/app/src/GranularityPicker.tsx | 24 +- packages/app/src/HDXMultiSeriesTableChart.tsx | 6 +- packages/app/src/HDXMultiSeriesTimeChart.tsx | 20 +- packages/app/src/InstallInstructionsModal.tsx | 45 +- packages/app/src/JoinTeamPage.tsx | 14 +- packages/app/src/KubernetesDashboardPage.tsx | 185 +++--- packages/app/src/LandingHeader.tsx | 22 +- packages/app/src/LogSidePanelElements.tsx | 34 +- .../app/src/NamespaceDetailsSidePanel.tsx | 28 +- packages/app/src/NodeDetailsSidePanel.tsx | 27 +- packages/app/src/OnboardingChecklist.tsx | 19 +- packages/app/src/PasswordCheck.tsx | 12 +- packages/app/src/Playbar.tsx | 8 +- packages/app/src/PodDetailsSidePanel.tsx | 38 +- .../src/ServicesDashboardPage/DatabaseTab.tsx | 14 +- .../src/ServicesDashboardPage/ErrorsTab.tsx | 4 +- .../app/src/ServicesDashboardPage/HttpTab.tsx | 33 +- .../ServicesDashboardPage.tsx | 44 +- packages/app/src/SessionEventList.tsx | 15 +- packages/app/src/SessionSidePanel.tsx | 25 +- packages/app/src/SessionSubpanel.tsx | 26 +- packages/app/src/SessionsPage.tsx | 88 +-- packages/app/src/Spotlights.tsx | 77 +-- packages/app/src/TeamPage.tsx | 33 +- packages/app/src/ThemeWrapper.tsx | 14 +- packages/app/src/UserPreferencesModal.tsx | 65 ++- .../app/src/__tests__/ThemeWrapper.test.tsx | 43 ++ .../__tests__/UserPreferencesModal.test.tsx | 79 +++ .../src/__tests__/useUserPreferences.test.tsx | 69 ++- .../app/src/components/ActiveFilterPills.tsx | 30 +- packages/app/src/components/AggFnSelect.tsx | 6 +- .../app/src/components/AlertNoteField.tsx | 7 +- .../src/components/AlertScheduleFields.tsx | 28 +- .../app/src/components/AlertStatusIcon.tsx | 8 +- packages/app/src/components/Alerts.tsx | 12 +- .../components/AppNav/AppNav.components.tsx | 46 +- packages/app/src/components/AppNav/AppNav.tsx | 76 ++- .../src/components/AppNav/AppNavFeedback.tsx | 16 +- .../src/components/AppNav/ChangelogModal.tsx | 6 +- .../AppNav/KeyboardShortcutsModal.tsx | 52 +- .../src/components/BackgroundChartInput.tsx | 25 +- .../components/ChartDisplaySettingsDrawer.tsx | 46 +- .../ChartEditor/PromqlChartEditor.tsx | 8 +- .../ChartEditor/RawSqlChartEditor.tsx | 17 +- .../ChartEditor/RawSqlChartInstructions.tsx | 48 +- .../src/components/ChartEditor/constants.tsx | 190 +++---- .../app/src/components/ChartEditor/utils.ts | 38 +- .../app/src/components/ChartSQLPreview.tsx | 9 +- .../ClickStackOnboarding/CopySnippet.tsx | 5 +- .../ClickStackOnboarding/DeeplinkInstall.tsx | 9 +- .../ClickStackOnboarding/McpInstallPanel.tsx | 31 +- .../__tests__/OnboardingLocalization.test.tsx | 56 ++ .../app/src/components/ColorRulesEditor.tsx | 36 +- .../app/src/components/ColorSwatchInput.tsx | 14 +- .../app/src/components/ConfirmDeleteMenu.tsx | 7 +- .../app/src/components/ConnectionForm.tsx | 88 +-- .../app/src/components/ConnectionSelect.tsx | 4 +- .../app/src/components/ContactSupportText.tsx | 16 +- .../app/src/components/ContextSidePanel.tsx | 56 +- .../app/src/components/CsvExportButton.tsx | 4 +- packages/app/src/components/DBBarChart.tsx | 6 +- packages/app/src/components/DBDeltaChart.tsx | 22 +- .../DBEditTimeChartForm/ChartActionBar.tsx | 12 +- .../ChartEditorControls.tsx | 36 +- .../DBEditTimeChartForm/ChartPreviewPanel.tsx | 19 +- .../DBEditTimeChartForm/ChartSeriesEditor.tsx | 36 +- .../DBEditTimeChartForm/EditTimeChartForm.tsx | 46 +- .../HeatmapSeriesEditor.tsx | 4 +- .../OnClickForm/FilterTemplateList.tsx | 12 +- .../OnClickForm/OnClickDrawer.tsx | 54 +- .../OnClickForm/OnClickFormButton.tsx | 4 +- .../OnClickTargetInputControlled.tsx | 6 +- .../DBEditTimeChartForm/TileAlertEditor.tsx | 27 +- .../__tests__/ChartLocalization.test.tsx | 49 ++ .../useBuilderToSqlConversion.ts | 7 +- .../app/src/components/DBHeatmapChart.tsx | 32 +- .../DBHighlightedAttributesList.tsx | 6 +- .../app/src/components/DBHistogramChart.tsx | 15 +- packages/app/src/components/DBInfraPanel.tsx | 46 +- .../app/src/components/DBListBarChart.tsx | 6 +- packages/app/src/components/DBNumberChart.tsx | 6 +- packages/app/src/components/DBPieChart.tsx | 6 +- .../app/src/components/DBRowJsonViewer.tsx | 50 +- .../app/src/components/DBRowOverviewPanel.tsx | 24 +- .../app/src/components/DBRowSidePanel.tsx | 166 +++--- .../components/DBRowSidePanelErrorState.tsx | 17 +- .../src/components/DBRowSidePanelHeader.tsx | 6 +- packages/app/src/components/DBRowTable.tsx | 82 ++- .../src/components/DBSearchPageFilters.tsx | 94 ++-- .../FilterSettingsPopover.tsx | 29 +- .../DBSearchPageFilters/NestedFilterGroup.tsx | 4 +- .../DBSearchPageFilters/PinShareMenu.tsx | 12 +- .../DBSearchPageFilters/SharedFilters.tsx | 8 +- .../app/src/components/DBSessionPanel.tsx | 13 +- .../components/DBSqlRowTableWithSidebar.tsx | 11 +- .../DBTable/DBRowTableFieldWithPopover.tsx | 8 +- .../DBTable/DBRowTableRowButtons.tsx | 14 +- .../src/components/DBTable/TableHeader.tsx | 6 +- .../components/DBTable/TableSearchInput.tsx | 37 +- packages/app/src/components/DBTableChart.tsx | 8 +- packages/app/src/components/DBTableSelect.tsx | 4 +- packages/app/src/components/DBTimeChart.tsx | 13 +- packages/app/src/components/DBTracePanel.tsx | 27 +- .../src/components/DBTraceWaterfallChart.tsx | 90 +-- .../app/src/components/DashboardContainer.tsx | 39 +- .../src/components/DashboardDndComponents.tsx | 5 +- .../components/DashboardTableOfContents.tsx | 10 +- .../Dashboards/DashboardTemplatesPage.tsx | 24 +- .../Dashboards/DashboardsListPage.tsx | 95 ++-- .../DashboardsListPage.i18n.test.tsx | 118 ++++ .../app/src/components/DatabaseSelect.tsx | 4 +- packages/app/src/components/DrawerUtils.tsx | 4 +- .../src/components/Error/ErrorBoundary.tsx | 6 +- packages/app/src/components/EventTag.tsx | 6 +- .../app/src/components/ExceptionSubpanel.tsx | 49 +- .../app/src/components/ExpandableRowTable.tsx | 13 +- .../app/src/components/FavoriteButton.tsx | 6 +- packages/app/src/components/GroupTabBar.tsx | 24 +- .../src/components/HeatmapSettingsDrawer.tsx | 22 +- packages/app/src/components/HyperJson.tsx | 22 +- .../app/src/components/KubeComponents.tsx | 20 +- .../app/src/components/KubernetesFilters.tsx | 19 +- packages/app/src/components/ListingCard.tsx | 14 +- .../app/src/components/ListingListRow.tsx | 7 +- .../MaterializedViews/MVConfigSummary.tsx | 20 +- .../MVOptimizationIndicator.tsx | 17 +- .../MaterializedViews/MVOptimizationModal.tsx | 32 +- .../components/MetricAttributeHelperPanel.tsx | 20 +- .../app/src/components/MetricNameSelect.tsx | 8 +- .../src/components/NetworkPropertyPanel.tsx | 36 +- packages/app/src/components/NumberFormat.tsx | 239 ++++---- .../app/src/components/OnboardingModal.tsx | 61 +- .../app/src/components/PatternSidePanel.tsx | 20 +- packages/app/src/components/PatternTable.tsx | 14 +- .../Patterns/PatternColumnSelector.tsx | 10 +- .../components/PropertyComparisonChart.tsx | 43 +- .../src/components/SaveToDashboardModal.tsx | 56 +- .../SavedSearches/SavedSearchesListPage.tsx | 48 +- .../Search/DBSearchHeatmapChart.tsx | 4 +- .../Search/DirectTraceSidePanel.tsx | 37 +- .../__tests__/SearchLocalization.test.tsx | 61 ++ .../SearchInput/AutocompleteInput.tsx | 16 +- .../SearchInput/InputLanguageSwitch.tsx | 5 +- .../components/SearchInput/SearchInputV2.tsx | 26 +- .../SearchInput/SearchWhereInput.tsx | 19 +- .../SearchInput/SyntaxReferenceModal.tsx | 133 +++-- .../src/components/SearchPageActionBar.tsx | 6 +- .../src/components/SearchTotalCountChart.tsx | 10 +- .../app/src/components/SeriesColorDrawer.tsx | 14 +- .../components/SeriesNumberFormatDrawer.tsx | 16 +- .../ServiceDashboardDbQuerySidePanel.tsx | 10 +- ...rviceDashboardEndpointPerformanceChart.tsx | 4 +- .../ServiceDashboardEndpointSidePanel.tsx | 10 +- .../ServiceDashboardSlowestEventsTile.tsx | 10 +- .../src/components/ServiceMap/ServiceMap.tsx | 14 +- .../ServiceMap/ServiceMapLegend.tsx | 6 +- .../ServiceMap/ServiceMapSidePanel.tsx | 6 +- .../ServiceMap/ServiceMapTooltip.tsx | 12 +- .../__tests__/ServiceMapLocalization.test.tsx | 54 ++ .../src/components/SidePanelBreadcrumbs.tsx | 6 +- .../src/components/SourceSchemaPreview.tsx | 45 +- packages/app/src/components/SourceSelect.tsx | 17 +- .../SourceForm/ExpressionValidationStatus.tsx | 6 +- .../SourceForm/HighlightedAttributes.tsx | 19 +- .../Sources/SourceForm/LogTableModelForm.tsx | 61 +- .../Sources/SourceForm/MaterializedViews.tsx | 77 +-- .../SourceForm/MetricTableModelForm.tsx | 26 +- .../Sources/SourceForm/OrderByFormRow.tsx | 14 +- .../SourceForm/SessionTableModelForm.tsx | 16 +- .../SourceForm/SourceFieldCandidateHint.tsx | 11 +- .../Sources/SourceForm/SourceForm.tsx | 104 ++-- .../SourceForm/TraceTableModelForm.tsx | 102 ++-- .../SourceForm/UseTextIndexFormRow.tsx | 14 +- .../Sources/SourceForm/constants.ts | 60 +- .../src/components/Sources/SourcesList.tsx | 21 +- .../app/src/components/SpanEventsSubpanel.tsx | 38 +- .../app/src/components/SpanLinksSubpanel.tsx | 22 +- .../app/src/components/StacktraceFrame.tsx | 4 +- packages/app/src/components/Tags.tsx | 28 +- .../TeamSettings/ApiKeysSection.tsx | 29 +- .../TeamSettings/ConnectionsSection.tsx | 22 +- .../TeamSettings/IntegrationsSection.tsx | 5 +- .../TeamSettings/McpServerSection.tsx | 4 +- .../TeamSettings/SecurityPoliciesSection.tsx | 17 +- .../TeamSettings/SourcesSection.tsx | 5 +- .../TeamSettings/TeamMembersSection.tsx | 66 +-- .../TeamSettings/TeamQueryConfigSection.tsx | 109 ++-- .../components/TeamSettings/WebhookForm.tsx | 82 +-- .../TeamSettings/WebhooksSection.tsx | 26 +- .../TeamSettingsLocalization.test.tsx | 52 ++ .../src/components/TimePicker/TimePicker.tsx | 46 +- .../__tests__/TimePickerPresets.test.tsx | 87 +++ .../app/src/components/TimePicker/utils.ts | 33 +- .../TimelineChart/TimelineChart.tsx | 5 +- .../TimelineChart/TimelineMinimap.tsx | 6 +- .../TimelineChart/TimelineSpanEventMarker.tsx | 6 +- .../VirtualMultiSelect/VirtualMultiSelect.tsx | 4 +- .../__tests__/CommonLocalization.test.tsx | 32 ++ .../InfrastructureLocalization.test.tsx | 43 ++ .../__tests__/SourcesLocalization.test.tsx | 76 +++ .../__tests__/infraCorrelations.test.ts | 12 +- .../components/aiSummarize/AISummaryPanel.tsx | 14 +- .../app/src/components/alerts/AckAlert.tsx | 31 +- .../components/alerts/AlertHistoryCards.tsx | 43 +- .../src/components/charts/ChartContainer.tsx | 8 +- .../src/components/charts/ChartErrorState.tsx | 14 +- .../components/charts/ChartSeriesTooltip.tsx | 19 +- .../src/components/charts/ChartTooltip.tsx | 60 +- .../app/src/components/infraCorrelations.ts | 21 +- packages/app/src/dashboard.ts | 3 +- .../app/src/hooks/useAlertAnnotations.tsx | 5 +- .../app/src/hooks/useDashboardContainers.tsx | 19 +- .../app/src/hooks/useOnClickLinkBuilder.ts | 3 +- .../src/hooks/usePresetDashboardFilters.tsx | 5 +- packages/app/src/hooks/useTileSelection.ts | 5 +- packages/app/src/i18n/I18nProvider.tsx | 18 + .../src/i18n/__tests__/I18nProvider.test.tsx | 75 +++ .../app/src/i18n/__tests__/catalogs.test.ts | 92 +++ .../app/src/i18n/__tests__/copy-audit.test.ts | 127 +++++ packages/app/src/i18n/__tests__/i18n.test.ts | 30 + .../app/src/i18n/__type-tests__/ko-common.ts | 15 + packages/app/src/i18n/config.ts | 8 + packages/app/src/i18n/index.ts | 23 + packages/app/src/i18n/locales/en/alerts.ts | 142 +++++ packages/app/src/i18n/locales/en/auth.ts | 56 ++ .../app/src/i18n/locales/en/charts/common.ts | 16 + .../app/src/i18n/locales/en/charts/display.ts | 87 +++ .../app/src/i18n/locales/en/charts/editor.ts | 122 ++++ .../src/i18n/locales/en/charts/exploration.ts | 42 ++ .../app/src/i18n/locales/en/charts/form.ts | 89 +++ .../app/src/i18n/locales/en/charts/heatmap.ts | 24 + .../app/src/i18n/locales/en/charts/index.ts | 71 +++ .../i18n/locales/en/charts/numberFormat.ts | 87 +++ .../app/src/i18n/locales/en/charts/onClick.ts | 32 ++ .../i18n/locales/en/charts/seriesCharts.ts | 33 ++ packages/app/src/i18n/locales/en/common.ts | 90 +++ packages/app/src/i18n/locales/en/dashboard.ts | 99 ++++ .../app/src/i18n/locales/en/dashboards.ts | 236 ++++++++ packages/app/src/i18n/locales/en/index.ts | 33 ++ .../app/src/i18n/locales/en/infrastructure.ts | 166 ++++++ packages/app/src/i18n/locales/en/marketing.ts | 23 + .../app/src/i18n/locales/en/navigation.ts | 128 +++++ .../app/src/i18n/locales/en/onboarding.ts | 54 ++ packages/app/src/i18n/locales/en/search.ts | 528 ++++++++++++++++++ packages/app/src/i18n/locales/en/services.ts | 75 +++ packages/app/src/i18n/locales/en/sessions.ts | 83 +++ packages/app/src/i18n/locales/en/settings.ts | 207 +++++++ packages/app/src/i18n/locales/en/sources.ts | 269 +++++++++ packages/app/src/i18n/locales/ko/alerts.ts | 4 + packages/app/src/i18n/locales/ko/auth.ts | 4 + packages/app/src/i18n/locales/ko/charts.ts | 4 + packages/app/src/i18n/locales/ko/common.ts | 4 + packages/app/src/i18n/locales/ko/dashboard.ts | 4 + .../app/src/i18n/locales/ko/dashboards.ts | 4 + packages/app/src/i18n/locales/ko/index.ts | 33 ++ .../app/src/i18n/locales/ko/infrastructure.ts | 6 + packages/app/src/i18n/locales/ko/marketing.ts | 4 + .../app/src/i18n/locales/ko/navigation.ts | 4 + .../app/src/i18n/locales/ko/onboarding.ts | 4 + packages/app/src/i18n/locales/ko/search.ts | 4 + packages/app/src/i18n/locales/ko/services.ts | 4 + packages/app/src/i18n/locales/ko/sessions.ts | 4 + packages/app/src/i18n/locales/ko/settings.ts | 4 + packages/app/src/i18n/locales/ko/sources.ts | 4 + packages/app/src/i18n/react-i18next.d.ts | 11 + packages/app/src/i18n/resources.ts | 4 + packages/app/src/i18n/testing.ts | 50 ++ packages/app/src/i18n/types.ts | 7 + packages/app/src/i18n/useLocale.ts | 17 + packages/app/src/layout.tsx | 29 +- packages/app/src/setupTests.tsx | 1 + packages/app/src/source.ts | 7 +- packages/app/src/useConfirm.tsx | 4 +- packages/app/src/useUserPreferences.tsx | 56 +- packages/app/src/utils/materializedViews.ts | 65 ++- .../app/src/utils/sourceFieldSuggestions.tsx | 28 +- yarn.lock | 61 +- 306 files changed, 9470 insertions(+), 2904 deletions(-) create mode 100644 .changeset/web-app-internationalization.md create mode 100644 packages/app/i18n-audit-allowlist.json create mode 100644 packages/app/scripts/__fixtures__/i18n-audit/deterministic/a-second.tsx create mode 100644 packages/app/scripts/__fixtures__/i18n-audit/deterministic/z-first.tsx create mode 100644 packages/app/scripts/__fixtures__/i18n-audit/invalid-ts.ts create mode 100644 packages/app/scripts/__fixtures__/i18n-audit/invalid.tsx create mode 100644 packages/app/scripts/__fixtures__/i18n-audit/valid.tsx create mode 100644 packages/app/scripts/check-i18n.mjs create mode 100644 packages/app/src/__tests__/ThemeWrapper.test.tsx create mode 100644 packages/app/src/__tests__/UserPreferencesModal.test.tsx create mode 100644 packages/app/src/components/ClickStackOnboarding/__tests__/OnboardingLocalization.test.tsx create mode 100644 packages/app/src/components/DBEditTimeChartForm/__tests__/ChartLocalization.test.tsx create mode 100644 packages/app/src/components/Dashboards/__tests__/DashboardsListPage.i18n.test.tsx create mode 100644 packages/app/src/components/Search/__tests__/SearchLocalization.test.tsx create mode 100644 packages/app/src/components/ServiceMap/__tests__/ServiceMapLocalization.test.tsx create mode 100644 packages/app/src/components/TeamSettings/__tests__/TeamSettingsLocalization.test.tsx create mode 100644 packages/app/src/components/TimePicker/__tests__/TimePickerPresets.test.tsx create mode 100644 packages/app/src/components/__tests__/CommonLocalization.test.tsx create mode 100644 packages/app/src/components/__tests__/InfrastructureLocalization.test.tsx create mode 100644 packages/app/src/components/__tests__/SourcesLocalization.test.tsx create mode 100644 packages/app/src/i18n/I18nProvider.tsx create mode 100644 packages/app/src/i18n/__tests__/I18nProvider.test.tsx create mode 100644 packages/app/src/i18n/__tests__/catalogs.test.ts create mode 100644 packages/app/src/i18n/__tests__/copy-audit.test.ts create mode 100644 packages/app/src/i18n/__tests__/i18n.test.ts create mode 100644 packages/app/src/i18n/__type-tests__/ko-common.ts create mode 100644 packages/app/src/i18n/config.ts create mode 100644 packages/app/src/i18n/index.ts create mode 100644 packages/app/src/i18n/locales/en/alerts.ts create mode 100644 packages/app/src/i18n/locales/en/auth.ts create mode 100644 packages/app/src/i18n/locales/en/charts/common.ts create mode 100644 packages/app/src/i18n/locales/en/charts/display.ts create mode 100644 packages/app/src/i18n/locales/en/charts/editor.ts create mode 100644 packages/app/src/i18n/locales/en/charts/exploration.ts create mode 100644 packages/app/src/i18n/locales/en/charts/form.ts create mode 100644 packages/app/src/i18n/locales/en/charts/heatmap.ts create mode 100644 packages/app/src/i18n/locales/en/charts/index.ts create mode 100644 packages/app/src/i18n/locales/en/charts/numberFormat.ts create mode 100644 packages/app/src/i18n/locales/en/charts/onClick.ts create mode 100644 packages/app/src/i18n/locales/en/charts/seriesCharts.ts create mode 100644 packages/app/src/i18n/locales/en/common.ts create mode 100644 packages/app/src/i18n/locales/en/dashboard.ts create mode 100644 packages/app/src/i18n/locales/en/dashboards.ts create mode 100644 packages/app/src/i18n/locales/en/index.ts create mode 100644 packages/app/src/i18n/locales/en/infrastructure.ts create mode 100644 packages/app/src/i18n/locales/en/marketing.ts create mode 100644 packages/app/src/i18n/locales/en/navigation.ts create mode 100644 packages/app/src/i18n/locales/en/onboarding.ts create mode 100644 packages/app/src/i18n/locales/en/search.ts create mode 100644 packages/app/src/i18n/locales/en/services.ts create mode 100644 packages/app/src/i18n/locales/en/sessions.ts create mode 100644 packages/app/src/i18n/locales/en/settings.ts create mode 100644 packages/app/src/i18n/locales/en/sources.ts create mode 100644 packages/app/src/i18n/locales/ko/alerts.ts create mode 100644 packages/app/src/i18n/locales/ko/auth.ts create mode 100644 packages/app/src/i18n/locales/ko/charts.ts create mode 100644 packages/app/src/i18n/locales/ko/common.ts create mode 100644 packages/app/src/i18n/locales/ko/dashboard.ts create mode 100644 packages/app/src/i18n/locales/ko/dashboards.ts create mode 100644 packages/app/src/i18n/locales/ko/index.ts create mode 100644 packages/app/src/i18n/locales/ko/infrastructure.ts create mode 100644 packages/app/src/i18n/locales/ko/marketing.ts create mode 100644 packages/app/src/i18n/locales/ko/navigation.ts create mode 100644 packages/app/src/i18n/locales/ko/onboarding.ts create mode 100644 packages/app/src/i18n/locales/ko/search.ts create mode 100644 packages/app/src/i18n/locales/ko/services.ts create mode 100644 packages/app/src/i18n/locales/ko/sessions.ts create mode 100644 packages/app/src/i18n/locales/ko/settings.ts create mode 100644 packages/app/src/i18n/locales/ko/sources.ts create mode 100644 packages/app/src/i18n/react-i18next.d.ts create mode 100644 packages/app/src/i18n/resources.ts create mode 100644 packages/app/src/i18n/testing.ts create mode 100644 packages/app/src/i18n/types.ts create mode 100644 packages/app/src/i18n/useLocale.ts diff --git a/.changeset/web-app-internationalization.md b/.changeset/web-app-internationalization.md new file mode 100644 index 0000000000..e9e6686bbc --- /dev/null +++ b/.changeset/web-app-internationalization.md @@ -0,0 +1,12 @@ +--- +"@hyperdx/app": minor +--- + +feat(i18n): internationalize the web app. All frontend-owned user-facing copy +now resolves through i18next translation catalogs, and Preferences gains a +Language selector for English and Korean. English remains the default for new +and existing users, the locale is never inferred from the browser or OS, and the +selection is stored only in the existing local preferences record. Korean +catalogs can be filled in one reviewed key at a time; any key without a Korean +entry renders the English source copy. Routes, API contracts, user content, +query syntax, and server error details are unchanged. diff --git a/knip.json b/knip.json index 8e9cf032c8..f06cd0a570 100644 --- a/knip.json +++ b/knip.json @@ -4,7 +4,7 @@ "packages/app": { "entry": ["pages/**/*.{ts,tsx}", "scripts/*.js", "tests/e2e/**/*.ts"], "project": ["src/**/*.{ts,tsx}", "pages/**/*.{ts,tsx}"], - "ignore": [".storybook/public/**"], + "ignore": [".storybook/public/**", "src/i18n/__type-tests__/**"], "ignoreDependencies": [ "eslint-config-next", "@chromatic-com/storybook", diff --git a/packages/app/i18n-audit-allowlist.json b/packages/app/i18n-audit-allowlist.json new file mode 100644 index 0000000000..57d3ee45c7 --- /dev/null +++ b/packages/app/i18n-audit-allowlist.json @@ -0,0 +1,472 @@ +[ + { + "file": "src/InstallInstructionsModal.tsx", + "text": "Node.js", + "reason": "SDK name displayed verbatim" + }, + { + "file": "src/InstallInstructionsModal.tsx", + "text": "Go", + "reason": "Programming language name displayed verbatim" + }, + { + "file": "src/InstallInstructionsModal.tsx", + "text": "Python", + "reason": "Programming language name displayed verbatim" + }, + { + "file": "src/InstallInstructionsModal.tsx", + "text": "Java", + "reason": "Programming language name displayed verbatim" + }, + { + "file": "src/InstallInstructionsModal.tsx", + "text": "Elixir", + "reason": "Programming language name displayed verbatim" + }, + { + "file": "src/InstallInstructionsModal.tsx", + "text": "Ruby on Rails", + "reason": "Framework name displayed verbatim" + }, + { + "file": "src/InstallInstructionsModal.tsx", + "text": "Kubernetes", + "reason": "Platform name displayed verbatim" + }, + { + "file": "src/InstallInstructionsModal.tsx", + "text": "JavaScript/TypeScript", + "reason": "Programming language names displayed verbatim" + }, + { + "file": "src/InstallInstructionsModal.tsx", + "text": "OpenTelemetry", + "reason": "Project name displayed verbatim" + }, + { + "file": "src/components/SearchInput/InputLanguageSwitch.tsx", + "text": "SQL", + "reason": "Query language identifier displayed verbatim" + }, + { + "file": "src/components/SearchInput/InputLanguageSwitch.tsx", + "text": "Lucene", + "reason": "Query language identifier displayed verbatim" + }, + { + "file": "src/SessionEventList.tsx", + "text": "console.error", + "reason": "Telemetry event name displayed verbatim" + }, + { + "file": "src/SessionEventList.tsx", + "text": "console.log", + "reason": "Telemetry event name displayed verbatim" + }, + { + "file": "src/SessionEventList.tsx", + "text": "console.warn", + "reason": "Telemetry event name displayed verbatim" + }, + { + "file": "src/components/DBSearchPageFilters/NestedFilterGroup.tsx", + "text": "p-0", + "reason": "Mantine class name, not user-visible copy" + }, + { + "file": "src/components/SpanEventsSubpanel.tsx", + "text": "An error occurred while rendering span events", + "reason": "Error message is explicitly outside the localization scope" + }, + { + "file": "src/components/SearchInput/SearchInputV2.tsx", + "text": "my log", + "reason": "Lucene query example displayed and inserted verbatim" + }, + { + "file": "src/components/SearchInput/SearchInputV2.tsx", + "text": "*err*", + "reason": "Lucene query example displayed and inserted verbatim" + }, + { + "file": "src/components/SearchInput/SearchInputV2.tsx", + "text": "level:", + "reason": "Lucene query example displayed verbatim" + }, + { + "file": "src/components/SearchInput/SearchInputV2.tsx", + "text": "info", + "reason": "Lucene query example displayed verbatim" + }, + { + "file": "src/components/SearchInput/SearchInputV2.tsx", + "text": "-level:info", + "reason": "Lucene query example displayed and inserted verbatim" + }, + { + "file": "src/components/SearchInput/SearchInputV2.tsx", + "text": "service:*", + "reason": "Lucene query example displayed and inserted verbatim" + }, + { + "file": "src/components/SearchInput/SearchInputV2.tsx", + "text": "(foo OR bar)", + "reason": "Lucene query example displayed verbatim" + }, + { + "file": "src/components/SearchInput/SyntaxReferenceModal.tsx", + "text": "SQL", + "reason": "Query language identifier displayed verbatim" + }, + { + "file": "src/components/SearchInput/SyntaxReferenceModal.tsx", + "text": "Lucene", + "reason": "Query language identifier displayed verbatim" + }, + { + "file": "src/LogSidePanelElements.tsx", + "text": "in_app: false", + "reason": "OpenTelemetry stack-frame attribute displayed verbatim" + }, + { + "file": "src/components/ExceptionSubpanel.tsx", + "text": "in_app: false", + "reason": "OpenTelemetry stack-frame attribute displayed verbatim" + }, + { + "file": "src/components/ExceptionSubpanel.tsx", + "text": "An error occurred while rendering stacktrace", + "reason": "Error message is explicitly outside the localization scope" + }, + { + "file": "src/SessionsPage.tsx", + "text": "hyperdx_sessions", + "reason": "ClickHouse table identifier displayed verbatim" + }, + { + "file": "src/components/DBSearchPageFilters.tsx", + "text": "p-0", + "reason": "Mantine class name, not user-visible copy" + }, + { + "file": "src/components/DBRowSidePanelErrorState.tsx", + "text": "This query may have failed due to an invalid", + "reason": "Error guidance is explicitly outside the localization scope" + }, + { + "file": "src/components/DBRowSidePanelErrorState.tsx", + "text": "Known Columns List", + "reason": "Error guidance and source configuration field name are outside the localization scope" + }, + { + "file": "src/components/DBRowSidePanelErrorState.tsx", + "text": "configuration. Check the", + "reason": "Error guidance is explicitly outside the localization scope" + }, + { + "file": "src/components/DBRowSidePanelErrorState.tsx", + "text": "for this source and ensure that it references valid columns that exist in all target tables of the Distributed or Merge table.", + "reason": "Error guidance is explicitly outside the localization scope" + }, + { + "file": "src/components/DBRowSidePanelErrorState.tsx", + "text": "This query may have failed due to a", + "reason": "Error guidance is explicitly outside the localization scope" + }, + { + "file": "src/components/DBRowSidePanelErrorState.tsx", + "text": "SELECT *", + "reason": "Executable SQL syntax in excluded error guidance is displayed verbatim" + }, + { + "file": "src/components/DBRowSidePanelErrorState.tsx", + "text": "query on a Distributed table that declares columns missing in one or more of its target tables. If this is the case, the", + "reason": "Error guidance is explicitly outside the localization scope" + }, + { + "file": "src/components/DBRowSidePanelErrorState.tsx", + "text": "can be overridden by setting a", + "reason": "Error guidance is explicitly outside the localization scope" + }, + { + "file": "src/components/DBRowSidePanelErrorState.tsx", + "text": "for this source.", + "reason": "Error guidance is explicitly outside the localization scope" + }, + { + "file": "src/DBSearchPage.tsx", + "text": "SELECT", + "reason": "Executable SQL keyword displayed verbatim" + }, + { + "file": "src/DBSearchPage.tsx", + "text": "FROM", + "reason": "Executable SQL keyword displayed verbatim" + }, + { + "file": "src/DBSearchPage.tsx", + "text": "WHERE", + "reason": "Executable SQL keyword displayed verbatim" + }, + { + "file": "src/DBSearchPage.tsx", + "text": "ORDER BY", + "reason": "Executable SQL keyword displayed verbatim" + }, + { + "file": "src/DBSearchPage.tsx", + "text": "FILTERS", + "reason": "Executable query section keyword displayed verbatim" + }, + { + "file": "src/components/ChartEditor/PromqlChartEditor.tsx", + "text": "rate(http_requests_total{service='api'}[5m])", + "reason": "PromQL example expression shown as an editor placeholder; executable syntax, not natural-language UI copy" + }, + { + "file": "src/components/DBEditTimeChartForm/OnClickForm/FilterTemplateList.tsx", + "text": "Template (e.g. {{ServiceName}})", + "reason": "Placeholder contains literal i18next interpolation syntax {{ }}; moving it to the catalog would blank out {{ServiceName}}" + }, + { + "file": "src/components/Sources/SourceForm/HighlightedAttributes.tsx", + "text": "ResourceAttributes['http.host']", + "reason": "ClickHouse map access expression shown as an editor placeholder" + }, + { + "file": "src/components/Sources/SourceForm/HighlightedAttributes.tsx", + "text": "AS", + "reason": "Executable SQL keyword displayed verbatim" + }, + { + "file": "src/components/Sources/SourceForm/LogTableModelForm.tsx", + "text": "ServiceName", + "reason": "ClickHouse column identifier shown as an editor placeholder" + }, + { + "file": "src/components/Sources/SourceForm/LogTableModelForm.tsx", + "text": "SeverityText", + "reason": "ClickHouse column identifier shown as an editor placeholder" + }, + { + "file": "src/components/Sources/SourceForm/LogTableModelForm.tsx", + "text": "Body", + "reason": "ClickHouse column identifier shown as an editor placeholder" + }, + { + "file": "src/components/Sources/SourceForm/LogTableModelForm.tsx", + "text": "LogAttributes", + "reason": "ClickHouse column identifier shown as an editor placeholder" + }, + { + "file": "src/components/Sources/SourceForm/LogTableModelForm.tsx", + "text": "ResourceAttributes", + "reason": "ClickHouse column identifier shown as an editor placeholder" + }, + { + "file": "src/components/Sources/SourceForm/LogTableModelForm.tsx", + "text": "TraceId", + "reason": "ClickHouse column identifier shown as an editor placeholder" + }, + { + "file": "src/components/Sources/SourceForm/LogTableModelForm.tsx", + "text": "SpanId", + "reason": "ClickHouse column identifier shown as an editor placeholder" + }, + { + "file": "src/components/Sources/SourceForm/LogTableModelForm.tsx", + "text": "Timestamp, Body", + "reason": "ClickHouse column identifier list shown as an editor placeholder" + }, + { + "file": "src/components/Sources/SourceForm/LogTableModelForm.tsx", + "text": "Timestamp, Body, ServiceName", + "reason": "ClickHouse column identifier list shown as an editor placeholder" + }, + { + "file": "src/components/Sources/SourceForm/MaterializedViews.tsx", + "text": "Timestamp", + "reason": "ClickHouse column identifier shown as an editor placeholder" + }, + { + "file": "src/components/Sources/SourceForm/MaterializedViews.tsx", + "text": "ServiceName, StatusCode", + "reason": "ClickHouse column identifier list shown as an editor placeholder" + }, + { + "file": "src/components/Sources/SourceForm/MaterializedViews.tsx", + "text": "YYYY-MM-DD HH:mm:ss", + "reason": "Date input format pattern that must match the parsed value format" + }, + { + "file": "src/components/Sources/SourceForm/SessionTableModelForm.tsx", + "text": "ResourceAttributes", + "reason": "ClickHouse column identifier shown as an editor placeholder" + }, + { + "file": "src/components/Sources/SourceForm/TraceTableModelForm.tsx", + "text": "Timestamp", + "reason": "ClickHouse column identifier shown as an editor placeholder" + }, + { + "file": "src/components/Sources/SourceForm/TraceTableModelForm.tsx", + "text": "TraceId", + "reason": "ClickHouse column identifier shown as an editor placeholder" + }, + { + "file": "src/components/Sources/SourceForm/TraceTableModelForm.tsx", + "text": "SpanId", + "reason": "ClickHouse column identifier shown as an editor placeholder" + }, + { + "file": "src/components/Sources/SourceForm/TraceTableModelForm.tsx", + "text": "ParentSpanId", + "reason": "ClickHouse column identifier shown as an editor placeholder" + }, + { + "file": "src/components/Sources/SourceForm/TraceTableModelForm.tsx", + "text": "SpanName", + "reason": "ClickHouse column identifier shown as an editor placeholder" + }, + { + "file": "src/components/Sources/SourceForm/TraceTableModelForm.tsx", + "text": "SpanKind", + "reason": "ClickHouse column identifier shown as an editor placeholder" + }, + { + "file": "src/components/Sources/SourceForm/TraceTableModelForm.tsx", + "text": "StatusCode", + "reason": "ClickHouse column identifier shown as an editor placeholder" + }, + { + "file": "src/components/Sources/SourceForm/TraceTableModelForm.tsx", + "text": "StatusMessage", + "reason": "ClickHouse column identifier shown as an editor placeholder" + }, + { + "file": "src/components/Sources/SourceForm/TraceTableModelForm.tsx", + "text": "ServiceName", + "reason": "ClickHouse column identifier shown as an editor placeholder" + }, + { + "file": "src/components/Sources/SourceForm/TraceTableModelForm.tsx", + "text": "ResourceAttributes", + "reason": "ClickHouse column identifier shown as an editor placeholder" + }, + { + "file": "src/components/Sources/SourceForm/TraceTableModelForm.tsx", + "text": "SpanAttributes", + "reason": "ClickHouse column identifier shown as an editor placeholder" + }, + { + "file": "src/components/Sources/SourceForm/TraceTableModelForm.tsx", + "text": "SampleRate", + "reason": "ClickHouse column identifier shown as an editor placeholder" + }, + { + "file": "src/components/Sources/SourceForm/TraceTableModelForm.tsx", + "text": "Events", + "reason": "ClickHouse column identifier shown as an editor placeholder" + }, + { + "file": "src/components/Sources/SourceForm/TraceTableModelForm.tsx", + "text": "Timestamp, ServiceName, StatusCode, Duration, SpanName", + "reason": "ClickHouse column identifier list shown as an editor placeholder" + }, + { + "file": "src/components/Sources/SourceForm/TraceTableModelForm.tsx", + "text": "Timestamp, Body, ServiceName", + "reason": "ClickHouse column identifier list shown as an editor placeholder" + }, + { + "file": "src/components/ConnectionForm.tsx", + "text": "hyperdx", + "reason": "ClickHouse custom setting prefix value shown as an input placeholder" + }, + { + "file": "src/components/ClickStackOnboarding/McpInstallPanel.tsx", + "text": "Claude Code", + "reason": "MCP host product name displayed verbatim" + }, + { + "file": "src/components/ClickStackOnboarding/McpInstallPanel.tsx", + "text": "Cursor", + "reason": "MCP host product name displayed verbatim" + }, + { + "file": "src/components/ClickStackOnboarding/McpInstallPanel.tsx", + "text": "VS Code", + "reason": "MCP host product name displayed verbatim" + }, + { + "file": "src/components/ClickStackOnboarding/McpInstallPanel.tsx", + "text": "Codex CLI", + "reason": "MCP host product name displayed verbatim" + }, + { + "file": "src/components/ClickStackOnboarding/McpInstallPanel.tsx", + "text": "OpenCode", + "reason": "MCP host product name displayed verbatim" + }, + { + "file": "src/components/TimePicker/TimePicker.tsx", + "text": "d", + "reason": "Keyboard shortcut key hint that must match the bound hotkey" + }, + { + "file": "src/components/HyperJson.tsx", + "text": "null", + "reason": "JSON literal rendered verbatim" + }, + { + "file": "src/components/HyperJson.tsx", + "text": "true", + "reason": "JSON literal rendered verbatim" + }, + { + "file": "src/components/HyperJson.tsx", + "text": "false", + "reason": "JSON literal rendered verbatim" + }, + { + "file": "src/components/MetricNameSelect.tsx", + "text": "(Gauge)", + "reason": "OpenTelemetry metric type suffix displayed verbatim" + }, + { + "file": "src/components/MetricNameSelect.tsx", + "text": "(Histogram)", + "reason": "OpenTelemetry metric type suffix displayed verbatim" + }, + { + "file": "src/components/MetricNameSelect.tsx", + "text": "(Sum)", + "reason": "OpenTelemetry metric type suffix displayed verbatim" + }, + { + "file": "src/hooks/usePromqlMetadata.ts", + "text": "__name__", + "reason": "PromQL reserved label name displayed verbatim" + }, + { + "file": "src/SVGIcons.tsx", + "text": "Incident.io", + "reason": "Product name displayed verbatim" + }, + { + "file": "src/theme/themes/hyperdx/Wordmark.tsx", + "text": "HyperDX", + "reason": "Brand wordmark displayed verbatim" + }, + { + "file": "src/components/Sources/SourceForm/TraceTableModelForm.tsx", + "text": "Links", + "reason": "ClickHouse column identifier shown as an editor placeholder" + }, + { + "file": "src/components/MetricNameSelect.tsx", + "text": "(Exponential Histogram)", + "reason": "OpenTelemetry metric type suffix displayed verbatim" + } +] diff --git a/packages/app/package.json b/packages/app/package.json index 0e7a3805e4..d653a079c6 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -14,10 +14,12 @@ "build:clickhouse": "NEXT_PUBLIC_THEME=clickstack NEXT_PUBLIC_IS_LOCAL_MODE=true NEXT_PUBLIC_CLICKHOUSE_BUILD=true next build --webpack && node scripts/prepare-clickhouse-build-export.js", "run:clickhouse": "test -d out && npx rimraf tmp && mkdir tmp && cp -r out tmp/clickstack && echo 'visit http://localhost:3000/clickstack to start' && npx serve tmp -l 3000 || echo 'run build:clickhouse first'", "start": "next start", - "lint": "npx eslint . --ext .ts,.tsx --max-warnings 740", + "lint": "npx eslint . --ext .ts,.tsx --max-warnings 745", "lint:fix": "npx eslint . --ext .ts,.tsx --fix", + "lint:i18n": "node scripts/check-i18n.mjs", + "lint:i18n:script": "prettier --check scripts/check-i18n.mjs", "lint:styles": "stylelint **/*/*.{css,scss}", - "ci:lint": "yarn lint && yarn tsc --noEmit && yarn lint:styles --quiet", + "ci:lint": "yarn lint && yarn lint:i18n && yarn lint:i18n:script && yarn tsc --noEmit && yarn lint:styles --quiet", "ci:unit": "jest --ci --coverage", "dev:unit": "jest --watchAll", "test:e2e": "node scripts/run-e2e.js", @@ -64,6 +66,7 @@ "flat": "^5.0.2", "fuse.js": "^6.6.2", "http-proxy-middleware": "^4.0.0", + "i18next": "^26.3.6", "immer": "^9.0.21", "jotai": "^2.5.1", "ky": "^0.30.0", @@ -84,6 +87,7 @@ "react-grid-layout": "^1.3.4", "react-hook-form": "^7.43.8", "react-hotkeys-hook": "^4.3.7", + "react-i18next": "^17.0.9", "react-is": "^19.0.0", "react-json-tree": "^0.20.0", "react-markdown": "^10.1.0", diff --git a/packages/app/pages/_app.tsx b/packages/app/pages/_app.tsx index 28e4602d37..ac243b6e9b 100644 --- a/packages/app/pages/_app.tsx +++ b/packages/app/pages/_app.tsx @@ -22,6 +22,7 @@ import { MANTINE_FONT_MAP, } from '@/config/fonts'; import { ibmPlexMono, inter, roboto, robotoMono } from '@/fonts'; +import { I18nProvider } from '@/i18n/I18nProvider'; import { AppThemeProvider, useAppTheme } from '@/theme/ThemeProvider'; import { ThemeWrapper } from '@/ThemeWrapper'; import { NextApiConfigResponseData } from '@/types'; @@ -183,12 +184,14 @@ export default function MyApp({ Component, pageProps }: AppPropsWithLayout) { return ( - - - - - - + + + + + + + + ); diff --git a/packages/app/pages/trace/[traceId].tsx b/packages/app/pages/trace/[traceId].tsx index 61a1df7154..4200315631 100644 --- a/packages/app/pages/trace/[traceId].tsx +++ b/packages/app/pages/trace/[traceId].tsx @@ -1,11 +1,13 @@ import { useEffect } from 'react'; import { useRouter } from 'next/router'; +import { useTranslation } from 'react-i18next'; import { Center, Text } from '@mantine/core'; import { withAppNav } from '@/layout'; import { buildTraceRedirectUrl } from '@/utils/directTrace'; export function TraceRedirectPage() { + const { t } = useTranslation('search'); const { isReady, query, replace } = useRouter(); const traceIdParam = Array.isArray(query.traceId) ? query.traceId[0] @@ -30,7 +32,7 @@ export function TraceRedirectPage() { return (
- Redirecting to search... + {t('redirecting')}
); diff --git a/packages/app/scripts/__fixtures__/i18n-audit/deterministic/a-second.tsx b/packages/app/scripts/__fixtures__/i18n-audit/deterministic/a-second.tsx new file mode 100644 index 0000000000..e64de3fd2d --- /dev/null +++ b/packages/app/scripts/__fixtures__/i18n-audit/deterministic/a-second.tsx @@ -0,0 +1,6 @@ +export const Second = () => ( +
+ Second text + +
+); diff --git a/packages/app/scripts/__fixtures__/i18n-audit/deterministic/z-first.tsx b/packages/app/scripts/__fixtures__/i18n-audit/deterministic/z-first.tsx new file mode 100644 index 0000000000..ed35149dc8 --- /dev/null +++ b/packages/app/scripts/__fixtures__/i18n-audit/deterministic/z-first.tsx @@ -0,0 +1,6 @@ +export const First = () => ( +
+ + First text +
+); diff --git a/packages/app/scripts/__fixtures__/i18n-audit/invalid-ts.ts b/packages/app/scripts/__fixtures__/i18n-audit/invalid-ts.ts new file mode 100644 index 0000000000..742385b3f7 --- /dev/null +++ b/packages/app/scripts/__fixtures__/i18n-audit/invalid-ts.ts @@ -0,0 +1,3 @@ +export const options = { + description: 'Type asserted filter', +}; diff --git a/packages/app/scripts/__fixtures__/i18n-audit/invalid.tsx b/packages/app/scripts/__fixtures__/i18n-audit/invalid.tsx new file mode 100644 index 0000000000..87e5f21fb0 --- /dev/null +++ b/packages/app/scripts/__fixtures__/i18n-audit/invalid.tsx @@ -0,0 +1,47 @@ +const Widget = (_props: { + description?: string; + label?: string; + title?: string; +}) => null; + +const t = (key: string) => key; + +export const Invalid = () => { + const copied = true; + const name = 'Taylor'; + const notification = { message: 'Saved successfully' }; + const copyOptions = { + description: copied ? 'Added filter' : 'Edited filter', + label: 'Add ' + 'filter', + placeholder: 'Filter query' satisfies string, + title: ('Edit filter' as string)!, + }; + + return ( +
+ Welcome back + {'Warning: Alerts require attention'} + {`Hello ${name}`} + {`${copied ? 'Created' : 'Updated'}`} + {`${'Add ' + 'filter'}`} + {`${('Wrapped filter' as string)!}`} + {`${t('actions.save')}`} + {`${name}`} + {copied ? 'Copied!' : 'Copy'} + {'Add filter'} + {'Edit ' + 'filter'} + {'Copy ' + name} + + + {notification.message} + {copyOptions.label} +
+ ); +}; diff --git a/packages/app/scripts/__fixtures__/i18n-audit/valid.tsx b/packages/app/scripts/__fixtures__/i18n-audit/valid.tsx new file mode 100644 index 0000000000..32feab8955 --- /dev/null +++ b/packages/app/scripts/__fixtures__/i18n-audit/valid.tsx @@ -0,0 +1,37 @@ +import { Trans, useTranslation } from 'react-i18next'; + +const documentationUrl = 'https://www.hyperdx.io/docs'; + +const Field = (_props: { + description?: string; + label?: boolean | string; + title?: string; +}) => null; + +export const Valid = () => { + const { t } = useTranslation(); + const value = 'dynamic value'; + const copied = true; + + return ( + + {t('actions.save')} + {`${value}`} + {`${copied ? t('actions.save') : t('actions.cancel')}`} + {`${t('actions.save') + value}`} + {`${value}`} + + + + {'Fallback title'} + · + + ); +}; diff --git a/packages/app/scripts/check-i18n.mjs b/packages/app/scripts/check-i18n.mjs new file mode 100644 index 0000000000..7a7f0b0d86 --- /dev/null +++ b/packages/app/scripts/check-i18n.mjs @@ -0,0 +1,440 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const packageRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', +); +const userFacingAttributes = new Set( + 'aria-label,ariaLabel,description,label,placeholder,title,tooltip,alt'.split(','), +); +const userFacingProperties = new Set( + 'description,label,message,placeholder,title'.split(','), +); +const containsLatin = /[A-Za-z]/; + +const toPackageRelative = filePath => + path.relative(packageRoot, filePath).split(path.sep).join('/'); + +const isExcluded = filePath => { + const relativePath = toPackageRelative(filePath); + const segments = relativePath.split('/'); + const basename = path.basename(relativePath); + + return ( + relativePath === 'src/i18n' || + relativePath.startsWith('src/i18n/') || + relativePath === 'src/mocks' || + relativePath.startsWith('src/mocks/') || + relativePath === 'src/__mocks__' || + relativePath.startsWith('src/__mocks__/') || + relativePath === 'pages/api' || + relativePath.startsWith('pages/api/') || + segments.includes('__tests__') || + /\.(test|spec|stories)\.[cm]?[jt]sx?$/.test(basename) + ); +}; + +const collectSourceFiles = directory => { + if (!fs.existsSync(directory)) { + return []; + } + + return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => { + const entryPath = path.join(directory, entry.name); + + if (entry.isDirectory()) { + return collectSourceFiles(entryPath); + } + + return /\.tsx?$/.test(entry.name) && !isExcluded(entryPath) + ? [entryPath] + : []; + }); +}; + +const normalizeText = value => value.replace(/\s+/g, ' ').trim(); + +const textWithoutJsxEntities = value => + value.replace(/&(?:#\d+|#x[\da-f]+|[a-z][\da-z]*);/gi, ''); + +const isUrl = value => + /^(?:(?:https?|ftp|wss?):\/\/|file:\/\/|(?:mailto|tel):|\/\/|\/|\.{1,2}\/|#)/i.test( + value, + ); + +const staticText = (node, text) => ({ + candidates: [{ node, text }], + isStatic: true, +}); + +const dynamicText = candidates => ({ candidates, isStatic: false }); + +const getStaticTexts = node => { + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + return staticText(node, node.text); + } + + if (ts.isTemplateExpression(node)) { + const candidates = node.head.text + ? [{ node: node.head, text: node.head.text }] + : []; + + for (const span of node.templateSpans) { + candidates.push(...getStaticTexts(span.expression).candidates); + + if (span.literal.text) { + candidates.push({ node: span.literal, text: span.literal.text }); + } + } + + return dynamicText(candidates); + } + + if (ts.isConditionalExpression(node)) { + const whenTrue = getStaticTexts(node.whenTrue); + const whenFalse = getStaticTexts(node.whenFalse); + + return dynamicText([...whenTrue.candidates, ...whenFalse.candidates]); + } + + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.PlusToken + ) { + const left = getStaticTexts(node.left); + const right = getStaticTexts(node.right); + + if (left.isStatic && right.isStatic) { + return staticText( + node, + `${left.candidates[0].text}${right.candidates[0].text}`, + ); + } + + return dynamicText([...left.candidates, ...right.candidates]); + } + + if ( + ts.isParenthesizedExpression(node) || + ts.isAsExpression(node) || + ts.isSatisfiesExpression(node) || + ts.isTypeAssertionExpression(node) || + ts.isNonNullExpression(node) + ) { + return getStaticTexts(node.expression); + } + + return dynamicText([]); +}; + +const propertyName = name => { + if ( + ts.isIdentifier(name) || + ts.isStringLiteral(name) || + ts.isNumericLiteral(name) + ) { + return name.text; + } + + return undefined; +}; + +const tagName = tag => tag.getText().split('.').at(-1); + +const isWithinTrans = node => { + let ancestor = node.parent; + + while (ancestor != null) { + if ( + ts.isJsxElement(ancestor) && + tagName(ancestor.openingElement.tagName) === 'Trans' + ) { + return true; + } + + ancestor = ancestor.parent; + } + + return false; +}; + +const isJsxChildExpression = node => + ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent); + +const createDiagnostic = (sourceFile, node, text, context) => { + const position = sourceFile.getLineAndCharacterOfPosition( + node.getStart(sourceFile), + ); + + return { + file: toPackageRelative(sourceFile.fileName), + line: position.line + 1, + column: position.character + 1, + text: normalizeText(text), + context, + }; +}; + +const scanFile = filePath => { + const sourceFile = ts.createSourceFile( + filePath, + fs.readFileSync(filePath, 'utf8'), + ts.ScriptTarget.Latest, + true, + filePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + const diagnostics = []; + + const addIfUserFacing = (node, text, context) => { + const normalized = normalizeText(text); + + if ( + normalized && + !isUrl(normalized) && + containsLatin.test(textWithoutJsxEntities(normalized)) + ) { + diagnostics.push(createDiagnostic(sourceFile, node, normalized, context)); + } + }; + + const addStaticTexts = (result, context) => { + for (const candidate of result.candidates) { + addIfUserFacing(candidate.node, candidate.text, context); + } + }; + + const visit = node => { + if (ts.isJsxText(node) && !isWithinTrans(node)) { + addIfUserFacing(node, node.getText(sourceFile), 'JSX text'); + } + + if ( + ts.isJsxExpression(node) && + isJsxChildExpression(node) && + !isWithinTrans(node) && + node.expression != null + ) { + addStaticTexts(getStaticTexts(node.expression), 'JSX child expression'); + } + + if (ts.isJsxAttribute(node) && userFacingAttributes.has(node.name.text)) { + const initializer = node.initializer; + + if (initializer != null) { + const expression = ts.isJsxExpression(initializer) + ? initializer.expression + : initializer; + + if (expression != null) { + addStaticTexts( + getStaticTexts(expression), + `${node.name.text} attribute`, + ); + } + } + } + + if (ts.isPropertyAssignment(node)) { + const name = propertyName(node.name); + + if (name != null && userFacingProperties.has(name)) { + addStaticTexts(getStaticTexts(node.initializer), `${name} property`); + } + } + + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + + return diagnostics; +}; + +const readAllowlist = ( + allowlistPath = path.join(packageRoot, 'i18n-audit-allowlist.json'), +) => { + let parsed; + + try { + parsed = JSON.parse(fs.readFileSync(allowlistPath, 'utf8')); + } catch (error) { + throw new Error( + `Unable to read ${path.basename(allowlistPath)}: ${error.message}`, + ); + } + + if (!Array.isArray(parsed)) { + throw new Error('i18n-audit-allowlist.json must contain an array'); + } + + const entries = parsed.map((entry, index) => { + if ( + entry == null || + typeof entry !== 'object' || + Array.isArray(entry) || + Object.keys(entry).length !== 3 || + !Object.hasOwn(entry, 'file') || + !Object.hasOwn(entry, 'text') || + !Object.hasOwn(entry, 'reason') || + typeof entry.file !== 'string' || + !entry.file.trim() || + typeof entry.text !== 'string' || + !entry.text.trim() || + typeof entry.reason !== 'string' || + !entry.reason.trim() + ) { + throw new Error( + `Allowlist entry ${index} must be exactly { file, text, reason } with nonblank strings`, + ); + } + + return entry; + }); + const keys = new Set(); + + for (const entry of entries) { + const key = `${entry.file}\u0000${entry.text}`; + + if (keys.has(key)) { + throw new Error( + `Duplicate allowlist entry for ${entry.file}: ${entry.text}`, + ); + } + + keys.add(key); + } + + return entries; +}; + +const compareStrings = (left, right) => + left === right ? 0 : left < right ? -1 : 1; + +const sortDiagnostics = diagnostics => + diagnostics.sort( + (left, right) => + compareStrings(left.file, right.file) || + left.line - right.line || + left.column - right.column || + compareStrings(left.text, right.text) || + compareStrings(left.context, right.context), + ); + +const formatDiagnostic = diagnostic => + `${diagnostic.file}:${diagnostic.line}:${diagnostic.column} ${diagnostic.context}: ${JSON.stringify(diagnostic.text)}`; + +const usage = + 'Usage: node scripts/check-i18n.mjs [--fixture valid|invalid|invalid-ts] [--allowlist path]'; + +const parseArguments = args => { + const options = { allowlistPath: undefined, fixtureName: undefined }; + + for (let index = 0; index < args.length; index += 1) { + const option = args[index]; + const value = args[index + 1]; + + if ((option === '--fixture' || option === '--allowlist') && value != null) { + options[option === '--fixture' ? 'fixtureName' : 'allowlistPath'] = value; + index += 1; + continue; + } + + return undefined; + } + + return options; +}; + +const options = parseArguments(process.argv.slice(2)); +const fixtureFiles = { + deterministic: 'deterministic', + 'deterministic-reverse': [ + 'deterministic/z-first.tsx', + 'deterministic/a-second.tsx', + ], + valid: 'valid.tsx', + invalid: 'invalid.tsx', + 'invalid-ts': 'invalid-ts.ts', +}; + +if ( + options == null || + (options.fixtureName != null && + !Object.hasOwn(fixtureFiles, options.fixtureName)) +) { + console.error(usage); + process.exitCode = 2; +} else { + try { + const fixturePaths = options.fixtureName + ? (Array.isArray(fixtureFiles[options.fixtureName]) + ? fixtureFiles[options.fixtureName] + : [fixtureFiles[options.fixtureName]] + ).map(file => + path.join(packageRoot, 'scripts', '__fixtures__', 'i18n-audit', file), + ) + : []; + const files = + fixturePaths.length > 0 + ? fixturePaths.flatMap(fixturePath => + path.extname(fixturePath) + ? [fixturePath] + : collectSourceFiles(fixturePath), + ) + : [ + ...collectSourceFiles(path.join(packageRoot, 'src')), + ...collectSourceFiles(path.join(packageRoot, 'pages')), + ]; + const diagnostics = sortDiagnostics(files.flatMap(scanFile)); + const allowlist = options.allowlistPath + ? readAllowlist(options.allowlistPath) + : options.fixtureName + ? [] + : readAllowlist(); + const allowlistKeys = new Set( + allowlist.map(entry => `${entry.file}\u0000${entry.text}`), + ); + const usedAllowlistKeys = new Set(); + const unallowlisted = diagnostics.filter(diagnostic => { + const key = `${diagnostic.file}\u0000${diagnostic.text}`; + + if (!allowlistKeys.has(key)) { + return true; + } + + usedAllowlistKeys.add(key); + return false; + }); + const staleEntries = allowlist.filter( + entry => !usedAllowlistKeys.has(`${entry.file}\u0000${entry.text}`), + ); + + if (unallowlisted.length > 0) { + console.error( + `i18n audit found ${unallowlisted.length} hardcoded string(s):`, + ); + for (const diagnostic of unallowlisted) { + console.error(formatDiagnostic(diagnostic)); + } + } + + if (staleEntries.length > 0) { + console.error('i18n audit has unused allowlist entry/entries:'); + for (const entry of staleEntries) { + console.error(`${entry.file}: ${JSON.stringify(entry.text)}`); + } + } + + if (unallowlisted.length > 0 || staleEntries.length > 0) { + process.exitCode = 1; + } else { + console.log(`i18n audit passed (${files.length} file(s) scanned)`); + } + } catch (error) { + console.error(`i18n audit failed: ${error.message}`); + process.exitCode = 2; + } +} diff --git a/packages/app/src/AlertsPage.tsx b/packages/app/src/AlertsPage.tsx index 1d1345c0f3..f1d47a096c 100644 --- a/packages/app/src/AlertsPage.tsx +++ b/packages/app/src/AlertsPage.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import Head from 'next/head'; import Link from 'next/link'; import { useQueryState } from 'nuqs'; +import { Trans, useTranslation } from 'react-i18next'; import ReactMarkdown from 'react-markdown'; import { AlertSource, @@ -51,10 +52,10 @@ import type { AlertsPageItem } from './types'; import styles from '@styles/AlertsPage.module.scss'; -function getAlertDisplayName(alert: AlertsPageItem): string { +function getAlertDisplayName(alert: AlertsPageItem, tileLabel: string): string { if (alert.source === AlertSource.TILE && alert.dashboard) { const tile = alert.dashboard.tiles.find(t => t.id === alert.tileId); - const tileName = tile?.config.name || 'Tile'; + const tileName = tile?.config.name || tileLabel; return `${alert.dashboard.name} ${tileName}`; } if (alert.source === AlertSource.SAVED_SEARCH && alert.savedSearch) { @@ -73,6 +74,7 @@ function getAlertCreatorLabel(alert: AlertsPageItem): string | undefined { } function AlertNote({ note }: { note: string }) { + const { t } = useTranslation('alerts'); const [opened, { toggle }] = useDisclosure(false); return ( @@ -88,7 +90,7 @@ function AlertNote({ note }: { note: string }) { /> - Note + {t('page.note')} @@ -123,12 +125,13 @@ function AlertNote({ note }: { note: string }) { } function AlertDetails({ alert }: { alert: AlertsPageItem }) { + const { t } = useTranslation('alerts'); const alertName = React.useMemo(() => { if (alert.source === AlertSource.TILE && alert.dashboard) { const tile = alert.dashboard?.tiles.find( tile => tile.id === alert.tileId, ); - const tileName = tile?.config.name || 'Tile'; + const tileName = tile?.config.name || t('page.tile'); return ( <> {alert.dashboard?.name} @@ -145,7 +148,7 @@ function AlertDetails({ alert }: { alert: AlertsPageItem }) { return alert.savedSearch?.name; } return '–'; - }, [alert]); + }, [alert, t]); const alertUrl = React.useMemo(() => { if (alert.source === AlertSource.TILE && alert.dashboard) { @@ -174,55 +177,59 @@ function AlertDetails({ alert }: { alert: AlertsPageItem }) { alert.thresholdType; return ( <> - If value {thresholdLabel}{' '} + {t('page.ifValue')} {thresholdLabel}{' '} {alert.threshold} {isRangeThresholdType(alert.thresholdType) && ( <> {' '} - and {alert.thresholdMax ?? '-'} + {t('page.and')}{' '} + {alert.thresholdMax ?? '-'} )} · ); - }, [alert]); + }, [alert, t]); const notificationMethod = React.useMemo(() => { return ( - Notify via {getWebhookChannelIcon(alert.channel.type)} Webhook + {t('page.notifyVia')} {getWebhookChannelIcon(alert.channel.type)}{' '} + {t('page.webhook')} ); - }, [alert]); + }, [alert, t]); const linkTitle = React.useMemo(() => { switch (alert.source) { case AlertSource.TILE: - return 'Dashboard tile'; + return t('page.sourceTypes.dashboardTile'); case AlertSource.SAVED_SEARCH: - return 'Saved search'; + return t('page.sourceTypes.savedSearch'); default: return ''; } - }, [alert]); + }, [alert, t]); return (
{alert.state === AlertState.ALERT && ( - Alert + {t('page.states.alert')} )} {alert.state === AlertState.PENDING && ( - Pending + {t('page.states.pending')} )} - {alert.state === AlertState.OK && Ok} + {alert.state === AlertState.OK && ( + {t('page.states.ok')} + )} {alert.state === AlertState.DISABLED && ( - Disabled + {t('page.states.disabled')} )} @@ -247,7 +254,8 @@ function AlertDetails({ alert }: { alert: AlertsPageItem }) { <> · - Created by {alert.createdBy.name || alert.createdBy.email} + {t('page.createdBy')}{' '} + {alert.createdBy.name || alert.createdBy.email} )} @@ -274,6 +282,7 @@ function AlertDetails({ alert }: { alert: AlertsPageItem }) { } function AlertCardList({ alerts }: { alerts: AlertsPageItem[] }) { + const { t } = useTranslation('alerts'); const alarmAlerts = alerts.filter(alert => alert.state === AlertState.ALERT); const pendingAlerts = alerts.filter( alert => alert.state === AlertState.PENDING, @@ -285,7 +294,7 @@ function AlertCardList({ alerts }: { alerts: AlertsPageItem[] }) { {alarmAlerts.length > 0 && (
- Triggered + {t('page.states.triggered')} {alarmAlerts.map(alert => ( @@ -295,7 +304,7 @@ function AlertCardList({ alerts }: { alerts: AlertsPageItem[] }) { {pendingAlerts.length > 0 && (
- Pending + {t('page.states.pending')} {pendingAlerts.map(alert => ( @@ -304,14 +313,14 @@ function AlertCardList({ alerts }: { alerts: AlertsPageItem[] }) { )}
- OK + {t('page.states.okSection')} {okData.length === 0 && ( } - title="No alerts" - description="All alerts in OK state will appear here." + title={t('page.noAlerts')} + description={t('page.okEmpty')} /> )} {okData.map(alert => ( @@ -323,6 +332,7 @@ function AlertCardList({ alerts }: { alerts: AlertsPageItem[] }) { } export default function AlertsPage() { + const { t } = useTranslation('alerts'); const brandName = useBrandDisplayName(); const { data, isError, isLoading } = api.useAlerts(); @@ -359,12 +369,12 @@ export default function AlertsPage() { const q = search.toLowerCase(); result = result.filter( a => - getAlertDisplayName(a).toLowerCase().includes(q) || + getAlertDisplayName(a, t('page.tile')).toLowerCase().includes(q) || getAlertTags(a).some(t => t.toLowerCase().includes(q)), ); } return result; - }, [alerts, search, tagFilter, creatorFilter]); + }, [alerts, creatorFilter, search, t, tagFilter]); const hasFilters = !!(search?.trim() || tagFilter || creatorFilter); @@ -375,14 +385,14 @@ export default function AlertsPage() { style={{ display: 'flex', flexDirection: 'column', height: '100%' }} > - Alerts - {brandName} + {t('page.browserTitle', { brandName })} - +
{isLoading ? ( -
Loading...
+
{t('page.loading')}
) : isError ? ( -
Error
+
{t('page.error')}
) : alerts?.length ? ( - Alerts can be{' '} - - created - {' '} - from dashboard charts and saved searches. + + ), + }} + /> } value={search ?? ''} onChange={e => setSearch(e.currentTarget.value || null)} @@ -413,7 +427,7 @@ export default function AlertsPage() { /> {allTags.length > 0 && ( setCreatorFilter(v)} @@ -442,11 +456,9 @@ export default function AlertsPage() { } - title={hasFilters ? 'No matching alerts' : 'No alerts'} + title={hasFilters ? t('page.noMatching') : t('page.noAlerts')} description={ - hasFilters - ? 'Try adjusting your search or filters.' - : 'All alerts in OK state will appear here.' + hasFilters ? t('page.adjustFilters') : t('page.okEmpty') } /> )} @@ -455,19 +467,16 @@ export default function AlertsPage() { } - title="No alerts created yet" + title={t('page.empty')} description={ - <> - Alerts can be created from{' '} - - dashboard charts - {' '} - and{' '} - - saved searches - - . - + , + searches: , + }} + /> } /> )} diff --git a/packages/app/src/AuthLoadingBlocker.tsx b/packages/app/src/AuthLoadingBlocker.tsx index 455e20f690..65dabbf29c 100644 --- a/packages/app/src/AuthLoadingBlocker.tsx +++ b/packages/app/src/AuthLoadingBlocker.tsx @@ -1,9 +1,11 @@ import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useBrandDisplayName } from './theme/ThemeProvider'; import api from './api'; export default function AuthLoadingBlocker() { + const { t } = useTranslation('common'); const brandName = useBrandDisplayName(); const { data: meData } = api.useMe(); @@ -27,7 +29,7 @@ export default function AuthLoadingBlocker() { role="status" aria-live="polite" > - Loading {brandName} + {t('states.loading')} {brandName} diff --git a/packages/app/src/AuthPage.tsx b/packages/app/src/AuthPage.tsx index 1aabb78088..6bada91bb7 100644 --- a/packages/app/src/AuthPage.tsx +++ b/packages/app/src/AuthPage.tsx @@ -4,6 +4,7 @@ import { useRouter } from 'next/router'; import { NextSeo } from 'next-seo'; import { HTTPError } from 'ky'; import { SubmitHandler, useForm, useWatch } from 'react-hook-form'; +import { Trans, useTranslation } from 'react-i18next'; import { Button, Notification, @@ -27,6 +28,7 @@ type FormData = { }; export default function AuthPage({ action }: { action: 'register' | 'login' }) { + const { t } = useTranslation('auth'); const brandName = useBrandDisplayName(); const { data: team, isLoading: teamIsLoading } = api.useTeam(); const router = useRouter(); @@ -57,7 +59,9 @@ export default function AuthPage({ action }: { action: 'register' | 'login' }) { const verificationSent = msg === 'verify'; - const title = `${brandName} - ${isRegister ? 'Sign up' : 'Login'}`; + const title = t(isRegister ? 'register.browserTitle' : 'login.browserTitle', { + brandName, + }); useEffect(() => { // If an OSS user accidentally lands on /register after already creating a team @@ -106,7 +110,7 @@ export default function AuthPage({ action }: { action: 'register' | 'login' }) { } setError('root', { type: 'manual', - message: 'An unexpected error occurred, please try again later.', + message: t('errors.unexpected'), }); }, }, @@ -135,19 +139,27 @@ export default function AuthPage({ action }: { action: 'register' | 'login' }) {
- {config.IS_OSS && isRegister - ? 'Setup ' - : isRegister - ? 'Register for ' - : 'Login to '} - {brandName} + , + }} + />
{action === 'login' && ( -
Welcome back!
+
{t('login.title')}
)} {isRegister && config.IS_OSS === true && (
- Let{"'"}s create your user account. + {t('register.intro')}
)}
@@ -155,10 +167,10 @@ export default function AuthPage({ action }: { action: 'register' | 'login' }) { } error={errors.email?.message} @@ -167,12 +179,12 @@ export default function AuthPage({ action }: { action: 'register' | 'login' }) { /> } error={errors.password?.message} required - placeholder="Password" + placeholder={t('common.password')} {...form.password} /> {isRegister && ( @@ -183,7 +195,7 @@ export default function AuthPage({ action }: { action: 'register' | 'login' }) { handler={confirmPass} password={currentPassword} > - Confirm Password + {t('common.confirmPassword')} } size="md" @@ -191,7 +203,7 @@ export default function AuthPage({ action }: { action: 'register' | 'login' }) { withAsterisk={false} leftSection={} error={errors.confirmPassword?.message} - placeholder="Confirm Password" + placeholder={t('common.confirmPassword')} {...form.confirmPassword} /> @@ -209,10 +221,10 @@ export default function AuthPage({ action }: { action: 'register' | 'login' }) { data-test-id="submit" > {config.IS_OSS && isRegister - ? 'Create' + ? t('register.create') : isRegister - ? 'Register' - : 'Login'} + ? t('register.submit') + : t('login.submit')} @@ -225,16 +237,16 @@ export default function AuthPage({ action }: { action: 'register' | 'login' }) { data-test-id="auth-error-msg" > {err === 'missing' - ? 'Please provide a valid email and password' + ? t('errors.validCredentials') : err === 'invalid' - ? 'Email or password is invalid' + ? t('errors.invalidCredentials') : err === 'authFail' - ? 'Failed to login with email and password, please try again.' + ? t('errors.loginFailed') : err === 'passwordAuthNotAllowed' - ? 'Password authentication is not allowed by your team admin.' + ? t('errors.passwordNotAllowed') : err === 'teamAlreadyExists' - ? 'Team already exists, please login instead.' - : 'Unknown error occurred, please try again later.'} + ? t('errors.teamExists') + : t('errors.unknown')} )} @@ -245,20 +257,26 @@ export default function AuthPage({ action }: { action: 'register' | 'login' }) { color="green" data-test-id="auth-msg" > - Sent verification email! Please check your email inbox + {t('register.verificationSent')} )} {isRegister && config.IS_OSS === false && (
- Already have an account? Log in{' '} - instead. + }} + />
)} {action === 'login' && config.IS_OSS === false && (
- Don{"'"}t have an account yet?{' '} - Register instead. + }} + />
)} diff --git a/packages/app/src/BenchmarkPage.tsx b/packages/app/src/BenchmarkPage.tsx index 9a2c689364..f2e1d0c096 100644 --- a/packages/app/src/BenchmarkPage.tsx +++ b/packages/app/src/BenchmarkPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import dynamic from 'next/dynamic'; import { parseAsInteger, parseAsJson, useQueryState } from 'nuqs'; import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; import { DataFormat } from '@hyperdx/common-utils/dist/clickhouse'; import { DisplayType } from '@hyperdx/common-utils/dist/types'; import { Button, Code, Grid, Stack, Table, Text, Title } from '@mantine/core'; @@ -133,6 +134,7 @@ function useIndexes( } function BenchmarkPage() { + const { t } = useTranslation('marketing'); const [queries, setQueries] = useQueryState( 'queries', parseAsJson(), @@ -217,14 +219,14 @@ function BenchmarkPage() { return (
- Clickhouse Query Benchmark + {t('benchmark.title')} - Query 1 + {t('benchmark.queryOne')} - Query 2 + {t('benchmark.queryTwo')} {isQueryIdsLoading && ( - Running Benchmark... + {t('benchmark.running')} )} - Query Estimate & Indexes - Index utilization of your query + {t('benchmark.estimates')} + {t('benchmark.estimatesDescription')} diff --git a/packages/app/src/CareersPage.tsx b/packages/app/src/CareersPage.tsx index 82c842761f..13e02148f1 100644 --- a/packages/app/src/CareersPage.tsx +++ b/packages/app/src/CareersPage.tsx @@ -1,5 +1,6 @@ import { useMemo } from 'react'; import Head from 'next/head'; +import { useTranslation } from 'react-i18next'; import { Anchor, Avatar, @@ -80,6 +81,7 @@ function useGreenhouseJobs() { } export default function CareersPage() { + const { t } = useTranslation('marketing'); const { data: commitsData, isLoading: commitsLoading } = useRecentCommits(); const { data, isLoading, isError } = useGreenhouseJobs(); @@ -91,33 +93,23 @@ export default function CareersPage() { return ( - Careers | HyperDX + {t('careers.browserTitle')} - - ClickHouse Careers: Help Build the Future of Observability - + {t('careers.title')} - Join us to build ClickStack at ClickHouse, scaling a high performance - observability platform that ingests and queries petabytes of telemetry - across metrics, logs, and traces. + {t('careers.intro')}

- Open positions are listed below. + {t('careers.openPositions')}
{isLoading && } - {isError && ( - - Unable to load job listings. Please try again later. - - )} + {isError && {t('careers.loadError')}} {!isLoading && !isError && filteredJobs.length === 0 && ( - - No open positions at the moment. Check back soon! - + {t('careers.noPositions')} )} {filteredJobs.map(job => ( @@ -146,11 +138,10 @@ export default function CareersPage() { ))} - Recent Activity + {t('careers.recentActivity')} - See what types of problems our team (and community) have been working - on lately. + {t('careers.recentActivityDescription')} {commitsLoading && } diff --git a/packages/app/src/ChartUtils.tsx b/packages/app/src/ChartUtils.tsx index d9e2ad8a70..bfaba6fdcf 100644 --- a/packages/app/src/ChartUtils.tsx +++ b/packages/app/src/ChartUtils.tsx @@ -36,6 +36,8 @@ import { } from '@hyperdx/common-utils/dist/types'; import { notifications } from '@mantine/notifications'; +import i18n from '@/i18n'; + import DateRangeIndicator from './components/charts/DateRangeIndicator'; import { MVOptimizationExplanationResult } from './hooks/useMVOptimizationExplanation'; import { DEFAULT_SERIES_LIMIT } from './defaults'; @@ -47,23 +49,22 @@ import { getColorProps, getLogLevelColorOrder, logLevelColor } from './utils'; type SortOrder = 'asc' | 'desc'; export const AGG_FNS = [ - { value: 'count' as const, label: 'Count of Events', isAttributable: false }, - { value: 'sum' as const, label: 'Sum', isAttributable: false }, - { value: 'p99' as const, label: '99th Percentile' }, - { value: 'p95' as const, label: '95th Percentile' }, - { value: 'p90' as const, label: '90th Percentile' }, - { value: 'p50' as const, label: 'Median' }, - { value: 'avg' as const, label: 'Average' }, - { value: 'max' as const, label: 'Maximum' }, - { value: 'min' as const, label: 'Minimum' }, + { value: 'count' as const, isAttributable: false }, + { value: 'sum' as const, isAttributable: false }, + { value: 'p99' as const }, + { value: 'p95' as const }, + { value: 'p90' as const }, + { value: 'p50' as const }, + { value: 'avg' as const }, + { value: 'max' as const }, + { value: 'min' as const }, { value: 'count_distinct' as const, - label: 'Count Distinct', isAttributable: false, }, - { value: 'any' as const, label: 'Any' }, - { value: 'increase' as const, label: 'Increase', isAttributable: false }, - { value: 'none' as const, label: 'Custom' }, + { value: 'any' as const }, + { value: 'increase' as const, isAttributable: false }, + { value: 'none' as const }, ]; export const DEFAULT_CHART_CONFIG: Omit< @@ -966,7 +967,7 @@ export function buildEventsSearchUrl({ if (logSourceId == null) { notifications.show({ color: 'yellow', - message: 'No log source is associated with the selected metric source.', + message: i18n.t('charts:page.noLogSource'), }); return null; } diff --git a/packages/app/src/ClickhousePage.tsx b/packages/app/src/ClickhousePage.tsx index 1ad6df4fad..71af040bbb 100644 --- a/packages/app/src/ClickhousePage.tsx +++ b/packages/app/src/ClickhousePage.tsx @@ -9,6 +9,7 @@ import { useQueryStates, } from 'nuqs'; import { useForm, useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; import { format as formatSql } from '@hyperdx/common-utils/dist/sqlFormatter'; import { ChartConfigWithDateRange, @@ -67,12 +68,14 @@ function InfrastructureTab({ connection: string; onTimeRangeSelect: (start: Date, end: Date) => void; }) { + const { t } = useTranslation('infrastructure'); + return ( - Network + {t('clickhouse.charts.network')} - Network activity for the entire machine, not only Clickhouse. + {t('clickhouse.charts.networkDescription')}
} @@ -265,6 +268,7 @@ function InsertsTab({ connection: string; onTimeRangeSelect: (start: Date, end: Date) => void; }) { + const { t } = useTranslation('infrastructure'); const [insertsBy, setInsertsBy] = useQueryState( 'insertsBy', parseAsStringEnum(['queries', 'rows', 'bytes']).withDefault('queries'), @@ -359,13 +363,13 @@ function InsertsTab({ - Insert{' '} + {t('clickhouse.charts.insert')}{' '} {insertsBy === 'queries' - ? 'Queries' + ? t('clickhouse.charts.insertQueries') : insertsBy === 'rows' - ? 'Rows' - : 'Bytes'}{' '} - Per Table + ? t('clickhouse.charts.insertRows') + : t('clickhouse.charts.insertBytes')}{' '} + {t('clickhouse.charts.insertPerTable')} } toolbarPrefix={[ @@ -378,9 +382,12 @@ function InsertsTab({ setInsertsBy(value); }} data={[ - { label: 'Queries', value: 'queries' }, - { label: 'Rows', value: 'rows' }, - { label: 'Bytes', value: 'bytes' }, + { + label: t('clickhouse.charts.insertQueries'), + value: 'queries', + }, + { label: t('clickhouse.charts.insertRows'), value: 'rows' }, + { label: t('clickhouse.charts.insertBytes'), value: 'bytes' }, ]} />, ]} @@ -392,7 +399,7 @@ function InsertsTab({ - Active Parts Per Partition + {t('clickhouse.charts.activePartsPerPartition')} - Recommended to stay under 300, ClickHouse will automatically - throttle inserts after 1,000 parts per partition and stop - inserts at 3,000 parts per partition. + {t('clickhouse.charts.activePartsDescription')} } @@ -491,6 +496,8 @@ function InsertsTab({ } function ClickhousePage() { + const { t } = useTranslation('infrastructure'); + const { t: tDashboard } = useTranslation('dashboard'); const brandName = useBrandDisplayName(); const { colorScheme } = useMantineColorScheme(); const { data: connections } = useConnections(); @@ -581,11 +588,11 @@ function ClickhousePage() { onSearch(DEFAULT_INTERVAL); }} > - Reset + {t('clickhouse.reset')} , ]; } - }, [latencyFilter, onSearch, setLatencyFilter]); + }, [latencyFilter, onSearch, setLatencyFilter, t]); const headerActions = ( @@ -602,14 +609,19 @@ function ClickhousePage() { onSearch={onSearch} /> - + @@ -621,10 +633,10 @@ function ClickhousePage() { const pageBreadcrumbs = ( - Dashboards + {tDashboard('page.dashboards')} - ClickHouse + {t('clickhouse.breadcrumb')} ); @@ -645,7 +657,7 @@ function ClickhousePage() { content={ <> - ClickHouse Dashboard – {brandName} + {t('clickhouse.browserTitle', { brandName })} - Select - Inserts + {t('clickhouse.tabs.select')} + + {t('clickhouse.tabs.inserts')} + {/* Merges / Mutations */} - Infrastructure + + {t('clickhouse.tabs.infrastructure')} + - Slowest Queries + {t('clickhouse.charts.slowestQueries')} { diff --git a/packages/app/src/DBChartPage.tsx b/packages/app/src/DBChartPage.tsx index b30215a5d8..afce93afa5 100644 --- a/packages/app/src/DBChartPage.tsx +++ b/packages/app/src/DBChartPage.tsx @@ -4,11 +4,13 @@ import Head from 'next/head'; import { parseAsJson, useQueryState } from 'nuqs'; import { useForm } from 'react-hook-form'; import { useHotkeys } from 'react-hotkeys-hook'; +import { Trans, useTranslation } from 'react-i18next'; import { SavedChartConfig, SourceKind } from '@hyperdx/common-utils/dist/types'; import { Alert, Box, Button, + Code, Collapse, Divider, Group, @@ -56,6 +58,7 @@ function AIAssistant({ aiAssistantEnabled: boolean; }) { const brandName = useBrandDisplayName(); + const { t } = useTranslation('charts'); const [opened, setOpened] = useState(false); const [alertDismissed, setAlertDismissed] = useLocalStorage( 'ai-assistant-alert-dismissed', @@ -96,14 +99,14 @@ function AIAssistant({ notifications.show({ color: 'green', - message: 'Chart generated successfully', + message: t('page.chartGenerated'), autoClose: 2000, }); }, onError(err) { notifications.show({ color: 'red', - title: 'Error Generating Chart', + title: t('page.errorGenerating'), message: err.message, autoClose: 2000, }); @@ -134,9 +137,12 @@ function AIAssistant({ p="xxs" > - New AI Assistant available, enable with configuring the{' '} - ANTHROPIC_API_KEY environment variable on the{' '} - {brandName} server. + }} + /> @@ -156,10 +162,10 @@ function AIAssistant({ ) : ( )} - AI Assistant [A] + {t('page.aiAssistantWithShortcut')} - Experimental + {t('page.experimental')} {opened && ( @@ -176,7 +182,7 @@ function AIAssistant({ /> ) : ( )} @@ -202,6 +208,7 @@ function AIAssistant({ function DBChartExplorerPage() { const brandName = useBrandDisplayName(); + const { t } = useTranslation('charts'); const { searchedTimeRange, displayedTimeInputValue, @@ -230,7 +237,7 @@ function DBChartExplorerPage() { return ( - Chart Explorer - {brandName} + {t('page.browserTitle', { brandName })} @@ -106,7 +108,7 @@ function CollapsibleMessage({ transform: detailsOpen ? 'rotate(90deg)' : 'rotate(0deg)', }} /> - {detailsOpen ? 'Hide Details' : 'Show Details'} + {detailsOpen ? t('import.hideDetails') : t('import.showDetails')} {details} @@ -121,6 +123,7 @@ function FileSelection({ }: { onComplete: (input: DashboardTemplate | null) => void; }) { + const { t } = useTranslation('dashboards'); // The schema for the form data we expect to receive const FormSchema = z.object({ file: z.instanceof(File).nullable() }); @@ -157,8 +160,8 @@ function FileSelection({ } catch (e: unknown) { onComplete(null); setError({ - message: 'Invalid JSON File', - details: e instanceof Error ? e.message : 'Failed to parse JSON', + message: t('import.invalidJson'), + details: e instanceof Error ? e.message : t('import.parseFailed'), }); return; } @@ -175,7 +178,7 @@ function FileSelection({ if (!result.success) { onComplete(null); setError({ - message: 'Failed to Import Dashboard', + message: t('import.importFailed'), details: ( {result.error.issues.map(issue => ( @@ -230,9 +233,7 @@ function FileSelection({ field.onChange(files[0]); handleSubmit(onSubmit)(); }} - onReject={() => - setError({ message: 'Invalid File Type or Size' }) - } + onReject={() => setError({ message: t('import.invalidFile') })} maxSize={5 * 1024 ** 2} maxFiles={1} accept={['application/json']} @@ -267,11 +268,10 @@ function FileSelection({
- Import Dashboard + {t('import.dropTitle')} - Drag and drop a JSON file here, or click to select from your - computer. + {t('import.dropDescription')}
@@ -292,11 +292,9 @@ function FileSelection({ {filterWarnings.map(warning => ( @@ -304,7 +302,10 @@ function FileSelection({ key={`${warning.index}:${warning.condition}`} c="yellow" > - Invalid {warning.language} condition: {warning.condition} + {t('import.invalidCondition', { + language: warning.language, + condition: warning.condition, + })} ))}
@@ -316,17 +317,18 @@ function FileSelection({ {filterQueryWarnings.map(warning => ( - {warning.filterName}: invalid {warning.language} query:{' '} - {warning.where} + {t('import.invalidQuery', { + name: warning.filterName, + language: warning.language, + query: warning.where, + })} ))} @@ -338,11 +340,13 @@ function FileSelection({ - Invalid {savedQueryWarning.language} query:{' '} - {savedQueryWarning.query} + {t('import.invalidQueryNoName', { + language: savedQueryWarning.language, + query: savedQueryWarning.query, + })} } /> @@ -407,6 +411,7 @@ const MappingFormStateSchema = z.object({ type MappingFormState = z.infer; export function Mapping({ input }: { input: DashboardTemplate }) { + const { t } = useTranslation('dashboards'); const router = useRouter(); const { data: sources } = useSources(); const { data: connections } = useConnections(); @@ -846,13 +851,13 @@ export function Mapping({ input }: { input: DashboardTemplate }) { // Redirect to the new/updated dashboard notifications.show({ color: 'green', - message: 'Import Successful!', + message: t('import.successful'), }); router.push(`/dashboards/${_dashboardId}`); } catch { notifications.show({ color: 'red', - message: 'Something went wrong. Please try again.', + message: t('import.unknownError'), }); } }; @@ -861,14 +866,14 @@ export function Mapping({ input }: { input: DashboardTemplate }) {
- Step 2: Map Data + {t('import.mappingStep')} ( @@ -879,8 +884,8 @@ export function Mapping({ input }: { input: DashboardTemplate }) { control={control} render={({ field }) => ( @@ -889,10 +894,10 @@ export function Mapping({ input }: { input: DashboardTemplate }) { - Tile / Filter - Mapping Type - From - To + {t('import.columns.item')} + {t('import.columns.mappingType')} + {t('import.columns.from')} + {t('import.columns.to')} @@ -906,7 +911,7 @@ export function Mapping({ input }: { input: DashboardTemplate }) { {tile.config.source && ( {tile.config.name} - Data Source + {t('import.dataSource')} {config.source ?? ''} @@ -925,7 +930,7 @@ export function Mapping({ input }: { input: DashboardTemplate }) { {isRawSql && ( {tile.config.name} - Data Connection + {t('import.dataConnection')} {config.connection} @@ -944,7 +949,7 @@ export function Mapping({ input }: { input: DashboardTemplate }) { {isOnClickSearchById(tile.config.onClick) && ( {tile.config.name} - On Click - Search Source + {t('import.onClickSearch')} {tile.config.onClick.target.id} @@ -965,7 +970,7 @@ export function Mapping({ input }: { input: DashboardTemplate }) { {isOnClickDashboardById(tile.config.onClick) && ( {tile.config.name} - On Click - Dashboard + {t('import.onClickDashboard')} {tile.config.onClick.target.id} @@ -988,8 +993,10 @@ export function Mapping({ input }: { input: DashboardTemplate }) { {input.filters?.map((filter, i) => ( - {filter.name} (Filter) - Data Source + + {filter.name} {t('import.filterSuffix')} + + {t('import.dataSource')} {filter.source} @@ -1007,10 +1014,12 @@ export function Mapping({ input }: { input: DashboardTemplate }) { {!!filter.appliesToSourceIds?.length && ( - {filter.name} (Filter) - - Applies to Sources + {filter.name} {t('import.filterSuffix')} + + + + {t('import.appliesToSources')} {filter.appliesToSourceIds.join(', ')} @@ -1018,7 +1027,7 @@ export function Mapping({ input }: { input: DashboardTemplate }) { @@ -1034,7 +1043,7 @@ export function Mapping({ input }: { input: DashboardTemplate }) { {createDashboard.error.toString()} )} @@ -1042,6 +1051,7 @@ export function Mapping({ input }: { input: DashboardTemplate }) { } function DBDashboardImportPage() { + const { t } = useTranslation('dashboards'); const brandName = useBrandDisplayName(); const router = useRouter(); @@ -1062,11 +1072,11 @@ function DBDashboardImportPage() { return (
- Import Dashboard - {brandName} + {t('import.browserTitle', { brandName })} - Dashboards + {t('import.dashboards')} {isTemplate && ( - Templates + {t('import.templates')} )} - Import + {t('import.breadcrumb')}
@@ -1089,13 +1099,17 @@ function DBDashboardImportPage() { ) : isTemplateNotFound ? ( - Oops! We couldn't find that template. + {t('import.templateNotFound')} - Try{' '} - - browsing available templates - - . + + ), + }} + /> ) : !isTemplate ? ( diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index c6892ae767..c86b0536a6 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -23,6 +23,7 @@ import { import { ErrorBoundary } from 'react-error-boundary'; import RGL from 'react-grid-layout'; import { useForm, useWatch } from 'react-hook-form'; +import { Trans, useTranslation } from 'react-i18next'; import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata'; import { convertToDashboardTemplate, @@ -223,6 +224,7 @@ function HeatmapTile({ dateRange: [Date, Date]; enabled?: boolean; }) { + const { t } = useTranslation('dashboard'); const { heatmapConfig, scaleType } = toHeatmapChartConfig(queriedConfig); const [clickPos, setClickPos] = useState<{ x: number; y: number } | null>( @@ -326,7 +328,7 @@ function HeatmapTile({ > - View in Event Deltas + {t('heatmap.viewEventDeltas')} @@ -418,6 +420,7 @@ const Tile = forwardRef( }, ref: ForwardedRef, ) => { + const { t } = useTranslation('dashboard'); const [isFullscreen, setIsFullscreen] = useState(false); const [isFocused, setIsFocused] = useState(false); @@ -623,16 +626,20 @@ const Tile = forwardRef( const alertTooltip = useMemo(() => { if (!alert) { - return 'Add alert'; + return t('tile.addAlert'); } - let tooltip = `Has alert and is in ${alert.state} state`; + let tooltip = t('tile.alertState', { state: alert.state ?? '' }); if (alert.silenced?.at) { const silencedAt = new Date(alert.silenced.at); // eslint-disable-next-line no-restricted-syntax - tooltip += `. Ack'd ${formatRelative(silencedAt, new Date())}`; + const currentTime = new Date(); + + tooltip += `. ${t('tile.acknowledged', { + time: formatRelative(silencedAt, currentTime), + })}`; } return tooltip; - }, [alert]); + }, [alert, t]); // Firing/recovery markers for this tile's alert, scoped to the *visible* // window — the fullscreen range while the fullscreen view is open, else the @@ -672,17 +679,17 @@ const Tile = forwardRef( return null; const message = isMissingFiltersMacro - ? 'Filters are not applied because the SQL does not include the required $__filters macro' + ? t('tile.filtersMacroMissing') : isMetricsSourceWithLuceneFilter - ? 'Lucene filters are not applied because they are not supported for metrics sources.' - : 'Filters are not applied because no Source is set for this chart'; + ? t('tile.luceneMetricsUnsupported') + : t('tile.filterSourceMissing'); return ( ); - }, [filters, queriedConfig, source]); + }, [filters, queriedConfig, source, t]); const hoverToolbar = useMemo(() => { const isRawSql = isRawSqlSavedChartConfig(chart.config); @@ -741,7 +748,7 @@ const Tile = forwardRef( - + } onClick={onDuplicateClick} > - Duplicate + {t('tile.duplicate')} } onClick={() => openFullscreen()} > - View fullscreen + {t('tile.fullscreen')} } onClick={onEditClick} > - Edit + {t('tile.edit')} {canMoveToGroup && ( <> - Move to Group + {t('tile.moveToGroup')} {chart.containerId && ( } onClick={() => onMoveToGroup(undefined)} > - (Ungrouped) + {t('tile.ungrouped')} )} {moveTargets @@ -840,7 +847,7 @@ const Tile = forwardRef( leftSection={} onClick={onDeleteClick} > - Delete + {t('tile.delete')} @@ -860,6 +867,7 @@ const Tile = forwardRef( onEditClick, onMoveToGroup, openFullscreen, + t, ]); // Flat Menu.Item list for the collapsed (narrow-tile) toolbar. @@ -894,30 +902,30 @@ const Tile = forwardRef( leftSection={} onClick={onDuplicateClick} > - Duplicate + {t('tile.duplicate')} } onClick={() => openFullscreen()} > - View fullscreen + {t('tile.fullscreen')} } onClick={onEditClick} > - Edit + {t('tile.edit')} {canMoveToGroup && ( <> - Move to Group + {t('tile.moveToGroup')} {chart.containerId && ( } onClick={() => onMoveToGroup(undefined)} > - (Ungrouped) + {t('tile.ungrouped')} )} {moveTargets @@ -974,7 +982,7 @@ const Tile = forwardRef( leftSection={} onClick={onDeleteClick} > - Delete + {t('tile.delete')} ); @@ -990,6 +998,7 @@ const Tile = forwardRef( onEditClick, onMoveToGroup, openFullscreen, + t, ]); const title = useMemo( @@ -1042,7 +1051,7 @@ const Tile = forwardRef( onError={console.error} fallback={
- An error occurred while rendering the chart. + {t('tile.renderError')}
} > @@ -1050,8 +1059,7 @@ const Tile = forwardRef( - The data source for this tile no longer exists. Edit the - tile to select a new source. + {t('tile.sourceMissing')} @@ -1059,8 +1067,7 @@ const Tile = forwardRef( - The data source for this tile is not set. Edit the tile to - select a data source. + {t('tile.sourceUnset')} @@ -1285,6 +1292,7 @@ const Tile = forwardRef( isSourceUnset, hasBeenVisible, alertAnnotations, + t, ], ); @@ -1405,6 +1413,7 @@ const EditTileModal = ({ isSaving?: boolean; onSave: (chart: Tile) => void; }) => { + const { t } = useTranslation('dashboard'); const contextZIndex = useZIndex(); const modalZIndex = contextZIndex + 10; const confirm = useConfirm(); @@ -1423,10 +1432,7 @@ const EditTileModal = ({ const handleClose = useCallback(() => { if (isSaving) return; if (hasUnsavedChanges) { - confirm( - 'You have unsaved changes. Discard them and close the editor?', - 'Discard', - ).then(ok => { + confirm(t('editor.discardPrompt'), t('editor.discard')).then(ok => { if (ok) { // Reset dirty state before closing so any re-invocation of // handleClose (e.g. from Mantine focus management after the @@ -1438,7 +1444,7 @@ const EditTileModal = ({ } else { onClose(); } - }, [confirm, isSaving, hasUnsavedChanges, onClose]); + }, [confirm, hasUnsavedChanges, isSaving, onClose, t]); return ( { notifications.show({ color: 'green', - title: 'Query saved and executed', - message: - 'Filter query and dropdown values have been saved with the dashboard', + title: t('page.querySavedTitle'), + message: t('page.querySavedMessage'), autoClose: 3000, }); }, @@ -1924,6 +1930,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { getValues, rawFilterQueries, onSubmit, + t, ]); const handleRemoveSavedQuery = useCallback(() => { if (!dashboard || isLocalDashboard) return; @@ -1937,13 +1944,13 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { () => { notifications.show({ color: 'green', - title: 'Default query and filters removed', - message: 'Dashboard will no longer auto-apply saved defaults', + title: t('page.defaultsRemovedTitle'), + message: t('page.defaultsRemovedMessage'), autoClose: 3000, }); }, ); - }, [dashboard, isLocalDashboard, setDashboard]); + }, [dashboard, isLocalDashboard, setDashboard, t]); const [editedTile, setEditedTile] = useState(); @@ -2158,14 +2165,13 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { if (dashboard != null) { if ( !(await confirm( - <> - Duplicate {'"'} - - {chart.config.name} - - {'"'}? - , - 'Duplicate', + }} + />, + t('tile.duplicate'), )) ) { return; @@ -2190,14 +2196,13 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { if (dashboard != null) { if ( !(await confirm( - <> - Delete{' '} - - {chart.config.name} - - ? - , - 'Delete', + }} + />, + t('tile.delete'), { variant: 'danger' }, )) ) { @@ -2236,6 +2241,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { handleMoveTileToGroup, selectedTileIds, handleToggleTileSelect, + t, ], ); @@ -2493,7 +2499,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { () => { notifications.show({ color: 'green', - message: 'Tags updated successfully', + message: t('page.tagsUpdated'), }); }, () => { @@ -2501,7 +2507,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { color: 'red', message: ( <> - An error occurred. + {t('page.updateError')} ), }); @@ -2509,14 +2515,14 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { ); } }, - [dashboard, setDashboard], + [dashboard, setDashboard, t], ); const createDashboard = useCreateDashboard(); const onCreateDashboard = useCallback(() => { createDashboard.mutate( { - name: 'My Dashboard', + name: t('page.defaultName'), tiles: [], tags: [], }, @@ -2526,7 +2532,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { }, }, ); - }, [createDashboard, router]); + }, [createDashboard, router, t]); const [isSaving, setIsSaving] = useState(false); @@ -2540,7 +2546,8 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { {dashboard.createdBy && ( - Created by {dashboard.createdBy.name || dashboard.createdBy.email}.{' '} + {t('page.createdBy')}{' '} + {dashboard.createdBy.name || dashboard.createdBy.email}.{' '} )} {dashboard.updatedAt && ( @@ -2549,12 +2556,18 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { <> {dashboard.updatedBy - ? ` by ${dashboard.updatedBy.name || dashboard.updatedBy.email}` + ? ` ${t('page.updatedBy')} ${dashboard.updatedBy.name || dashboard.updatedBy.email}` : ''} } > - {`Updated ${formatDistanceToNow(new Date(dashboard.updatedAt), { addSuffix: true })}.`} + + {t('page.updated', { + time: formatDistanceToNow(new Date(dashboard.updatedAt), { + addSuffix: true, + }), + })} + )} @@ -2564,12 +2577,12 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { - Dashboards + {t('page.dashboards')} {isLocalDashboard - ? 'Temporary Dashboard' - : (dashboard?.name ?? 'Untitled')} + ? t('page.temporary') + : (dashboard?.name ?? t('page.untitled'))} {dashboardMeta} @@ -2609,8 +2622,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { style={{ flexShrink: 0 }} > - {dashboard?.tags?.length || 0}{' '} - {dashboard?.tags?.length === 1 ? 'Tag' : 'Tags'} + {t('page.tagCount', { count: dashboard?.tags?.length || 0 })} )} @@ -2629,7 +2641,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { {(hasTiles || containers.length > 0) && ( <> - View + {t('page.view')} {hasTiles && ( } @@ -2637,8 +2649,8 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { data-testid="toggle-alert-annotations-menu-item" > {showAlertAnnotations - ? 'Hide alert annotations' - : 'Show alert annotations'} + ? t('page.hideAnnotations') + : t('page.showAnnotations')} )} {containers.length > 0 && ( @@ -2654,9 +2666,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { onClick={() => setTocVisible(v => !v)} data-testid="toggle-toc-menu-item" > - {tocVisible - ? 'Hide table of contents' - : 'Show table of contents'} + {tocVisible ? t('page.hideToc') : t('page.showToc')} } @@ -2664,7 +2674,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { disabled={collapsibleContainers.length === 0} data-testid="collapse-all-sections-menu-item" > - Collapse all sections + {t('page.collapseAll')} } @@ -2672,7 +2682,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { disabled={collapsibleContainers.length === 0} data-testid="expand-all-sections-menu-item" > - Expand all sections + {t('page.expandAll')} )} @@ -2687,7 +2697,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { if (!sources || !dashboard) { notifications.show({ color: 'red', - message: 'Export Failed', + message: t('page.exportFailed'), }); return; } @@ -2702,7 +2712,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { ); }} > - Export Dashboard + {t('page.export')} )} - {hasTiles ? 'Import New Dashboard' : 'Import Dashboard'} + {hasTiles ? t('page.importNew') : t('page.import')} {hasSavedQueryAndFilterDefaults - ? 'Update Default Query & Filters' - : 'Save Query & Filters as Default'} + ? t('page.updateDefaults') + : t('page.saveDefaults')} {hasSavedQueryAndFilterDefaults && ( - Remove Default Query & Filters + {t('page.removeDefaults')} )} @@ -2749,7 +2759,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { }) } > - Delete Dashboard + {t('page.delete')} @@ -2781,7 +2791,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { onLanguageChange={(lang: 'sql' | 'lucene') => setValue('whereLanguage', lang) } - label="WHERE" + label={t('page.where')} enableHotkey allowMultiline minWidth={300} @@ -2799,8 +2809,8 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { withArrow label={ isRefreshEnabled - ? `Auto-refreshing with ${granularityOverride} interval` - : 'Enable auto-refresh' + ? t('page.autoRefresh', { interval: granularityOverride }) + : t('page.enableAutoRefresh') } fz="xs" color="gray" @@ -2809,24 +2819,26 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { onClick={() => setIsLive(prev => !prev)} size="sm" variant={isLive ? 'primary' : 'secondary'} - title={isLive ? 'Disable auto-refresh' : 'Enable auto-refresh'} + title={ + isLive ? t('page.disableAutoRefresh') : t('page.enableAutoRefresh') + } > - Live + {t('page.live')} - + - + setShowFiltersModal(true)} @@ -2843,7 +2855,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { leftSection={} style={{ flexShrink: 0 }} > - Run + {t('page.run')} ); @@ -2855,7 +2867,10 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { <> - {dashboard?.name ? `${dashboard.name}` : 'Dashboard'} – {brandName} + {t('page.browserTitle', { + name: dashboard?.name || t('page.defaultTitle'), + brandName, + })} @@ -2897,16 +2912,14 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { {isLocalDashboard && ( - - This is a temporary dashboard and can not be saved. - + {t('page.temporaryDescription')} @@ -2917,18 +2930,16 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { mt="sm" color="yellow" icon={} - title="Some filters could not be applied" + title={t('page.ignoredTitle')} data-testid="ignored-url-filters-banner" withCloseButton - closeButtonLabel="Dismiss" + closeButtonLabel={t('page.dismiss')} onClose={() => setShouldShowIgnoredFiltersBanner(false)} > - No dashboard filter(s) found for{' '} - {ignoredFilterExpressions.length === 1 - ? 'expression' - : 'expressions'}{' '} - in the URL: {ignoredFilterExpressions.join(', ')}. Add a filter with - a matching expression to apply these filters. + {t('page.ignoredDescription', { + count: ignoredFilterExpressions.length, + expressions: ignoredFilterExpressions.join(', '), + })} )} - {selectedTileIds.size} tile{selectedTileIds.size > 1 ? 's' : ''}{' '} - selected + {t('page.selected', { count: selectedTileIds.size })} @@ -2971,7 +2981,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { onError={console.error} fallback={
- An error occurred while rendering the dashboard. + {t('page.renderError')}
} > @@ -3064,7 +3074,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { w="100%" leftSection={} > - Add + {t('page.add')} @@ -3073,7 +3083,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { leftSection={} onClick={() => onAddTile()} > - New Tile + {t('page.newTile')} } onClick={() => handleAddContainer()} > - New Group + {t('page.newGroup')} diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 9995a976dd..147d399b27 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -23,6 +23,7 @@ import { useQueryStates, } from 'nuqs'; import { useForm, useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; import HyperDX from '@hyperdx/browser'; @@ -166,13 +167,6 @@ import { FormatTime } from './useFormatTime'; import searchPageStyles from '@styles/SearchPage.module.scss'; -const LIVE_TAIL_REFRESH_FREQUENCY_OPTIONS = [ - { value: '1000', label: '1s' }, - { value: '2000', label: '2s' }, - { value: '4000', label: '4s' }, - { value: '10000', label: '10s' }, - { value: '30000', label: '30s' }, -]; const DEFAULT_REFRESH_FREQUENCY = 10000; const ALLOWED_SOURCE_KINDS = [SourceKind.Log, SourceKind.Trace]; @@ -237,8 +231,14 @@ function SourceEditModal({ onClose: () => void; inputSource: string | undefined; }) { + const { t } = useTranslation('search'); return ( - + ); @@ -253,14 +253,19 @@ function NewSourceModal({ onClose: () => void; onCreate: (source: TSource) => void; }) { + const { t } = useTranslation('search'); return ( - + ); } @@ -270,6 +275,7 @@ function ResumeLiveTailButton({ }: { handleResumeLiveTail: () => void; }) { + const { t } = useTranslation('search'); const { themeName } = useAppTheme(); const variant = themeName === 'clickstack' ? 'secondary' : 'primary'; @@ -280,7 +286,7 @@ function ResumeLiveTailButton({ onClick={handleResumeLiveTail} leftSection={} > - Resume Live Tail + {t('page.resumeLiveTail')} ); } @@ -290,6 +296,7 @@ function SearchSubmitButton({ }: { isFormStateDirty: boolean; }) { + const { t } = useTranslation('search'); return ( ); } function ExpandFiltersButton({ onExpand }: { onExpand: () => void }) { + const { t } = useTranslation('search'); return ( - + @@ -359,6 +367,7 @@ export function SearchNumRows({ isSearching: boolean; isLiveTail?: boolean; }) { + const { t } = useTranslation('search'); const [statsOpened, { open: openStats, close: closeStats }] = useDisclosure(false); const { data, isLoading, error } = useExplainQuery(config, { @@ -387,7 +396,11 @@ export function SearchNumRows({ @@ -395,10 +408,12 @@ export function SearchNumRows({ {isLoading - ? 'Scanned Rows ...' + ? t('page.scannedRowsLoading') : error || numRows == null ? '' - : `Scanned Rows: ${Number(numRows).toLocaleString()}`} + : t('page.scannedRows', { + displayCount: Number(numRows).toLocaleString(), + })} {showElapsed && ( <> @@ -409,8 +424,10 @@ export function SearchNumRows({ )} {showElapsedLoading - ? 'Elapsed Time: ...' - : `Elapsed Time: ${formatDurationMs(searchElapsedMs!)}`} + ? t('page.elapsedLoading') + : t('page.elapsed', { + duration: formatDurationMs(searchElapsedMs!), + })} )} @@ -418,13 +435,13 @@ export function SearchNumRows({ explain query, so it renders unconditionally. Gating it on explain loading/data would make it flicker on every live-tail poll, since each poll changes the dateRange (and thus the explain queryKey). */} - + @@ -447,6 +464,7 @@ function SaveSearchModalComponent({ isUpdate: boolean; savedSearchId: string | undefined | null; }) { + const { t } = useTranslation('search'); const { data: savedSearch } = useSavedSearch( { id: savedSearchId ?? '' }, { @@ -528,9 +546,8 @@ function SaveSearchModalComponent({ console.error('Error updating saved search:', error); notifications.show({ color: 'red', - title: 'Error', - message: - 'An error occurred while updating your saved search. Please try again.', + title: t('page.error'), + message: t('page.updateSavedSearchError'), }); }, }, @@ -555,9 +572,8 @@ function SaveSearchModalComponent({ console.error('Error creating saved search:', error); notifications.show({ color: 'red', - title: 'Error', - message: - 'An error occurred while saving your search. Please try again.', + title: t('page.error'), + message: t('page.saveSearchError'), }); } } @@ -573,7 +589,7 @@ function SaveSearchModalComponent({ data-testid="save-search-modal" opened={opened} onClose={closeAndReset} - title="Save Search" + title={t('page.saveSearch')} centered size="lg" > @@ -598,7 +614,7 @@ function SaveSearchModalComponent({ {chartConfig.where} ) : ( - None + {t('page.none')} )} @@ -623,11 +639,11 @@ function SaveSearchModalComponent({ )} ) : ( - Loading Chart Config... + {t('page.loadingChartConfig')} )} - Name + {t('page.name')} - Tags + {t('page.tags')} {tags.map(tag => ( @@ -670,7 +686,7 @@ function SaveSearchModalComponent({ size="xs" > - Add Tag + {t('page.addTag')} @@ -682,7 +698,7 @@ function SaveSearchModalComponent({ disabled={!formState.isValid} loading={isPending} > - {isUpdate ? 'Update' : 'Save'} + {isUpdate ? t('page.update') : t('page.save')} @@ -961,6 +977,17 @@ export function useSearchTelemetry({ } export function DBSearchPage() { + const { t } = useTranslation('search'); + const liveTailRefreshFrequencyOptions = useMemo( + () => [ + { value: '1000', label: t('page.refresh1s') }, + { value: '2000', label: t('page.refresh2s') }, + { value: '4000', label: t('page.refresh4s') }, + { value: '10000', label: t('page.refresh10s') }, + { value: '30000', label: t('page.refresh30s') }, + ], + [t], + ); const brandName = useBrandDisplayName(); // Next router is laggy behind window.location, which causes race // conditions with useQueryStates, so we'll parse it directly @@ -1462,7 +1489,7 @@ export function DBSearchPage() { onSuccess: () => { notifications.show({ color: 'green', - message: 'Tags updated successfully', + message: t('page.tagsUpdated'), }); }, onError: () => { @@ -1470,7 +1497,7 @@ export function DBSearchPage() { color: 'red', message: ( <> - An error occurred. + {t('page.genericError')} ), }); @@ -1479,7 +1506,7 @@ export function DBSearchPage() { ); } }, - [savedSearch, searchedConfig, updateSavedSearch], + [savedSearch, searchedConfig, updateSavedSearch, t], ); const [newSourceModalOpened, setNewSourceModalOpened] = useState(false); @@ -2014,7 +2041,12 @@ export function DBSearchPage() { > - {savedSearch ? `${savedSearch.name} Search` : 'Search'} - {brandName} + {savedSearch + ? t('page.savedBrowserTitle', { + name: savedSearch.name, + brandName, + }) + : t('page.browserTitle', { brandName })} {!IS_LOCAL_MODE && isAlertModalOpen && ( @@ -2031,7 +2063,7 @@ export function DBSearchPage() { - Saved Searches + {t('page.savedSearches')} {savedSearch.name} @@ -2040,7 +2072,7 @@ export function DBSearchPage() { {savedSearch.createdBy && ( - Created by{' '} + {t('page.createdBy')}{' '} {savedSearch.createdBy.name || savedSearch.createdBy.email}.{' '} )} @@ -2053,12 +2085,22 @@ export function DBSearchPage() { format="short" /> {savedSearch.updatedBy - ? ` by ${savedSearch.updatedBy.name || savedSearch.updatedBy.email}` + ? ` ${t('page.by')} ${ + savedSearch.updatedBy.name || + savedSearch.updatedBy.email + }` : ''} } > - {`Updated ${formatDistanceToNow(new Date(savedSearch.updatedAt), { addSuffix: true })}.`} + + {t('page.updated', { + time: formatDistanceToNow( + new Date(savedSearch.updatedAt), + { addSuffix: true }, + ), + })} + )} @@ -2067,7 +2109,7 @@ export function DBSearchPage() {
{ updateSavedSearch.mutate({ id: savedSearch.id, @@ -2180,7 +2222,7 @@ export function DBSearchPage() { onClick={onSaveSearch} style={{ flexShrink: 0 }} > - Save + {t('page.save')} ) : ( )} {!IS_LOCAL_MODE && ( @@ -2204,7 +2246,7 @@ export function DBSearchPage() { style={{ flexShrink: 0 }} > - Alerts + {t('page.alerts')} @@ -2257,12 +2299,12 @@ export function DBSearchPage() { size="xs" /> {isLive && ( - + diff --git a/packages/app/src/HDXMultiSeriesTableChart.tsx b/packages/app/src/HDXMultiSeriesTableChart.tsx index 9a48664d92..c7356bcb3b 100644 --- a/packages/app/src/HDXMultiSeriesTableChart.tsx +++ b/packages/app/src/HDXMultiSeriesTableChart.tsx @@ -1,6 +1,7 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import Link from 'next/link'; import cx from 'classnames'; +import { useTranslation } from 'react-i18next'; import { ChartPaletteToken, ColorCondition, @@ -102,6 +103,7 @@ export const Table = ({ // (see the CSS module). alternateRowBackground?: boolean; }) => { + const { t } = useTranslation('charts'); const brandName = useBrandDisplayName(); const MIN_COLUMN_WIDTH_PX = 100; //we need a reference to the scrolling element for logic down below @@ -450,7 +452,7 @@ export const Table = ({ data={csvData} filename={`${brandName}_table_results`} className="fs-8 ms-2" - title="Download table as CSV" + title={t('tableChart.downloadCsv')} > @@ -585,7 +587,7 @@ export const Table = ({
{isTruncated && (
- Showing the first {MAX_TABLE_ROWS} rows. + {t('tableChart.showingFirst', { count: MAX_TABLE_ROWS })}
)} {tableBottom} diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx index b71b49a143..08ff065caa 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart.tsx @@ -10,6 +10,7 @@ import { import cx from 'classnames'; import { add, isSameSecond, sub } from 'date-fns'; import { withErrorBoundary } from 'react-error-boundary'; +import { Trans, useTranslation } from 'react-i18next'; import { Area, AreaChart, @@ -251,7 +252,9 @@ const HDXLineChartTooltip = withErrorBoundary( onError: console.error, fallback: (
- An error occurred while rendering the tooltip. + + An error occurred while rendering the tooltip. +
), }, @@ -270,6 +273,7 @@ function ExpandableLegendItem({ isDisabled?: boolean; onToggle?: (isShiftKey: boolean) => void; }) { + const { t } = useTranslation('charts'); const [_expanded, setExpanded] = useState(false); const isExpanded = _expanded || expanded; @@ -292,8 +296,8 @@ function ExpandableLegendItem({ }} title={ isSelected - ? 'Click to show all (Shift+click to deselect)' - : 'Click to show only this (Shift+click for multi-select)' + ? t('timeChart.legendShowAll') + : t('timeChart.legendShowOnly') } >
@@ -328,6 +332,7 @@ const LegendRenderer = memo<{ selectedSeries?: Set; onToggleSeries?: (seriesName: string, isShiftKey?: boolean) => void; }>(props => { + const { t } = useTranslation('charts'); const { payload, lineDataMap, allLineData, selectedSeries, onToggleSeries } = props; @@ -392,7 +397,7 @@ const LegendRenderer = memo<{
- +{restItems.length} more + {t('timeChart.more', { count: restItems.length })}
@@ -676,6 +681,7 @@ export const MemoChart = memo(function MemoChart({ **/ fitYAxisToData?: boolean; }) { + const { t } = useTranslation('charts'); const _id = useId(); const id = _id.replace(/:/g, ''); @@ -1032,7 +1038,7 @@ export const MemoChart = memo(function MemoChart({ style={{ position: 'relative', width: '100%', height: '100%' }} > {onTimeRangeSelect != null && zoomOrigin != null ? ( - + ) : null} @@ -1327,7 +1333,7 @@ export const MemoChart = memo(function MemoChart({ x={logReferenceTimestamp} stroke="#ff5d5b" strokeDasharray="3 3" - label="Event" + label={t('timeChart.eventLabel')} /> ) : null} diff --git a/packages/app/src/InstallInstructionsModal.tsx b/packages/app/src/InstallInstructionsModal.tsx index d52f2ff413..bb6dfd6520 100644 --- a/packages/app/src/InstallInstructionsModal.tsx +++ b/packages/app/src/InstallInstructionsModal.tsx @@ -1,4 +1,5 @@ import cx from 'classnames'; +import { useTranslation } from 'react-i18next'; import { Button, Group, Modal } from '@mantine/core'; import { IconClipboard, IconClipboardCheck } from '@tabler/icons-react'; @@ -12,6 +13,8 @@ function CopyableValue({ label?: React.ReactNode; value: string; }) { + const { t } = useTranslation('common'); + return ( {({ isCopied }) => { @@ -36,7 +39,7 @@ function CopyableValue({ ) : ( )} - {isCopied ? 'Copied!' : 'Copy'} + {isCopied ? t('actions.copied') : t('actions.copy')}
); @@ -52,13 +55,14 @@ export default function InstallInstructionModal({ show: boolean; onHide: () => void; }) { + const { t } = useTranslation('navigation'); const { data: team } = api.useTeam(); return ( @@ -67,19 +71,14 @@ export default function InstallInstructionModal({
- Your Ingestion API Key:{' '} - + {t('install.apiKey')} } value={team.apiKey} />
)} -
- Click on a link below to view installation instructions for your - application. -
-
Backend
+
{t('install.instructions')}
+
{t('install.backend')}
Node.js - (Logs + Traces) + {t('install.logsAndTraces')}
Go - (Logs + Traces) + {t('install.logsAndTraces')}
Python - (Logs + Traces) + {t('install.logsAndTraces')}
Java - (Logs + Traces) + {t('install.logsAndTraces')}
Elixir - (Logs) + {t('install.logs')}
Ruby on Rails - (Traces) + {t('install.traces')}
-
Platform
+
{t('install.platform')}
Kubernetes - (Logs + Metrics) + {t('install.logsAndMetrics')}
-
Browser
+
{t('install.browser')}
JavaScript/TypeScript - (Logs + Traces) + {t('install.logsAndTraces')}
-
Data Collector
+
{t('install.dataCollector')}
OpenTelemetry - (Logs + Traces) + {t('install.logsAndTraces')}
diff --git a/packages/app/src/JoinTeamPage.tsx b/packages/app/src/JoinTeamPage.tsx index e5131c8c18..d13e64da02 100644 --- a/packages/app/src/JoinTeamPage.tsx +++ b/packages/app/src/JoinTeamPage.tsx @@ -1,21 +1,23 @@ import { useRouter } from 'next/router'; import { NextSeo } from 'next-seo'; +import { useTranslation } from 'react-i18next'; import { Button, Paper, Text, TextInput } from '@mantine/core'; import { useBrandDisplayName } from './theme/ThemeProvider'; export default function JoinTeam() { + const { t } = useTranslation('auth'); const router = useRouter(); const brandName = useBrandDisplayName(); const { err, token } = router.query; return (
- +
-

Join Team

+

{t('joinTeam.title')}

@@ -28,7 +30,7 @@ export default function JoinTeam() { id="password" name="password" type="password" - label="Password" + label={t('common.password')} styles={{ label: { fontSize: '0.875rem', @@ -40,8 +42,8 @@ export default function JoinTeam() { {err != null && ( {err === 'invalid' - ? 'Password is invalid' - : 'Unknown error occurred, please try again later.'} + ? t('errors.invalidPassword') + : t('errors.unknown')} )}
@@ -51,7 +53,7 @@ export default function JoinTeam() { type="submit" data-test-id="submit" > - Setup a password + {t('joinTeam.setupPassword')}
diff --git a/packages/app/src/KubernetesDashboardPage.tsx b/packages/app/src/KubernetesDashboardPage.tsx index cdeaab0bb7..84847476c0 100644 --- a/packages/app/src/KubernetesDashboardPage.tsx +++ b/packages/app/src/KubernetesDashboardPage.tsx @@ -7,6 +7,7 @@ import cx from 'classnames'; import sub from 'date-fns/sub'; import { useQueryState } from 'nuqs'; import { useForm, useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; import { convertDateRangeToGranularityString } from '@hyperdx/common-utils/dist/core/utils'; import { isLogSource, @@ -157,6 +158,7 @@ export const InfraPodsStatusTable = ({ metricSource: TMetricSource; where: string; }) => { + const { t } = useTranslation('infrastructure'); const [phaseFilter, setPhaseFilter] = React.useState('running'); // Auto-switch to "All" when search filters are applied @@ -360,17 +362,20 @@ export const InfraPodsStatusTable = ({ - Pods + {t('kubernetes.pods.title')} @@ -378,8 +383,9 @@ export const InfraPodsStatusTable = ({ {isAtFetchLimit && !isLoading && !isError && podsList.length > 0 && ( - Showing first {TABLE_FETCH_LIMIT.toLocaleString()} pods. Use the - filters above to narrow your search. + {t('kubernetes.pods.fetchLimit', { + limit: TABLE_FETCH_LIMIT.toLocaleString(), + })} )} @@ -395,31 +401,33 @@ export const InfraPodsStatusTable = ({ ) : isError ? (
- Unable to load pod metrics + {t('kubernetes.pods.loadError')}
) : podsList.length === 0 ? ( -
No pods found
+
+ {t('kubernetes.pods.empty')} +
) : ( - - - + + + @@ -458,12 +466,12 @@ export const InfraPodsStatusTable = ({ { + const { t } = useTranslation('infrastructure'); const groupBy = ['k8s.node.name']; const { data, isError, isLoading } = useQueriedChartConfig({ @@ -631,7 +642,7 @@ const NodesTable = ({ return ( - Nodes + {t('kubernetes.nodes.title')}
) : isError ? (
- Unable to load nodes + {t('kubernetes.nodes.loadError')}
) : nodesList.length === 0 ? (
- No nodes found + {t('kubernetes.nodes.empty')}
) : (
NameNamespaceNode{t('kubernetes.pods.columnName')}{t('kubernetes.pods.columnNamespace')}{t('kubernetes.pods.columnNode')} - Status + {t('kubernetes.pods.columnStatus')} - CPU/Limit + {t('kubernetes.pods.columnCpuLimit')} - Mem/Limit + {t('kubernetes.pods.columnMemLimit')} - Age + {t('kubernetes.pods.columnAge')} - Restarts + {t('kubernetes.pods.columnRestarts')}
- Node - Status - CPU - Memory - Uptime + {t('kubernetes.nodes.columnNode')} + + {t('kubernetes.nodes.columnStatus')} + + + {t('kubernetes.nodes.columnCpu')} + + + {t('kubernetes.nodes.columnMemory')} + + + {t('kubernetes.nodes.columnUptime')} + @@ -694,7 +713,7 @@ const NodesTable = ({ tt="none" size="md" > - Ready + {t('kubernetes.nodes.ready')} ) : ( - Not Ready + {t('kubernetes.nodes.notReady')} )} @@ -750,6 +769,7 @@ const NamespacesTable = ({ metricSource: TMetricSource; where: string; }) => { + const { t } = useTranslation('infrastructure'); const groupBy = ['k8s.namespace.name']; const { data, isError, isLoading } = useQueriedChartConfig({ @@ -830,7 +850,7 @@ const NamespacesTable = ({ return ( - Namespaces + {t('kubernetes.namespaces.title')}
) : isError ? (
- Unable to load namespaces + {t('kubernetes.namespaces.loadError')}
) : namespacesList.length === 0 ? (
- No namespaces found + {t('kubernetes.namespaces.empty')}
) : (
- Namespace - Phase - CPU - Memory + + {t('kubernetes.namespaces.columnNamespace')} + + + {t('kubernetes.namespaces.columnPhase')} + + + {t('kubernetes.namespaces.columnCpu')} + + + {t('kubernetes.namespaces.columnMemory')} + @@ -892,7 +920,7 @@ const NamespacesTable = ({ tt="none" size="md" > - Ready + {t('kubernetes.namespaces.ready')} ) : ( - Terminating + {t('kubernetes.namespaces.terminating')} )} @@ -1047,6 +1075,8 @@ export const resolveSourceIds = ( }; function KubernetesDashboardPage() { + const { t } = useTranslation('infrastructure'); + const { t: tDashboard } = useTranslation('dashboard'); const brandName = useBrandDisplayName(); const { data: sources } = useSources(); @@ -1119,14 +1149,16 @@ function KubernetesDashboardPage() { setMetricSourceId(correlatedMetricSource.id); notifications.show({ id: `${correlatedMetricSource.id}-auto-correlated-metric-source`, - title: 'Updated Metrics Source', - message: `Using correlated metrics source: ${correlatedMetricSource.name}`, + title: t('kubernetes.sourceCorrelation.metricsUpdatedTitle'), + message: t('kubernetes.sourceCorrelation.metricsUpdatedMessage', { + name: correlatedMetricSource.name, + }), }); } else if (logSource && !correlatedMetricSource) { notifications.show({ id: `${logSource.id}-not-correlated`, - title: 'Warning', - message: `The selected logs source is not correlated with a metrics source. Source correlations can be configured in Team Settings.`, + title: t('kubernetes.sourceCorrelation.warningTitle'), + message: t('kubernetes.sourceCorrelation.metricsMissingMessage'), color: 'yellow', }); } @@ -1137,6 +1169,7 @@ function KubernetesDashboardPage() { sources, setLogSourceId, setMetricSourceId, + t, ]); // Handle metric source changes @@ -1165,14 +1198,16 @@ function KubernetesDashboardPage() { setLogSourceId(correlatedLogSource.id); notifications.show({ id: `${correlatedLogSource.id}-auto-correlated-log-source`, - title: 'Updated Logs Source', - message: `Using correlated logs source: ${correlatedLogSource.name}`, + title: t('kubernetes.sourceCorrelation.logsUpdatedTitle'), + message: t('kubernetes.sourceCorrelation.logsUpdatedMessage', { + name: correlatedLogSource.name, + }), }); } else if (metricSource && !correlatedLogSource) { notifications.show({ id: `${metricSource.id}-not-correlated`, - title: 'Warning', - message: `The selected metrics source is not correlated with a logs source. Source correlations can be configured in Team Settings.`, + title: t('kubernetes.sourceCorrelation.warningTitle'), + message: t('kubernetes.sourceCorrelation.logsMissingMessage'), color: 'yellow', }); } @@ -1183,6 +1218,7 @@ function KubernetesDashboardPage() { sources, setMetricSourceId, setLogSourceId, + t, ]); const [activeTab, setActiveTab] = useQueryState('tab', { @@ -1307,14 +1343,19 @@ function KubernetesDashboardPage() { onSearch={onSearch} /> - + @@ -1326,10 +1367,10 @@ function KubernetesDashboardPage() { const pageBreadcrumbs = ( - Dashboards + {tDashboard('page.dashboards')} - Kubernetes + {t('kubernetes.breadcrumb')} ); @@ -1342,7 +1383,7 @@ function KubernetesDashboardPage() { const dashboardBody = ( <> - Kubernetes Dashboard – {brandName} + {t('kubernetes.browserTitle', { brandName })} {metricSource && logSource && ( @@ -1381,9 +1422,11 @@ function KubernetesDashboardPage() { value={activeTab} > - Pods - Nodes - Namespaces + {t('kubernetes.tabs.pods')} + {t('kubernetes.tabs.nodes')} + + {t('kubernetes.tabs.namespaces')} + {/* Clusters */} @@ -1395,7 +1438,7 @@ function KubernetesDashboardPage() { {metricSource && ( {metricSource && ( - Latest Kubernetes Warning Events + {t('kubernetes.events.warningTitle')} @@ -1539,7 +1582,7 @@ function KubernetesDashboardPage() { {metricSource && ( {metricSource && ( {metricSource && ( {metricSource && ( - Clusters + + {t('kubernetes.tabs.clusters')} + diff --git a/packages/app/src/LandingHeader.tsx b/packages/app/src/LandingHeader.tsx index e5889011ab..3a1c76673b 100644 --- a/packages/app/src/LandingHeader.tsx +++ b/packages/app/src/LandingHeader.tsx @@ -1,4 +1,5 @@ import Link from 'next/link'; +import { useTranslation } from 'react-i18next'; import { Anchor, Burger, Button, Container, Group } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; @@ -12,6 +13,7 @@ export default function LandingHeader({ activeKey: string; fixed?: boolean; }) { + const { t } = useTranslation('auth'); const brandName = useBrandDisplayName(); const wordmark = useWordmark(); const { data: me } = api.useMe(); @@ -55,7 +57,7 @@ export default function LandingHeader({ style={{ fontWeight: activeKey === 'cloud' ? 600 : 400 }} size="sm" > - {brandName} Cloud + {t('header.cloud', { brandName })} - Docs + {t('header.docs')} {!isLoggedIn && installation?.isTeamExisting === true && ( - Login + {t('header.login')} )} {!isLoggedIn && @@ -86,14 +88,14 @@ export default function LandingHeader({ installation?.isTeamExisting === false && ( )} {isLoggedIn && ( )} @@ -108,14 +110,14 @@ export default function LandingHeader({ underline="never" style={{ fontWeight: activeKey === 'cloud' ? 600 : 400 }} > - {brandName} Cloud + {t('header.cloud', { brandName })} - Docs + {t('header.docs')} {!isLoggedIn && installation?.isTeamExisting === true && ( - Login + {t('header.login')} )} {!isLoggedIn && @@ -131,14 +133,14 @@ export default function LandingHeader({ installation?.isTeamExisting === false && ( )} {isLoggedIn && ( )} diff --git a/packages/app/src/LogSidePanelElements.tsx b/packages/app/src/LogSidePanelElements.tsx index 66bc9b9125..1f5773322d 100644 --- a/packages/app/src/LogSidePanelElements.tsx +++ b/packages/app/src/LogSidePanelElements.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import Link from 'next/link'; import cx from 'classnames'; +import { useTranslation } from 'react-i18next'; import { JSONTree } from 'react-json-tree'; import { Alert, @@ -95,6 +96,11 @@ const StacktraceValue = ({ ); }; +const EmptyValue = () => { + const { t } = useTranslation('search'); + return {t('logPanel.empty')}; +}; + const StacktraceRowExpandButton = ({ onClick, isOpen, @@ -333,7 +339,7 @@ const breadcrumbColumns: ColumnDef[] = [ return
{row.original.message}
; } - return Empty; + return ; }, }, { @@ -495,11 +501,11 @@ export const NetworkBody = ({ * Keyboard shortcuts */ const SHORTCUTS = [ - { keys: ['esc'], label: 'Close panel' }, - { keys: ['←', '→'], label: 'Navigate between events' }, - { keys: ['↑', '↓'], label: 'Navigate between events' }, - { keys: ['k', 'j'], label: 'Navigate between events' }, - { keys: ['⌘/Ctrl', 'scroll'], label: 'Zoom trace timeline' }, + { keys: ['esc'], labelKey: 'closePanel' }, + { keys: ['←', '→'], labelKey: 'navigateEvents' }, + { keys: ['↑', '↓'], labelKey: 'navigateEvents' }, + { keys: ['k', 'j'], labelKey: 'navigateEvents' }, + { keys: ['⌘/Ctrl', 'scroll'], labelKey: 'zoomTimeline' }, ] as const; export const KeyboardShortcutsModal = ({ @@ -509,17 +515,18 @@ export const KeyboardShortcutsModal = ({ opened: boolean; onClose: () => void; }) => { + const { t } = useTranslation('search'); return ( - {SHORTCUTS.map(({ keys, label }) => ( - + {SHORTCUTS.map(({ keys, labelKey }) => ( + {keys.map((key, i) => ( @@ -532,7 +539,7 @@ export const KeyboardShortcutsModal = ({ ))} - {label} + {t(`logPanel.${labelKey}`)} ))} @@ -541,6 +548,7 @@ export const KeyboardShortcutsModal = ({ }; const SourceMapsFtux = () => { + const { t } = useTranslation('search'); const [isDismissed, setIsDismissed] = useLocalStorage( 'sourceMapsFtuxDismissed', false, @@ -559,13 +567,11 @@ const SourceMapsFtux = () => { onClose={() => setIsDismissed(true)} > - Some of the stack frames are pointing to minified - files. Use hyperdx-cli to upload your source maps and see the original - code. + {t('logPanel.sourceMapsNotice')} diff --git a/packages/app/src/NamespaceDetailsSidePanel.tsx b/packages/app/src/NamespaceDetailsSidePanel.tsx index b53dd918fd..5e9eae1817 100644 --- a/packages/app/src/NamespaceDetailsSidePanel.tsx +++ b/packages/app/src/NamespaceDetailsSidePanel.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { parseAsString, useQueryState } from 'nuqs'; +import { useTranslation } from 'react-i18next'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; import { convertDateRangeToGranularityString } from '@hyperdx/common-utils/dist/core/utils'; import { TLogSource, TMetricSource } from '@hyperdx/common-utils/dist/types'; @@ -58,6 +59,7 @@ const NamespaceDetails = ({ dateRange: [Date, Date]; metricSource?: TMetricSource; }) => { + const { t } = useTranslation('infrastructure'); const where = `${metricSource?.resourceAttributesExpression}.k8s.namespace.name:"${name}"`; const groupBy = ['k8s.namespace.name']; @@ -99,10 +101,13 @@ const NamespaceDetails = ({ className="p-2 gap-2 d-flex flex-wrap" data-testid="k8s-namespace-details-panel" > - + {properties.ready !== undefined && ( - Ready + {t('kubernetes.namespaces.ready')} ) : ( - Not Ready + {t('kubernetes.nodes.notReady')} ) } @@ -142,6 +147,7 @@ function NamespaceLogs({ logSource: TLogSource; where: string; }) { + const { t } = useTranslation('infrastructure'); const [resultType, setResultType] = React.useState<'all' | 'error'>('all'); const _where = where + (resultType === 'error' ? ' Severity:err' : ''); @@ -150,7 +156,7 @@ function NamespaceLogs({ - Latest Namespace Logs & Spans + {t('kubernetes.details.namespaceLogs')} {/* @@ -228,6 +237,7 @@ export default function NamespaceDetailsSidePanel({ metricSource: TMetricSource; logSource: TLogSource; }) { + const { t } = useTranslation('infrastructure'); const [namespaceName, setNamespaceName] = useQueryState( 'namespaceName', parseAsString.withDefault(''), @@ -355,7 +365,7 @@ export default function NamespaceDetailsSidePanel({ { + const { t } = useTranslation('infrastructure'); const where = `${metricSource.resourceAttributesExpression}.k8s.node.name:"${name}"`; const groupBy = ['k8s.node.name']; @@ -106,10 +108,10 @@ const NodeDetails = ({ return (
- + {properties.ready !== undefined && ( - Ready + {t('kubernetes.nodes.ready')} ) : ( - Not Ready + {t('kubernetes.nodes.notReady')} ) } @@ -137,7 +139,7 @@ const NodeDetails = ({ )} {properties.uptime && ( )} @@ -155,6 +157,7 @@ function NodeLogs({ logSource: TLogSource; where: string; }) { + const { t } = useTranslation('infrastructure'); const [resultType, setResultType] = React.useState<'all' | 'error'>('all'); const _where = where + (resultType === 'error' ? ' Severity:err' : ''); @@ -163,7 +166,7 @@ function NodeLogs({ - Latest Node Logs & Spans + {t('kubernetes.details.nodeLogs')} {/* @@ -241,6 +247,7 @@ export default function NodeDetailsSidePanel({ metricSource: TMetricSource; logSource: TLogSource; }) { + const { t } = useTranslation('infrastructure'); const [nodeName, setNodeName] = useQueryState( 'nodeName', parseAsString.withDefault(''), @@ -368,7 +375,7 @@ export default function NodeDetailsSidePanel({ void; }) => { + const { t } = useTranslation('navigation'); const [isCollapsed, setIsCollapsed] = useLocalStorage( 'onboardingChecklistCollapsed', false, @@ -113,24 +115,24 @@ const OnboardingChecklist = ({ () => [ { id: 'connection', - title: 'Connect to ClickHouse', - description: 'Set up your database connection', + title: t('onboarding.connectClickHouse'), + description: t('onboarding.connectClickHouseDescription'), isComplete: hasConnections ?? false, isLoading: isConnectionsLoading, href: hasConnections ? undefined : '/team', }, { id: 'sources', - title: 'Create Data Sources', - description: 'Configure where your data comes from', + title: t('onboarding.createSources'), + description: t('onboarding.createSourcesDescription'), isComplete: hasSources ?? false, isLoading: isSourcesLoading, href: hasSources ? undefined : '/team', }, { id: 'data', - title: 'Add Data', - description: 'Start sending logs, metrics, or traces', + title: t('onboarding.addData'), + description: t('onboarding.addDataDescription'), isComplete: hasData, isLoading: isSourceRowsLoading, // We'll implement data checking later onClick: hasData ? undefined : onAddDataClick, @@ -144,6 +146,7 @@ const OnboardingChecklist = ({ isSourcesLoading, onAddDataClick, isSourceRowsLoading, + t, ], ); @@ -160,7 +163,7 @@ const OnboardingChecklist = ({ - Get Started + {t('onboarding.getStarted')} - 🎉 Great job! You're all set up. + {t('onboarding.complete')} )} diff --git a/packages/app/src/PasswordCheck.tsx b/packages/app/src/PasswordCheck.tsx index 59b35ad461..f720155551 100644 --- a/packages/app/src/PasswordCheck.tsx +++ b/packages/app/src/PasswordCheck.tsx @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import { IconCheck, IconX } from '@tabler/icons-react'; const checkLength = (password: string) => password.length >= 12; @@ -8,32 +9,33 @@ const checkOneNumber = (password: string) => /\d+/.test(password); const checkOneSpecial = (password: string) => /\W+/.test(password); export const PasswordCheck = (opts: { password: string }) => { + const { t } = useTranslation('auth'); const password = opts.password; return (
- minimum 12 characters + {t('passwordCheck.minLength')}
- at least 1 uppercase + {t('passwordCheck.uppercase')}
- at least 1 lowercase + {t('passwordCheck.lowercase')}
- at least 1 number + {t('passwordCheck.number')}
- at least 1 special character + {t('passwordCheck.special')}
diff --git a/packages/app/src/Playbar.tsx b/packages/app/src/Playbar.tsx index b41d040848..7213e580e9 100644 --- a/packages/app/src/Playbar.tsx +++ b/packages/app/src/Playbar.tsx @@ -1,5 +1,6 @@ import { useMemo } from 'react'; import uniqBy from 'lodash/uniqBy'; +import { useTranslation } from 'react-i18next'; import { ChartConfigWithOptDateRange } from '@hyperdx/common-utils/dist/types'; import { keepPreviousData } from '@tanstack/react-query'; @@ -25,6 +26,7 @@ export default function Playbar({ playbackRange: [Date, Date]; queriedConfig: ChartConfigWithOptDateRange; }) { + const { t } = useTranslation('sessions'); // might be outdated? state update or something? that's why the max slider val can be wrong? const minTs = playbackRange[0].getTime(); const maxTs = playbackRange[1].getTime(); @@ -94,13 +96,13 @@ export default function Playbar({ ((event.startOffset - minTs) / (maxTs - minTs)) * 100, ), description: isNavigation - ? `Navigated to ${shortLocationHref}` + ? t('player.navigatedTo', { href: shortLocationHref }) : url.length > 0 ? `${statusCode} ${method}${url.length > 0 ? ` ${shortUrl}` : ''}` : errorMessage != null && errorMessage.length > 0 ? errorMessage : spanName === 'intercom.onShow' - ? 'Intercom Chat Opened' + ? t('player.intercomOpened') : (event.body ?? ''), isError, isSuccess, @@ -108,7 +110,7 @@ export default function Playbar({ }) ?? [], 'percentage', ); - }, [events, maxTs, minTs]); + }, [events, maxTs, minTs, t]); return ( { + const { t } = useTranslation('infrastructure'); const { data: logsData } = useV2LogBatch<{ 'k8s.node.name': string; 'k8s.pod.name': string; @@ -111,15 +113,24 @@ const PodDetails = ({ return (
- - - + + +
@@ -136,6 +147,7 @@ function PodLogs({ logSource: TLogSource; where: string; }) { + const { t } = useTranslation('infrastructure'); const [resultType, setResultType] = React.useState<'all' | 'error'>('all'); const _where = where + (resultType === 'error' ? ' Severity:err' : ''); @@ -183,7 +195,7 @@ function PodLogs({ - Latest Pod Logs & Spans + {t('kubernetes.details.podLogs')} @@ -220,6 +235,7 @@ export default function PodDetailsSidePanel({ logSource: TLogSource; metricSource: TMetricSource; }) { + const { t } = useTranslation('infrastructure'); const [podName, setPodName] = useQueryState( 'podName', parseAsString.withDefault(''), @@ -363,7 +379,7 @@ export default function PodDetailsSidePanel({ - Latest Pod Events + {t('kubernetes.events.podTitle')} , value: 'list', }, { - label: 'Show as Table', + label: t('database.showAsTable'), icon: , value: 'table', }, @@ -308,7 +310,7 @@ function DatabaseTab({ {source && totalTimePerQueryConfig && ( {source && totalThroughputPerQueryConfig && ( ) : ( {source && expressions && ( , }, { value: 'histogram', - label: 'Display as Histogram', + label: t('http.displayAsHistogram'), icon: , }, ]} @@ -78,7 +80,7 @@ export function EndpointLatencyChart({ expressions && (latencyChartType === 'line' ? ( ) : ( {source && requestErrorRateConfig && ( , ]} @@ -432,7 +438,7 @@ function HttpTab({ > {source && expressions && ( {source && expressions && ( - Top 20{' '} {topEndpointsChartType === 'time' - ? 'Most Time Consuming' - : 'Highest Error Rate'} + ? t('http.topEndpointsTimeTitle') + : t('http.topEndpointsErrorTitle')} } toolbarSuffix={[ @@ -592,8 +597,8 @@ function HttpTab({ } }} data={[ - { label: 'Sort by Time', value: 'time' }, - { label: 'Sort by Errors', value: 'error' }, + { label: t('http.sortByTime'), value: 'time' }, + { label: t('http.sortByErrors'), value: 'error' }, ]} />, ]} diff --git a/packages/app/src/ServicesDashboardPage/ServicesDashboardPage.tsx b/packages/app/src/ServicesDashboardPage/ServicesDashboardPage.tsx index b69af94da0..d56764afc3 100644 --- a/packages/app/src/ServicesDashboardPage/ServicesDashboardPage.tsx +++ b/packages/app/src/ServicesDashboardPage/ServicesDashboardPage.tsx @@ -9,6 +9,7 @@ import { useQueryStates, } from 'nuqs'; import { UseControllerProps, useForm, useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; import { PresetDashboard, SourceKind } from '@hyperdx/common-utils/dist/types'; import { @@ -65,6 +66,7 @@ function ServiceSelectControlled({ dateRange: [Date, Date]; onCreate?: () => void; } & UseControllerProps) { + const { t } = useTranslation('services'); const { data: source } = useSource({ id: sourceId, kinds: [SourceKind.Trace], @@ -108,11 +110,11 @@ function ServiceSelectControlled({ return [ { value: '', - label: 'All Services', + label: t('dashboard.allServices'), }, ...services, ]; - }, [data]); + }, [data, t]); return ( - Services Dashboard – {brandName} + {t('dashboard.browserTitle', { brandName })} - Dashboards + {tDashboard('page.dashboards')} - Services + {t('dashboard.breadcrumb')} @@ -342,7 +346,12 @@ function ServicesDashboardPage() { onSearch={onSearch} /> {!IS_LOCAL_MODE && ( - + setShowFiltersModal(true)} @@ -352,14 +361,19 @@ function ServicesDashboardPage() { )} - + @@ -372,7 +386,7 @@ function ServicesDashboardPage() { leftSection={} style={{ flexShrink: 0 }} > - Run + {t('dashboard.run')}
@@ -385,7 +399,7 @@ function ServicesDashboardPage() { /> {source?.kind !== 'trace' ? ( - Please select a trace source + {t('dashboard.selectTraceSource')} ) : ( - HTTP Service - Database - Errors + {t('dashboard.tabs.http')} + {t('dashboard.tabs.database')} + {t('dashboard.tabs.errors')} , ) => { + const { t } = useTranslation('sessions'); return (
{event.title}{' '} - {event.duration > 0 && {event.duration}ms} + {event.duration > 0 && ( + + {t('events.durationMs', { duration: event.duration })} + + )}
{event.description} @@ -113,6 +119,7 @@ export const SessionEventList = ({ onTimeClick: (ts: number) => void; eventsFollowPlayerPosition: boolean; }) => { + const { t } = useTranslation('sessions'); const { data, isLoading } = useQueriedChartConfig(queriedConfig, { placeholderData: (prev: any) => prev, queryKey: ['SessionEventList', queriedConfig], @@ -182,7 +189,7 @@ export const SessionEventList = ({ ? 'chat' : 'log', title: isNavigation - ? `Navigated` + ? t('events.navigated') : spanName === 'console.error' ? 'console.error' : spanName === 'console.log' @@ -192,7 +199,7 @@ export const SessionEventList = ({ : url.length > 0 ? `${statusCode} ${method}` : spanName === 'intercom.onShow' - ? 'Intercom Chat Opened' + ? t('events.intercomChatOpened') : isCustomEvent ? spanName : component === 'console' @@ -217,7 +224,7 @@ export const SessionEventList = ({ } satisfies SessionEvent; }) ?? [] ); - }, [events, showRelativeTime, minTs, formatTime]); + }, [events, showRelativeTime, minTs, formatTime, t]); const parentRef = React.useRef(null); diff --git a/packages/app/src/SessionSidePanel.tsx b/packages/app/src/SessionSidePanel.tsx index a5ff7414ae..13f88badeb 100644 --- a/packages/app/src/SessionSidePanel.tsx +++ b/packages/app/src/SessionSidePanel.tsx @@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react'; import { useQueryState } from 'nuqs'; import { ErrorBoundary } from 'react-error-boundary'; import { useHotkeys } from 'react-hotkeys-hook'; +import { useTranslation } from 'react-i18next'; import { z } from 'zod'; import { DateRange, @@ -96,6 +97,7 @@ export default function SessionSidePanel({ closeOnClickOutside?: boolean; keepOpenSelector?: string; }) { + const { t } = useTranslation('sessions'); // A single in-place event view (session → event), persisted to the URL so it // survives reload and shared links. Deeper navigation (View Trace, // surrounding context) is handled by `DBRowSidePanelInner`, which persists @@ -123,7 +125,8 @@ export default function SessionSidePanel({ setSize(isFullWidth ? INITIAL_DRAWER_WIDTH_PERCENT : 100); }, [isFullWidth, setSize]); - const sessionLabel = session?.userEmail || `Anonymous Session ${sessionId}`; + const sessionLabel = + session?.userEmail || t('panel.anonymous', { id: sessionId }); const sidePanelStack = useSidePanelStack({ initialRowId: selectedEvent?.rowId, @@ -163,10 +166,10 @@ export default function SessionSidePanel({ const ok = await copyTextToClipboard(window.location.href); notifications.show( ok - ? { color: 'green', message: 'Copied link to clipboard' } + ? { color: 'green', message: t('panel.copiedLink') } : { color: 'red', message: CLIPBOARD_ERROR_MESSAGE }, ); - }, []); + }, [t]); const breadcrumbs = useMemo( (): BreadcrumbItem[] => [ @@ -259,19 +262,19 @@ export default function SessionSidePanel({ style={{ fontSize: '12px' }} onClick={shareSession} > - Share Session + {t('panel.share')} - + @@ -279,17 +282,21 @@ export default function SessionSidePanel({ - Last active {timeAgo} ago + {t('panel.lastActive', { time: timeAgo })} {Number.parseInt(session?.errorCount ?? '0') > 0 && ( <> {' · '} - {session?.errorCount} Errors + {t('panel.errors', { + displayCount: session?.errorCount, + })} )} {' · '} - {session?.sessionCount} Events + {t('panel.eventCount', { + displayCount: session?.sessionCount, + })}
diff --git a/packages/app/src/SessionSubpanel.tsx b/packages/app/src/SessionSubpanel.tsx index c5ef31a75d..60bddaea53 100644 --- a/packages/app/src/SessionSubpanel.tsx +++ b/packages/app/src/SessionSubpanel.tsx @@ -3,6 +3,7 @@ import cx from 'classnames'; import throttle from 'lodash/throttle'; import { parseAsInteger, useQueryState } from 'nuqs'; import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; import { ChartConfigWithOptDateRange, @@ -251,6 +252,7 @@ export default function SessionSubpanel({ onLanguageChange?: (lang: 'sql' | 'lucene') => void; onEventNavigate?: (rowId: string, aliasWith: WithClause[]) => void; }) { + const { t } = useTranslation('sessions'); const [tsQuery, setTsQuery] = useQueryState( 'ts', parseAsInteger.withOptions({ history: 'replace' }), @@ -465,13 +467,13 @@ export default function SessionSubpanel({ flex={1} size="xs" data={[ - { value: 'highlighted', label: 'Highlighted' }, - { value: 'events', label: 'All Events' }, + { value: 'highlighted', label: t('events.highlighted') }, + { value: 'events', label: t('events.all') }, ]} value={tab} onChange={value => setTab(value)} /> - +
- +
- + - + setSkipInactive(!skipInactive)} > - Skip Idle + {t('player.skipIdle')} diff --git a/packages/app/src/SessionsPage.tsx b/packages/app/src/SessionsPage.tsx index 4ce37009c2..f099dfc966 100644 --- a/packages/app/src/SessionsPage.tsx +++ b/packages/app/src/SessionsPage.tsx @@ -9,6 +9,7 @@ import { useQueryStates, } from 'nuqs'; import { useForm, useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; import { SearchCondition, @@ -72,6 +73,7 @@ function SessionCard({ onClick: () => void; sessionId: string; }) { + const { t } = useTranslation('sessions'); const timeAgo = formatDistanceToNowStrictShort(maxTime); const durationStr = new Date(maxTime.getTime() - minTime.getTime()) .toISOString() @@ -85,19 +87,25 @@ function SessionCard({ role="button" >
- {email || `Anonymous Session ${sessionId}`} + {email || t('panel.anonymous', { id: sessionId })}
-
{numEvents} Events
+
+ {t('list.events', { count: numEvents })} +
{numErrors > 0 && ( -
{numErrors} Errors
+
+ {t('list.errors', { count: numErrors })} +
)} -
Duration {durationStr}
+
+ {t('list.duration', { duration: durationStr })} +
-
Last active {timeAgo} ago
+
{t('list.lastActive', { time: timeAgo })}
- Started on + {t('list.startedOn')}
@@ -113,6 +121,7 @@ function SessionCardList({ isSessionLoading?: boolean; onClick: (session: Session) => void; }) { + const { t } = useTranslation('sessions'); const brandName = useBrandDisplayName(); const parentRef = useRef(null); @@ -129,18 +138,15 @@ function SessionCardList({ {isSessionLoading === true && ( - Searching sessions... + {t('list.searching')} )} {!isSessionLoading && sessions.length === 0 && (
- No results found. + {t('list.noResults')} +
{t('list.queryHint')}
- Try checking the query explainer in the search bar if there are any - search syntax issues. -
-
- Add new data sources by setting up a {brandName} integration. + {t('list.addSources', { brandName })}
)} @@ -232,6 +238,7 @@ const appliedConfigMap = { whereLanguage: parseAsStringEnum<'sql' | 'lucene'>(['sql', 'lucene']), }; function SessionsPage() { + const { t } = useTranslation('sessions'); const brandName = useBrandDisplayName(); const [appliedConfig, setAppliedConfig] = useQueryStates(appliedConfigMap); @@ -369,7 +376,7 @@ function SessionsPage() { return ( <> - Client Sessions - {brandName} + {t('list.pageTitle', { brandName })} {selectedSession != null && @@ -441,15 +448,20 @@ function SessionsPage() { leftSection={} style={{ flexShrink: 0 }} > - Run + {t('list.run')} - + @@ -465,8 +477,8 @@ function SessionsPage() { {isSessionSourceLoading - ? 'Loading...' - : 'Searching sessions...'} + ? t('list.loading') + : t('list.searching')} ) : ( <> @@ -512,17 +524,13 @@ SessionsPageDynamic.getLayout = withAppNav; export default SessionsPageDynamic; function SessionSetupInstructions() { + const { t } = useTranslation('sessions'); const brandName = useBrandDisplayName(); return ( } - title="Set up session replays" - description={ - <> - Follow these steps to start recording and viewing session replays with - the {brandName} Otel Collector. - - } + title={t('setup.title')} + description={t('setup.description', { brandName })} maw={600} > @@ -530,43 +538,49 @@ function SessionSetupInstructions() { - Create a new source with Session type + {t('setup.createSourcePrefix')}{' '} + {t('setup.sourceType')}{' '} + {t('setup.createSourceSuffix')} } description={ <> - Go to Team Settings, click Add Source under Sources - section, and select Session as the source type. + {t('setup.sourceInstructionsPrefix')}{' '} + {t('setup.addSource')}{' '} + {t('setup.sourceInstructionsMiddle')}{' '} + {t('setup.sourceType')}{' '} + {t('setup.sourceInstructionsSuffix')} } /> - Choose the hyperdx_sessions table + {t('setup.chooseTablePrefix')} hyperdx_sessions{' '} + {t('setup.chooseTableSuffix')} } description={ <> - Select the hyperdx_sessions table from the - dropdown, and select the corresponding trace source. + {t('setup.selectTablePrefix')} hyperdx_sessions{' '} + {t('setup.selectTableSuffix')} } /> - Install the{' '} + {t('setup.installPrefix')}{' '} - {brandName} Browser Integration + {t('setup.browserIntegration', { brandName })} {' '} - to start recording sessions. + {t('setup.installSuffix')} } /> diff --git a/packages/app/src/Spotlights.tsx b/packages/app/src/Spotlights.tsx index bbb593d651..908c5f97e0 100644 --- a/packages/app/src/Spotlights.tsx +++ b/packages/app/src/Spotlights.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { useRouter } from 'next/router'; +import { useTranslation } from 'react-i18next'; import { Spotlight, SpotlightActionData } from '@mantine/spotlight'; import { IconActivityHeartbeat, @@ -22,6 +23,7 @@ import { useSavedSearches } from './savedSearch'; import '@mantine/spotlight/styles.css'; export const useSpotlightActions = () => { + const { t } = useTranslation('navigation'); const router = useRouter(); const brandName = useBrandDisplayName(); const logomark = useLogomark({ size: 16 }); @@ -39,7 +41,7 @@ export const useSpotlightActions = () => { logViews.forEach(logView => { logViewActions.push({ id: logView.id, - group: 'Saved searches', + group: t('spotlight.groups.savedSearches'), leftSection: , label: logView.name, keywords: ['search', 'log', 'saved'], @@ -53,7 +55,7 @@ export const useSpotlightActions = () => { dashboards.forEach(dashboard => { logViewActions.push({ id: dashboard.id, - group: 'Dashboards', + group: t('spotlight.groups.dashboards'), leftSection: , label: dashboard.name, keywords: ['dashboard'], @@ -67,15 +69,15 @@ export const useSpotlightActions = () => { const presetDashboards = [ { id: 'preset-services', - label: 'Services', - description: 'Monitor HTTP endpoints, latency, and error rates', + label: t('links.services'), + description: t('spotlight.servicesDescription'), href: '/services', keywords: ['preset', 'dashboard', 'http', 'latency', 'errors'], }, { id: 'preset-clickhouse', - label: 'ClickHouse', - description: 'ClickHouse cluster health and query performance', + label: t('links.clickhouse'), + description: t('spotlight.clickhouseDescription'), href: '/clickhouse', keywords: ['preset', 'dashboard', 'database', 'queries'], }, @@ -83,8 +85,8 @@ export const useSpotlightActions = () => { ? [ { id: 'preset-kubernetes', - label: 'Kubernetes', - description: 'Kubernetes cluster monitoring and pod health', + label: t('links.kubernetes'), + description: t('spotlight.kubernetesDescription'), href: '/kubernetes', keywords: ['preset', 'dashboard', 'k8s', 'pods', 'cluster'], }, @@ -95,7 +97,7 @@ export const useSpotlightActions = () => { presetDashboards.forEach(preset => { logViewActions.push({ id: preset.id, - group: 'Preset Dashboards', + group: t('spotlight.groups.presetDashboards'), leftSection: , label: preset.label, description: preset.description, @@ -109,10 +111,10 @@ export const useSpotlightActions = () => { logViewActions.push( { id: 'search', - group: 'Menu', + group: t('spotlight.groups.menu'), leftSection: , - label: 'Search', - description: 'Start a new search', + label: t('links.search'), + description: t('spotlight.searchDescription'), keywords: ['log', 'events', 'logs'], onClick: () => { router.push('/search'); @@ -120,10 +122,10 @@ export const useSpotlightActions = () => { }, { id: 'chart-explorer', - group: 'Menu', + group: t('spotlight.groups.menu'), leftSection: , - label: 'Chart Explorer', - description: 'Explore your data', + label: t('links.chartExplorer'), + description: t('spotlight.chartDescription'), keywords: ['graph', 'metrics'], onClick: () => { router.push('/chart'); @@ -131,10 +133,10 @@ export const useSpotlightActions = () => { }, { id: 'new-dashboard', - group: 'Menu', + group: t('spotlight.groups.menu'), leftSection: , - label: 'New Dashboard', - description: 'Create a new dashboard', + label: t('links.newDashboard'), + description: t('spotlight.dashboardDescription'), keywords: ['graph'], onClick: () => { router.push('/dashboards'); @@ -142,10 +144,10 @@ export const useSpotlightActions = () => { }, { id: 'sessions', - group: 'Menu', + group: t('spotlight.groups.menu'), leftSection: , - label: 'Client Sessions', - description: 'View client sessions', + label: t('links.clientSessions'), + description: t('spotlight.sessionsDescription'), keywords: ['browser', 'web'], onClick: () => { router.push('/sessions'); @@ -153,29 +155,29 @@ export const useSpotlightActions = () => { }, { id: 'alerts', - group: 'Menu', + group: t('spotlight.groups.menu'), leftSection: , - label: 'Alerts', - description: 'View and manage alerts', + label: t('links.alerts'), + description: t('spotlight.alertsDescription'), onClick: () => { router.push('/alerts'); }, }, { id: 'service-health', - group: 'Menu', - label: 'Service Health', + group: t('spotlight.groups.menu'), + label: t('links.serviceHealth'), leftSection: , - description: 'HTTP, Database and Infrastructure metrics', + description: t('spotlight.serviceHealthDescription'), onClick: () => { router.push('/services'); }, }, { id: 'team-settings', - group: 'Menu', + group: t('spotlight.groups.menu'), leftSection: , - label: 'Team Settings', + label: t('links.teamSettings'), onClick: () => { router.push('/team'); @@ -183,9 +185,9 @@ export const useSpotlightActions = () => { }, { id: 'documentation', - group: 'Menu', + group: t('spotlight.groups.menu'), leftSection: , - label: 'Documentation', + label: t('links.documentation'), keywords: ['help', 'docs'], onClick: () => { router.push( @@ -195,10 +197,10 @@ export const useSpotlightActions = () => { }, { id: 'cloud', - group: 'Menu', + group: t('spotlight.groups.menu'), leftSection: logomark, - label: `${brandName} Cloud`, - description: `Ready to use ${brandName} Cloud? Get started for free.`, + label: `${brandName} ${t('spotlight.cloud')}`, + description: t('spotlight.cloudReady', { brandName }), keywords: ['account', 'profile'], onClick: () => { router.push( @@ -209,7 +211,7 @@ export const useSpotlightActions = () => { ); return logViewActions; - }, [brandName, logomark, logViewsData, dashboardsData, router]); + }, [brandName, dashboardsData, logViewsData, logomark, router, t]); return { actions }; }; @@ -219,6 +221,7 @@ export const HDXSpotlightProvider = ({ }: { children: React.ReactNode; }) => { + const { t } = useTranslation('navigation'); const { actions } = useSpotlightActions(); return ( @@ -228,9 +231,9 @@ export const HDXSpotlightProvider = ({ shortcut="mod + K" searchProps={{ leftSection: , - placeholder: 'Search', + placeholder: t('spotlight.searchPlaceholder'), }} - nothingFound="Nothing found" + nothingFound={t('spotlight.nothingFound')} zIndex={200001} // above the autocomplete tagsToIgnore={[]} highlightQuery diff --git a/packages/app/src/TeamPage.tsx b/packages/app/src/TeamPage.tsx index 19cd5e98f8..89fc87ffce 100644 --- a/packages/app/src/TeamPage.tsx +++ b/packages/app/src/TeamPage.tsx @@ -2,6 +2,7 @@ import { ReactNode, useCallback, useEffect, useState } from 'react'; import Head from 'next/head'; import { useRouter } from 'next/router'; import { SubmitHandler, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; import { Box, Button, @@ -51,6 +52,8 @@ function TeamTabContent({ sections }: { sections: TeamTab['sections'] }) { } export default function TeamPage() { + const { t } = useTranslation('settings'); + const { t: tCommon } = useTranslation('common'); const brandName = useBrandDisplayName(); const router = useRouter(); const { data: team, refetch: refetchTeam, isLoading } = api.useTeam(); @@ -72,13 +75,13 @@ export default function TeamPage() { onError: () => { notifications.show({ color: 'red', - message: 'Failed to update team name', + message: t('team.nameUpdateFailed'), }); }, onSuccess: () => { notifications.show({ color: 'green', - message: 'Updated team name', + message: t('team.nameUpdated'), }); refetchTeam(); setIsEditingTeamName(false); @@ -86,13 +89,13 @@ export default function TeamPage() { }, ); }, - [refetchTeam, setTeamName], + [refetchTeam, setTeamName, t], ); const tabs: TeamTab[] = [ { value: 'data', - label: 'Data', + label: t('team.tabs.data'), sections: [ { id: 'team-data-sources', @@ -106,7 +109,7 @@ export default function TeamPage() { }, { value: 'team', - label: 'Members', + label: t('team.tabs.members'), sections: [ { id: 'team-members', @@ -118,7 +121,7 @@ export default function TeamPage() { ? [ { value: 'access', - label: 'Access', + label: t('team.tabs.access'), sections: [ { id: 'team-access-security-policies', @@ -134,7 +137,7 @@ export default function TeamPage() { : []), { value: 'api-agents', - label: 'API & Agents', + label: t('team.tabs.apiAgents'), sections: [ { id: 'team-api-agents-api-keys', @@ -148,7 +151,7 @@ export default function TeamPage() { }, { value: 'integrations', - label: 'Integrations', + label: t('team.tabs.integrations'), sections: [ { id: 'team-integrations-webhooks', @@ -158,7 +161,7 @@ export default function TeamPage() { }, { value: 'advanced', - label: 'Query Settings', + label: t('team.tabs.querySettings'), sections: [ { id: 'team-advanced-query-settings', @@ -231,7 +234,7 @@ export default function TeamPage() { return (
- My Team - {brandName} + {t('team.pageTitle', { brandName })}
@@ -241,7 +244,7 @@ export default function TeamPage() { - Save + {tCommon('actions.save')} ) : ( - {team?.name || 'My team'} + {team?.name || t('team.nameFallback')} {hasAdminAccess && (