From 79893c9ad68dd50e8203d7cf48ad5d1cfa83f2a2 Mon Sep 17 00:00:00 2001 From: "Carol A. Scott" Date: Wed, 21 Oct 2020 21:25:52 -0700 Subject: [PATCH] Add more translations to dashboard and introduce i18n test wrapper (#5082) Add more translations to the dashboard; introduce i18n test wrapper --- web/app/js/components/BreadcrumbHeader.jsx | 13 ++-- .../js/components/BreadcrumbHeader.test.jsx | 5 +- web/app/js/components/CallToAction.jsx | 11 ++-- web/app/js/components/CheckModal.jsx | 6 +- .../js/components/ConfigureProfilesMsg.jsx | 24 ++++--- web/app/js/components/ExpandableTable.jsx | 3 +- web/app/js/components/Gateway.jsx | 2 +- web/app/js/components/Octopus.jsx | 2 +- web/app/js/components/QueryToCliCmd.jsx | 5 +- web/app/js/components/QueryToCliCmd.test.jsx | 25 ++++---- web/app/js/components/ResourceDetail.jsx | 6 +- web/app/js/components/ServiceMesh.jsx | 18 ++++-- web/app/js/components/ServiceMesh.test.jsx | 15 +++-- web/app/js/components/StatusTable.jsx | 6 +- web/app/js/components/TapEventTable.jsx | 28 ++++---- web/app/js/components/TapQueryForm.jsx | 13 ++-- web/app/js/components/TopRoutes.jsx | 14 ++-- web/app/js/components/TopRoutesTabs.jsx | 5 +- web/app/js/components/Version.jsx | 19 ++++-- web/app/js/components/Version.test.js | 13 ++-- web/app/js/components/util/Chip.jsx | 2 +- web/app/js/components/util/TapUtils.jsx | 9 +-- web/app/js/locales/en/messages.json | 64 ++++++++++++++++++- web/app/js/locales/es/messages.json | 64 ++++++++++++++++++- web/app/test/testHelpers.jsx | 15 +++++ 25 files changed, 271 insertions(+), 116 deletions(-) diff --git a/web/app/js/components/BreadcrumbHeader.jsx b/web/app/js/components/BreadcrumbHeader.jsx index bb5e55deb..09b70defd 100644 --- a/web/app/js/components/BreadcrumbHeader.jsx +++ b/web/app/js/components/BreadcrumbHeader.jsx @@ -3,17 +3,18 @@ import { friendlyTitle, isResource, singularResource } from './util/Utils.js'; import PropTypes from 'prop-types'; import React from 'react'; import ReactRouterPropTypes from 'react-router-prop-types'; +import { Trans } from '@lingui/macro'; import _chunk from 'lodash/chunk'; import _takeWhile from 'lodash/takeWhile'; import { withContext } from './util/AppContext.jsx'; const routeToCrumbTitle = { - controlplane: 'Control Plane', - overview: 'Overview', - tap: 'Tap', - top: 'Top', - routes: 'Top Routes', - community: 'Community', + controlplane: menuItemControlPlane, + tap: menuItemTap, + top: menuItemTop, + routes: menuItemRoutes, + community: menuItemCommunity, + gateways: menuItemGateway, }; class BreadcrumbHeader extends React.Component { diff --git a/web/app/js/components/BreadcrumbHeader.test.jsx b/web/app/js/components/BreadcrumbHeader.test.jsx index 1ccab7e7a..b749ffa6b 100644 --- a/web/app/js/components/BreadcrumbHeader.test.jsx +++ b/web/app/js/components/BreadcrumbHeader.test.jsx @@ -2,6 +2,7 @@ import { BrowserRouter } from 'react-router-dom'; import BreadcrumbHeader from './BreadcrumbHeader.jsx'; import React from 'react'; import { mount } from 'enzyme'; +import { i18nWrap } from '../../test/testHelpers.jsx'; const loc = { pathname: '', @@ -48,13 +49,13 @@ describe('Tests for ', () => { it("renders correct breadcrumb text for top-level pages [Control Plane]", () => { loc.pathname = "/controlplane"; - const component = mount( + const component = mount(i18nWrap( - ); + )); const crumbs = component.find("span"); expect(crumbs).toHaveLength(1); const crumbText = crumbs.reduce((acc, crumb) => { diff --git a/web/app/js/components/CallToAction.jsx b/web/app/js/components/CallToAction.jsx index 0f372a55e..91f2c24e0 100644 --- a/web/app/js/components/CallToAction.jsx +++ b/web/app/js/components/CallToAction.jsx @@ -1,3 +1,4 @@ +import { Plural, Trans } from '@lingui/macro'; import PropTypes from 'prop-types'; import React from 'react'; import Step from '@material-ui/core/Step'; @@ -17,9 +18,9 @@ const styles = theme => ({ function getSteps(numResources, resource) { return [ - { label: 'Controller successfully installed' }, - { label: `${numResources || 'No'} ${resource}s detected` }, - { label: `Connect your first ${resource}`, content: incompleteMeshMessage() }, + { label: controllerInstalledMsg, key: 'installedMsg' }, + { label: , key: 'resourceMsg' }, + { label: connectResourceMsg {resource}, content: incompleteMeshMessage(), key: 'connectMsg' }, ]; } @@ -29,7 +30,7 @@ const CallToAction = ({ resource, numResources, classes }) => { return ( - The service mesh was successfully installed! + serviceMeshInstalledMsg { props.completed = i < lastStep; // select the last step as the currently active one return ( - + {step.label} {step.content} diff --git a/web/app/js/components/CheckModal.jsx b/web/app/js/components/CheckModal.jsx index d284b2ac1..6180987e4 100644 --- a/web/app/js/components/CheckModal.jsx +++ b/web/app/js/components/CheckModal.jsx @@ -285,7 +285,7 @@ class CheckModal extends React.Component { {success !== undefined && labelSuccess : labelError} type={success ? 'good' : 'bad'} /> } @@ -316,11 +316,11 @@ class CheckModal extends React.Component { diff --git a/web/app/js/components/ConfigureProfilesMsg.jsx b/web/app/js/components/ConfigureProfilesMsg.jsx index 689e91f00..3d29eb311 100644 --- a/web/app/js/components/ConfigureProfilesMsg.jsx +++ b/web/app/js/components/ConfigureProfilesMsg.jsx @@ -13,6 +13,7 @@ import InputLabel from '@material-ui/core/InputLabel'; import NoteAddIcon from '@material-ui/icons/NoteAdd'; import PropTypes from 'prop-types'; import React from 'react'; +import { Trans } from '@lingui/macro'; import Typography from '@material-ui/core/Typography'; import _isEmpty from 'lodash/isEmpty'; import { withContext } from './util/AppContext.jsx'; @@ -131,7 +132,7 @@ class ConfigureProfilesMsg extends React.Component { color="primary" size="small" onClick={this.handleClickOpen}> - Create Service Profile + buttonCreateServiceProfile ); } @@ -144,7 +145,7 @@ class ConfigureProfilesMsg extends React.Component { disabled={disableDownloadButton} onClick={() => this.handleClose(downloadUrl)} color="primary"> - Download + buttonDownload ); @@ -155,11 +156,12 @@ class ConfigureProfilesMsg extends React.Component { open={open} onClose={this.handleClose} aria-labelledby="form-dialog-title"> - New service profile + + New service profile + - To create a service profile, download a profile and then apply it - with `kubectl apply`. + formCreateServiceProfileHelpText {error.service && ( - Service name must consist of lower case alphanumeric characters or - - start with an alphabetic character, and end with an alphanumeric character + formServiceNameErrorText )} @@ -190,15 +191,14 @@ class ConfigureProfilesMsg extends React.Component { aria-describedby="component-error-text" /> {error.namespace && ( - Namespace must consist of lower case alphanumeric characters or - - and must start and end with an alphanumeric character + formNamespaceErrorText )} {disableDownloadButton ? downloadButton : @@ -221,9 +221,7 @@ class ConfigureProfilesMsg extends React.Component { return ( - No named route traffic found. This could be because the service is - not receiving any traffic, or because there is no service profile - configured. Does the service have a service profile? + formNoNamedRouteTrafficFound {this.renderDownloadProfileForm()} diff --git a/web/app/js/components/ExpandableTable.jsx b/web/app/js/components/ExpandableTable.jsx index 89af24afe..051e8fcc7 100644 --- a/web/app/js/components/ExpandableTable.jsx +++ b/web/app/js/components/ExpandableTable.jsx @@ -14,6 +14,7 @@ import TableBody from '@material-ui/core/TableBody'; import TableCell from '@material-ui/core/TableCell'; import TableHead from '@material-ui/core/TableHead'; import TableRow from '@material-ui/core/TableRow'; +import { Trans } from '@lingui/macro'; import { withStyles } from '@material-ui/core/styles'; const styles = theme => ({ @@ -131,7 +132,7 @@ class ExpandableTable extends React.Component { open={open} onClose={this.handleDialogClose} aria-labelledby="form-dialog-title"> - Request Details + tableTitleRequestDetails {expandedRowRender(datum, classes.expandedWrap)} diff --git a/web/app/js/components/Gateway.jsx b/web/app/js/components/Gateway.jsx index ddee3a936..468513404 100644 --- a/web/app/js/components/Gateway.jsx +++ b/web/app/js/components/Gateway.jsx @@ -99,7 +99,7 @@ class Gateways extends React.Component { ) : (
- {noMetrics ?
No resources detected.
: null} + {noMetrics ?
noResourcesDetectedMsg
: null} {noMetrics ? null : (
{ const { cmdName, query, resource, controllerNamespace } = this.props; + const cmdNameDisplay = _startCase(cmdName); + return ( _isEmpty(resource) ? null : - Current {_startCase(cmdName)} query + Current {cmdNameDisplay} query
diff --git a/web/app/js/components/QueryToCliCmd.test.jsx b/web/app/js/components/QueryToCliCmd.test.jsx index 26fa9a7d8..9514f451c 100644 --- a/web/app/js/components/QueryToCliCmd.test.jsx +++ b/web/app/js/components/QueryToCliCmd.test.jsx @@ -1,6 +1,7 @@ import QueryToCliCmd from './QueryToCliCmd.jsx'; import React from 'react'; import { mount } from 'enzyme'; +import { i18nWrap } from '../../test/testHelpers.jsx'; describe('QueryToCliCmd', () => { it('renders a query as a linkerd CLI command', () => { @@ -10,12 +11,12 @@ describe('QueryToCliCmd', () => { "scheme": "" } - let component = mount( + let component = mount(i18nWrap( + controllerNamespace="linkerd" />) ); expect(component).toIncludeText("Current Routes query"); @@ -28,12 +29,12 @@ describe('QueryToCliCmd', () => { "namespace": "linkerd" } - let component = mount( + let component = mount(i18nWrap( + controllerNamespace="my-linkerd-ns" />) ); expect(component).toIncludeText("Current Routes query"); @@ -49,12 +50,12 @@ describe('QueryToCliCmd', () => { "authority": "foo.bar:8080" } - let component = mount( + let component = mount(i18nWrap( + controllerNamespace="linkerd" />) ); expect(component).toIncludeText("Current Tap query"); @@ -71,12 +72,12 @@ describe('QueryToCliCmd', () => { "authority": "foo.bar:8080" } - let component = mount( + let component = mount(i18nWrap( + controllerNamespace="linkerd" />) ); expect(component).toIncludeText("Current Tap query"); @@ -89,12 +90,12 @@ describe('QueryToCliCmd', () => { "namespace": "linkerd" } - let component = mount( + let component = mount(i18nWrap( + controllerNamespace="linkerd" />) ); expect(component).toIncludeText("Current Top query"); @@ -109,12 +110,12 @@ describe('QueryToCliCmd', () => { "theLimitDoesNotExist": 999 } - let component = mount( + let component = mount(i18nWrap( + controllerNamespace="linkerd" />) ); expect(component).toIncludeText("Current Tap query"); diff --git a/web/app/js/components/ResourceDetail.jsx b/web/app/js/components/ResourceDetail.jsx index 3b0edeb9e..52bcf2948 100644 --- a/web/app/js/components/ResourceDetail.jsx +++ b/web/app/js/components/ResourceDetail.jsx @@ -367,11 +367,11 @@ export class ResourceDetailBase extends React.Component { {resourceType}/{resourceName} - {showNoTrafficMsg ? : null} + {showNoTrafficMsg ? columnTitleNoTraffic} type="warning" /> : null} {resourceIsMeshed ? - : - + columnTitleMeshed} type="good" /> : + columnTitleUnmeshed} type="bad" /> } diff --git a/web/app/js/components/ServiceMesh.jsx b/web/app/js/components/ServiceMesh.jsx index 92a28d1eb..804f90abb 100644 --- a/web/app/js/components/ServiceMesh.jsx +++ b/web/app/js/components/ServiceMesh.jsx @@ -1,3 +1,4 @@ +import { Plural, Trans } from '@lingui/macro'; import { formatDistanceToNow, subSeconds } from 'date-fns'; import { handlePageVisibility, withPageVisibility } from './util/PageVisibility.jsx'; import BaseTable from './BaseTable.jsx'; @@ -13,7 +14,6 @@ import PropTypes from 'prop-types'; import React from 'react'; import Spinner from './util/Spinner.jsx'; import StatusTable from './StatusTable.jsx'; -import { Trans } from '@lingui/macro'; import Typography from '@material-ui/core/Typography'; import _compact from 'lodash/compact'; import _countBy from 'lodash/countBy'; @@ -107,7 +107,7 @@ class ServiceMesh extends React.Component { const { productName, releaseVersion, controllerNamespace } = this.props; return [ - { key: 1, name: `${productName} version`, value: releaseVersion }, + { key: 1, name: {productName} version, value: releaseVersion }, { key: 2, name: {productName} namespace, value: controllerNamespace }, { key: 3, name: Control plane components, value: components.length }, { key: 4, name: Data plane proxies, value: this.proxyCount() }, @@ -223,7 +223,7 @@ class ServiceMesh extends React.Component { - Control plane + Control plane componentsMsg @@ -243,7 +243,7 @@ class ServiceMesh extends React.Component { renderServiceMeshDetails() { return ( - Service mesh details + Service mesh details noNamespacesDetectedMsg; } else { const meshedCount = _countBy(nsStatuses, pod => { return pod.meshedPercent.get() > 0; }); numUnadded = meshedCount.false || 0; - message = numUnadded === 0 ? `All namespaces have a ${productName} install.` : - `${numUnadded} ${numUnadded === 1 ? 'namespace has' : 'namespaces have'} no meshed resources.`; + message = numUnadded === 0 ? All namespaces have a {productName} install. : ( + + ); } return ( diff --git a/web/app/js/components/ServiceMesh.test.jsx b/web/app/js/components/ServiceMesh.test.jsx index b9e8d2c19..488804cc2 100644 --- a/web/app/js/components/ServiceMesh.test.jsx +++ b/web/app/js/components/ServiceMesh.test.jsx @@ -8,6 +8,7 @@ import sinon from 'sinon'; import sinonStubPromise from 'sinon-stub-promise'; import Spinner from './util/Spinner.jsx'; import { mount } from 'enzyme'; +import { i18nWrap } from '../../test/testHelpers.jsx'; sinonStubPromise(sinon); @@ -91,7 +92,7 @@ describe('ServiceMesh', () => { ok: true, json: () => Promise.resolve({ metrics: [] }) }); - component = mount(routerWrap(ServiceMesh)); + component = mount(i18nWrap(routerWrap(ServiceMesh))); return withPromise(() => { component.update(); @@ -107,7 +108,7 @@ describe('ServiceMesh', () => { ok: true, json: () => Promise.resolve({ metrics: [] }) }); - component = mount(routerWrap(ServiceMesh)); + component = mount(i18nWrap(routerWrap(ServiceMesh))); return withPromise(() => { component.update(); @@ -122,7 +123,7 @@ describe('ServiceMesh', () => { ok: true, json: () => Promise.resolve({ metrics: [] }) }); - component = mount(routerWrap(ServiceMesh)); + component = mount(i18nWrap(routerWrap(ServiceMesh))); return withPromise(() => { component.update(); @@ -138,7 +139,7 @@ describe('ServiceMesh', () => { ok: true, json: () => Promise.resolve({}) }); - component = mount(routerWrap(ServiceMesh)); + component = mount(i18nWrap(routerWrap(ServiceMesh))); return withPromise(() => { expect(component).toIncludeText("No namespaces detected"); @@ -163,7 +164,7 @@ describe('ServiceMesh', () => { ok: true, json: () => Promise.resolve(nsAllResourcesAdded) }); - component = mount(routerWrap(ServiceMesh)); + component = mount(i18nWrap(routerWrap(ServiceMesh))); return withPromise(() => { expect(component).toIncludeText("4 namespaces have no meshed resources."); @@ -183,7 +184,7 @@ describe('ServiceMesh', () => { ok: true, json: () => Promise.resolve(nsOneResourceNotAdded) }); - component = mount(routerWrap(ServiceMesh)); + component = mount(i18nWrap(routerWrap(ServiceMesh))); return withPromise(() => { expect(component).toIncludeText("1 namespace has no meshed resources."); @@ -200,7 +201,7 @@ describe('ServiceMesh', () => { ok: true, json: () => Promise.resolve(nsAllResourcesAdded) }); - component = mount(routerWrap(ServiceMesh)); + component = mount(i18nWrap(routerWrap(ServiceMesh))); return withPromise(() => { expect(component).toIncludeText("All namespaces have a ShinyProductName install."); diff --git a/web/app/js/components/StatusTable.jsx b/web/app/js/components/StatusTable.jsx index 6ba5089db..cac32b387 100644 --- a/web/app/js/components/StatusTable.jsx +++ b/web/app/js/components/StatusTable.jsx @@ -25,18 +25,18 @@ const columnConfig = { width: 200, wrapDotsAt: 7, // dots take up more than one line in the table; space them out dotExplanation: status => { - return status.value === 'good' ? 'is up and running' : 'has not been started'; + return status.value === 'good' ? statusExplanationGood : statusExplanationNotStarted; }, }, 'Proxy Status': { width: 250, wrapDotsAt: 9, dotExplanation: pod => { - const addedStatus = !pod.added ? 'Not in mesh' : 'Added to mesh'; + const addedStatus = !pod.added ? statusExplanationNotInMesh : statusExplanationInMesh; return ( -
Pod status: {pod.status}
+
Pod status: {pod.status}
{addedStatus}
); diff --git a/web/app/js/components/TapEventTable.jsx b/web/app/js/components/TapEventTable.jsx index e07c1b852..b3ed6da77 100644 --- a/web/app/js/components/TapEventTable.jsx +++ b/web/app/js/components/TapEventTable.jsx @@ -138,39 +138,39 @@ const itemDisplay = (title, value) => { const requestInitSection = d => ( - Request Init + tableTitleRequestInit
- {itemDisplay('Authority', _get(d, 'requestInit.http.requestInit.authority'))} - {itemDisplay('Path', _get(d, 'requestInit.http.requestInit.path'))} - {itemDisplay('Scheme', _get(d, 'requestInit.http.requestInit.scheme.registered'))} - {itemDisplay('Method', _get(d, 'requestInit.http.requestInit.method.registered'))} - {headersDisplay('Headers', _get(d, 'requestInit.http.requestInit.headers'))} + {itemDisplay(formAuthority, _get(d, 'requestInit.http.requestInit.authority'))} + {itemDisplay(formPath, _get(d, 'requestInit.http.requestInit.path'))} + {itemDisplay(formScheme, _get(d, 'requestInit.http.requestInit.scheme.registered'))} + {itemDisplay(formMethod, _get(d, 'requestInit.http.requestInit.method.registered'))} + {headersDisplay(formHeaders, _get(d, 'requestInit.http.requestInit.headers'))}
); const responseInitSection = d => _isEmpty(d.responseInit) ? null : ( - Response Init + tableTitleResponseInit
- {itemDisplay('HTTP Status', _get(d, 'responseInit.http.responseInit.httpStatus'))} - {itemDisplay('Latency', formatTapLatency(_get(d, 'responseInit.http.responseInit.sinceRequestInit')))} - {headersDisplay('Headers', _get(d, 'responseInit.http.responseInit.headers'))} + {itemDisplay(formHTTPStatus, _get(d, 'responseInit.http.responseInit.httpStatus'))} + {itemDisplay(formLatency, formatTapLatency(_get(d, 'responseInit.http.responseInit.sinceRequestInit')))} + {headersDisplay(formHeaders, _get(d, 'responseInit.http.responseInit.headers'))}
); const responseEndSection = d => _isEmpty(d.responseEnd) ? null : ( - Response End + tableTitleResponseEnd
- {itemDisplay('GRPC Status', _isNull(_get(d, 'responseEnd.http.responseEnd.eos')) ? 'N/A' : grpcStatusCodes[_get(d, 'responseEnd.http.responseEnd.eos.grpcStatusCode')])} - {itemDisplay('Latency', formatTapLatency(_get(d, 'responseEnd.http.responseEnd.sinceResponseInit')))} - {itemDisplay('Response Length (B)', formatWithComma(_get(d, 'responseEnd.http.responseEnd.responseBytes')))} + {itemDisplay(formGRPCStatus, _isNull(_get(d, 'responseEnd.http.responseEnd.eos')) ? 'N/A' : grpcStatusCodes[_get(d, 'responseEnd.http.responseEnd.eos.grpcStatusCode')])} + {itemDisplay(formLatency, formatTapLatency(_get(d, 'responseEnd.http.responseEnd.sinceResponseInit')))} + {itemDisplay(formResponseLengthB, formatWithComma(_get(d, 'responseEnd.http.responseEnd.responseBytes')))}
); diff --git a/web/app/js/components/TapQueryForm.jsx b/web/app/js/components/TapQueryForm.jsx index e4d5e97dd..9f6dc56a1 100644 --- a/web/app/js/components/TapQueryForm.jsx +++ b/web/app/js/components/TapQueryForm.jsx @@ -36,7 +36,6 @@ import _noop from 'lodash/noop'; import _omit from 'lodash/omit'; import _pick from 'lodash/pick'; import _some from 'lodash/some'; -import _startCase from 'lodash/startCase'; import _uniq from 'lodash/uniq'; import _values from 'lodash/values'; import { withStyles } from '@material-ui/core/styles'; @@ -245,7 +244,7 @@ class TapQueryForm extends React.Component { return ( - {_startCase(resourceKey)} + {resourceKey === 'resource' ? formResource : formToResource} - Display requests with this HTTP method + formHTTPMethodHelpText
diff --git a/web/app/js/components/TopRoutes.jsx b/web/app/js/components/TopRoutes.jsx index f3fd5f6ce..6470cef0a 100644 --- a/web/app/js/components/TopRoutes.jsx +++ b/web/app/js/components/TopRoutes.jsx @@ -203,11 +203,11 @@ class TopRoutes extends React.Component { - { this.renderNamespaceDropdown('Namespace', 'namespace', 'Namespace to query') } + { this.renderNamespaceDropdown(formNamespace, 'namespace', formNamespaceHelpText) } - { this.renderResourceDropdown('Resource', 'resource_name', 'resource_type', 'Resource to query') } + { this.renderResourceDropdown(formResource, 'resource_name', 'resource_type', formResourceHelpText) } @@ -216,7 +216,7 @@ class TopRoutes extends React.Component { variant="outlined" disabled={requestInProgress || !query.namespace || !query.resource_type} onClick={this.handleBtnClick(true)}> - Start + buttonStart @@ -226,23 +226,23 @@ class TopRoutes extends React.Component { variant="outlined" disabled={!requestInProgress} onClick={this.handleBtnClick(false)}> - Stop + buttonStop - { this.renderNamespaceDropdown(formToNamespace, 'to_namespace', 'Namespace of target resource') } + { this.renderNamespaceDropdown(formToNamespace, 'to_namespace', formToNamespaceHelpText) } - { this.renderResourceDropdown(formToResource, 'to_name', 'to_type', 'Target resource') } + { this.renderResourceDropdown(formToResource, 'to_name', 'to_type', formToResourceHelpText) }
- You can also create a new profile + createNewProfileMsg
); } diff --git a/web/app/js/components/TopRoutesTabs.jsx b/web/app/js/components/TopRoutesTabs.jsx index b7aa1509c..9bee35423 100644 --- a/web/app/js/components/TopRoutesTabs.jsx +++ b/web/app/js/components/TopRoutesTabs.jsx @@ -8,6 +8,7 @@ import Tab from '@material-ui/core/Tab'; import Tabs from '@material-ui/core/Tabs'; import TopModule from './TopModule.jsx'; import TopRoutesModule from './TopRoutesModule.jsx'; +import { Trans } from '@lingui/macro'; import _isEmpty from 'lodash/isEmpty'; import _noop from 'lodash/noop'; import { withStyles } from '@material-ui/core/styles'; @@ -91,8 +92,8 @@ class TopRoutesTabs extends React.Component { onChange={this.handleChange} indicatorColor="primary" textColor="primary"> - - + tabLiveCalls} /> + tabRouteMetrics} /> {value === 0 && this.renderTopComponent()} diff --git a/web/app/js/components/Version.jsx b/web/app/js/components/Version.jsx index cf407fe5e..c1b20d98d 100644 --- a/web/app/js/components/Version.jsx +++ b/web/app/js/components/Version.jsx @@ -1,6 +1,7 @@ import Button from '@material-ui/core/Button'; import PropTypes from 'prop-types'; import React from 'react'; +import { Trans } from '@lingui/macro'; import Typography from '@material-ui/core/Typography'; import { apiErrorPropType } from './util/ApiHelpers.jsx'; import { withContext } from './util/AppContext.jsx'; @@ -39,25 +40,31 @@ class Version extends React.Component { if (!latestVersion) { return ( - Version check failed{error ? `: ${error.statusText}` : ''}. + Version check failed{error ? `: ${error.statusText}` : ''}. ); } if (isLatest) { - return Linkerd is up to date.; + return LinkerdIsUpToDateMsg; } + const versionText = this.numericVersion(latestVersion); + return (
- A new version ({this.numericVersion(latestVersion)}) is available. + + + A new version ({versionText}) is available. + +
); @@ -66,7 +73,7 @@ class Version extends React.Component { render() { const { classes, releaseVersion, productName } = this.props; const channel = this.versionChannel(releaseVersion); - let message = `Running ${productName || 'controller'}`; + let message = ` ${productName || 'controller'}`; message += ` ${this.numericVersion(releaseVersion)}`; if (channel) { message += ` (${channel})`; @@ -75,7 +82,7 @@ class Version extends React.Component { return (
- {message} + Running{message} {this.renderVersionCheck()}
); diff --git a/web/app/js/components/Version.test.js b/web/app/js/components/Version.test.js index 73ea11f99..175be1ae2 100644 --- a/web/app/js/components/Version.test.js +++ b/web/app/js/components/Version.test.js @@ -1,30 +1,31 @@ import React from 'react'; import Version from './Version.jsx'; import { mount } from 'enzyme'; +import { i18nWrap } from '../../test/testHelpers.jsx'; describe('Version', () => { let curVer = "edge-1.2.3"; let newVer = "edge-2.3.4"; it('renders up to date message when versions match', () => { - const component = mount( + const component = mount(i18nWrap( + releaseVersion={curVer} />) ); expect(component).toIncludeText("Linkerd is up to date"); }); it('renders update message when versions do not match', () => { - const component = mount( + const component = mount(i18nWrap( + releaseVersion={curVer} />) ); expect(component).toIncludeText("A new version (2.3.4) is available."); @@ -33,14 +34,14 @@ describe('Version', () => { it('renders error when version check fails', () => { let errMsg = "Fake error"; - const component = mount( + const component = mount(i18nWrap( + releaseVersion={curVer} />) ); expect(component).toIncludeText("Version check failed: Fake error."); diff --git a/web/app/js/components/util/Chip.jsx b/web/app/js/components/util/Chip.jsx index 16349df16..512c3ffdc 100644 --- a/web/app/js/components/util/Chip.jsx +++ b/web/app/js/components/util/Chip.jsx @@ -30,7 +30,7 @@ function SimpleChip(props) { } SimpleChip.propTypes = { - label: PropTypes.string.isRequired, + label: PropTypes.shape({}).isRequired, type: PropTypes.string.isRequired, }; diff --git a/web/app/js/components/util/TapUtils.jsx b/web/app/js/components/util/TapUtils.jsx index 2fe3d8533..408a311d0 100644 --- a/web/app/js/components/util/TapUtils.jsx +++ b/web/app/js/components/util/TapUtils.jsx @@ -8,6 +8,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import TapLink from '../TapLink.jsx'; import Tooltip from '@material-ui/core/Tooltip'; +import { Trans } from '@lingui/macro'; import _each from 'lodash/each'; import _get from 'lodash/get'; import _has from 'lodash/has'; @@ -228,9 +229,9 @@ export const processTapEvent = jsonString => { const displayLimit = 3; // how many upstreams/downstreams to display in the popover table const popoverSrcDstColumns = [ - { title: 'Source', dataIndex: 'source' }, + { title: columnTitleSource, dataIndex: 'source' }, { title: '', key: 'arrow', render: () => }, - { title: 'Destination', dataIndex: 'destination' }, + { title: columnTitleDestination, dataIndex: 'destination' }, ]; const getPodOwner = (labels, ResourceLink) => { @@ -310,8 +311,8 @@ const popoverResourceTable = (d, ResourceLink) => { // eslint-disable-line no-un }; export const directionColumn = d => ( - - {d === 'INBOUND' ? 'FROM' : 'TO'} + tooltipInbound : tooltipOutbound} placement="right"> + {d === 'INBOUND' ? columnTitleFrom : columnTitleTo} ); diff --git a/web/app/js/locales/en/messages.json b/web/app/js/locales/en/messages.json index fac755632..81dd7feb1 100644 --- a/web/app/js/locales/en/messages.json +++ b/web/app/js/locales/en/messages.json @@ -1,10 +1,25 @@ { "404Msg": "Page not found.", + "A new version ({versionText}) is available.": "A new version ({versionText}) is available.", "Add {0} to the k8s.yml file<0/><1/>Then run {inject} to add it to the service mesh": "Add {0} to the k8s.yml file<0/><1/>Then run {inject} to add it to the service mesh", "All namespaces have a {productName} install.": "All namespaces have a {productName} install.", + "Control plane": "Control plane", "Control plane components": "Control plane components", + "Current {cmdNameDisplay} query": "Current {cmdNameDisplay} query", "Data plane proxies": "Data plane proxies", + "LinkerdIsUpToDateMsg": "Linkerd is up to date.", + "New service profile": "New service profile", "NoDataToDisplayMsg": "No data to display", + "Pod status: {0}": "Pod status: {0}", + "Running": "Running", + "Service mesh details": "Service mesh details", + "Update Now": "Update Now", + "Version check failed{0}.": "Version check failed{0}.", + "buttonCancel": "Cancel", + "buttonClose": "Close", + "buttonCreateServiceProfile": "Create Service Profile", + "buttonDownload": "Download", + "buttonReRunCheck": "Re-Run Check", "buttonReset": "Reset", "buttonRunLinkerdCheck": "Run Linkerd Check", "buttonStart": "Start", @@ -15,7 +30,9 @@ "columnTitleClusterName": "Cluster Name", "columnTitleCount": "Count", "columnTitleDeployment": "Deployment", + "columnTitleDestination": "Destination", "columnTitleDirection": "Direction", + "columnTitleFrom": "FROM", "columnTitleGRPCStatus": "GRPC Status", "columnTitleGrafana": "Grafana", "columnTitleHTTPStatus": "HTTP Status", @@ -30,6 +47,7 @@ "columnTitleMethod": "Method", "columnTitleName": "Name", "columnTitleNamespace": "Namespace", + "columnTitleNoTraffic": "No Traffic", "columnTitleOpenConnections": "Connections", "columnTitleP50Latency": "P50 Latency", "columnTitleP95Latency": "P95 Latency", @@ -43,23 +61,51 @@ "columnTitleRoute": "Route", "columnTitleSecured": "Secured", "columnTitleService": "Service", + "columnTitleSource": "Source", "columnTitleSuccessRate": "Success Rate", "columnTitleTap": "Tap", + "columnTitleTo": "TO", + "columnTitleUnmeshed": "Unmeshed", "columnTitleValue": "Value", "columnTitleWeight": "Weight", "columnTitleWorst": "Worst", "columnTitleWriteRate": "Write Bytes / sec", "componentsMsg": "Components", + "connectResourceMsg {resource}": "Connect your first {resource}", + "controllerInstalledMsg": "Controller successfully installed", + "createNewProfileMsg": "You can also create a new profile", "formAuthority": "Authority", + "formAuthorityHelpText": "Display requests with this :authority", + "formCreateServiceProfileHelpText": "To create a service profile, download a profile and then apply it with `kubectl apply`.", + "formGRPCStatus": "GRPC Status", "formHTTPMethod": "HTTP Method", + "formHTTPMethodHelpText": "Display requests with this HTTP method", + "formHTTPStatus": "HTTP Status", + "formHeaders": "Headers", "formHideFilters": "Hide filters", + "formLatency": "Latency", "formMaxRPS": "Max RPS", + "formMaxRPSHelpText {defaultMaxRps}": "Maximum requests per second to tap. Default {defaultMaxRps}", + "formMethod": "Method", "formNamespace": "Namespace", + "formNamespaceErrorText": "Namespace must consist of lower case alphanumeric characters or '-' and must start and end with an alphanumeric character", + "formNamespaceHelpText": "Namespace to query", + "formNoNamedRouteTrafficFound": "No named route traffic found. This could be because the service is not receiving any traffic, or because there is no service profile configured. Does the service have a service profile?", "formPath": "Path", + "formPathHelpText": "Display requests with paths that start with this prefix", + "formResource": "Resource", + "formResourceHelpText": "Resource to query", + "formResponseLengthB": "Response Length (B)", "formScheme": "Scheme", + "formSchemeHelpText": "Display requests with this scheme", + "formServiceNameErrorText": "Service name must consist of lower case alphanumeric characters or '-', start with an alphabetic character, and end with an alphanumeric character", "formShowFilters": "Show more filters", "formToNamespace": "To Namespace", + "formToNamespaceHelpText": "Namespace of target resource", "formToResource": "To Resource", + "formToResourceHelpText": "Target resource", + "labelError": "Error", + "labelSuccess": "Success", "menuItemCommunity": "Community", "menuItemControlPlane": "Control Plane", "menuItemCronJobs": "Cron Jobs", @@ -80,12 +126,20 @@ "menuItemTap": "Tap", "menuItemTop": "Top", "menuItemTrafficSplits": "Traffic Splits", + "noNamespacesDetectedMsg": "No namespaces detected.", "noResourcesDetectedMsg": "No resources detected.", "podsAreInitializingMsg": "Pods are initializing", + "serviceMeshInstalledMsg": "The service mesh was successfully installed!", "sidebarHeadingCluster": "Cluster", "sidebarHeadingConfiguration": "Configuration", "sidebarHeadingTools": "Tools", "sidebarHeadingWorkloads": "Workloads", + "statusExplanationGood": "is up and running", + "statusExplanationInMesh": "Added to mesh", + "statusExplanationNotInMesh": "Not in mesh", + "statusExplanationNotStarted": "has not been started", + "tabLiveCalls": "Live Calls", + "tabRouteMetrics": "Route Metrics", "tableTitleEdgesEmpty": "Edges", "tableTitleEdgesWithIdentity {identity}": "Edges (Identity: {identity})", "tableTitleGateways": "Gateways", @@ -94,11 +148,17 @@ "tableTitleLeafServices": "Leaf Services", "tableTitleOutbound": "Outbound", "tableTitlePods": "Pods", + "tableTitleRequestDetails": "Request Details", + "tableTitleRequestInit": "Request Init", + "tableTitleResponseEnd": "Response End", + "tableTitleResponseInit": "Response Init", "tableTitleTCP": "TCP", "tableTitleTCPMetrics": "TCP Metrics", + "tooltipInbound": "INBOUND", + "tooltipOutbound": "OUTBOUND", "unspecifiedResourcesMsg": "one or more resources", - "{numUnadded} namespace has no meshed resources.": "{numUnadded} namespace has no meshed resources.", - "{numUnadded} namespaces have no meshed resources.": "{numUnadded} namespaces have no meshed resourcesß", + "{numResources, plural, zero {{resource}} one {{resource}} other {{resource}}}": "{numResources, plural, zero {No {resource}s detected} one {# {resource} detected} other {# {resource}s detected}}", + "{numUnadded, plural, one {# namespace has no meshed resources} other {# namespaces have no meshed resources}}": "{numUnadded, plural, one {# namespace has no meshed resources.} other {# namespaces have no meshed resources.}}", "{productName} namespace": "{productName} namespace", "{productName} version": "{productName} version" } diff --git a/web/app/js/locales/es/messages.json b/web/app/js/locales/es/messages.json index 698825593..eb8f46130 100644 --- a/web/app/js/locales/es/messages.json +++ b/web/app/js/locales/es/messages.json @@ -1,10 +1,25 @@ { "404Msg": "Página no encontrada.", + "A new version ({versionText}) is available.": "Una nueva version ({versionText}) está disponible", "Add {0} to the k8s.yml file<0/><1/>Then run {inject} to add it to the service mesh": "Agrega {0} al archivo k8s.yml<0/><1/>Luego ejecuta {inject} para inyectarlo en la malla de servicios", "All namespaces have a {productName} install.": "Todos los namespaces tienen una instalación de {productName}.", + "Control plane": "Plano de control", "Control plane components": "Componentes del plano de control", + "Current {cmdNameDisplay} query": "Consulta {cmdNameDisplay} actual", "Data plane proxies": "Proxies del plano de datos", + "LinkerdIsUpToDateMsg": "Linkerd está actualizado.", + "New service profile": "Nuevo service profile", "NoDataToDisplayMsg": "No hay datos que mostrar", + "Pod status: {0}": "Estado pod: {0}", + "Running": "Ejecutando", + "Service mesh details": "Detalles de la malla de servicios", + "Update Now": "Actualizar Ahora", + "Version check failed{0}.": "Error al comprobar la versión{0}", + "buttonCancel": "Cancelar", + "buttonClose": "Cerrar", + "buttonCreateServiceProfile": "Crear Service Profile", + "buttonDownload": "Descargar", + "buttonReRunCheck": "Volver a Check", "buttonReset": "Restablecer", "buttonRunLinkerdCheck": "Ejecutar Linkerd Check", "buttonStart": "Empezar", @@ -15,7 +30,9 @@ "columnTitleClusterName": "Nombre del Clúster", "columnTitleCount": "Total", "columnTitleDeployment": "Deployment", + "columnTitleDestination": "Destino", "columnTitleDirection": "Dirección", + "columnTitleFrom": "DESDE", "columnTitleGRPCStatus": "Estado GRPC", "columnTitleGrafana": "Grafana", "columnTitleHTTPStatus": "Estado HTTP", @@ -30,6 +47,7 @@ "columnTitleMethod": "Método", "columnTitleName": "Nombre", "columnTitleNamespace": "Namespace", + "columnTitleNoTraffic": "No Hay Tráfico", "columnTitleOpenConnections": "Conexiones", "columnTitleP50Latency": "Latencia P50", "columnTitleP95Latency": "Latencia P95", @@ -43,23 +61,51 @@ "columnTitleRoute": "Ruta", "columnTitleSecured": "Asegurado", "columnTitleService": "Servicio", + "columnTitleSource": "Fuente", "columnTitleSuccessRate": "Tasa de éxito", "columnTitleTap": "Tap", + "columnTitleTo": "HACIA", + "columnTitleUnmeshed": "Ausente en la malla de servicios", "columnTitleValue": "Valor", "columnTitleWeight": "Peso", "columnTitleWorst": "Peor", "columnTitleWriteRate": "Escritura Bytes / seg", "componentsMsg": "Componentes", + "connectResourceMsg {resource}": "Conecta tu primer {resource}", + "controllerInstalledMsg": "Controller instalado correctamente", + "createNewProfileMsg": "También puede crear un nuevo perfil", "formAuthority": "Authority", + "formAuthorityHelpText": "Mostrar solicitudes con este :authority", + "formCreateServiceProfileHelpText": "Para crear un service profile, descargue un perfil y luego aplíquelo con `kubectl apply`.", + "formGRPCStatus": "Estado GRPC", "formHTTPMethod": "Método HTTP", + "formHTTPMethodHelpText": "Mostrar solicitudes con este método HTTP", + "formHTTPStatus": "Estado HTTP", + "formHeaders": "Encabezados", "formHideFilters": "Ocultar filtros", + "formLatency": "Latencia", "formMaxRPS": "Max PPS", + "formMaxRPSHelpText {defaultMaxRps}": "Máximo de solicitudes por segundo para tap. Por defecto {defaultMaxRps}", + "formMethod": "Método", "formNamespace": "Namespace", + "formNamespaceErrorText": "El namespace debe constar de caracteres alfanuméricos en minúscula o '-' y debe comenzar y terminar con un carácter alfanumérico", + "formNamespaceHelpText": "Namespace para consultar", + "formNoNamedRouteTrafficFound": "No se encontró tráfico de ruta con nombre. Esto podría deberse a que el servicio no está recibiendo tráfico o porque no hay ningún service profile configurado. ¿El servicio tiene un service profile?", "formPath": "Ruta", + "formPathHelpText": "Mostrar solicitudes con rutas que comienzan con este prefijo", + "formResource": "Recurso", + "formResourceHelpText": "Recurso para consultar", + "formResponseLengthB": "Longitud De Respuesta (B)", "formScheme": "Esquema", + "formSchemeHelpText": "Mostrar solicitudes con este esquema", + "formServiceNameErrorText": "El nombre del servicio debe constar de caracteres alfanuméricos en minúscula o '-' comenzar con un carácter alfabético y terminar con un carácter alfanumérico", "formShowFilters": "Mostrar más filtros", "formToNamespace": "Al Namespace", + "formToNamespaceHelpText": "Namespace del recurso de destino", "formToResource": "Al Recurso", + "formToResourceHelpText": "Recurso destino", + "labelError": "Error", + "labelSuccess": "Éxito", "menuItemCommunity": "Comunidad", "menuItemControlPlane": "Plano de Control", "menuItemCronJobs": "Cron Jobs", @@ -80,12 +126,20 @@ "menuItemTap": "Tap", "menuItemTop": "Top", "menuItemTrafficSplits": "Traffic Splits", + "noNamespacesDetectedMsg": "No se han encontrado namespaces.", "noResourcesDetectedMsg": "No se han encontrado recursos.", "podsAreInitializingMsg": "Los pods se están inicializando", + "serviceMeshInstalledMsg": "¡La malla de servicios se instaló correctamente!", "sidebarHeadingCluster": "Clúster", "sidebarHeadingConfiguration": "Configuración", "sidebarHeadingTools": "Herramientas", "sidebarHeadingWorkloads": "Cargas de trabajo", + "statusExplanationGood": "está en funcionamiento", + "statusExplanationInMesh": "Añadido a la malla", + "statusExplanationNotInMesh": "No en malla", + "statusExplanationNotStarted": "no se ha iniciado", + "tabLiveCalls": "Llamadas En Vivo", + "tabRouteMetrics": "Métricas De Ruta", "tableTitleEdgesEmpty": "Bordes", "tableTitleEdgesWithIdentity {identity}": "Bordes (Identidad: {identity})", "tableTitleGateways": "Gateways", @@ -94,11 +148,17 @@ "tableTitleLeafServices": "Servicios Hoja", "tableTitleOutbound": "Salida", "tableTitlePods": "Pods", + "tableTitleRequestDetails": "Detalle Solicitudes", + "tableTitleRequestInit": "Solicitud Inicial", + "tableTitleResponseEnd": "Fin De Respuesta", + "tableTitleResponseInit": "Respuesta Inicial", "tableTitleTCP": "TCP", "tableTitleTCPMetrics": "Métricas TCP", + "tooltipInbound": "ENTRADA", + "tooltipOutbound": "SALIDA", "unspecifiedResourcesMsg": "uno o más recursos", - "{numUnadded} namespace has no meshed resources.": "El namespace {numUnadded} no tiene recursos en la malla de servicios.", - "{numUnadded} namespaces have no meshed resources.": "Los namespaces {numUnadded} no tienen recursos en la malla de servicios.", + "{numResources, plural, zero {{resource}} one {{resource}} other {{resource}}}": "{numResources, plural, zero {No {resource}s detectados} one {# {resource} detectado} other {# {resource}s detectados}}", + "{numUnadded, plural, one {# namespace has no meshed resources} other {# namespaces have no meshed resources}}": "{numUnadded, plural, one {# namespace no tiene recursos en la malla de servicios.} other {# namespaces no tienen recursos en la malla de servicios.}}", "{productName} namespace": "Namespace de {productName}", "{productName} version": "Versión de {productName}" } diff --git a/web/app/test/testHelpers.jsx b/web/app/test/testHelpers.jsx index d5c8d1cbe..fab5296a7 100644 --- a/web/app/test/testHelpers.jsx +++ b/web/app/test/testHelpers.jsx @@ -3,6 +3,8 @@ import ApiHelpers from '../js/components/util/ApiHelpers.jsx'; import { createMemoryHistory } from 'history'; import React from 'react'; import { Route, Router } from 'react-router'; +import { I18nProvider } from '@lingui/react'; +import catalogEn from './../js/locales/en/messages.js'; const componentDefaultProps = { api: ApiHelpers(''), @@ -11,6 +13,9 @@ const componentDefaultProps = { releaseVersion: '' }; +const selectedLocale = 'en'; +const selectedCatalog = catalogEn; + export function routerWrap(Component, extraProps={}, route="/", currentLoc="/") { const createElement = (ComponentToWrap, props) => ( @@ -21,3 +26,13 @@ export function routerWrap(Component, extraProps={}, route="/", currentLoc="/") ); } + +export function i18nWrap(Component) { + return ( + + {Component} + + ); +}