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 = () => (
+
+);
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 = () => (
+
+);
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 (
+
+ );
+};
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 && (