1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Merge branch 'master' into vue_react_migration

Signed-off-by: Lauri Nevala <lauri.nevala@gmail.com>
This commit is contained in:
Lauri Nevala 2020-08-10 09:03:14 +03:00
commit efcf02e0ad
23 changed files with 312 additions and 134 deletions

View File

@ -2407,8 +2407,8 @@ msgid "This field is required"
msgstr "This field is required" msgstr "This field is required"
#: src/renderer/components/input/input.validators.ts:39 #: src/renderer/components/input/input.validators.ts:39
msgid "This field must contain only lowercase latin characters, numbers and dash." msgid "A System Name must be lowercase DNS labels separated by dots. DNS labels are alphanumerics and dashes enclosed by alphanumerics."
msgstr "This field must contain only lowercase latin characters, numbers and dash." msgstr "A System Name must be lowercase DNS labels separated by dots. DNS labels are alphanumerics and dashes enclosed by alphanumerics."
#: src/renderer/components/cluster-manager/clusters-menu.tsx:84 #: src/renderer/components/cluster-manager/clusters-menu.tsx:84
msgid "This is the quick launch menu." msgid "This is the quick launch menu."

View File

@ -2390,7 +2390,7 @@ msgid "This field is required"
msgstr "" msgstr ""
#: src/renderer/components/input/input.validators.ts:39 #: src/renderer/components/input/input.validators.ts:39
msgid "This field must contain only lowercase latin characters, numbers and dash." msgid "A System Name must be lowercase DNS labels separated by dots. DNS labels are alphanumerics and dashes enclosed by alphanumerics."
msgstr "" msgstr ""
#: src/renderer/components/cluster-manager/clusters-menu.tsx:84 #: src/renderer/components/cluster-manager/clusters-menu.tsx:84

View File

@ -2408,7 +2408,7 @@ msgid "This field is required"
msgstr "Это обязательное поле" msgstr "Это обязательное поле"
#: src/renderer/components/input/input.validators.ts:39 #: src/renderer/components/input/input.validators.ts:39
msgid "This field must contain only lowercase latin characters, numbers and dash." msgid "A System Name must be lowercase DNS labels separated by dots. DNS labels are alphanumerics and dashes enclosed by alphanumerics."
msgstr "Это поле может содержать только латинские буквы в нижнем регистре, номера и дефис." msgstr "Это поле может содержать только латинские буквы в нижнем регистре, номера и дефис."
#: src/renderer/components/cluster-manager/clusters-menu.tsx:84 #: src/renderer/components/cluster-manager/clusters-menu.tsx:84

View File

