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

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 2020-08-10 20:31:46 +03:00
commit 312fdfa4d3
31 changed files with 399 additions and 169 deletions

View File

@ -2407,8 +2407,8 @@ msgid "This field is required"
msgstr "This field is required"
#: src/renderer/components/input/input.validators.ts:39
msgid "This field must contain only lowercase latin characters, numbers and dash."
msgstr "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 "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
msgid "This is the quick launch menu."

View File

@ -2390,7 +2390,7 @@ msgid "This field is required"
msgstr ""
#: 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 ""
#: src/renderer/components/cluster-manager/clusters-menu.tsx:84

View File

@ -2408,7 +2408,7 @@ msgid "This field is required"
msgstr "Это обязательное поле"
#: 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 "Это поле может содержать только латинские буквы в нижнем регистре, номера и дефис."
#: 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))
// 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))

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

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]

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: {

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>
)
}
}

View File

@ -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;
}
}
}

View File

@ -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>
</>
)}
</>
);
}

View File

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

View File

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

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

@ -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>

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 { 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}/>
)
})
}

View File

@ -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`)}
/>

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 {

View File

@ -14,6 +14,7 @@
#lens-views {
grid-area: main;
display: flex;
overflow: hidden;
&.active {
z-index: 1;

View File

@ -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 = {

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

@ -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;

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)
}

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`
}

View File

@ -20,3 +20,4 @@ export * from './formatDuration'
export * from './isReactNode'
export * from './convertMemory'
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);
});
});