Merge pull request #10761 from richard-cox/pagination-node

Update Node list to support server-side pagination
This commit is contained in:
Richard Cox 2024-05-02 11:54:01 +01:00 committed by GitHub
commit 01219b751e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1056 additions and 396 deletions

View File

@ -8,6 +8,7 @@ Developer documentation and documentation for our UI components is available her
Rancher Dashboard supports an extension mechanism that allows developers to independently provide additional functionality to Rancher. You can learn more from our [Rancher Extensions Docs](https://rancher.github.io/dashboard/extensions/introduction).
# What is it?
Rancher Dashboard provides a sophisticated UI for managing Kubernetes clusters and Workloads.

View File

@ -1,5 +1,7 @@
import PagePo from '@/cypress/e2e/po/pages/page.po';
import BaseResourceList from '@/cypress/e2e/po/lists/base-resource-list.po';
import BurgerMenuPo from '@/cypress/e2e/po/side-bars/burger-side-menu.po';
import ProductNavPo from '@/cypress/e2e/po/side-bars/product-side-nav.po';
export class ConfigMapPagePo extends PagePo {
private static createPath(clusterId: string) {
@ -10,19 +12,33 @@ export class ConfigMapPagePo extends PagePo {
return super.goTo(ConfigMapPagePo.createPath(clusterId));
}
static navTo(clusterId = 'local') {
const burgerMenu = new BurgerMenuPo();
const sideNav = new ProductNavPo();
BurgerMenuPo.toggle();
burgerMenu.clusters().contains(clusterId).click();
sideNav.navToSideMenuGroupByLabel('Storage');
sideNav.navToSideMenuEntryByLabel('ConfigMaps');
}
constructor(clusterId = 'local') {
super(ConfigMapPagePo.createPath(clusterId));
}
clickCreate() {
const baseResourceList = new BaseResourceList(this.self());
list() {
return new BaseResourceList(this.self());
}
return baseResourceList.masthead().actions().eq(0).click();
clickCreate() {
return this.list().masthead().create();
}
listElementWithName(name:string) {
const baseResourceList = new BaseResourceList(this.self());
return this.list().resourceTable().sortableTable().rowElementWithName(name);
}
return baseResourceList.resourceTable().sortableTable().rowElementWithName(name);
searchForConfigMap(name: string) {
return this.list().resourceTable().sortableTable().filter(name);
}
}

View File

@ -1,20 +1,17 @@
import { APIServicesPagePo } from '@/cypress/e2e/po/pages/explorer/api-services.po';
const apiServicesPage = new APIServicesPagePo('local');
describe('Cluster Explorer', { tags: ['@explorer', '@adminUser'] }, () => {
beforeEach(() => {
cy.login();
});
describe('API: APIServices', () => {
let apiServicesPage: APIServicesPagePo;
beforeEach(() => {
apiServicesPage = new APIServicesPagePo('local');
it('Should be able to use shift+j to select rows and the count of selected is correct', () => {
apiServicesPage.goTo();
apiServicesPage.waitForRequests();
});
it('Should be able to use shift+j to select rows and the count of selected is correct', () => {
apiServicesPage.title().should('contain', 'APIServices');
const sortableTable = apiServicesPage.sortableTable();

View File

@ -15,7 +15,7 @@ describe('Nodes list', { tags: ['@explorer', '@adminUser'], testIsolation: 'off'
after(() => {
// Ensure we delete the dummy node
cy.deleteRancherResource('v1', 'nodes', 'bigip1');
cy.deleteRancherResource('v1', 'nodes', dummyNode.metadata.name);
});
it('should show the nodes list page', () => {
@ -48,7 +48,7 @@ describe('Nodes list', { tags: ['@explorer', '@adminUser'], testIsolation: 'off'
// Check the node names
nodeList.sortableTable().rowNames().should((names: any) => {
expect(names).to.have.length(count);
expect(names).to.contain('bigip1');
expect(names).to.contain(dummyNode.metadata.name);
});
});

View File

@ -1,14 +1,14 @@
import { ConfigMapPagePo } from '@/cypress/e2e/po/pages/explorer/config-map.po';
import ConfigMapPo from '@/cypress/e2e/po/components/storage/config-map.po';
describe('ConfigMap', { tags: ['@explorer', '@adminUser'] }, () => {
const configMapPage = new ConfigMapPagePo('local');
describe('ConfigMap', { testIsolation: 'off', tags: ['@explorer', '@adminUser'] }, () => {
beforeEach(() => {
cy.login();
});
it('has the correct title', () => {
const configMapPage = new ConfigMapPagePo('local');
configMapPage.goTo();
cy.title().should('eq', 'Rancher - local - ConfigMaps');
@ -27,9 +27,7 @@ skipGeometric=true`;
// Visit the main menu and select the 'local' cluster
// Navigate to Service Discovery => ConfigMaps
const configMapPage = new ConfigMapPagePo('local');
configMapPage.goTo();
ConfigMapPagePo.navTo();
// Click on Create
configMapPage.clickCreate();
@ -40,7 +38,9 @@ skipGeometric=true`;
// Enter ConfigMap description
const configMapPo = new ConfigMapPo();
configMapPo.nameInput().set('custom-config-map');
const configMapName = 'custom-config-map';
configMapPo.nameInput().set(configMapName);
configMapPo.keyInput().set('managerApiConfiguration.properties');
configMapPo.valueInput().set(expectedValue);
configMapPo.descriptionInput().set('Custom Config Map Description');
@ -49,14 +49,12 @@ skipGeometric=true`;
configMapPo.saveCreateForm().click();
// Check if the ConfigMap is created successfully
configMapPage.listElementWithName('custom-config-map').should('exist');
configMapPage.waitForPage();
configMapPage.searchForConfigMap(configMapName);
configMapPage.listElementWithName(configMapName).should('exist');
// Navigate back to the edit page
configMapPage.listElementWithName('custom-config-map')
.find(`button[data-testid="sortable-table-0-action-button"]`)
.click()
.get(`li[data-testid="action-menu-0-item"]`)
.click();
configMapPage.list().actionMenu(configMapName).getMenuItem('Edit Config').click();
// Assert the current value yaml dumps will append a newline at the end
configMapPo.valueInput().value().should('eq', `${ expectedValue }\n`);

View File

@ -8,7 +8,7 @@ import { RANCHER_PAGE_EXCEPTIONS, catchTargetPageException } from '~/cypress/sup
const homePage = new HomePagePo();
const homeClusterList = homePage.list();
const provClusterList = new ClusterManagerListPagePo('local');
const longClusterDescription = 'this-is-some-really-really-really-really-really-really-long-decription';
const longClusterDescription = 'this-is-some-really-really-really-really-really-really-long-description';
const rowDetails = (text) => text.split('\n').map((r) => r.trim()).filter((f) => f);

View File

@ -172,7 +172,7 @@ export default {
* This covers case 1
*/
pagination(neu, old) {
if (neu && !this.componentWillFetch && this.paginationEqual(neu, old)) {
if (neu && !this.componentWillFetch && !this.paginationEqual(neu, old)) {
this.$fetchType(this.resource);
}
},

View File

@ -29,21 +29,6 @@ import { getParent } from '@shell/utils/dom';
// NOTE: This is populated by a plugin (formatters.js) to avoid issues with plugins
export const FORMATTERS = {};
export const COLUMN_BREAKPOINTS = {
/**
* Only show column if at tablet width or wider
*/
TABLET: 'tablet',
/**
* Only show column if at laptop width or wider
*/
LAPTOP: 'laptop',
/**
* Only show column if at desktop width or wider
*/
DESKTOP: 'desktop'
};
// @TODO:
// Fixed header/scrolling

View File

@ -19,7 +19,7 @@ import {
STORAGE_CLASS_PROVISIONER, PERSISTENT_VOLUME_SOURCE,
HPA_REFERENCE, MIN_REPLICA, MAX_REPLICA, CURRENT_REPLICA,
ACCESS_KEY, DESCRIPTION, EXPIRES, EXPIRY_STATE, SUB_TYPE, AGE_NORMAN, SCOPE_NORMAN, PERSISTENT_VOLUME_CLAIM, RECLAIM_POLICY, PV_REASON, WORKLOAD_HEALTH_SCALE, POD_RESTARTS,
DURATION, MESSAGE, REASON, LAST_SEEN_TIME, EVENT_TYPE, OBJECT, ROLE, SECRET_DATA
DURATION, MESSAGE, REASON, LAST_SEEN_TIME, EVENT_TYPE, OBJECT, ROLE, ROLES, VERSION, INTERNAL_EXTERNAL_IP, KUBE_NODE_OS, CPU, RAM, SECRET_DATA
} from '@shell/config/table-headers';
import { DSL } from '@shell/store/type-map';
@ -27,6 +27,8 @@ import {
STEVE_AGE_COL, STEVE_LIST_GROUPS, STEVE_NAMESPACE_COL, STEVE_NAME_COL, STEVE_STATE_COL
} from '@shell/config/pagination-table-headers';
import { COLUMN_BREAKPOINTS } from '@shell/types/store/type-map';
export const NAME = 'explorer';
export function init(store) {
@ -233,8 +235,11 @@ export function init(store) {
value: 'metadata.fields.1',
sort: 'metadata.fields.1',
search: 'metadata.fields.1',
}, {
...SECRET_DATA,
sort: false,
search: false,
},
SECRET_DATA,
STEVE_AGE_COL
]);
@ -267,6 +272,66 @@ export function init(store) {
STEVE_AGE_COL
]);
headers(NODE,
[
STATE,
NAME_COL,
ROLES,
VERSION,
INTERNAL_EXTERNAL_IP,
{
...KUBE_NODE_OS,
breakpoint: COLUMN_BREAKPOINTS.LAPTOP,
getValue: (row) => row.status?.nodeInfo?.operatingSystem
},
{
...CPU,
breakpoint: COLUMN_BREAKPOINTS.LAPTOP,
getValue: (row) => row.cpuUsagePercentage
}, {
...RAM,
breakpoint: COLUMN_BREAKPOINTS.LAPTOP,
getValue: (row) => row.ramUsagePercentage
},
AGE
],
[
STEVE_STATE_COL,
STEVE_NAME_COL,
{
...ROLES,
sort: false,
search: false
},
{
...VERSION,
value: 'status.nodeInfo.kubeletVersion',
getValue: undefined,
sort: ['status.nodeInfo.kubeletVersion'],
search: 'status.nodeInfo.kubeletVersion'
}, {
...INTERNAL_EXTERNAL_IP,
sort: false,
search: false,
}, {
...KUBE_NODE_OS,
breakpoint: COLUMN_BREAKPOINTS.LAPTOP,
getValue: undefined
}, {
...CPU,
breakpoint: COLUMN_BREAKPOINTS.LAPTOP,
getValue: (row) => row.cpuUsagePercentage,
sort: false,
search: false,
}, {
...RAM,
breakpoint: COLUMN_BREAKPOINTS.LAPTOP,
sort: false,
search: false,
},
STEVE_AGE_COL
]);
headers(MANAGEMENT.PSA, [STATE, NAME_COL, {
...DESCRIPTION,
width: undefined

View File

@ -1,6 +1,6 @@
import { CATTLE_PUBLIC_ENDPOINTS } from '@shell/config/labels-annotations';
import { NODE as NODE_TYPE } from '@shell/config/types';
import { COLUMN_BREAKPOINTS } from '@shell/components/SortableTable/index.vue';
import { COLUMN_BREAKPOINTS } from '@shell/types/store/type-map';
// Note: 'id' is always the last sort, so you don't have to specify it here.

View File

@ -1,21 +1,28 @@
<script>
import ResourceTable from '@shell/components/ResourceTable';
import Tag from '@shell/components/Tag';
<script lang="ts">
import ResourceTable from '@shell/components/ResourceTable.vue';
import Tag from '@shell/components/Tag.vue';
import { Banner } from '@components/Banner';
import {
STATE, NAME, ROLES, VERSION, INTERNAL_EXTERNAL_IP, CPU, RAM, PODS, AGE, KUBE_NODE_OS
} from '@shell/config/table-headers';
import { PODS } from '@shell/config/table-headers';
import metricPoller from '@shell/mixins/metric-poller';
import { CAPI as CAPI_ANNOTATIONS } from '@shell/config/labels-annotations.js';
import { defineComponent } from 'vue';
import { ActionFindPageArgs } from '@shell/types/store/dashboard-store.types';
import { PaginationArgs, PaginationFilterField, PaginationParamFilter } from '@shell/types/store/pagination.types';
import {
CAPI,
MANAGEMENT, METRIC, NODE, NORMAN, POD
} from '@shell/config/types';
import { allHash } from '@shell/utils/promise';
import { GROUP_RESOURCES, mapPref } from '@shell/store/prefs';
import { COLUMN_BREAKPOINTS } from '@shell/components/SortableTable/index.vue';
import { COLUMN_BREAKPOINTS } from '@shell/types/store/type-map';
import ResourceFetch from '@shell/mixins/resource-fetch';
export default {
import { mapGetters } from 'vuex';
export default defineComponent({
name: 'ListNode',
components: {
ResourceTable,
@ -36,94 +43,92 @@ export default {
useQueryParamsForSimpleFiltering: {
type: Boolean,
default: false
},
listComponent: {
type: Boolean,
default: false
}
},
async fetch() {
this.$initializeFetchData(this.resource);
const hash = { kubeNodes: this.$fetchType(this.resource) };
this.canViewPods = this.$store.getters[`cluster/schemaFor`](POD);
if (this.$store.getters[`management/schemaFor`](MANAGEMENT.NODE)) {
// Required for Drain/Cordon action
hash.normanNodes = this.$fetchType(NORMAN.NODE, [], 'rancher');
}
if (this.$store.getters[`rancher/schemaFor`](NORMAN.NODE)) {
hash.mgmtNodes = this.$fetchType(MANAGEMENT.NODE, [], 'management');
}
if (this.$store.getters[`management/schemaFor`](CAPI.MACHINE)) {
// Required for ssh / download key actions
hash.machines = this.$fetchType(CAPI.MACHINE, [], 'management');
}
if (this.canViewPods) {
// Used for running pods metrics - we don't need to block on this to show the list of nodes
this.$fetchType(POD);
}
await allHash(hash);
await allHash({
kubeNodes: this.$fetchType(this.resource),
...this.fetchSecondaryResources(),
});
},
data() {
return { canViewPods: false };
return {
// Pods required for `Pods` column's running pods metrics
// podConsumedUsage = podConsumed / podConsumedUsage. podConsumed --> pods. allPods.filter((pod) => pod.spec.nodeName === this.name)
canViewPods: !!this.$store.getters[`cluster/schemaFor`](POD),
// Norman node required for Drain/Cordon/Uncordon action
canViewNormanNodes: !!this.$store.getters[`rancher/schemaFor`](NORMAN.NODE),
// Mgmt Node required to find Norman node
canViewMgmtNodes: !!this.$store.getters[`management/schemaFor`](MANAGEMENT.NODE),
// Required for ssh / download key actions
canViewMachines: !!this.$store.getters[`management/schemaFor`](CAPI.MACHINE),
// Required for CPU and RAM columns
canViewNodeMetrics: !!this.$store.getters['cluster/schemaFor'](METRIC.NODE),
};
},
beforeDestroy() {
// Stop watching pods, nodes and node metrics
this.$store.dispatch('cluster/forgetType', POD);
if (this.canViewPods) {
this.$store.dispatch('cluster/forgetType', POD);
}
this.$store.dispatch('cluster/forgetType', NODE);
this.$store.dispatch('cluster/forgetType', METRIC.NODE);
},
computed: {
...mapGetters(['currentCluster']),
hasWindowsNodes() {
return (this.rows || []).some((node) => node.status.nodeInfo.operatingSystem === 'windows');
// Note if server side pagination is used this is only applicable to the current page
return (this.rows || []).some((node: any) => node.status.nodeInfo.operatingSystem === 'windows');
},
tableGroup: mapPref(GROUP_RESOURCES),
parsedRows() {
this.rows.forEach((row) => {
row.displayTaintsAndLabels = (row.spec.taints && row.spec.taints.length) || !!row.customLabelCount;
});
return this.rows;
},
headers() {
const headers = [
STATE,
NAME,
ROLES,
VERSION,
INTERNAL_EXTERNAL_IP,
{
...KUBE_NODE_OS,
breakpoint: COLUMN_BREAKPOINTS.LAPTOP,
getValue: (row) => row.status?.nodeInfo?.operatingSystem
},
{
...CPU,
breakpoint: COLUMN_BREAKPOINTS.LAPTOP,
getValue: (row) => row.cpuUsagePercentage
}, {
...RAM,
breakpoint: COLUMN_BREAKPOINTS.LAPTOP,
getValue: (row) => row.ramUsagePercentage
}];
// This is all about adding the pods column... if the user can see pods
if (this.canPaginate) {
const paginationHeaders = [...this.$store.getters['type-map/headersFor'](this.schema, true)];
if (paginationHeaders) {
if (this.canViewPods) {
paginationHeaders.splice(paginationHeaders.length - 1, 0, {
...PODS,
breakpoint: COLUMN_BREAKPOINTS.DESKTOP,
sort: false,
search: false,
getValue: (row: any) => row.podConsumedUsage
});
}
return paginationHeaders;
} else {
console.warn('Nodes list expects pagination headers but none found'); // eslint-disable-line no-console
return [];
}
}
const headers = [...this.$store.getters['type-map/headersFor'](this.schema, false)];
if (this.canViewPods) {
headers.push({
headers.splice(headers.length - 1, 0, {
...PODS,
breakpoint: COLUMN_BREAKPOINTS.DESKTOP,
getValue: (row) => row.podConsumedUsage
getValue: (row: any) => row.podConsumedUsage
});
}
headers.push(AGE);
return headers;
},
@ -131,24 +136,158 @@ export default {
methods: {
async loadMetrics() {
const schema = this.$store.getters['cluster/schemaFor'](METRIC.NODE);
if (!this.canViewNodeMetrics) {
return;
}
if (schema) {
if (this.canPaginate) {
if (!this.rows.length) {
return;
}
const opt: ActionFindPageArgs = {
force: true,
pagination: new PaginationArgs({
page: -1,
filters: new PaginationParamFilter({
fields: this.rows.map((r: any) => new PaginationFilterField({
field: 'metadata.name',
value: r.id
}))
})
})
};
await this.$store.dispatch('cluster/findPage', {
type: METRIC.NODE,
opt
});
} else {
await this.$store.dispatch('cluster/findAll', {
type: METRIC.NODE,
opt: { force: true }
});
this.$forceUpdate();
}
this.$forceUpdate();
},
toggleLabels(row) {
toggleLabels(row: any) {
this.$set(row, 'displayLabels', !row.displayLabels);
},
}
};
fetchSecondaryResources(): { [key: string]: Promise<any>} {
if (this.canPaginate) {
return {};
}
const hash: { [key: string]: Promise<any>} = {};
if (this.canViewMgmtNodes) {
hash.mgmtNodes = this.$fetchType(MANAGEMENT.NODE, [], 'management');
}
if (this.canViewNormanNodes) {
hash.normanNodes = this.$fetchType(NORMAN.NODE, [], 'rancher');
}
if (this.canViewMachines) {
hash.machines = this.$fetchType(CAPI.MACHINE, [], 'management');
}
if (this.canViewPods) {
// No need to block on this
this.$fetchType(POD);
}
return hash;
},
/**
* Nodes columns need other resources in order to show data in some columns
*
* In the paginated world we want to resrict the fetch of those resources to only the one's we need
*
* So when we have a page.... use those entries as filters when fetching the other resources
*/
async fetchPageSecondaryResources(force = false) {
if (!this.rows?.length) {
return;
}
if (this.canViewMgmtNodes && this.canViewNormanNodes) {
// We only fetch mgmt node to get norman node. We only fetch node to get node actions
// See https://github.com/rancher/dashboard/issues/10743
const opt: ActionFindPageArgs = {
force,
pagination: new PaginationArgs({
page: -1,
filters: PaginationParamFilter.createMultipleFields(this.rows.map((r: any) => new PaginationFilterField({
field: 'status.nodeName',
value: r.id
}))),
})
};
this.$store.dispatch(`management/findPage`, { type: MANAGEMENT.NODE, opt })
.then(() => {
this.$store.dispatch(`rancher/findAll`, { type: NORMAN.NODE, opt: { force } });
});
}
if (this.canViewMachines) {
const namespace = this.currentCluster.provClusterId?.split('/')[0];
if (namespace) {
const opt: ActionFindPageArgs = {
force,
namespaced: namespace,
pagination: new PaginationArgs({
page: -1,
filters: PaginationParamFilter.createMultipleFields(
this.rows.reduce((res: PaginationFilterField[], r: any ) => {
const name = r.metadata?.annotations?.[CAPI_ANNOTATIONS.MACHINE_NAME];
if (name) {
res.push(new PaginationFilterField({
field: 'metadata.name',
value: name,
}));
}
return res;
}, [])
)
})
};
this.$store.dispatch(`management/findPage`, { type: CAPI.MACHINE, opt });
}
}
if (this.canViewPods) {
// Note - fetching pods for current page could be a LOT still (probably max of 3k - 300 pods per node x 100 nodes in a page)
const opt: ActionFindPageArgs = {
force,
pagination: new PaginationArgs({
page: -1,
filters: PaginationParamFilter.createMultipleFields(
this.rows.map((r: any) => new PaginationFilterField({
field: 'spec.nodeName',
value: r.id,
}))
)
})
};
this.$store.dispatch(`cluster/findPage`, { type: POD, opt });
}
// Fetch metrics given the current page
this.loadMetrics();
},
},
});
</script>
<template>
@ -162,12 +301,15 @@ export default {
v-bind="$attrs"
:schema="schema"
:headers="headers"
:rows="parsedRows"
:rows="rows"
:sub-rows="true"
:loading="loading"
:use-query-params-for-simple-filtering="useQueryParamsForSimpleFiltering"
:force-update-live-and-delayed="forceUpdateLiveAndDelayed"
data-testid="cluster-node-list"
:external-pagination-enabled="canPaginate"
:external-pagination-result="paginationResult"
@pagination-changed="paginationChanged"
v-on="$listeners"
>
<template #sub-row="{fullColspan, row, onRowMouseEnter, onRowMouseLeave}">

View File

@ -35,15 +35,19 @@ function beforeMount() {
}
}
function $fetch() {
if (!this._fetchPromise) {
this._fetchPromise = $_fetch.call(this)
.then(() => {
delete this._fetchPromise;
});
function $fetch(cached = true) {
if (cached) {
if (!this._fetchPromise) {
this._fetchPromise = $_fetch.call(this)
.then(() => {
delete this._fetchPromise;
});
}
return this._fetchPromise;
}
return this._fetchPromise;
return $_fetch.call(this);
}
async function $_fetch() { // eslint-disable-line camelcase

View File

@ -1,12 +1,12 @@
import { NAMESPACE_FILTER_NAMESPACED_YES, NAMESPACE_FILTER_NAMESPACED_NO, NAMESPACE_FILTER_ALL } from '@shell/utils/namespace-filter';
import { NAMESPACE } from '@shell/config/types';
import { ALL_NAMESPACES } from '@shell/store/prefs';
import { mapGetters } from 'vuex';
import { ResourceListComponentName } from '../components/ResourceList/resource-list.config';
import paginationUtils from '@shell/utils/pagination-utils';
import debounce from 'lodash/debounce';
import { OptPaginationFilter, OptPaginationFilterField } from '@shell/types/store/dashboard-store.types';
import { PaginationParamFilter, PaginationFilterField, PaginationArgs } from '@shell/types/store/pagination.types';
import stevePaginationUtils from '@shell/plugins/steve/steve-pagination-utils';
import { ALL_NAMESPACES } from '@shell/store/prefs';
import { NAMESPACE } from '@shell/config/types';
/**
* Companion mixin used with `resource-fetch` for `ResourceList` to determine if the user needs to filter the list by a single namespace
@ -34,18 +34,22 @@ export default {
},
methods: {
/**
* @param {PaginationArgs} pagination
*/
setPagination(pagination) {
this.pPagination = pagination;
if (pagination) {
this.pPagination = pagination;
}
},
paginationChanged(event) {
const searchFilters = event.filter.searchQuery ? event.filter.searchFields.map((field) => new OptPaginationFilterField({
const searchFilters = event.filter.searchQuery ? event.filter.searchFields.map((field) => new PaginationFilterField({
field,
value: event.filter.searchQuery,
})) : [];
this.debouncedSetPagination({
...this.pPagination,
const pagination = new PaginationArgs({
page: event.page,
pageSize: event.perPage,
sort: event.sort?.map((field) => ({
@ -53,11 +57,13 @@ export default {
asc: !event.descending
})),
projectsOrNamespaces: this.requestFilters.projectsOrNamespaces,
filter: [
new OptPaginationFilter({ fields: searchFilters }),
filters: [
new PaginationParamFilter({ fields: searchFilters }),
...this.requestFilters.filters, // Apply the additional filters. these aren't from the user but from ns filtering
]
});
this.debouncedSetPagination(pagination);
},
namespaceFilterChanged(neu) {
@ -81,26 +87,40 @@ export default {
this.requestFilters.projectsOrNamespaces = projectsOrNamespaces;
// Kick off a change
this.debouncedSetPagination({ ...this.pPagination });
if (this.pPagination) {
this.debouncedSetPagination({ ...this.pPagination });
}
},
/**
* @param {PaginationArgs} neu
* @param {PaginationArgs} old
*/
paginationEqual(neu, old) {
if (!neu.page) {
// Not valid, don't bother
// Not valid, count as not equal
return false;
}
if (paginationUtils.paginationEqual(neu, old)) {
// Same, nae bother
return false;
return true;
}
return true;
return false;
}
},
computed: {
...mapGetters(['currentProduct', 'namespaceFilters', 'isAllNamespaces']),
...mapGetters(['currentProduct', 'isAllNamespaces']),
/**
* Why is this a specific getter and not not in mapGetters?
*
* Adding it to mapGetters means the kubewarden unit tests fail as they don't mock it....
*/
namespaceFilters() {
return this.$store.getters['namespaceFilters'];
},
/**
* Does the user need to update the filter to supply valid options?
@ -120,14 +140,14 @@ export default {
* ResourceList imports resource-fetch --> this mixin
* When there is no custom list this is fine (ResourceList with mixins --> ResourceTable)
* When there is a custom list there are two instances of this mixin (ResourceList with mixins --> CustomList with mixins --> ResourceTable)
* - In this scenario, reduce churn by existing earlier if mixin is from parent ResourceList and leave work for CustomList mixins
* - In this scenario, reduce churn by exiting earlier if mixin is from parent ResourceList and leave work for CustomList mixins
*/
isResourceList() {
return !!this.hasListComponent;
},
/**
* Is Pagination supported and ready for this list?
* Is Pagination supported and has the table supplied pagination settings from the table?
*/
pagination() {
if (this.isResourceList) {
@ -152,7 +172,7 @@ export default {
},
paginationResult() {
if (this.isResourceList) {
if (this.isResourceList || !this.canPaginate) {
return;
}
@ -188,7 +208,7 @@ export default {
* result in an empty page
*/
rows(neu) {
if (!this.pagination || this.isResourceList) {
if (!this.canPaginate || !this.pagination || this.isResourceList) {
return;
}
@ -203,6 +223,10 @@ export default {
namespaceFilters: {
immediate: true,
async handler(neu, old) {
if (!this.canPaginate || !this.schema?.attributes?.namespaced) {
return;
}
if (this.isResourceList) {
return;
}
@ -231,25 +255,52 @@ export default {
}
},
/**
* When a pagination is required and the user changes page / sort / filter, kick off a new set of API requests
*
* @param {StorePaginationResult} neu
* @param {StorePaginationResult} old
*/
async pagination(neu, old) {
if (this.isResourceList) {
if (!this.canPaginate) {
return;
}
// When a pagination is required and the user changes page / sort / filter, kick off a new set of API requests
//
// ResourceList has two modes
// 1) ResourceList component handles API request to fetch resources
// 2) Custom list component handles API request to fetch resources
//
// This covers case 2
if (neu && this.$options.name !== ResourceListComponentName && !!this.$fetch && this.paginationEqual(neu, old)) {
await this.$fetch();
// This covers case 2, so ignore case 1
if (this.isResourceList) {
return;
}
if (neu && this.$options.name !== ResourceListComponentName && !!this.$fetch && !this.paginationEqual(neu, old)) {
await this.$fetch(false);
// Ensure any live/delayed columns get updated
this.forceUpdateLiveAndDelayed = new Date().getTime();
}
},
/**
* If the pagination result has changed fetch secondary resources
*
* Lists should implement fetchPageSecondaryResources to fetch them
*
* @param {StorePaginationResult} neu
* @param {StorePaginationResult} old
*/
async paginationResult(neu, old) {
if (!this.fetchPageSecondaryResources || !neu ) { // || neu.timestamp === old?.timestamp
return;
}
if (neu.timestamp === old?.timestamp) {
// This occurs when the user returns to the page... and pagination hasn't actually changed
return;
}
await this.fetchPageSecondaryResources();
}
},
};

View File

@ -46,6 +46,7 @@ export default {
paginating: null,
};
},
beforeDestroy() {
// make sure this only runs once, for the initialized instance
if (this.init) {
@ -77,14 +78,19 @@ export default {
return this.rows.length ? false : this.$fetchState.pending;
},
},
watch: {
refreshFlag(neu) {
async refreshFlag(neu) {
// this is where the data assignment will trigger the update of the list view...
if (this.init && neu) {
this.$fetch();
await this.$fetch();
if (this.canPaginate && this.fetchPageSecondaryResources) {
this.fetchPageSecondaryResources(true);
}
}
}
},
methods: {
// this defines all the flags needed for the mechanism
// to work. They should be defined based on the main list view
@ -127,7 +133,6 @@ export default {
// when pagination is enabled we want to wait for the correct set of initial pagination settings to make the call
return;
}
const opt = {
hasManualRefresh: this.hasManualRefresh,
pagination: { ...this.pagination },
@ -141,7 +146,8 @@ export default {
return this.$store.dispatch(`${ currStore }/findPage`, {
type,
opt
}).finally(() => Vue.set(that, 'paginating', false));
})
.finally(() => Vue.set(that, 'paginating', false));
}
let incremental = 0;

View File

@ -450,6 +450,10 @@ export default class ClusterNode extends SteveModel {
get provider() {
return this.$rootGetters['currentCluster'].provisioner.toLowerCase();
}
get displayTaintsAndLabels() {
return !!this.spec.taints?.length || !!this.customLabelCount;
}
}
function calculatePercentage(allocatable, capacity) {

View File

@ -123,7 +123,7 @@ export default {
}
});
} else {
// We have everything!
// We have everything!
if (opt.hasManualRefresh) {
dispatch('resource-fetch/updateManualRefreshIsLoading', false, { root: true });
}
@ -145,7 +145,7 @@ export default {
/**
*
* @param {*} ctx
* @param { {type: string, opt: FindAllOpt} } opt
* @param { {type: string, opt: ActionFindPageArgs} } opt
*/
async findAll(ctx, { type, opt }) {
const {
@ -164,8 +164,7 @@ export default {
!opt.force &&
(
getters['haveAll'](type) ||
getters['haveAllNamespace'](type, opt.namespaced) ||
(opt.pagination ? getters['havePaginatedPage'](type, opt.pagination) : false)
getters['haveAllNamespace'](type, opt.namespaced)
)
) {
if (opt.watch !== false ) {
@ -323,8 +322,9 @@ export default {
pagination: opt.pagination ? {
request: opt.pagination,
result: {
count: out.count,
pages: out.pages
count: out.count,
pages: out.pages,
timestamp: new Date().getTime()
}
} : undefined,
});
@ -383,7 +383,7 @@ export default {
}
// No need to request the resources if we have them already
if (!opt.force && getters['havePaginatedPage'](type, opt.pagination)) {
if (!opt.force && getters['havePaginatedPage'](type, opt)) {
return findAllGetter(getters, type, opt);
}
@ -412,10 +412,14 @@ export default {
type,
data: out.data,
pagination: opt.pagination ? {
request: opt.pagination,
result: {
count: out.count,
pages: out.pages
request: {
namespace: opt.namespaced,
pagination: opt.pagination
},
result: {
count: out.count,
pages: out.pages,
timestamp: new Date().getTime()
}
} : undefined,
});

View File

@ -15,8 +15,10 @@ export const urlFor = (state, getters) => (type, id, opt) => {
type = getters.normalizeType(type);
let url = opt.url;
let schema;
if ( !url ) {
const schema = getters.schemaFor(type);
schema = getters.schemaFor(type);
if ( !schema ) {
throw new Error(`Unknown schema for type: ${ type }`);
@ -39,7 +41,7 @@ export const urlFor = (state, getters) => (type, id, opt) => {
url = `${ baseUrl }/${ url }`;
}
url = getters.urlOptions(url, opt);
url = getters.urlOptions(url, opt, schema);
return url;
};
@ -298,16 +300,22 @@ export default {
return false;
},
havePaginatedPage: (state, getters) => (type, pagination) => {
if (!pagination) {
havePaginatedPage: (state, getters) => (type, opt) => {
if (!opt.pagination) {
return false;
}
type = getters.normalizeType(type);
const entry = state.types[type];
if ( entry ) {
return entry.havePage && paginationUtils.paginationEqual(entry.havePage.request, pagination);
if ( entry?.havePage ) {
const { namespace: aNamespace = undefined, pagination: aPagination } = entry.havePage.request;
const { namespace: bNamespace = undefined, pagination: bPagination } = {
namespace: opt.namespaced,
pagination: opt.pagination
};
return entry.havePage && aNamespace === bNamespace && paginationUtils.paginationEqual(aPagination, bPagination);
}
return false;
@ -346,7 +354,7 @@ export default {
urlFor,
urlOptions: () => (url, opt) => {
urlOptions: () => (url, opt, schema) => {
return url;
},

View File

@ -20,7 +20,7 @@ describe('steve: getters:', () => {
}
},
// this has its own tests so it just returns the input string
urlOptions: (url: string, opt: any) => {
urlOptions: (url: string, opt: any, type: string) => {
if (opt.addParam) {
url += '?param=true';
}

View File

@ -3,12 +3,11 @@ import pAndNFiltering from '@shell/plugins/steve/projectAndNamespaceFiltering.ut
type TypeIsCached = { [type: string]: boolean }
/**
* There are scenarios where we can't subscribe to subsets of a resources
* There are scenarios where we can't subscribe to subsets of a resource type
* - Multiple namespaces or projects
* - Result of Pagination (a single page of resources that have been sorted / filtered)
*
* For those scenarios we subscribe to allll changes BUT ignore changes that are not applicable to that subset
*
*/
class AcceptOrRejectSocketMessage {
typeIsNamespaced({ getters }: any, type: string): boolean {

View File

@ -36,12 +36,12 @@ const GC_IGNORE_TYPES = {
const steveRegEx = new RegExp('(/v1)|(\/k8s\/clusters\/[a-z0-9-]+\/v1)');
export default {
urlOptions: () => (url, opt) => {
urlOptions: () => (url, opt, schema) => {
opt = opt || {};
const parsedUrl = parse(url);
const isSteve = steveRegEx.test(parsedUrl.path);
const stevePagination = stevePaginationUtils.checkAndCreateParam(opt);
const stevePagination = stevePaginationUtils.createParamsForPagination(schema, opt);
if (stevePagination) {
url += `${ (url.includes('?') ? '&' : '?') + stevePagination }`;
@ -323,7 +323,7 @@ export default {
*/
optionsFor: () => (ctx, { schema, pagination, opts }) => {
if (pagination) {
// As headers are hardcoded each list should specify the specific default sort option
// As headers are hardcoded each list should have specific default sort option
// This avoids the sortable table adding both name and id (which when combined with group would result in 3 sort args, which isn't supported)
const steveOpts = { listMandatorySort: [] };

View File

@ -1,6 +1,6 @@
import { NAMESPACE_FILTER_NS_FULL_PREFIX, NAMESPACE_FILTER_P_FULL_PREFIX } from '@shell/utils/namespace-filter';
import { getPerformanceSetting } from '@shell/utils/settings';
import { FindAllOpt } from '@shell/types/store/dashboard-store.types';
import { ActionFindAllArgs } from '@shell/types/store/dashboard-store.types';
class ProjectAndNamespaceFiltering {
static param = 'projectsornamespaces'
@ -8,7 +8,7 @@ class ProjectAndNamespaceFiltering {
/**
* Does the request `opt` definition require resources are fetched from a specific set namespaces/projects?
*/
isApplicable(opt: FindAllOpt): boolean {
isApplicable(opt: ActionFindAllArgs): boolean {
return Array.isArray(opt.namespaced);
}
@ -36,7 +36,7 @@ class ProjectAndNamespaceFiltering {
/**
* Check if `opt` requires resources from specific ns/projects, if so return the required query param (x=y)
*/
checkAndCreateParam(opt: FindAllOpt): string {
checkAndCreateParam(opt: ActionFindAllArgs): string {
if (!this.isApplicable(opt)) {
return '';
}

View File

@ -1,3 +1,4 @@
export interface SchemaAttributeColumn {
description: string,
field: string,
@ -11,3 +12,11 @@ export interface SchemaAttribute {
columns: SchemaAttributeColumn[],
namespaced: boolean
}
/**
* At some point this will be properly typed, until then...
*/
export interface Schema {
id: string,
attributes: SchemaAttribute
}

View File

@ -1,12 +1,16 @@
import { FindPageOpt, OptPaginationFilter, OptPaginationFilterField, OptPaginationProjectOrNamespace } from '@shell/types/store/dashboard-store.types';
import { ActionFindPageArgs } from '@shell/types/store/dashboard-store.types';
import { PaginationParam, PaginationFilterField, PaginationParamProjectOrNamespace, PaginationParamFilter } from '@shell/types/store/pagination.types';
import { NAMESPACE_FILTER_ALL_SYSTEM, NAMESPACE_FILTER_ALL_USER, NAMESPACE_FILTER_P_FULL_PREFIX } from '@shell/utils/namespace-filter';
import Namespace from '@shell/models/namespace';
import { uniq } from '@shell/utils/array';
import { MANAGEMENT, NODE, POD } from '@shell/config/types';
import { Schema } from 'plugins/steve/schema';
class NamespaceProjectFilters {
/**
* User needs all resources.... except if there's some settings which should remove resources in specific circumstances
*/
protected handlePrefAndSettingFilter(allNamespaces: Namespace[], showDynamicRancherNamespaces: boolean, productHidesSystemNamespaces: boolean): OptPaginationFilter[] {
protected handlePrefAndSettingFilter(allNamespaces: Namespace[], showDynamicRancherNamespaces: boolean, productHidesSystemNamespaces: boolean): PaginationParamFilter[] {
// These are AND'd together
// Not ns 1 AND ns 2
return allNamespaces.reduce((res, ns) => {
@ -16,15 +20,13 @@ class NamespaceProjectFilters {
const hideSystem = productHidesSystemNamespaces ? ns.isSystem : false;
if (hideObscure || hideSystem) {
res.push(new OptPaginationFilter({
fields: [new OptPaginationFilterField({
field: 'metadata.namespace', value: ns.name, equals: false
})]
res.push(PaginationParamFilter.createSingleField({
field: 'metadata.namespace', value: ns.name, equals: false
}));
}
return res;
}, [] as OptPaginationFilter[]);
}, [] as PaginationParamFilter[]);
}
/**
@ -42,20 +44,16 @@ class NamespaceProjectFilters {
if (isAllSystem) {
// return resources in system ns 1 OR in system ns 2 ...
// &filter=metadata.namespace=system ns 1,metadata.namespace=system ns 2
return [
new OptPaginationFilter({
fields: allSystem.map(
(ns) => new OptPaginationFilterField({ field: 'metadata.namespace', value: ns.name })
)
})
];
return [PaginationParamFilter.createMultipleFields(
allSystem.map(
(ns) => new PaginationFilterField({ field: 'metadata.namespace', value: ns.name })
)
)];
} else { // if isAllUser
// return resources not in system ns 1 AND not in system ns 2 ...
// &filter=metadata.namespace!=system ns 1&filter=metadata.namespace!=system ns 2
return allSystem.map((ns) => new OptPaginationFilter({
fields: [{
field: 'metadata.namespace', value: ns.name, equals: false
}]
return allSystem.map((ns) => PaginationParamFilter.createSingleField({
field: 'metadata.namespace', value: ns.name, equals: false
}));
}
}
@ -68,7 +66,9 @@ class NamespaceProjectFilters {
// return resources in project 1 OR namespace 2
// &projectsornamespaces=project 1,namespace 2
const projectsOrNamespaces = [new OptPaginationProjectOrNamespace({ fields: neu.map((selection) => new OptPaginationFilterField({ value: selection })) })];
const projectsOrNamespaces = [
new PaginationParamProjectOrNamespace({ projectOrNamespace: neu })
];
if (isLocalCluster) {
// > As per `handleSystemOrUserFilter` above, we need to be careful of the local cluster where there's namespaces related to projects with the same id
@ -82,10 +82,8 @@ class NamespaceProjectFilters {
projectsOrNamespaces,
filters: neu
.filter((selection) => selection.startsWith(NAMESPACE_FILTER_P_FULL_PREFIX))
.map((projects) => new OptPaginationFilter({
fields: [{
field: 'metadata.namespace', value: projects.replace(NAMESPACE_FILTER_P_FULL_PREFIX, ''), equals: false
}]
.map((projects) => PaginationParamFilter.createSingleField({
field: 'metadata.namespace', value: projects.replace(NAMESPACE_FILTER_P_FULL_PREFIX, ''), equals: false
}))
};
}
@ -98,6 +96,30 @@ class NamespaceProjectFilters {
* Helper functions for steve pagination
*/
class StevePaginationUtils extends NamespaceProjectFilters {
/**
* Filtering with the vai cache supports specific fields
* 1) Those listed here
* 2) Those references in the schema's attributes.fields list (which is used by generic lists)
*/
static VALID_FIELDS: { [type: string]: { field: string, startsWith?: boolean }[]} = {
'': [// all types
{ field: 'metadata.name' },
{ field: 'metadata.namespace' },
{ field: 'metadata.state.name' },
],
[NODE]: [
{ field: 'status.nodeInfo.kubeletVersion' },
{ field: 'status.nodeInfo.operatingSystem' },
],
[POD]: [
{ field: 'spec.containers.image' },
{ field: 'spec.nodeName' },
],
[MANAGEMENT.NODE]: [
{ field: 'status.nodeName' }
]
}
/**
* Given the selection of projects or namespaces come up with `filter` and `projectsornamespace` query params
*/
@ -116,7 +138,7 @@ class StevePaginationUtils extends NamespaceProjectFilters {
*/
isAllNamespaces: boolean,
/**
* Weird things be happening if the target cluster is local / upstream
* Weird things be happening if the target cluster is local / upstream. Uses this to check what cluster we're in
*/
isLocalCluster: boolean,
/**
@ -128,19 +150,19 @@ class StevePaginationUtils extends NamespaceProjectFilters {
*/
productHidesSystemNamespaces: boolean,
}): {
projectsOrNamespaces: OptPaginationFilter[],
filters: OptPaginationFilter[]
projectsOrNamespaces: PaginationParamProjectOrNamespace[],
filters: PaginationParamFilter[]
} {
// Hold up, why are we doing yet another way to convert the user's project / namespace filter to a set of something?
// - When doing this for local pagination `getActiveNamespaces` provides a full list of applicable namespaces. Lists then filter resource locally
// - When doing this for local pagination `getActiveNamespaces` provides a full list of applicable namespaces.
// Lists then filter resource locally using those namespaces
// - Pagination cannot take this approach of 'gimme all resources in these namespaces' primarily for the 'Only User Namespaces' case
// - User could have 2k namespaces. This would result in 2k+ namespaces added to the url (namespace=1,namespace=2,namespace=3, etc)
// - Instead we do // TODO: RC
// - Instead we do
// - All but not given settings - Gimme resources NOT in system or obscure namespaces
// - Only System Namespaces - Gimme resources in the system namespaces (which shouldn't be many namespaces)
// - Only User Namespaces - Gimme resources NOT in system namespaces
// - User selection - Gimme resources in specific Projects or Namespaces
if (isAllNamespaces && (showDynamicRancherNamespaces && !productHidesSystemNamespaces)) {
// No-op. Everything is returned
return {
@ -151,10 +173,10 @@ class StevePaginationUtils extends NamespaceProjectFilters {
// used to return resources in / not in projects/namespaces (entries are checked in both types)
// &projectsornamespaces=project 1,namespace 2
let projectsOrNamespaces: OptPaginationFilter[] = [];
let projectsOrNamespaces: PaginationParamProjectOrNamespace[] = [];
// used to return resources in / not in namespaces
// &filter=metadata.namespace=abc
let filters: OptPaginationFilter[] = [];
let filters: PaginationParamFilter[] = [];
if (!showDynamicRancherNamespaces || productHidesSystemNamespaces) {
// We need to hide dynamic namespaces ('c-', 'p-', etc) OR system namespaces
@ -181,13 +203,13 @@ class StevePaginationUtils extends NamespaceProjectFilters {
};
}
checkAndCreateParam(opt: FindPageOpt): string | undefined {
public createParamsForPagination(schema: Schema, opt: ActionFindPageArgs): string | undefined {
if (!opt.pagination) {
return;
}
const params: string[] = [];
const namespaceParam = this.convertPaginationFilter(opt.pagination.projectsOrNamespaces);
const namespaceParam = this.convertPaginationParams(schema, opt.pagination.projectsOrNamespaces);
if (namespaceParam) {
params.push(namespaceParam);
@ -201,8 +223,6 @@ class StevePaginationUtils extends NamespaceProjectFilters {
if (opt.pagination.pageSize) {
params.push(`pagesize=${ opt.pagination.pageSize }`);
} else {
throw new Error(`A pagination request is required but no 'page' property provided: ${ JSON.stringify(opt) }`);
}
if (opt.pagination.sort?.length) {
@ -213,11 +233,11 @@ class StevePaginationUtils extends NamespaceProjectFilters {
params.push(`sort=${ joined }`);
}
if (opt.pagination.filter?.length) {
const andFilters = this.convertPaginationFilter(opt.pagination.filter);
if (opt.pagination.filters?.length) {
const filters = this.convertPaginationParams(schema, opt.pagination.filters);
if (andFilters) {
params.push(andFilters);
if (filters) {
params.push(filters);
}
}
@ -227,13 +247,64 @@ class StevePaginationUtils extends NamespaceProjectFilters {
return params.join('&');
}
private convertPaginationFilter(filters: OptPaginationFilter[] = []): string {
return filters
/**
* Check if the API supports filtering by this field
*/
private validateField(state: { checked: string[], invalid: string[]}, schema: Schema, field?: string) {
if (!field) {
return; // no field, so not invalid
}
if (state.checked.includes(field)) {
return; // already checked, exit early
}
state.checked.push(field);
// First check in our hardcoded list of supported filters
if ([
StevePaginationUtils.VALID_FIELDS[''], // Global
StevePaginationUtils.VALID_FIELDS[schema.id], // Type specific
].find((fields) => fields?.find((f) => {
if (f.startsWith) {
if (field.startsWith(f.field)) {
return true;
}
} else {
return field === f.field;
}
}))) {
return;
}
// Then check in schema (the api automatically supports these)
if (!!schema?.attributes.columns.find(
// This isn't the most performant, but the string is tiny
(at) => at.field.replace('$.', '').replace('[', '.').replace(']', '') === field
)) {
return;
}
state.invalid.push(field);
}
/**
* Convert our {@link PaginationParam} definition of params to a set of url params
*/
private convertPaginationParams(schema: Schema, filters: PaginationParam[] = []): string {
const validateFields = {
checked: new Array<string>(),
invalid: new Array<string>(),
};
const res = filters
.filter((filter) => !!filter.fields.length)
.map((filter) => {
const joined = filter.fields
.map((field) => {
if (field.field) {
// Check if the API supports filtering by this field
this.validateField(validateFields, schema, field.field);
return `${ field.field }${ field.equals ? '=' : '!=' }${ field.value }`;
}
@ -243,6 +314,12 @@ class StevePaginationUtils extends NamespaceProjectFilters {
return `${ filter.param }${ filter.equals ? '=' : '!=' }${ joined }`;
}).join('&'); // This means AND
if (validateFields.invalid.length) {
console.warn(`Pagination API does not support filtering '${ schema.id }' by the requested fields: ${ uniq(validateFields.invalid).join(', ') }`); // eslint-disable-line no-console
}
return res;
}
}

View File

@ -1,169 +1,16 @@
import { NAMESPACE_FILTER_NS_FULL_PREFIX, NAMESPACE_FILTER_P_FULL_PREFIX } from '@shell/utils/namespace-filter';
import { PaginationArgs } from '@shell/types/store/pagination.types';
/**
* Sort the pagination result
*
* For more information see https://github.com/rancher/steve?tab=readme-ov-file#sort
* Properties on all findX actions
*/
export interface OptPaginationSort {
/**
* Name of field within the object to sort by
*/
field: string,
asc: boolean
}
/**
* Filter the pagination result by these specific fields
*
* For example
*
* - metadata.name=test
* - metadata.namespace!=system
*
* For more information see https://github.com/rancher/steve?tab=readme-ov-file#query-parameters
*/
export class OptPaginationFilterField {
/**
* Name of field within the object to sort by
*
* This can be optional for some (projectsornamespaces)
*/
field?: string;
value: string;
equals: boolean;
constructor(
{ field, value, equals = true }:
{ field?: string; value: string; equals?: boolean; }
) {
this.field = field;
this.value = value;
this.equals = equals;
}
}
/**
* Filter the pagination result by a number of fields
*
* OptPaginationFilter can be used in two ways
*
* 1) OR'd together
* - a=1 OR b=2 OR c=3
- Query Param - filter=a=1,b=2,c=3
* - Object structure - [
* [a,1],[b,2],[c,3]
* ]
*
* 2) AND'd together
*- a=1 AND b=2 AND c=3
* - Query Param - filter=a=1&filter=b=2&filter=c=3
* - Object structure - [
* [a,1]
* ],
* [
* [b,2]
* ],
* [
* [c,3]
* ]
*
* This structure should give enough flexibility to cover all uses
*
* For more information see https://github.com/rancher/steve?tab=readme-ov-file#filter
*/
export class OptPaginationFilter {// implements OptPaginationFilter
/**
* Query Param. For example `filter` or `projectsornamespaces`
*/
param: string;
/**
* should fields equal param.
*
* For example projectsornamexspaces=x or projectsornamexspaces!=x
*/
equals: boolean;
/**
* Fields to filter by
*
* For example metadata.namespace=abc OR metadata.namespace=xyz
*/
fields: OptPaginationFilterField[];
constructor(
{ param = 'filter', equals = true, fields = [] }:
{ param?: string; equals?: boolean; fields?: OptPaginationFilterField[];
}) {
this.param = param;
this.equals = equals;
this.fields = fields;
}
}
/**
* This is a convenience class which works some magic, adds defaults and converts to the required OptPaginationFilter format
*
* For more information see https://github.com/rancher/steve?tab=readme-ov-file#projectsornamespaces
*/
export class OptPaginationProjectOrNamespace extends OptPaginationFilter {
constructor(
{ equals = true, fields = [] }:
{ equals?: boolean; fields?: OptPaginationFilterField[]; }
) {
const safeFields = fields.map((f) => {
return {
...f,
value: f.value
.replace(NAMESPACE_FILTER_NS_FULL_PREFIX, '')
.replace(NAMESPACE_FILTER_P_FULL_PREFIX, '')
};
});
super({
param: 'projectsornamespaces',
equals,
fields: safeFields
});
}
}
/**
* Pagination settings sent to actions and persisted to store
*/
export interface OptPagination {
page: number,
pageSize: number,
sort: OptPaginationSort[],
filter: OptPaginationFilter[],
projectsOrNamespaces: OptPaginationFilter[],
}
/**
* Object persisted to store
*/
export interface StorePagination {
/**
* This set of pagination settings that created the result
*/
request: OptPagination,
/**
* Information in the response outside of the actual resources returned
*/
result: {
count: number,
pages: number
}
}
export type CoreFindOpt = {
type: string,
export type ActionCoreFindArgs = {
force?: boolean,
}
/**
* Args used for findAll action
*/
export interface FindAllOpt extends CoreFindOpt {
export interface ActionFindAllArgs extends ActionCoreFindArgs {
watch?: boolean,
namespaced?: string[],
incremental?: boolean,
@ -174,7 +21,16 @@ export interface FindAllOpt extends CoreFindOpt {
/**
* Args used for findPage action
*/
export interface FindPageOpt extends CoreFindOpt {
pagination: OptPagination,
export interface ActionFindPageArgs extends ActionCoreFindArgs {
/**
* Set of pagination settings that creates the url.
*
* This is stored and can be used to compare in new request to determine if we already have this page
*/
pagination: PaginationArgs,
/**
* The single namespace to filter by (used in url path, not part of pagination params)
*/
namespaced?: string,
hasManualRefresh?: boolean,
}

View File

@ -0,0 +1,403 @@
import { NAMESPACE_FILTER_NS_FULL_PREFIX, NAMESPACE_FILTER_P_FULL_PREFIX } from '@shell/utils/namespace-filter';
// Pagination Typing
// These structures are designed to offer both convenience and flexibility based on a common structure and are
// converted to the url param format as per https://github.com/rancher/steve.
//
// Simple use cases such as filtering by a single param should be easy to use.
// More complex filtering (and'ing and 'or'ing multiple fields) is also supported.
//
// The top level object `PaginationArgs` contains all properties that will be converted to url params
//
// The two important / complex params are currently
// - `filter` https://github.com/rancher/steve?tab=readme-ov-file#filter
// - represented by `PaginationParamFilter extends PaginationParam`
// - Examples
// - filter=metadata.name=123
// - filter=metadata.name=123,metadata.name=456 (name is 123 OR 456)
// - filter=metadata.name=123&filter=metadata.namespace=abc (name 123 AND namespace abc)
// - `projectsornamespaces` https://github.com/rancher/steve?tab=readme-ov-file#projectsornamespaces
// - represented by `PaginationParamProjectOrNamespace extends PaginationParam`
// - Examples
// - projectsornamespaces=123
// - projectsornamespaces=123,456 (projects or namespaces that have id 123 OR 456)
//
//
// Some of the types below are defined using classes instead of TS types/interfaces
// - Avoid making complex json objects by using clearer instance constructors
// - Better documented
// - Defaults (a lot of the time convenience > utility)
// - Adds some kind of typing in pure js docs
// - class ctor links to definition, instead of object just being a random json blob)
// - helps VSCode jsdoc highlighting
/**
* Sort the pagination result
*
* For more information regarding the API see https://github.com/rancher/steve?tab=readme-ov-file#sort
*/
export interface PaginationSort {
/**
* Name of field within the object to sort by
*/
field: string,
asc: boolean
}
/**
* Filter the pagination result by these specific fields
*
* For example
*
* - metadata.name=test
* - metadata.namespace!=system
*
* For more information regarding the API see https://github.com/rancher/steve?tab=readme-ov-file#query-parameters
*/
export class PaginationFilterField {
/**
* Name of field within the object to filter by for example the x of x=y
*
* This can be optional for some (projectsornamespaces)
*/
field?: string;
/**
* Value of field within the object to filter by for example the y of x=y
*/
value: string;
/**
* Equality field within the object to filter by for example the `=` or `!=` of x=y
*/
equals: boolean;
constructor(
{ field, value, equals = true }:
{ field?: string; value: string; equals?: boolean; }
) {
this.field = field;
this.value = value;
this.equals = equals;
}
}
/**
* Represents filter like params, for example
*
* - `filter=abc!=xyz&def=123`
* - `projectsornamespace!=p-3456`
*
* ### Params
* #### Filter
* - For more information regarding the API see https://github.com/rancher/steve?tab=readme-ov-file#filter
*
* #### Projects Or Namespace
* - For more information regarding the API see https://github.com/rancher/steve?tab=readme-ov-file#projectsornamespaces
*
* ### Combining Params
* Params can be combined in two logical ways
*
* 1) AND
* - Used when you would like to filter by something like a=1 AND b=2 AND c=3
* - To do this multiple instances of `PaginationParam` are used in an array
* - Object Structure
* ```
* [
* PaginationParam,
* PaginationParam,
* PaginationParam
* ]
* ```
* - Results in url
* ```
* filter=a=1&filter=b=2&filter=c=3
* ```
* - Examples
* - `filter=metadata.namespace=abc&filter=metadata.name=123,property=123`
* 2) OR
* - Used when you would like to filter by something like a=1 OR b=2 OR c=3
* - To do this multiple fields within a single PaginationParam is used
* - Object Structure
* ```
* [
* PaginationParam {
* PaginationFilterField,
* PaginationFilterField,
* PaginationFilterField
* }
* ]
* ```
* - Results in url
* ```
* filter=a=1,b=2,c=3
* ```
*
* - For example `filter=a=1,b=2,c=3`
*
*
* This structure should give enough flexibility to cover all uses.
*
*
*/
export abstract class PaginationParam {
/**
* Query Param. For example `filter` or `projectsornamespaces`
*/
param: string;
/**
* should fields equal param.
*
* For example projectsornamexspaces=x or projectsornamexspaces!=x
*/
equals: boolean;
/**
* Fields to filter by
*
* For example metadata.namespace=abc OR metadata.namespace=xyz
*/
fields: PaginationFilterField[];
constructor(
{ param, equals = true, fields = [] }:
{
param: string;
/**
* should param equal fields
*
* For definition see {@link PaginationParam} `equals`
*/
equals?: boolean;
/**
* Collection of fields to filter by
*
* For definition see {@link PaginationParam} `fields`
*/
fields?: PaginationFilterField[];
}) {
this.param = param;
this.equals = equals;
this.fields = fields;
}
}
/**
* This is a convenience class for the `filter` param which works some magic, adds defaults and converts to the required PaginationParam format
*
* See description for {@link PaginationParam} for how multiple of these can be combined together to AND or OR together
*
* For more information regarding the API see https://github.com/rancher/steve?tab=readme-ov-file#filter
*/
export class PaginationParamFilter extends PaginationParam {
constructor(
{ equals = true, fields = [] }:
{
/**
* should param equal fields
*
* For definition see {@link PaginationParam} `equals`
*/
equals?: boolean;
/**
* Collection of fields to filter by.
*
* Fields are ORd together
*
* For definition see {@link PaginationParam} `fields`
*/
fields?: PaginationFilterField[];
}
) {
super({
param: 'filter',
equals,
fields
});
}
/**
* Convenience method when you just want an instance of {@link PaginationParamFilter} with a simple `filter=x=y` param
*/
static createSingleField(field: { field?: string; value: string; equals?: boolean; }): PaginationParam {
return new PaginationParamFilter({ fields: [new PaginationFilterField(field)] });
}
/**
* Convenience method when you just want an instance of {@link PaginationParamFilter} with a simple `filter=a=1,b=2,c=3` PaginationParam
*
* These will be OR'd together
*/
static createMultipleFields(fields: PaginationFilterField[]): PaginationParam {
return new PaginationParamFilter({ fields });
}
}
/**
* This is a convenience class for the `projectsornamespaces` param which works some magic, adds defaults and converts to the required PaginationParam format
*
* See description for {@link PaginationParam} for how multiple of these can be combined together to AND or OR together
*
* For more information regarding the API see https://github.com/rancher/steve?tab=readme-ov-file#projectsornamespaces
*/
export class PaginationParamProjectOrNamespace extends PaginationParam {
constructor(
{ equals = true, projectOrNamespace = [] }:
{
/**
* should param equal fields
* For definition see {@link PaginationParam} `equals`
*/
equals?: boolean;
/**
* Collection of projects / namespace id's to filter by
*
* These are OR'd together
*
* For clarification on definition see {@link PaginationFilterField}
*/
projectOrNamespace?: string[];
}
) {
const safeFields = projectOrNamespace.map((f) => {
return new PaginationFilterField({
value: f
.replace(NAMESPACE_FILTER_NS_FULL_PREFIX, '')
.replace(NAMESPACE_FILTER_P_FULL_PREFIX, '')
});
});
super({
param: 'projectsornamespaces',
equals,
fields: safeFields
});
}
}
/**
* Pagination settings sent to actions and persisted to store
*/
export class PaginationArgs {
/**
* Page number to fetch
*/
page: number;
/**
* Number of results in the page
*/
pageSize?: number;
/**
* Sort the results
*
* For more info see {@link PaginationSort}
*/
sort: PaginationSort[];
/**
* A collection of `filter` params
*
* For more info see {@link PaginationParamFilter}
*/
filters: PaginationParamFilter[];
/**
* A collection of `projectsornamespace` params
*
* For more info see {@link PaginationParamProjectOrNamespace}
*/
projectsOrNamespaces: PaginationParamProjectOrNamespace[];
/**
* Creates an instance of PaginationArgs.
*
* Contains defaults to avoid creating complex json objects all the time
*/
constructor({
page = 1,
pageSize = 10,
sort = [],
filters = [],
projectsOrNamespaces = [],
}:
// This would be neater as just Partial<PaginationArgs> but we lose all jsdoc
{
/**
* For definition see {@link PaginationArgs} `page`
*/
page?: number,
/**
* For definition see {@link PaginationArgs} `pageSize`
*/
pageSize?: number,
/**
* For definition see {@link PaginationArgs} `sort`
*/
sort?: PaginationSort[],
/**
* Automatically wrap if not an array
*
* For definition see {@link PaginationArgs} `filters`
*/
filters?: PaginationParamFilter | PaginationParamFilter[],
/**
* Automatically wrap if not an array
*
* For definition see {@link PaginationArgs} `projectsOrNamespaces`
*/
projectsOrNamespaces?: PaginationParamProjectOrNamespace | PaginationParamProjectOrNamespace[],
}) {
this.page = page;
this.pageSize = pageSize;
this.sort = sort;
if (filters) {
this.filters = Array.isArray(filters) ? filters : [filters];
} else {
this.filters = [];
}
if (projectsOrNamespaces) {
this.projectsOrNamespaces = Array.isArray(projectsOrNamespaces) ? projectsOrNamespaces : [projectsOrNamespaces];
} else {
this.projectsOrNamespaces = [];
}
}
}
/**
* Overall result of a pagination request.
*
* Does not contain actual resources but overall stats (count, pages, etc)
*/
export interface StorePaginationResult {
count: number,
pages: number,
/**
* The last time the resource was updated. Used to assist list watching for changes
*/
timestamp: number,
}
export interface StorePaginationRequest {
/**
* The single namespace to filter results by (as part of url path, not pagination params)
*/
namespace?: string,
/**
* The set of pagination args used to create the request
*/
pagination: PaginationArgs
}
/**
* Pagination settings
* - what was requested
* - what was received (minus actual resources)
*
* Object is persisted to store
*/
export interface StorePagination {
/**
* Collection of args that is used to make the request
*/
request: StorePaginationRequest,
/**
* Information in the response outside of the actual resources returned
*/
result: StorePaginationResult
}

View File

@ -13,3 +13,18 @@ export interface TableColumn {
tooltip?: string,
search?: string | boolean,
}
export const COLUMN_BREAKPOINTS = {
/**
* Only show column if at tablet width or wider
*/
TABLET: 'tablet',
/**
* Only show column if at laptop width or wider
*/
LAPTOP: 'laptop',
/**
* Only show column if at desktop width or wider
*/
DESKTOP: 'desktop'
};

View File

@ -180,7 +180,7 @@ export function sameContents<T>(aryA: T[], aryB: T[]): boolean {
return xor(aryA, aryB).length === 0;
}
export function sameArrayObjects<T>(aryA: T[], aryB: T[]): boolean {
export function sameArrayObjects<T>(aryA: T[], aryB: T[], positionAgnostic = false): boolean {
if (!aryA && !aryB) {
// catch calls from js (where props aren't type checked)
return false;
@ -190,9 +190,29 @@ export function sameArrayObjects<T>(aryA: T[], aryB: T[]): boolean {
return false;
}
for (let i = 0; i < aryA.length; i++) {
if (!isEqual(aryA[i], aryB[i])) {
return false;
if (positionAgnostic) {
const consumedB: { [pos: number]: boolean } = {};
aryB.forEach((_, index) => {
consumedB[index] = false;
});
for (let i = 0; i < aryA.length; i++) {
const a = aryA[i];
const validA = aryB.findIndex((arB, index) => isEqual(arB, a) && !consumedB[index] );
if (validA >= 0) {
consumedB[validA] = true;
} else {
return false;
}
}
} else {
for (let i = 0; i < aryA.length; i++) {
if (!isEqual(aryA[i], aryB[i])) {
return false;
}
}
}

View File

@ -9,7 +9,7 @@ import {
NAMESPACE_FILTER_NS_FULL_PREFIX,
NAMESPACE_FILTER_P_FULL_PREFIX,
} from '@shell/utils/namespace-filter';
import { OptPagination, OptPaginationFilter, OptPaginationSort } from '@shell/types/store/dashboard-store.types';
import { PaginationArgs, PaginationParam, PaginationSort } from '@shell/types/store/pagination.types';
import { sameArrayObjects } from '@shell/utils/array';
import { isEqual } from '@shell/utils/object';
@ -21,7 +21,7 @@ const settings: PaginationSettings = {
resources: {
enableAll: false,
enableSome: {
enabled: ['configmap', 'secret', 'pod'],
enabled: ['configmap', 'secret', 'pod', 'node'],
generic: true,
}
}
@ -110,15 +110,15 @@ class PaginationUtils {
return this.validNsProjectFilters.includes(nsProjectFilter);
}
paginationFilterEqual(a: OptPaginationFilter, b: OptPaginationFilter): boolean {
paginationFilterEqual(a: PaginationParam, b: PaginationParam): boolean {
if (a.param !== b.param || a.equals !== b.equals) {
return false;
}
return isEqual(a.fields, b.fields);
return sameArrayObjects(a.fields, b.fields, true);
}
paginationFiltersEqual(a: OptPaginationFilter[], b: OptPaginationFilter[]): boolean {
paginationFiltersEqual(a: PaginationParam[], b: PaginationParam[]): boolean {
if (!!a && a?.length !== b?.length) {
return false;
}
@ -132,18 +132,18 @@ class PaginationUtils {
return true;
}
paginationEqual(a?: OptPagination, b?: OptPagination): boolean {
paginationEqual(a?: PaginationArgs, b?: PaginationArgs): boolean {
const {
filter: aFilter = [], sort: aSort = [], projectsOrNamespaces: aPN = [], ...aPrimitiveTypes
filters: aFilter = [], sort: aSort = [], projectsOrNamespaces: aPN = [], ...aPrimitiveTypes
} = a || {};
const {
filter: bFilter = [], sort: bSort = [], projectsOrNamespaces: bPN = [], ...bPrimitiveTypes
filters: bFilter = [], sort: bSort = [], projectsOrNamespaces: bPN = [], ...bPrimitiveTypes
} = b || {};
return isEqual(aPrimitiveTypes, bPrimitiveTypes) &&
this.paginationFiltersEqual(aFilter, bFilter) &&
this.paginationFiltersEqual(aPN, bPN) &&
sameArrayObjects<OptPaginationSort>(aSort, bSort);
sameArrayObjects<PaginationSort>(aSort, bSort, true);
}
}