Merge remote-tracking branch 'origin/vue_react_migration' into views_management_refactoring

# Conflicts:
#	src/renderer/components/cluster-manager/cluster-manager.scss
This commit is contained in:
Roman committed 2020-08-10 20:31:46 +03:00
commit 312fdfa4d3
31 files changed
+399 -169

No files matched your search

+1 -1
View File
@@ -121,7 +121,7 @@ export class Router {
this.router.add({ method: "post", path: `${apiPrefix}/metrics` }, metricsRoute.routeMetrics.bind(metricsRoute))
// 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
this.router.add({ method: "get", path: `${apiPrefix}/v2/charts` }, helmRoute.listCharts.bind(helmRoute))
+11 -10
View File
@@ -14,7 +14,7 @@ class PortForward {
return PortForward.portForwards.find((pf) => {
return (
pf.clusterId == forward.clusterId &&
pf.kind == "service" &&
pf.kind == forward.kind &&
pf.name == forward.name &&
pf.namespace == forward.namespace &&
pf.port == forward.port
@@ -42,7 +42,7 @@ class PortForward {
"--kubeconfig", this.kubeConfig,
"port-forward",
"-n", this.namespace,
`service/${this.name}`,
`${this.kind}/${this.name}`,
`${this.localPort}:${this.port}`
]
@@ -72,21 +72,22 @@ class PortForward {
class PortForwardRoute extends LensApi {
public async routeServicePortForward(request: LensApiRequest) {
public async routePortForward(request: LensApiRequest) {
const { params, response, cluster} = request
const { namespace, port, resourceType, resourceName } = params
let portForward = PortForward.getPortforward({
clusterId: cluster.id, kind: "service", name: params.service,
namespace: params.namespace, port: params.port
clusterId: cluster.id, kind: resourceType, name: resourceName,
namespace: namespace, port: port
})
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({
clusterId: cluster.id,
kind: "service",
namespace: params.namespace,
name: params.service,
port: params.port,
kind: resourceType,
namespace: namespace,
name: resourceName,
port: port,
kubeConfig: cluster.getProxyKubeconfigPath()
})
const started = await portForward.start()
+4 -1
View File
@@ -50,6 +50,9 @@ export function parseKubeApi(path: string): IKubeApiParsed {
apiGroup = left.join("/");
} else {
switch (left.length) {
case 4:
[apiGroup, apiVersion, resource, name] = left
break;
case 2:
resource = left.pop();
// fallthrough
@@ -66,7 +69,7 @@ export function parseKubeApi(path: string): IKubeApiParsed {
* - `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
* seperated by '/'
* separated by '/'
*
* Solution is to create a huristic. Namely:
* 1. if '.' in left[0] then apiGroup <- left[0]
+13
View File
@@ -6,6 +6,19 @@ interface 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",
expected: {
-20
View File
@@ -1,20 +0,0 @@
import React from "react";
import { Notifications } from "./components/notifications";
import { Trans } from "@lingui/macro";
export function browserCheck() {
const ua = window.navigator.userAgent
const msie = ua.indexOf('MSIE ') // IE < 11
const trident = ua.indexOf('Trident/') // IE 11
const edge = ua.indexOf('Edge') // Edge
if (msie > 0 || trident > 0 || edge > 0) {
Notifications.info(
<p>
<Trans>
<b>Your browser does not support all Lens features. </b>{" "}
Please consider using another browser.
</Trans>
</p>
)
}
}
@@ -57,6 +57,10 @@
font-size: smaller;
opacity: 0.8;
}
p + p, .hint + p {
padding-top: $padding;
}
}
.status-table {
@@ -79,7 +83,13 @@
}
}
.Input,.Select {
.Input, .Select {
margin-top: 10px;
}
.Select {
&__control {
box-shadow: 0 0 0 1px $borderFaintColor;
}
}
}
@@ -1,10 +1,11 @@
import React from "react";
import merge from "lodash/merge";
import { observer } from "mobx-react";
import { prometheusProviders } from "../../../../common/prometheus-providers";
import { Cluster } from "../../../../main/cluster";
import { SubTitle } from "../../layout/sub-title";
import { Select, SelectOption } from "../../select";
import { Input } from "../../input";
import { observable, computed } from "mobx";
const options: SelectOption<string>[] = [
{ value: "", label: "Auto detect" },
@@ -17,6 +18,52 @@ interface Props {
@observer
export class ClusterPrometheusSetting extends React.Component<Props> {
@observable path = "";
@observable provider = "";
@computed get canEditPrometheusPath() {
if (this.provider === "" || this.provider === "lens") return false;
return true;
}
componentDidMount() {
const { prometheus, prometheusProvider } = this.props.cluster.preferences;
if (prometheus) {
const prefix = prometheus.prefix || "";
this.path = `${prometheus.namespace}/${prometheus.service}:${prometheus.port}${prefix}`;
}
if (prometheusProvider) {
this.provider = prometheusProvider.type;
}
}
parsePrometheusPath = () => {
if (!this.provider || !this.path) {
return null;
}
const parsed = this.path.split(/\/|:/, 3);
const apiPrefix = this.path.substring(parsed.join("/").length);
if (!parsed[0] || !parsed[1] || !parsed[2]) {
return null;
}
return {
namespace: parsed[0],
service: parsed[1],
port: parseInt(parsed[2]),
prefix: apiPrefix
}
}
onSaveProvider = () => {
this.props.cluster.preferences.prometheusProvider = this.provider ?
{ type: this.provider } :
null;
}
onSavePath = () => {
this.props.cluster.preferences.prometheus = this.parsePrometheusPath();
};
render() {
return (
<>
@@ -26,18 +73,32 @@ export class ClusterPrometheusSetting extends React.Component<Props> {
<a href="https://github.com/lensapp/lens/blob/master/troubleshooting/custom-prometheus.md" target="_blank">guide</a>{" "}
for possible configuration changes.
</p>
<p>Prometheus installation method.</p>
<Select
value={this.props.cluster.preferences.prometheusProvider?.type || ""}
value={this.provider}
onChange={({value}) => {
const provider = {
prometheusProvider: {
type: value
}
}
merge(this.props.cluster.preferences, provider);
this.provider = value;
this.onSaveProvider();
}}
options={options}
/>
<span className="hint">What query format is used to fetch metrics from Prometheus</span>
{this.canEditPrometheusPath && (
<>
<p>Prometheus service address.</p>
<Input
theme="round-black"
value={this.path}
onChange={(value) => this.path = value}
onBlur={this.onSavePath}
placeholder="<namespace>/<service>:<port>"
/>
<span className="hint">
An address to an existing Prometheus installation{" "}
({'<namespace>/<service>:<port>'}). Lens tries to auto-detect address if left empty.
</span>
</>
)}
</>
);
}
@@ -4,7 +4,7 @@ import kebabCase from "lodash/kebabCase";
import { observer } from "mobx-react";
import { Trans } from "@lingui/macro";
import { DrawerItem, DrawerTitle } from "../drawer";
import { cpuUnitsToNumber, cssNames, unitsToBytes } from "../../utils";
import { cpuUnitsToNumber, cssNames, unitsToBytes, metricUnitsToNumber } from "../../utils";
import { KubeObjectDetailsProps } from "../kube-object";
import { ResourceQuota, resourceQuotaApi } from "../../api/endpoints/resource-quota.api";
import { LineProgress } from "../line-progress";
@@ -15,24 +15,30 @@ import { KubeObjectMeta } from "../kube-object/kube-object-meta";
interface Props extends KubeObjectDetailsProps<ResourceQuota> {
}
@observer
export class ResourceQuotaDetails extends React.Component<Props> {
renderQuotas = (quota: ResourceQuota) => {
const { hard, used } = quota.status
if (!hard || !used) return null
const transformUnit = (name: string, value: string) => {
if (name.includes("memory") || name.includes("storage")) {
return unitsToBytes(value)
}
if (name.includes("cpu")) {
return cpuUnitsToNumber(value)
}
return parseInt(value)
}
return Object.entries(hard).map(([name, value]) => {
if (!used[name]) return null
const onlyNumbers = /$[0-9]*^/g;
function transformUnit(name: string, value: string): number {
if (name.includes("memory") || name.includes("storage")) {
return unitsToBytes(value)
}
if (name.includes("cpu")) {
return cpuUnitsToNumber(value)
}
return metricUnitsToNumber(value);
}
function renderQuotas(quota: ResourceQuota): JSX.Element[] {
const { hard = {}, used = {} } = quota.status
return Object.entries(hard)
.filter(([name]) => used[name])
.map(([name, value]) => {
const current = transformUnit(name, used[name])
const max = transformUnit(name, value)
const usage = max === 0 ? 100 : Math.ceil(current / max * 100); // special case 0 max as always 100% usage
return (
<div key={name} className={cssNames("param", kebabCase(name))}>
<span className="title">{name}</span>
@@ -41,14 +47,16 @@ export class ResourceQuotaDetails extends React.Component<Props> {
max={max}
value={current}
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>
)
})
}
}
@observer
export class ResourceQuotaDetails extends React.Component<Props> {
render() {
const { object: quota } = this.props;
if (!quota) return null;
@@ -57,7 +65,7 @@ export class ResourceQuotaDetails extends React.Component<Props> {
<KubeObjectMeta object={quota}/>
<DrawerItem name={<Trans>Quotas</Trans>} className="quota-list">
{this.renderQuotas(quota)}
{renderQuotas(quota)}
</DrawerItem>
{quota.getScopeSelector().length > 0 && (
@@ -11,7 +11,7 @@ import { Service, serviceApi, endpointApi } from "../../api/endpoints";
import { _i18n } from "../../i18n";
import { apiManager } from "../../api/api-manager";
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 { ServiceDetailsEndpoint } from "./service-details-endpoint";
@@ -61,7 +61,13 @@ export class ServiceDetails extends React.Component<Props> {
)}
<DrawerItem name={<Trans>Ports</Trans>}>
<ServicePorts service={service}/>
<div>
{
service.getPorts().map((port) => (
<ServicePortComponent service={service} port={port} key={port.toString()}/>
))
}
</div>
</DrawerItem>
{spec.type === "LoadBalancer" && spec.loadBalancerIP && (
@@ -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;
}
}
@@ -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>
);
}
}
@@ -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;
}
}
@@ -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>
);
}
}
@@ -98,7 +98,7 @@ export function DeploymentMenu(props: KubeObjectMenuProps<Deployment>) {
return (
<KubeObjectMenu {...props}>
<MenuItem onClick={() => DeploymentScaleDialog.open(object)}>
<Icon material="control_camera" title={_i18n._(t`Scale`)} interactive={toolbar}/>
<Icon material="open_with" title={_i18n._(t`Scale`)} interactive={toolbar}/>
<span className="title"><Trans>Scale</Trans></span>
</MenuItem>
</KubeObjectMenu>
@@ -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;
}
}
@@ -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>
)
}
}
@@ -8,6 +8,7 @@ import { cssNames } from "../../utils";
import { StatusBrick } from "../status-brick";
import { Badge } from "../badge";
import { ContainerEnvironment } from "./pod-container-env";
import { PodContainerPort } from "./pod-container-port";
import { ResourceMetrics } from "../resource-metrics";
import { IMetrics } from "../../api/endpoints/metrics.api";
import { ContainerCharts } from "./container-charts";
@@ -64,13 +65,10 @@ export class PodDetailsContainer extends React.Component<Props> {
{ports && ports.length > 0 &&
<DrawerItem name={<Trans>Ports</Trans>}>
{
ports.map(port => {
const { name, containerPort, protocol } = port;
const key = `${container.name}-port-${containerPort}-${protocol}`
return (
<div key={key}>
{name ? name + ': ' : ''}{containerPort}/{protocol}
</div>
ports.map((port) => {
const key = `${container.name}-port-${port.containerPort}-${port.protocol}`
return(
<PodContainerPort pod={pod} port={port} key={key}/>
)
})
}
@@ -226,7 +226,7 @@ export class PodLogsDialog extends React.Component<Props> {
tooltip={(showTimestamps ? _i18n._(t`Hide`) : _i18n._(t`Show`)) + " " + _i18n._(t`timestamps`)}
/>
<Icon
material="save_alt"
material="get_app"
onClick={this.downloadLogs}
tooltip={_i18n._(t`Save`)}
/>
+3 -3
View File
@@ -82,7 +82,7 @@ hr {
h1 {
color: white;
font-size: 28px;
font-weight: 300;
font-weight: normal;
letter-spacing: -.010em;
margin: 0;
}
@@ -99,13 +99,13 @@ h3 {
h4 {
@extend h3;
font-size: 16px;
font-size: 18px;
}
h5 {
@extend h4;
padding: $padding / 2 0;
font-size: 14px;
font-size: 16px;
}
h6 {
@@ -14,6 +14,7 @@
#lens-views {
grid-area: main;
display: flex;
overflow: hidden;
&.active {
z-index: 1;
@@ -53,9 +53,10 @@ export const maxLength: Validator = {
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 = {
message: () => _i18n._(t`This field must contain only lowercase latin characters, numbers and dash.`),
validate: value => !!value.match(/^[a-z0-9-]+$/),
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(systemNameMatcher),
};
export const accountId: Validator = {
@@ -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);
});
});
});
+1 -1
View File
@@ -31,7 +31,7 @@ html {
border-radius: $radius;
background: transparent;
min-height: 0;
box-shadow: 0 0 0 1px $borderFaintColor;
box-shadow: 0 0 0 1px $halfGray;
&--is-focused {
box-shadow: 0 0 0 2px $primary;
+8 -5
View File
@@ -1,10 +1,13 @@
// Helper to convert CPU K8S units to numbers
const thousand = 1000;
const million = thousand * thousand;
const shortBillion = thousand * million;
export function cpuUnitsToNumber(cpu: string) {
const cpuNum = parseInt(cpu)
const billion = 1000000 * 1000
if (cpu.includes("m")) return cpuNum / 1000
if (cpu.includes("u")) return cpuNum / 1000000
if (cpu.includes("n")) return cpuNum / billion
if (cpu.includes("m")) return cpuNum / thousand
if (cpu.includes("u")) return cpuNum / million
if (cpu.includes("n")) return cpuNum / shortBillion
return parseFloat(cpu)
}
}
+6 -4
View File
@@ -7,9 +7,9 @@ export function unitsToBytes(value: string) {
if (!suffixes.some(suffix => value.includes(suffix))) {
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(
(parseFloat(value) * Math.pow(base, index + 1)).toFixed(1)
)
@@ -21,8 +21,10 @@ export function bytesToUnits(bytes: number, precision = 1) {
if (!bytes) {
return "N/A"
}
if (index === 0) {
return `${bytes}${sizes[index]}`
}
return `${(bytes / (1024 ** index)).toFixed(precision)}${sizes[index]}i`
}
}
+1
View File
@@ -20,3 +20,4 @@ export * from './formatDuration'
export * from './isReactNode'
export * from './convertMemory'
export * from './convertCpu'
export * from './metricUnitsToNumber'
+10
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)
)
}
@@ -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);
});
});