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

Sort pod names by convering sub-parts (#3314)

This commit is contained in:
Sebastian Malton 2021-10-19 10:36:49 -04:00 committed by GitHub
parent e3e7c620a1
commit 64175b24e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 427 additions and 44 deletions

44
src/common/utils/array.ts Normal file
View File

@ -0,0 +1,44 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
export type Tuple<T, N extends number> = N extends N ? number extends N ? T[] : _TupleOf<T, N, []> : never;
type _TupleOf<T, N extends number, R extends unknown[]> = R["length"] extends N ? R : _TupleOf<T, N, [T, ...R]>;
/**
*
* @param sources The source arrays
* @yields A tuple of the next element from each of the sources
* @returns The tuple of all the sources as soon as at least one of the sources is exausted
*/
export function* zipStrict<T, N extends number>(...sources: Tuple<T[], N>): Iterator<Tuple<T, N>, Tuple<T[], N>> {
const maxSafeLength = sources.reduce((prev, cur) => Math.min(prev, cur.length), Number.POSITIVE_INFINITY);
if (!isFinite(maxSafeLength)) {
// There are no sources, thus just return
return [] as Tuple<T[], N>;
}
for (let i = 0; i < maxSafeLength; i += 1) {
yield sources.map(source => source[i]) as Tuple<T, N>;
}
return sources.map(source => source.slice(maxSafeLength)) as Tuple<T[], N>;
}

View File

@ -60,5 +60,6 @@ export * from "./convertMemory";
export * from "./convertCpu";
import * as iter from "./iter";
import * as array from "./array";
export { iter };
export { iter, array };

View File

@ -185,3 +185,19 @@ export function reduce<T, R = T>(src: Iterable<T>, reducer: (acc: R, cur: T) =>
export function join(src: Iterable<string>, connector = ","): string {
return reduce(src, (acc, cur) => `${acc}${connector}${cur}`, "");
}
/**
* Iterate through `src` and return `true` if `fn` returns a thruthy value for every yielded value.
* Otherwise, return `false`. This function short circuits.
* @param src The type to be iterated over
* @param fn A function to check each iteration
*/
export function every<T>(src: Iterable<T>, fn: (val: T) => any): boolean {
for (const val of src) {
if (!fn(val)) {
return false;
}
}
return true;
}

View File

@ -24,16 +24,44 @@ import * as iter from "./iter";
import type { RawHelmChart } from "../k8s-api/endpoints/helm-charts.api";
import logger from "../logger";
export function sortCompare<T>(left: T, right: T): -1 | 0 | 1 {
export enum Ordering {
LESS = -1,
EQUAL = 0,
GREATER = 1,
}
/**
* This function switches the direction of `ordering` if `direction` is `"desc"`
* @param ordering The original ordering (assumed to be an "asc" ordering)
* @param direction The new desired direction
*/
export function rectifyOrdering(ordering: Ordering, direction: "asc" | "desc"): Ordering {
if (direction === "desc") {
return -ordering;
}
return ordering;
}
/**
* An ascending sorting function
* @param left An item from an array
* @param right An item from an array
* @returns The relative ordering in an ascending manner.
* - Less if left < right
* - Equal if left == right
* - Greater if left > right
*/
export function sortCompare<T>(left: T, right: T): Ordering {
if (left < right) {
return -1;
return Ordering.LESS;
}
if (left === right) {
return 0;
return Ordering.EQUAL;
}
return 1;
return Ordering.GREATER;
}
interface ChartVersion {
@ -41,17 +69,17 @@ interface ChartVersion {
__version?: SemVer;
}
export function sortCompareChartVersions(left: ChartVersion, right: ChartVersion): -1 | 0 | 1 {
export function sortCompareChartVersions(left: ChartVersion, right: ChartVersion): Ordering {
if (left.__version && right.__version) {
return semver.compare(right.__version, left.__version);
}
if (!left.__version && right.__version) {
return 1;
return Ordering.GREATER;
}
if (left.__version && !right.__version) {
return -1;
return Ordering.LESS;
}
return sortCompare(left.version, right.version);

View File

@ -71,11 +71,11 @@ export class DeploymentReplicaSets extends React.Component<Props> {
<DrawerTitle title="Deploy Revisions"/>
<Table
selectable
tableId="deployment_replica_sets_view"
scrollable={false}
sortable={this.sortingCallbacks}
sortByDefault={{ sortBy: sortBy.pods, orderBy: "desc" }}
sortSyncWithUrl={false}
tableId="deployment_replica_sets_view"
className="box grow"
>
<TableHead>

View File

@ -31,7 +31,7 @@ import { eventStore } from "../+events/event.store";
import { KubeObjectListLayout } from "../kube-object-list-layout";
import { nodesApi, Pod } from "../../../common/k8s-api/endpoints";
import { StatusBrick } from "../status-brick";
import { cssNames, stopPropagation } from "../../utils";
import { cssNames, getConvertedParts, stopPropagation } from "../../utils";
import toPairs from "lodash/toPairs";
import startCase from "lodash/startCase";
import kebabCase from "lodash/kebabCase";
@ -98,7 +98,7 @@ export class Pods extends React.Component<Props> {
tableId = "workloads_pods"
isConfigurable
sortingCallbacks={{
[columnId.name]: pod => pod.getName(),
[columnId.name]: pod => getConvertedParts(pod.getName()),
[columnId.namespace]: pod => pod.getNs(),
[columnId.containers]: pod => pod.getContainers().length,
[columnId.restarts]: pod => pod.getRestartsCount(),

View File

@ -0,0 +1,155 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { cloneDeep } from "lodash";
import { getSorted } from "../sorting";
describe("Table tests", () => {
describe("getSorted", () => {
it.each([undefined, 5, "", true, {}, []])("should not sort since %j is not a function", () => {
expect(getSorted([1, 2, 4, 3], undefined, "asc")).toStrictEqual([1, 2, 4, 3]);
});
it("should sort numerically asc and not touch the original list", () => {
const i = [1, 2, 4, 3];
expect(getSorted(i, v => v, "asc")).toStrictEqual([1, 2, 3, 4]);
expect(i).toStrictEqual([1, 2, 4, 3]);
});
it("should sort numerically desc and not touch the original list", () => {
const i = [1, 2, 4, 3];
expect(getSorted(i, v => v, "desc")).toStrictEqual([4, 3, 2, 1]);
expect(i).toStrictEqual([1, 2, 4, 3]);
});
it("should sort numerically asc (by defaul) and not touch the original list", () => {
const i = [1, 2, 4, 3];
expect(getSorted(i, v => v, "foobar")).toStrictEqual([1, 2, 3, 4]);
expect(i).toStrictEqual([1, 2, 4, 3]);
});
describe("multi-part", () => {
it("should sort each part by its order", () => {
const i = ["a", "c", "b.1", "b.2", "d"];
expect(getSorted(i, v => v.split("."), "desc")).toStrictEqual(["d", "c", "b.2", "b.1", "a"]);
expect(i).toStrictEqual(["a", "c", "b.1", "b.2", "d"]);
});
it("should be a stable sort", () => {
const i = [{
val: "a",
k: 1,
}, {
val: "c",
k: 2
}, {
val: "b.1",
k: 3
}, {
val: "b.2",
k: 4
}, {
val: "d",
k: 5
}, {
val: "b.2",
k: -10
}];
const dup = cloneDeep(i);
const expected = [
{
val: "a",
k: 1,
}, {
val: "b.1",
k: 3
}, {
val: "b.2",
k: 4
}, {
val: "b.2",
k: -10
}, {
val: "c",
k: 2
}, {
val: "d",
k: 5
},
];
expect(getSorted(i, ({ val }) => val.split("."), "asc")).toStrictEqual(expected);
expect(i).toStrictEqual(dup);
});
it("should be a stable sort #2", () => {
const i = [{
val: "a",
k: 1,
}, {
val: "b.2",
k: -10
}, {
val: "c",
k: 2
}, {
val: "b.1",
k: 3
}, {
val: "b.2",
k: 4
}, {
val: "d",
k: 5
}];
const dup = cloneDeep(i);
const expected = [
{
val: "a",
k: 1,
}, {
val: "b.1",
k: 3
}, {
val: "b.2",
k: -10
}, {
val: "b.2",
k: 4
}, {
val: "c",
k: 2
}, {
val: "d",
k: 5
},
];
expect(getSorted(i, ({ val }) => val.split("."), "asc")).toStrictEqual(expected);
expect(i).toStrictEqual(dup);
});
});
});
});

View File

@ -0,0 +1,68 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import type { TableSortCallback } from "./table";
import { array, Ordering, rectifyOrdering, sortCompare } from "../../utils";
export function getSorted<T>(rawItems: T[], sortingCallback: TableSortCallback<T> | undefined, orderByRaw: string): T[] {
if (typeof sortingCallback !== "function") {
return rawItems;
}
const orderBy = orderByRaw === "asc" || orderByRaw === "desc" ? orderByRaw : "asc";
const sortData = rawItems.map((item, index) => ({
index,
sortBy: sortingCallback(item),
}));
sortData.sort((left, right) => {
if (!Array.isArray(left.sortBy) && !Array.isArray(right.sortBy)) {
return rectifyOrdering(sortCompare(left.sortBy, right.sortBy), orderBy);
}
const leftSortBy = [left.sortBy].flat();
const rightSortBy = [right.sortBy].flat();
const zipIter = array.zipStrict(leftSortBy, rightSortBy);
let r = zipIter.next();
for (; r.done === false; r = zipIter.next()) {
const [nextL, nextR] = r.value;
const sortOrder = rectifyOrdering(sortCompare(nextL, nextR), orderBy);
if (sortOrder !== Ordering.EQUAL) {
return sortOrder;
}
}
const [leftRest, rightRest] = r.value;
return leftRest.length - rightRest.length;
});
const res = [];
for (const { index } of sortData) {
res.push(rawItems[index]);
}
return res;
}

View File

@ -22,9 +22,8 @@
import "./table.scss";
import React from "react";
import { orderBy } from "lodash";
import { observer } from "mobx-react";
import { boundMethod, cssNames, noop } from "../../utils";
import { boundMethod, cssNames } from "../../utils";
import { TableRow, TableRowElem, TableRowProps } from "./table-row";
import { TableHead, TableHeadElem, TableHeadProps } from "./table-head";
import type { TableCellElem } from "./table-cell";
@ -32,6 +31,7 @@ import { VirtualList } from "../virtual-list";
import { createPageParam } from "../../navigation";
import { getSortParams, setSortParams } from "./table.storage";
import { computed, makeObservable } from "mobx";
import { getSorted } from "./sorting";
export type TableSortBy = string;
export type TableOrderBy = "asc" | "desc" | string;
@ -92,16 +92,22 @@ export class Table<Item> extends React.Component<TableProps<Item>> {
const { sortable, tableId } = this.props;
if (sortable && !tableId) {
console.error("[Table]: sorted table requires props.tableId to be specified");
console.error("Table must have props.tableId if props.sortable is specified");
}
}
@computed get isSortable() {
const { sortable, tableId } = this.props;
return Boolean(sortable && tableId);
}
@computed get sortParams() {
return Object.assign({}, this.props.sortByDefault, getSortParams(this.props.tableId));
}
renderHead() {
const { sortable, children } = this.props;
const { children } = this.props;
const content = React.Children.toArray(children) as (TableRowElem | TableHeadElem)[];
const headElem: React.ReactElement<TableHeadProps> = content.find(elem => elem.type === TableHead);
@ -109,7 +115,7 @@ export class Table<Item> extends React.Component<TableProps<Item>> {
return null;
}
if (sortable) {
if (this.isSortable) {
const columns = React.Children.toArray(headElem.props.children) as TableCellElem[];
return React.cloneElement(headElem, {
@ -136,14 +142,12 @@ export class Table<Item> extends React.Component<TableProps<Item>> {
return headElem;
}
getSorted(items: any[]) {
const { sortBy, orderBy: order } = this.sortParams;
const sortingCallback = this.props.sortable[sortBy] || noop;
getSorted(rawItems: Item[]) {
const { sortBy, orderBy: orderByRaw } = this.sortParams;
return orderBy(items, sortingCallback, order as any);
return getSorted(rawItems, this.props.sortable[sortBy], orderByRaw);
}
@boundMethod
protected onSort({ sortBy, orderBy }: TableSortParams) {
setSortParams(this.props.tableId, { sortBy, orderBy });
const { sortSyncWithUrl, onSort } = this.props;
@ -153,9 +157,7 @@ export class Table<Item> extends React.Component<TableProps<Item>> {
orderByUrlParam.set(orderBy);
}
if (onSort) {
onSort({ sortBy, orderBy });
}
onSort?.({ sortBy, orderBy });
}
@boundMethod
@ -183,12 +185,12 @@ export class Table<Item> extends React.Component<TableProps<Item>> {
}
renderRows() {
const { sortable, noItems, virtual, customRowHeights, rowLineHeight, rowPadding, items, getTableRow, selectedItemId, className } = this.props;
const { noItems, virtual, customRowHeights, rowLineHeight, rowPadding, items, getTableRow, selectedItemId, className } = this.props;
const content = this.getContent();
let rows: React.ReactElement<TableRowProps>[] = content.filter(elem => elem.type === TableRow);
let sortedItems = rows.length ? rows.map(row => row.props.sortItem) : [...items];
if (sortable) {
if (this.isSortable) {
sortedItems = this.getSorted(sortedItems);
if (rows.length) {
@ -228,15 +230,13 @@ export class Table<Item> extends React.Component<TableProps<Item>> {
}
render() {
const { selectable, scrollable, sortable, autoSize, virtual } = this.props;
let { className } = this.props;
className = cssNames("Table flex column", className, {
selectable, scrollable, sortable, autoSize, virtual,
const { selectable, scrollable, autoSize, virtual, className } = this.props;
const classNames = cssNames("Table flex column", className, {
selectable, scrollable, sortable: this.isSortable, autoSize, virtual,
});
return (
<div className={className}>
<div className={classNames}>
{this.renderHead()}
{this.renderRows()}
</div>

View File

@ -0,0 +1,36 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { getConvertedParts } from "../name-parts";
describe("getConvertedParts", () => {
it.each([
["hello", ["hello"]],
["hello.goodbye", ["hello", "goodbye"]],
["hello.1", ["hello", 1]],
["3-hello.1", [3, "hello", 1]],
["3_hello.1", [3, "hello", 1]],
["3_hello.1/foobar", [3, "hello", 1, "foobar"]],
["3_hello.1/foobar\\new", [3, "hello", 1, "foobar", "new"]],
])("Splits '%s' as into %j", (input, output) => {
expect(getConvertedParts(input)).toEqual(output);
});
});

View File

@ -22,19 +22,18 @@
// Common usage utils & helpers
export * from "../../common/utils";
export * from "./cssVar";
export * from "./cssNames";
export * from "../../common/event-emitter";
export * from "./saveFile";
export * from "./prevDefault";
export * from "./storageHelper";
export * from "./createStorage";
export * from "./interval";
export * from "./copyToClipboard";
export * from "./isReactNode";
export * from "../../common/utils/convertMemory";
export * from "../../common/utils/convertCpu";
export * from "./metricUnitsToNumber";
export * from "./createStorage";
export * from "./cssNames";
export * from "./cssVar";
export * from "./display-booleans";
export * from "./interval";
export * from "./isMiddleClick";
export * from "./isReactNode";
export * from "./metricUnitsToNumber";
export * from "./name-parts";
export * from "./prevDefault";
export * from "./saveFile";
export * from "./storageHelper";

View File

@ -0,0 +1,36 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
/**
* Split `name` into the parts seperated by one or more of (-, _, or .) and
* the sections can be converted to numbers will be converted
* @param name A kube object name
* @returns The converted parts of the name
*/
export function getConvertedParts(name: string): (string | number)[] {
return name
.split(/[-_./\\]+/)
.map(part => {
const converted = +part;
return isNaN(converted) ? part : converted;
});
}