@ -121,7 +121,7 @@ export class Router {
this.router.add({ method: "post", path: `${apiPrefix}/metrics` }, metricsRoute.routeMetrics.bind(metricsRoute)) this.router.add({ method: "post", path: `${apiPrefix}/metrics` }, metricsRoute.routeMetrics.bind(metricsRoute))
// Port-forward API // Port-forward API
this.router.add({ method: "post", path: `${apiPrefix}/services/{namespace}/{service}/port-forward/{port}` }, portForwardRoute.routeServicePortForward.bind(portForwardRoute)) this.router.add({ method: "post", path: `${apiPrefix}/pods/{namespace}/{resourceType}/{resourceName}/port-forward/{port}` }, portForwardRoute.routePortForward.bind(portForwardRoute))
// Helm API // Helm API
this.router.add({ method: "get", path: `${apiPrefix}/v2/charts` }, helmRoute.listCharts.bind(helmRoute)) this.router.add({ method: "get", path: `${apiPrefix}/v2/charts` }, helmRoute.listCharts.bind(helmRoute))

View File

@ -14,7 +14,7 @@ class PortForward {
return PortForward.portForwards.find((pf) => { return PortForward.portForwards.find((pf) => {
return ( return (
pf.clusterId == forward.clusterId && pf.clusterId == forward.clusterId &&
pf.kind == "service" && pf.kind == forward.kind &&
pf.name == forward.name && pf.name == forward.name &&
pf.namespace == forward.namespace && pf.namespace == forward.namespace &&
pf.port == forward.port pf.port == forward.port
@ -42,7 +42,7 @@ class PortForward {
"--kubeconfig", this.kubeConfig, "--kubeconfig", this.kubeConfig,
"port-forward", "port-forward",
"-n", this.namespace, "-n", this.namespace,
`service/${this.name}`, `${this.kind}/${this.name}`,
`${this.localPort}:${this.port}` `${this.localPort}:${this.port}`
] ]
@ -72,21 +72,22 @@ class PortForward {
class PortForwardRoute extends LensApi { class PortForwardRoute extends LensApi {
public async routeServicePortForward(request: LensApiRequest) { public async routePortForward(request: LensApiRequest) {
const { params, response, cluster} = request const { params, response, cluster} = request
const { namespace, port, resourceType, resourceName } = params
let portForward = PortForward.getPortforward({ let portForward = PortForward.getPortforward({
clusterId: cluster.id, kind: "service", name: params.service, clusterId: cluster.id, kind: resourceType, name: resourceName,
namespace: params.namespace, port: params.port namespace: namespace, port: port
}) })
if (!portForward) { if (!portForward) {
logger.info(`Creating a new port-forward ${params.namespace}/${params.service}:${params.port}`) logger.info(`Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`)
portForward = new PortForward({ portForward = new PortForward({
clusterId: cluster.id, clusterId: cluster.id,
kind: "service", kind: resourceType,
namespace: params.namespace, namespace: namespace,
name: params.service, name: resourceName,
port: params.port, port: port,
kubeConfig: cluster.getProxyKubeconfigPath() kubeConfig: cluster.getProxyKubeconfigPath()
}) })
const started = await portForward.start() const started = await portForward.start()

View File

@ -50,6 +50,9 @@ export function parseKubeApi(path: string): IKubeApiParsed {
apiGroup = left.join("/"); apiGroup = left.join("/");
} else { } else {
switch (left.length) { switch (left.length) {
case 4:
[apiGroup, apiVersion, resource, name] = left
break;
case 2: case 2:
resource = left.pop(); resource = left.pop();
// fallthrough // fallthrough
@ -66,7 +69,7 @@ export function parseKubeApi(path: string): IKubeApiParsed {
* - `GROUP` is /^D(\.D)*$/ where D is `DNS_LABEL` and length <= 253 * - `GROUP` is /^D(\.D)*$/ where D is `DNS_LABEL` and length <= 253
* *
* There is no well defined selection from an array of items that were * There is no well defined selection from an array of items that were
* seperated by '/' * separated by '/'
* *
* Solution is to create a huristic. Namely: * Solution is to create a huristic. Namely:
* 1. if '.' in left[0] then apiGroup <- left[0] * 1. if '.' in left[0] then apiGroup <- left[0]

View File

@ -6,6 +6,19 @@ interface KubeApi_Parse_Test {
} }
const tests: KubeApi_Parse_Test[] = [ const tests: KubeApi_Parse_Test[] = [
{
url: "/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions/prometheuses.monitoring.coreos.com",
expected: {
apiBase: "/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions",
apiPrefix: "/apis",
apiGroup: "apiextensions.k8s.io",
apiVersion: "v1beta1",
apiVersionWithGroup: "apiextensions.k8s.io/v1beta1",
namespace: undefined,
resource: "customresourcedefinitions",
name: "prometheuses.monitoring.coreos.com"
},
},
{ {
url: "/api/v1/namespaces/kube-system/pods/coredns-6955765f44-v8p27", url: "/api/v1/namespaces/kube-system/pods/coredns-6955765f44-v8p27",
expected: { expected: {

View File

@ -4,7 +4,7 @@ import kebabCase from "lodash/kebabCase";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Trans } from "@lingui/macro"; import { Trans } from "@lingui/macro";
import { DrawerItem, DrawerTitle } from "../drawer"; import { DrawerItem, DrawerTitle } from "../drawer";
import { cpuUnitsToNumber, cssNames, unitsToBytes } from "../../utils"; import { cpuUnitsToNumber, cssNames, unitsToBytes, metricUnitsToNumber } from "../../utils";
import { KubeObjectDetailsProps } from "../kube-object"; import { KubeObjectDetailsProps } from "../kube-object";
import { ResourceQuota, resourceQuotaApi } from "../../api/endpoints/resource-quota.api"; import { ResourceQuota, resourceQuotaApi } from "../../api/endpoints/resource-quota.api";
import { LineProgress } from "../line-progress"; import { LineProgress } from "../line-progress";
@ -15,24 +15,30 @@ import { KubeObjectMeta } from "../kube-object/kube-object-meta";
interface Props extends KubeObjectDetailsProps<ResourceQuota> { interface Props extends KubeObjectDetailsProps<ResourceQuota> {
} }
@observer const onlyNumbers = /$[0-9]*^/g;
export class ResourceQuotaDetails extends React.Component<Props> {
renderQuotas = (quota: ResourceQuota) => { function transformUnit(name: string, value: string): number {
const { hard, used } = quota.status if (name.includes("memory") || name.includes("storage")) {
if (!hard || !used) return null return unitsToBytes(value)
const transformUnit = (name: string, value: string) => { }
if (name.includes("memory") || name.includes("storage")) {
return unitsToBytes(value) if (name.includes("cpu")) {
} return cpuUnitsToNumber(value)
if (name.includes("cpu")) { }
return cpuUnitsToNumber(value)
} return metricUnitsToNumber(value);
return parseInt(value) }
}
return Object.entries(hard).map(([name, value]) => { function renderQuotas(quota: ResourceQuota): JSX.Element[] {
if (!used[name]) return null const { hard = {}, used = {} } = quota.status
return Object.entries(hard)
.filter(([name]) => used[name])
.map(([name, value]) => {
const current = transformUnit(name, used[name]) const current = transformUnit(name, used[name])
const max = transformUnit(name, value) const max = transformUnit(name, value)
const usage = max === 0 ? 100 : Math.ceil(current / max * 100); // special case 0 max as always 100% usage
return ( return (
<div key={name} className={cssNames("param", kebabCase(name))}> <div key={name} className={cssNames("param", kebabCase(name))}>
<span className="title">{name}</span> <span className="title">{name}</span>
@ -41,14 +47,16 @@ export class ResourceQuotaDetails extends React.Component<Props> {
max={max} max={max}
value={current} value={current}
tooltip={ tooltip={
<p><Trans>Set</Trans>: {value}. <Trans>Used</Trans>: {Math.ceil(current / max * 100) + "%"}</p> <p><Trans>Set</Trans>: {value}. <Trans>Usage</Trans>: {usage + "%"}</p>
} }
/> />
</div> </div>
) )
}) })
} }
@observer
export class ResourceQuotaDetails extends React.Component<Props> {
render() { render() {
const { object: quota } = this.props; const { object: quota } = this.props;
if (!quota) return null; if (!quota) return null;
@ -57,7 +65,7 @@ export class ResourceQuotaDetails extends React.Component<Props> {
<KubeObjectMeta object={quota}/> <KubeObjectMeta object={quota}/>
<DrawerItem name={<Trans>Quotas</Trans>} className="quota-list"> <DrawerItem name={<Trans>Quotas</Trans>} className="quota-list">
{this.renderQuotas(quota)} {renderQuotas(quota)}
</DrawerItem> </DrawerItem>
{quota.getScopeSelector().length > 0 && ( {quota.getScopeSelector().length > 0 && (

View File

@ -11,7 +11,7 @@ import { Service, serviceApi, endpointApi } from "../../api/endpoints";
import { _i18n } from "../../i18n"; import { _i18n } from "../../i18n";
import { apiManager } from "../../api/api-manager"; import { apiManager } from "../../api/api-manager";
import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { KubeObjectMeta } from "../kube-object/kube-object-meta";
import { ServicePorts } from "./service-ports"; import { ServicePortComponent } from "./service-port-component";
import { endpointStore } from "../+network-endpoints/endpoints.store"; import { endpointStore } from "../+network-endpoints/endpoints.store";
import { ServiceDetailsEndpoint } from "./service-details-endpoint"; import { ServiceDetailsEndpoint } from "./service-details-endpoint";
@ -61,7 +61,13 @@ export class ServiceDetails extends React.Component<Props> {
)} )}
<DrawerItem name={<Trans>Ports</Trans>}> <DrawerItem name={<Trans>Ports</Trans>}>
<ServicePorts service={service}/> <div>
{
service.getPorts().map((port) => (
<ServicePortComponent service={service} port={port} key={port.toString()}/>
))
}
</div>
</DrawerItem> </DrawerItem>
{spec.type === "LoadBalancer" && spec.loadBalancerIP && ( {spec.type === "LoadBalancer" && spec.loadBalancerIP && (

View File

@ -0,0 +1,22 @@
.ServicePortComponent {
&.waiting {
opacity: 0.5;
pointer-events: none;
}
&:not(:last-child) {
margin-bottom: $margin;
}
span {
cursor: pointer;
color: $primary;
text-decoration: underline;
}
.Spinner {
--spinner-size: #{$unit * 2};
margin-left: $margin;
position: absolute;
}
}

View File

@ -0,0 +1,48 @@
import "./service-port-component.scss"
import React from "react";
import { observer } from "mobx-react";
import { t } from "@lingui/macro";
import { Service, ServicePort } from "../../api/endpoints";
import { _i18n } from "../../i18n";
import { apiBase } from "../../api"
import { observable } from "mobx";
import { cssNames } from "../../utils";
import { Notifications } from "../notifications";
import { Spinner } from "../spinner"
interface Props {
service: Service;
port: ServicePort;
}
@observer
export class ServicePortComponent extends React.Component<Props> {
@observable waiting = false;
async portForward() {
const { service, port } = this.props;
this.waiting = true;
try {
await apiBase.post(`/pods/${service.getNs()}/service/${service.getName()}/port-forward/${port.port}`, {})
} catch(error) {
Notifications.error(error);
} finally {
this.waiting = false;
}
}
render() {
const { port } = this.props;
return (
<div className={cssNames("ServicePortComponent", { waiting: this.waiting })}>
<span title={_i18n._(t`Open in a browser`)} onClick={() => this.portForward() }>
{port.toString()}
{this.waiting && (
<Spinner />
)}
</span>
</div>
);
}
}

View File

@ -1,24 +0,0 @@
.ServicePorts {
&.waiting {
opacity: 0.5;
pointer-events: none;
}
p {
&:not(:last-child) {
margin-bottom: $margin;
}
span {
cursor: pointer;
color: $primary;
text-decoration: underline;
}
}
.Spinner {
--spinner-size: #{$unit * 2};
margin-left: $margin;
position: absolute;
}
}

View File

@ -1,54 +0,0 @@
import "./service-ports.scss"
import React from "react";
import { observer } from "mobx-react";
import { t } from "@lingui/macro";
import { Service, ServicePort } from "../../api/endpoints";
import { _i18n } from "../../i18n";
import { apiBase } from "../../api"
import { observable } from "mobx";
import { cssNames } from "../../utils";
import { Notifications } from "../notifications";
import { Spinner } from "../spinner"
interface Props {
service: Service;
}
@observer
export class ServicePorts extends React.Component<Props> {
@observable waiting = false;
async portForward(port: ServicePort) {
const { service } = this.props;
this.waiting = true;
apiBase.post(`/services/${service.getNs()}/${service.getName()}/port-forward/${port.port}`, {})
.catch(error => {
Notifications.error(error);
})
.finally(() => {
this.waiting = false;
});
}
render() {
const { service } = this.props;
return (
<div className={cssNames("ServicePorts", { waiting: this.waiting })}>
{
service.getPorts().map((port) => {
return(
<p key={port.toString()}>
<span title={_i18n._(t`Open in a browser`)} onClick={() => this.portForward(port) }>
{port.toString()}
{this.waiting && (
<Spinner />
)}
</span>
</p>
);
})}
</div>
);
}
}

View File

@ -0,0 +1,23 @@
.PodContainerPort {
&.waiting {
opacity: 0.5;
pointer-events: none;
}
&:not(:last-child) {
margin-bottom: $margin;
}
span {
cursor: pointer;
color: $primary;
text-decoration: underline;
position: relative;
}
.Spinner {
--spinner-size: #{$unit * 2};
margin-left: $margin;
position: absolute;
}
}

View File

@ -0,0 +1,54 @@
import "./pod-container-port.scss"
import React from "react";
import { observer } from "mobx-react";
import { t } from "@lingui/macro";
import { Pod, IPodContainer } from "../../api/endpoints";
import { _i18n } from "../../i18n";
import { apiBase } from "../../api"
import { observable } from "mobx";
import { cssNames } from "../../utils";
import { Notifications } from "../notifications";
import { Spinner } from "../spinner"
interface Props {
pod: Pod;
port: {
name?: string;
containerPort: number;
protocol: string;
}
}
@observer
export class PodContainerPort extends React.Component<Props> {
@observable waiting = false;
async portForward() {
const { pod, port } = this.props;
this.waiting = true;
try {
await apiBase.post(`/pods/${pod.getNs()}/pod/${pod.getName()}/port-forward/${port.containerPort}`, {})
} catch(error) {
Notifications.error(error);
} finally {
this.waiting = false;
}
}
render() {
const { port } = this.props;
const { name, containerPort, protocol } = port;
const text = (name ? name + ': ' : '')+`${containerPort}/${protocol}`
return (
<div className={cssNames("PodContainerPort", { waiting: this.waiting })}>
<span title={_i18n._(t`Open in a browser`)} onClick={() => this.portForward() }>
{text}
{this.waiting && (
<Spinner />
)}
</span>
</div>
)
}
}

View File

@ -8,6 +8,7 @@ import { cssNames } from "../../utils";
import { StatusBrick } from "../status-brick"; import { StatusBrick } from "../status-brick";
import { Badge } from "../badge"; import { Badge } from "../badge";
import { ContainerEnvironment } from "./pod-container-env"; import { ContainerEnvironment } from "./pod-container-env";
import { PodContainerPort } from "./pod-container-port";
import { ResourceMetrics } from "../resource-metrics"; import { ResourceMetrics } from "../resource-metrics";
import { IMetrics } from "../../api/endpoints/metrics.api"; import { IMetrics } from "../../api/endpoints/metrics.api";
import { ContainerCharts } from "./container-charts"; import { ContainerCharts } from "./container-charts";
@ -64,13 +65,10 @@ export class PodDetailsContainer extends React.Component<Props> {
{ports && ports.length > 0 && {ports && ports.length > 0 &&
<DrawerItem name={<Trans>Ports</Trans>}> <DrawerItem name={<Trans>Ports</Trans>}>
{ {
ports.map(port => { ports.map((port) => {
const { name, containerPort, protocol } = port; const key = `${container.name}-port-${port.containerPort}-${port.protocol}`
const key = `${container.name}-port-${containerPort}-${protocol}` return(
return ( <PodContainerPort pod={pod} port={port} key={key}/>
<div key={key}>
{name ? name + ': ' : ''}{containerPort}/{protocol}
</div>
) )
}) })
} }

View File

@ -53,9 +53,10 @@ export const maxLength: Validator = {
validate: (value, { maxLength }) => value.length <= maxLength, validate: (value, { maxLength }) => value.length <= maxLength,
}; };
const systemNameMatcher = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/;
export const systemName: Validator = { export const systemName: Validator = {
message: () => _i18n._(t`This field must contain only lowercase latin characters, numbers and dash.`), message: () => _i18n._(t`A System Name must be lowercase DNS labels separated by dots. DNS labels are alphanumerics and dashes enclosed by alphanumerics.`),
validate: value => !!value.match(/^[a-z0-9-]+$/), validate: value => !!value.match(systemNameMatcher),
}; };
export const accountId: Validator = { export const accountId: Validator = {

View File

@ -0,0 +1,48 @@
import { isEmail, systemName } from "./input.validators";
describe("input validation tests", () => {
describe("isEmail tests", () => {
it("should be valid", () => {
expect(isEmail.validate("abc@news.com")).toBe(true);
expect(isEmail.validate("abc@news.co.uk")).toBe(true);
expect(isEmail.validate("abc1.3@news.co.uk")).toBe(true);
expect(isEmail.validate("abc1.3@news.name")).toBe(true);
});
it("should be invalid", () => {
expect(isEmail.validate("@news.com")).toBe(false);
expect(isEmail.validate("abcnews.co.uk")).toBe(false);
expect(isEmail.validate("abc1.3@news")).toBe(false);
expect(isEmail.validate("abc1.3@news.name.a.b.c.d.d")).toBe(false);
});
});
describe("systemName tests", () => {
it("should be valid", () => {
expect(systemName.validate("a")).toBe(true);
expect(systemName.validate("ab")).toBe(true);
expect(systemName.validate("abc")).toBe(true);
expect(systemName.validate("1")).toBe(true);
expect(systemName.validate("12")).toBe(true);
expect(systemName.validate("123")).toBe(true);
expect(systemName.validate("1a2")).toBe(true);
expect(systemName.validate("1-2")).toBe(true);
expect(systemName.validate("1---------------2")).toBe(true);
expect(systemName.validate("1---------------2.a")).toBe(true);
expect(systemName.validate("1---------------2.a.1")).toBe(true);
expect(systemName.validate("1---------------2.9-a.1")).toBe(true);
});
it("should be invalid", () => {
expect(systemName.validate("")).toBe(false);
expect(systemName.validate("-")).toBe(false);
expect(systemName.validate(".")).toBe(false);
expect(systemName.validate("as.")).toBe(false);
expect(systemName.validate(".asd")).toBe(false);
expect(systemName.validate("a.-")).toBe(false);
expect(systemName.validate("a.1-")).toBe(false);
expect(systemName.validate("o.2-2.")).toBe(false);
expect(systemName.validate("o.2-2....")).toBe(false);
});
});
});

View File

@ -1,10 +1,13 @@
// Helper to convert CPU K8S units to numbers // Helper to convert CPU K8S units to numbers
const thousand = 1000;
const million = thousand * thousand;
const shortBillion = thousand * million;
export function cpuUnitsToNumber(cpu: string) { export function cpuUnitsToNumber(cpu: string) {
const cpuNum = parseInt(cpu) const cpuNum = parseInt(cpu)
const billion = 1000000 * 1000 if (cpu.includes("m")) return cpuNum / thousand
if (cpu.includes("m")) return cpuNum / 1000 if (cpu.includes("u")) return cpuNum / million
if (cpu.includes("u")) return cpuNum / 1000000 if (cpu.includes("n")) return cpuNum / shortBillion
if (cpu.includes("n")) return cpuNum / billion
return parseFloat(cpu) return parseFloat(cpu)
} }

View File

@ -7,9 +7,9 @@ export function unitsToBytes(value: string) {
if (!suffixes.some(suffix => value.includes(suffix))) { if (!suffixes.some(suffix => value.includes(suffix))) {
return parseFloat(value) return parseFloat(value)
} }
const index = suffixes.findIndex(suffix =>
suffix == value.replace(/[0-9]|i|\./g, '') const suffix = value.replace(/[0-9]|i|\./g, '');
) const index = suffixes.indexOf(suffix);
return parseInt( return parseInt(
(parseFloat(value) * Math.pow(base, index + 1)).toFixed(1) (parseFloat(value) * Math.pow(base, index + 1)).toFixed(1)
) )
@ -21,8 +21,10 @@ export function bytesToUnits(bytes: number, precision = 1) {
if (!bytes) { if (!bytes) {
return "N/A" return "N/A"
} }
if (index === 0) { if (index === 0) {
return `${bytes}${sizes[index]}` return `${bytes}${sizes[index]}`
} }
return `${(bytes / (1024 ** index)).toFixed(precision)}${sizes[index]}i` return `${(bytes / (1024 ** index)).toFixed(precision)}${sizes[index]}i`
} }

View File

@ -20,3 +20,4 @@ export * from './formatDuration'
export * from './isReactNode' export * from './isReactNode'
export * from './convertMemory' export * from './convertMemory'
export * from './convertCpu' export * from './convertCpu'
export * from './metricUnitsToNumber'

View File

@ -0,0 +1,10 @@
const base = 1000;
const suffixes = ["k", "m", "g", "t", "q"];
export function metricUnitsToNumber(value: string): number {
const suffix = value.toLowerCase().slice(-1);
const index = suffixes.indexOf(suffix);
return parseInt(
(parseFloat(value) * Math.pow(base, index + 1)).toFixed(1)
)
}

View File

@ -0,0 +1,15 @@
import { metricUnitsToNumber } from "./metricUnitsToNumber";
describe("metricUnitsToNumber tests", () => {
test("plain number", () => {
expect(metricUnitsToNumber("124")).toStrictEqual(124);
});
test("with k suffix", () => {
expect(metricUnitsToNumber("124k")).toStrictEqual(124000);
});
test("with m suffix", () => {
expect(metricUnitsToNumber("124m")).toStrictEqual(124000000);
});
});