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 && (