Integrate new label select filtering (#12919)

* Test / Wire in new label filtering to limited places

- Wired in to
  - Workload detail page - pods and services (broken due to reverse selector)
  - Service Detail - Pods List
  - Service create/edit - Pod selector tab
- Also
  - Fix showPodRestarts in random location
- TODOs
  - Lots of testing / validation required
  - reverse deployment --> service selector (not services given deployment, but for each service contains pods from deployment)
  - workload model usages of pods getter
  - services model usages of pods getter
  - many many more....

* wired in, NOT tested

* improvement

* sdfdsf

* ryrty

* werer

* The great test off begins

* testing and tidying

* testing and tidying

* First good run

* tests, fixes and improvements

* updates

* Tidying up

* Fixes (namespace orientated), tweaks, updates

* Fix unit test

* Fixes for vai off

* Re-write `matching`
- handle namespaces better (given if the resource is namespaced
- better align with legacy matching fn (given specific scenarios that should return none or all)

* Lots of fixes, but mainly vai off --> pods list --> deployment detail --> pods list updates correctly

* fix lint, unit test

* e2e fix

* Fix weird plugins build validation failure (TS error on expected param for JS method with a default)

* Fix workload services

* changes following mini code review

* Only show pods tab if workload type supports it, always show pod resource table if tab is shown
This commit is contained in:
Richard Cox 2025-05-28 11:17:25 +01:00 committed by GitHub
parent 85a7667abf
commit 980d4b06d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 897 additions and 329 deletions

View File

@ -545,7 +545,6 @@ export default {
{{ value.createdBy.displayName }}
</span>
</span>
<span v-if="value.showPodRestarts">{{ t("resourceDetail.masthead.restartCount") }}:<span class="live-data"> {{ value.restartCount }}</span></span>
</div>
</div>
<slot name="right">

View File

@ -25,7 +25,11 @@ export default {
default: 'edit'
},
// pod/node affinity types have different operator options
/**
* pod/node affinity types have different operator options
*
* Note - This prop should just be isNode
*/
type: {
type: String,
default: NODE

View File

@ -2,10 +2,11 @@
import { Banner } from '@components/Banner';
import MatchExpressions from '@shell/components/form/MatchExpressions';
import ResourceTable from '@shell/components/ResourceTable';
import { allHash } from '@shell/utils/promise';
import { _EDIT } from '@shell/config/query-params';
import { convert, matching, simplify } from '@shell/utils/selector';
import { convert, simplify } from '@shell/utils/selector';
import throttle from 'lodash/throttle';
import { COUNT } from '@shell/config/types';
import { matching } from '@shell/utils/selector-typed';
export default {
name: 'ResourceSelector',
@ -36,11 +37,6 @@ export default {
},
async fetch() {
// Used in conjunction with `matches/match/label selectors`. Requires https://github.com/rancher/dashboard/issues/10417 to fix
const hash = await allHash({ allResources: this.$store.dispatch('cluster/findAll', { type: this.type }) });
this.allResources = hash.allResources;
this.updateMatchingResources();
},
@ -53,11 +49,10 @@ export default {
sample: null,
total: 0,
},
allResources: [],
allResourcesInScope: [],
tableHeaders: this.$store.getters['type-map/headersFor'](
tableHeaders: this.$store.getters['type-map/headersFor'](
this.$store.getters['cluster/schemaFor'](this.type)
),
inStore: this.$store.getters['currentProduct'].inStore,
};
},
@ -71,6 +66,9 @@ export default {
schema() {
return this.$store.getters['cluster/schemaFor'](this.type);
},
/**
* of type matchExpression aka `KubeLabelSelectorExpression[]`
*/
selectorExpressions: {
get() {
return convert(
@ -85,22 +83,27 @@ export default {
this.value['matchExpressions'] = matchExpressions;
}
},
allResourcesInScope() {
const counts = this.$store.getters[`${ this.inStore }/all`](COUNT)?.[0]?.counts || {};
if (this.namespace) {
return counts?.[this.type].namespaces[this.namespace]?.count || 0;
}
return counts[this.type]?.summary?.count || 0;
}
},
methods: {
updateMatchingResources: throttle(function() {
this.allResourcesInScope = this.namespace ? this.allResources.filter((res) => res.metadata.namespace === this.namespace) : this.allResources;
const match = matching(this.allResourcesInScope, this.selectorExpressions);
const matched = match.length || 0;
const sample = match[0]?.nameDisplay;
this.matchingResources = {
matched,
matches: match,
none: matched === 0,
sample,
total: this.allResourcesInScope.length,
};
updateMatchingResources: throttle(async function() {
this.matchingResources = await matching({
labelSelector: { matchExpressions: this.selectorExpressions },
type: this.type,
inStore: this.inStore,
$store: this.$store,
inScopeCount: this.allResourcesInScope,
namespace: this.namespace,
});
}, 250, { leading: true }),
}

View File

@ -83,7 +83,6 @@ export default {
return {
servicePortInfoHeaders,
pods: [],
podTableHeaders: this.$store.getters['type-map/headersFor'](
this.$store.getters['cluster/schemaFor'](POD)
),

View File

@ -47,16 +47,20 @@ export default {
} catch {}
const hash = {
// Used in conjunction with `matches/match/label selectors`. Requires https://github.com/rancher/dashboard/issues/10417 to fix
allPods: this.$store.dispatch('cluster/findAll', { type: POD }),
// Used in conjunction with `matches/match/label selectors`. Requires https://github.com/rancher/dashboard/issues/10417 to fix
allServices: this.$store.dispatch('cluster/findAll', { type: SERVICE }),
allIngresses: this.$store.dispatch('cluster/findAll', { type: INGRESS }),
// Nodes should be fetched because they may be referenced in the target
// column of a service list item.
allNodes: hasNodes ? this.$store.dispatch('cluster/findAll', { type: NODE }) : []
};
if (this.podSchema) {
hash.pods = this.value.fetchPods();
}
if (this.serviceSchema) {
hash.servicesInNamespace = this.$store.dispatch('cluster/findAll', { type: SERVICE, opt: { namespaced: this.value.metadata.namespace } });
}
if (this.value.type === WORKLOAD_TYPES.CRON_JOB) {
hash.jobs = this.value.matchingJobs();
}
@ -87,8 +91,7 @@ export default {
data() {
return {
allPods: [],
allServices: [],
servicesInNamespace: [],
allIngresses: [],
matchingServices: [],
matchingIngresses: [],
@ -180,22 +183,6 @@ export default {
}, 0);
},
podRestarts() {
return this.value.pods.reduce((total, pod) => {
const { status:{ containerStatuses = [] } } = pod;
if (containerStatuses.length) {
total += containerStatuses.reduce((tot, container) => {
tot += container.restartCount;
return tot;
}, 0);
}
return total;
}, 0);
},
podHeaders() {
return this.$store.getters['type-map/headersFor'](this.podSchema).filter((h) => !h.name || h.name !== NAMESPACE_COL.name);
},
@ -213,12 +200,16 @@ export default {
},
showPodGaugeCircles() {
const podGauges = Object.values(this.value.podGauges);
const podGauges = Object.values(this.podGauges);
const total = this.value.pods.length;
return !podGauges.find((pg) => pg.count === total);
},
podGauges() {
return this.value.calcPodGauges(this.value.pods);
},
showJobGaugeCircles() {
const jobGauges = Object.values(this.value.jobGauges);
const total = this.isCronJob ? this.totalRuns : this.value.pods.length;
@ -230,6 +221,7 @@ export default {
return !!SCALABLE_TYPES.includes(this.value.type) && this.value.canUpdate;
},
},
methods: {
async scale(isUp) {
try {
@ -256,15 +248,13 @@ export default {
if (!this.serviceSchema) {
return [];
}
const matchingPods = this.value.pods;
// Find Services that have selectors that match this
// workload's Pod(s).
const matchingServices = this.allServices.filter((service) => {
// Find Services that have selectors that match this workload's Pod(s).
this.matchingServices = this.servicesInNamespace.filter((service) => {
const selector = service.spec.selector;
for (let i = 0; i < matchingPods.length; i++) {
const pod = matchingPods[i];
for (let i = 0; i < this.value.pods.length; i++) {
const pod = this.value.pods[i];
if (service.metadata?.namespace === this.value.metadata?.namespace && matches(pod, selector)) {
return true;
@ -273,8 +263,6 @@ export default {
return false;
});
this.matchingServices = matchingServices;
},
findMatchingIngresses() {
if (!this.ingressSchema) {
@ -358,7 +346,7 @@ export default {
</template>
<template v-else>
<CountGauge
v-for="(group, key) in value.podGauges"
v-for="(group, key) in podGauges"
:key="key"
:total="value.pods.length"
:useful="group.count || 0"
@ -387,13 +375,12 @@ export default {
/>
</Tab>
<Tab
v-else
v-else-if="value.podMatchExpression"
name="pods"
:label="t('tableHeaders.pods')"
:weight="4"
>
<ResourceTable
v-if="value.pods"
:rows="value.pods"
:headers="podHeaders"
key-field="id"

View File

@ -119,18 +119,24 @@ export default {
},
async removeCatalogResources(catalog) {
const selector = `${ UI_PLUGIN_LABELS.CATALOG_IMAGE }=${ catalog.name }`;
const namespace = UI_PLUGIN_NAMESPACE;
if ( catalog.name ) {
const namespace = UI_PLUGIN_NAMESPACE;
// of type KubeLabelSelector
const labelSelector = { matchLabels: { [UI_PLUGIN_LABELS.CATALOG_IMAGE]: catalog.name } };
if ( selector ) {
const hash = await allHash({
deployment: this.$store.dispatch('management/findMatching', {
type: WORKLOAD_TYPES.DEPLOYMENT, selector, namespace
deployment: this.$store.dispatch('management/findLabelSelector', {
type: WORKLOAD_TYPES.DEPLOYMENT,
matching: { namespace, labelSelector }
}),
service: this.$store.dispatch('management/findMatching', {
type: SERVICE, selector, namespace
service: this.$store.dispatch('management/findLabelSelector', {
type: SERVICE,
matching: { namespace, labelSelector }
}),
repo: this.$store.dispatch('management/findMatching', { type: CATALOG.CLUSTER_REPO, selector })
repo: this.$store.dispatch('management/findLabelSelector', {
type: CATALOG.CLUSTER_REPO,
matching: { labelSelector }
})
});
for ( const resource of Object.keys(hash) ) {

View File

@ -61,7 +61,8 @@ describe('service edit', () => {
$store: {
getters: {
'management/all': jest.fn(),
'i18n/t': jest.fn()
'i18n/t': jest.fn(),
currentStore: () => 'cluster',
}
}
},

View File

@ -5,6 +5,9 @@ import ArrayListGrouped from '@shell/components/form/ArrayListGrouped';
import { _EDIT } from '@shell/config/query-params';
import PolicyRuleTarget from '@shell/edit/networking.k8s.io.networkpolicy/PolicyRuleTarget';
// Components shown for Network Policy --> Ingress/Egress Rules --> Rule Type are...
// Edit Network Policy --> `PolicyRules` 1 --> M `PolicyRule` 1 --> M `PolicyRuleTarget`
export default {
components: {
ArrayListGrouped, PolicyRulePort, PolicyRuleTarget
@ -28,18 +31,6 @@ export default {
type: String,
default: ''
},
allPods: {
type: Array,
default: () => {
return [];
},
},
allNamespaces: {
type: Array,
default: () => {
return [];
},
},
},
data() {
return { targetKey: this.type === 'ingress' ? 'from' : 'to' };
@ -71,8 +62,6 @@ export default {
:mode="mode"
:type="type"
:namespace="namespace"
:all-namespaces="allNamespaces"
:all-pods="allPods"
:data-testid="`policy-rule-target-${props.i}`"
/>
</template>

View File

@ -4,12 +4,13 @@ import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { _EDIT } from '@shell/config/query-params';
import MatchExpressions from '@shell/components/form/MatchExpressions';
import { convert, matching, simplify } from '@shell/utils/selector';
import { POD } from '@shell/config/types';
import { convert, simplify } from '@shell/utils/selector';
import { NAMESPACE, POD } from '@shell/config/types';
import ArrayList from '@shell/components/form/ArrayList';
import { Banner } from '@components/Banner';
import throttle from 'lodash/throttle';
import debounce from 'lodash/debounce';
import { isValidCIDR } from '@shell/utils/validators/cidr';
import { matching } from '@shell/utils/selector-typed';
const TARGET_OPTIONS = {
IP_BLOCK: 'ipBlock',
@ -18,6 +19,9 @@ const TARGET_OPTIONS = {
NAMESPACE_AND_POD_SELECTOR: 'namespaceAndPodSelector',
};
// Components shown for Network Policy --> Ingress/Egress Rules --> Rule Type are...
// Edit Network Policy --> `PolicyRules` 1 --> M `PolicyRule` 1 --> M `PolicyRuleTarget`
export default {
components: {
ArrayList, Banner, LabeledInput, LabeledSelect, MatchExpressions
@ -41,18 +45,6 @@ export default {
type: String,
default: ''
},
allPods: {
type: Array,
default: () => {
return [];
},
},
allNamespaces: {
type: Array,
default: () => {
return [];
},
},
},
data() {
if (!this.value[TARGET_OPTIONS.IP_BLOCK] &&
@ -66,18 +58,26 @@ export default {
}
return {
portOptions: ['TCP', 'UDP'],
matchingPods: {},
matchingNamespaces: {},
invalidCidr: null,
invalidCidrs: [],
portOptions: ['TCP', 'UDP'],
matchingPods: {
matches: [], matched: 0, total: 0
},
matchingNamespaces: {
matches: [], matched: 0, total: 0
},
invalidCidr: null,
invalidCidrs: [],
POD,
TARGET_OPTIONS,
targetOptions: Object.values(TARGET_OPTIONS),
throttleTime: 250,
targetOptions: Object.values(TARGET_OPTIONS),
inStore: this.$store.getters['currentProduct'].inStore,
debouncedUpdateMatches: debounce(this.updateMatches, 500)
};
},
computed: {
/**
* of type matchExpression aka `KubeLabelSelectorExpression[]`
*/
podSelectorExpressions: {
get() {
return convert(
@ -89,6 +89,9 @@ export default {
this.value[TARGET_OPTIONS.POD_SELECTOR] = simplify(podSelectorExpressions);
}
},
/**
* of type matchExpression aka `KubeLabelSelectorExpression[]`
*/
namespaceSelectorExpressions: {
get() {
return convert(
@ -145,39 +148,40 @@ export default {
},
watch: {
namespace: {
handler: 'updateMatches',
immediate: true
},
allNamespaces: {
handler: 'updateMatches',
handler: 'debouncedUpdateMatches',
immediate: true
},
'value.podSelector': {
handler: 'updateMatches',
handler: 'debouncedUpdateMatches',
immediate: true
},
'value.namespaceSelector': {
handler: 'updateMatches',
handler: 'debouncedUpdateMatches',
immediate: true
},
'value.ipBlock.cidr': 'validateCIDR',
'value.ipBlock.except': 'validateCIDR',
podSelectorExpressions: {
handler: 'updateMatches',
handler: 'debouncedUpdateMatches',
immediate: true
},
namespaceSelectorExpressions: {
handler: 'updateMatches',
handler: 'debouncedUpdateMatches',
immediate: true
}
},
fetch() {
this.debouncedUpdateMatches();
},
methods: {
updateMatches() {
throttle(() => {
this.matchingNamespaces = this.getMatchingNamespaces();
this.matchingPods = this.getMatchingPods();
}, this.throttle, { leading: true })();
async updateMatches() {
// Note - needs to be sequential as getMatchingPods requires matchingNamespaces to be up-to-date
this.matchingNamespaces = await this.getMatchingNamespaces();
this.matchingPods = await this.getMatchingPods();
},
validateCIDR() {
const exceptCidrs = this.value[TARGET_OPTIONS.IP_BLOCK]?.except || [];
@ -191,34 +195,25 @@ export default {
this.invalidCidr = null;
}
},
getMatchingPods() {
const namespaces = this.targetType === TARGET_OPTIONS.NAMESPACE_AND_POD_SELECTOR ? this.matchingNamespaces.matches : [{ id: this.namespace }];
const allInNamespace = this.allPods.filter((pod) => namespaces.some((ns) => ns.id === pod.metadata.namespace));
const match = matching(allInNamespace, this.podSelectorExpressions);
const matched = match.length || 0;
const sample = match[0]?.nameDisplay;
return {
matched,
matches: match,
none: matched === 0,
sample,
total: allInNamespace.length,
};
async getMatchingPods() {
return await matching({
labelSelector: { matchExpressions: this.podSelectorExpressions },
type: POD,
$store: this.$store,
inStore: this.inStore,
namespace: this.targetType === TARGET_OPTIONS.NAMESPACE_AND_POD_SELECTOR ? this.matchingNamespaces.matches.map((ns) => ns.id) : this.namespace,
transient: true,
});
},
getMatchingNamespaces() {
const allNamespaces = this.allNamespaces;
const match = matching(allNamespaces, this.namespaceSelectorExpressions);
const matched = match.length || 0;
const sample = match[0]?.nameDisplay;
return {
matched,
matches: match,
none: matched === 0,
sample,
total: allNamespaces.length,
};
async getMatchingNamespaces() {
return await matching({
labelSelector: { matchExpressions: this.namespaceSelectorExpressions },
type: NAMESPACE,
$store: this.$store,
inStore: this.inStore,
transient: true,
});
},
}
};

View File

@ -6,6 +6,9 @@ import Tab from '@shell/components/Tabbed/Tab';
import Tabbed from '@shell/components/Tabbed';
import { removeAt } from '@shell/utils/array';
// Components shown for Network Policy --> Ingress/Egress Rules --> Rule Type are...
// Edit Network Policy --> `PolicyRules` 1 --> M `PolicyRule` 1 --> M `PolicyRuleTarget`
export default {
components: {
PolicyRule, Tabbed, Tab
@ -24,18 +27,6 @@ export default {
type: String,
default: 'ingress'
},
allPods: {
type: Array,
default: () => {
return [];
},
},
allNamespaces: {
type: Array,
default: () => {
return [];
},
},
},
data() {
if (!this.value.spec[this.type]) {
@ -82,8 +73,6 @@ export default {
:mode="mode"
:type="type"
:namespace="value.metadata.namespace"
:all-namespaces="allNamespaces"
:all-pods="allPods"
/>
</Tab>
</Tabbed>

View File

@ -2,6 +2,8 @@ import { mount } from '@vue/test-utils';
import PolicyRuleTarget from '@shell/edit/networking.k8s.io.networkpolicy/PolicyRuleTarget';
import mock from '@shell/edit/networking.k8s.io.networkpolicy/__tests__/utils/mock.json';
import { PolicyRuleTargetSelectors } from '@shell/edit/networking.k8s.io.networkpolicy/__tests__/utils/selectors.test.ts';
import { nextTick } from 'vue';
import { COUNT, NAMESPACE, POD } from '@shell/config/types';
type MatchData = {
matched: number;
@ -11,29 +13,6 @@ type MatchData = {
sample?: string;
}
const newNamespace = {
id: 'new-namespace',
type: 'namespace',
kind: 'Namespace',
spec: { finalizers: ['kubernetes'] },
status: { phase: 'Active' },
metadata: {
annotations: { user: 'john' },
name: 'default',
creationTimestamp: '2024-01-31T10:24:03Z',
fields: ['default', 'Active', '1d'],
labels: { user: 'john' },
relationships: null,
resourceVersion: '1',
state: {
error: false,
message: '',
name: 'active',
transitioning: false
}
}
};
describe.each([
'view',
'edit',
@ -45,18 +24,60 @@ describe.each([
return { throttleTime: 0 };
},
props: {
namespace: mock.defaultNamespace,
allNamespaces: mock.allNamespaces,
allPods: mock.allPods,
type: 'ingress',
namespace: mock.defaultNamespace,
type: 'ingress',
mode
},
global: {
mocks: {
$store: {
dispatch: (action: string, { type }: { type: string}) => {
switch (action) {
case 'cluster/findAll':
switch (type) {
case NAMESPACE:
return mock.allNamespaces;
case POD:
return mock.allPods;
default:
throw new Error(`unknown type ${ type }`);
}
default:
throw new Error(`unknown action ${ action }`);
}
},
getters: {
'i18n/exists': mockExists,
'i18n/t': (key: string, matchData: MatchData) => matchData ? `${ key }-${ matchData.total }` : key,
'i18n/exists': mockExists,
'i18n/t': (key: string, matchData: MatchData) => matchData ? `${ key }-${ matchData.total }` : key,
currentProduct: { inStore: 'cluster' },
'cluster/all': (type: string) => {
switch (type) {
case COUNT:
return mock.counts;
default:
throw new Error(`unknown type ${ type }`);
}
},
'cluster/findAll': ({ type }: {type: string}) => {
switch (type) {
case NAMESPACE:
return mock.allNamespaces;
case POD:
return mock.allPods;
default:
throw new Error(`unknown type ${ type }`);
}
},
'cluster/schemaFor': (type: string) => {
switch (type) {
case NAMESPACE:
return { attributes: { namespaced: false } };
case POD:
return { attributes: { namespaced: true } };
default:
throw new Error(`unknown type ${ type }`);
}
}
}
}
},
@ -64,6 +85,20 @@ describe.each([
});
describe(`${ mode } mode`, () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
const waitForUpdatedMatched = async() => {
jest.advanceTimersByTime(1000); // Wait for debounced call to fetch updated cluster list
await nextTick(); // Wait for changes to cluster list to trigger changes
};
it('should display ip-block selector rule', async() => {
const ipBlock = mock.selectors.ipBlock;
@ -83,6 +118,7 @@ describe.each([
});
it('should display namespace selector rule', async() => {
// This test needs improving - mock data needs to contain more than applicable to selector
const namespaceSelector = mock.selectors.namespace;
await wrapper.setProps({ value: { namespaceSelector } });
@ -92,6 +128,12 @@ describe.each([
// Check rule type selector
expect(wrapper.vm.targetType).toBe('namespaceSelector');
// Check the matching namespaces displayed by the banner
expect(wrapper.vm.$data.matchingNamespaces.matched).toBe(0);
await wrapper.vm.updateMatches();
await waitForUpdatedMatched();
// Check the matching namespaces displayed by the banner
expect(wrapper.vm.$data.matchingNamespaces.matched).toBe(1);
@ -105,21 +147,10 @@ describe.each([
expect(selectors.namespaceAndPod.podRule.exists()).toBe(false);
expect(selectors.namespace.element).toBeDefined();
// Updating allNamespace should update the matching namespaces message too
await wrapper.setProps({
allNamespaces: [
...wrapper.vm.$props.allNamespaces,
newNamespace
]
});
const expectedTotal = 3;
expect(wrapper.vm.$data.matchingNamespaces.total).toBe(expectedTotal);
});
it('should display pod selector rule', async() => {
// This test needs improving - mock data needs to contain more than applicable to selector
const podSelector = mock.selectors.pod;
await wrapper.setProps({ value: { podSelector } });

View File

@ -154,5 +154,21 @@
}
}
}
]
],
"counts": [{
"counts": {
"namespace": {
"summary": {
"count": 1
}
},
"pod": {
"namespaces": {
"default": {
"count": 1
}
}
}
}
}]
}

View File

@ -7,14 +7,15 @@ import Tabbed from '@shell/components/Tabbed';
import CruResource from '@shell/components/CruResource';
import { Banner } from '@components/Banner';
import Labels from '@shell/components/form/Labels';
import { NAMESPACE, POD } from '@shell/config/types';
import { convert, matching, simplify } from '@shell/utils/selector';
import { POD } from '@shell/config/types';
import { convert, simplify } from '@shell/utils/selector';
import { matching } from '@shell/utils/selector-typed';
import { Checkbox } from '@components/Form/Checkbox';
import { addObject, removeObject } from '@shell/utils/array';
import MatchExpressions from '@shell/components/form/MatchExpressions';
import PolicyRules from '@shell/edit/networking.k8s.io.networkpolicy/PolicyRules';
import ResourceTable from '@shell/components/ResourceTable';
import { allHash } from '@shell/utils/promise';
const POLICY_TYPES = {
INGRESS: 'Ingress',
@ -42,14 +43,6 @@ export default {
mixins: [CreateEditView],
async fetch() {
const hash = await allHash({
allPods: this.$store.dispatch('cluster/findAll', { type: POD }), // Used in conjunction with `matches/match/label selectors`. Requires https://github.com/rancher/dashboard/issues/10417 to fix
allNamespaces: this.$store.dispatch('cluster/findAll', { type: NAMESPACE }), // Used in conjunction with `matches/match/label selectors`. Requires https://github.com/rancher/dashboard/issues/10417 to fix
});
this.allPods = hash.allPods; // Used in matchingPods, and PolicyRules --> PolicyRule --> PolicyRuleTarget
this.allNamespaces = hash.allNamespaces; // Used in PolicyRules --> PolicyRule --> PolicyRuleTarget
this.updateMatchingPods();
},
@ -75,11 +68,10 @@ export default {
return {
POD,
matchingPods,
allPods: [],
allNamespaces: [],
podTableHeaders: this.$store.getters['type-map/headersFor'](
this.$store.getters['cluster/schemaFor'](POD)
),
inStore: this.$store.getters['currentProduct'].inStore,
};
},
@ -127,6 +119,9 @@ export default {
this.value.spec['policyTypes'] = policyTypes;
}
},
/**
* of type matchExpression aka `KubeLabelSelectorExpression[]`
*/
podSelectorExpressions: {
get() {
return convert(
@ -146,19 +141,14 @@ export default {
},
methods: {
updateMatchingPods: throttle(function() {
const allInNamespace = this.allPods.filter((pod) => pod.metadata.namespace === this.value.metadata.namespace);
const match = matching(allInNamespace, this.podSelectorExpressions);
const matched = match.length || 0;
const sample = match[0]?.nameDisplay;
this.matchingPods = {
matched,
matches: match,
none: matched === 0,
sample,
total: allInNamespace.length,
};
updateMatchingPods: throttle(async function() {
this.matchingPods = await matching({
labelSelector: { matchExpressions: this.podSelectorExpressions },
type: POD,
$store: this.$store,
inStore: this.inStore,
namespace: this.value.metadata.namespace,
});
}, 250, { leading: true }),
},
};
@ -206,8 +196,7 @@ export default {
:value="value"
type="ingress"
:mode="mode"
:all-namespaces="allNamespaces"
:all-pods="allPods"
@update:value="$emit('input', $event)"
/>
</Tab>
@ -231,8 +220,7 @@ export default {
:value="value"
type="egress"
:mode="mode"
:all-namespaces="allNamespaces"
:all-pods="allPods"
@update:value="$emit('input', $event)"
/>
</Tab>

View File

@ -21,7 +21,7 @@ import Labels from '@shell/components/form/Labels';
import HarvesterServiceAddOnConfig from '@shell/components/HarvesterServiceAddOnConfig';
import { clone } from '@shell/utils/object';
import { POD, CAPI, HCI } from '@shell/config/types';
import { matching } from '@shell/utils/selector';
import { matching } from '@shell/utils/selector-typed';
import { HARVESTER_NAME as HARVESTER } from '@shell/config/features';
import { allHash } from '@shell/utils/promise';
import { isHarvesterSatisfiesVersion } from '@shell/utils/cluster';
@ -98,7 +98,6 @@ export default {
return {
matchingPods,
allPods: [],
defaultServiceTypes: DEFAULT_SERVICE_TYPES,
saving: false,
sessionAffinityActionLabels: Object.values(SESSION_AFFINITY_ACTION_LABELS)
@ -109,7 +108,8 @@ export default {
),
fvFormRuleSets: [],
fvReportedValidationPaths: ['spec'],
closedErrorMessages: []
closedErrorMessages: [],
inStore: this.$store.getters['currentStore'](POD),
};
},
@ -257,42 +257,27 @@ export default {
},
methods: {
updateMatchingPods: throttle(function() {
updateMatchingPods: throttle(async function() {
// https://kubernetes.io/docs/reference/kubernetes-api/service-resources/service-v1/#ServiceSpec
const { value: { spec: { selector = { } } } } = this;
const allInNamespace = this.allPods.filter((pod) => pod.metadata.namespace === this.value?.metadata?.namespace);
if (isEmpty(selector)) {
this.matchingPods = {
matched: 0,
total: allInNamespace.length,
none: true,
sample: null,
};
} else {
const match = matching(allInNamespace, selector);
this.matchingPods = {
matched: match.length,
total: allInNamespace.length,
none: match.length === 0,
sample: match[0] ? match[0].nameDisplay : null,
};
}
this.matchingPods = await matching({
labelSelector: { matchLabels: selector },
type: POD,
$store: this.$store,
inStore: this.inStore,
namespace: this.value?.metadata?.namespace,
});
}, 250, { leading: true }),
async loadPods() {
try {
const inStore = this.$store.getters['currentStore'](POD);
const hash = {
provClusters: this.$store.dispatch('management/findAll', { type: CAPI.RANCHER_CLUSTER }),
pods: this.$store.dispatch(`${ inStore }/findAll`, { type: POD }), // Used in conjunction with `matches/match/label selectors`. Requires https://github.com/rancher/dashboard/issues/10417 to fix
harvesterConfigs: this.$store.dispatch(`management/findAll`, { type: HCI.HARVESTER_CONFIG }),
};
const res = await allHash(hash);
this.allPods = res.pods;
await allHash(hash);
this.updateMatchingPods();
} catch (e) { }
},

View File

@ -132,13 +132,18 @@ export default {
},
methods: {
/**
* Fetch resources required to populate POD_RESTARTS and WORKLOAD_HEALTH_SCALE columns
*/
loadHeathResources() {
// See https://github.com/rancher/dashboard/issues/10417, health comes from selectors applied locally to all pods (bad)
if (this.paginationEnabled) {
// Unfortunately with SSP enabled we cannot fetch all pods to then let each row find applicable pods by locally applied selectors (bad for scaling)
// See https://github.com/rancher/dashboard/issues/14211
return;
}
// Fetch these in the background to populate workload health
// Fetch these in the background
if ( this.allTypes ) {
this.$fetchType(POD);
this.$fetchType(WORKLOAD_TYPES.JOB);

View File

@ -408,6 +408,7 @@ export default class ClusterNode extends SteveModel {
}
get pods() {
// This fetches all pods that are in the store, rather than all pods in the cluster
const allPods = this.$rootGetters['cluster/all'](POD);
return allPods.filter((pod) => pod.spec.nodeName === this.name);

View File

@ -1,6 +1,7 @@
import find from 'lodash/find';
import { POD } from '@shell/config/types';
import SteveModel from '@shell/plugins/steve/steve-class';
import { parse } from '@shell/utils/selector';
// i18n-uses servicesPage.serviceTypes.clusterIp.*, servicesPage.serviceTypes.externalName.*, servicesPage.serviceTypes.headless.*
// i18n-uses servicesPage.serviceTypes.loadBalancer.*, servicesPage.serviceTypes.nodePort.*
@ -49,7 +50,7 @@ export const CLUSTERIP = (() => {
return clusterIp.id;
})();
export default class extends SteveModel {
export default class Service extends SteveModel {
get customValidationRules() {
return [
{
@ -137,18 +138,27 @@ export default class extends SteveModel {
}
async fetchPods() {
if (this.podRelationship) {
// Used in conjunction with `matches/match/label selectors`. Requires https://github.com/rancher/dashboard/issues/10417 to fix
await this.$dispatch('cluster/findMatching', {
type: POD,
selector: this.podRelationship.selector,
namespace: this.namespace
}, { root: true });
}
return await this.$dispatch('findLabelSelector', {
type: POD,
matching: {
namespace: this.metadata.namespace,
labelSelector: { matchExpressions: parse(this.podRelationship?.selector) },
}
});
}
/**
* This getter expects a superset of workload pods to have been fetched already
*
* It assumes fetchPods has been called and should be used instead of the response of fetchPods
* (findAll --> findLabelSelector world results won't trigger change detection)
*/
get pods() {
return this.podRelationship ? this.$getters.matching( POD, this.podRelationship.selector, this.namespace ) : [];
if (this.podRelationship?.selector) {
return this.$getters['matchingLabelSelector'](POD, { matchExpressions: parse(this.podRelationship?.selector) }, this.metadata.namespace);
} else {
return [];
}
}
get serviceType() {

View File

@ -3,9 +3,10 @@ import { TIMESTAMP, CATTLE_PUBLIC_ENDPOINTS } from '@shell/config/labels-annotat
import { WORKLOAD_TYPES, SERVICE, POD } from '@shell/config/types';
import { get, set } from '@shell/utils/object';
import day from 'dayjs';
import { convertSelectorObj, matching, matches } from '@shell/utils/selector';
import { convertSelectorObj, parse } from '@shell/utils/selector';
import { SEPARATOR } from '@shell/config/workload';
import WorkloadService from '@shell/models/workload.service';
import { matching } from '@shell/utils/selector-typed';
export const defaultContainer = {
imagePullPolicy: 'Always',
@ -18,6 +19,7 @@ export const defaultContainer = {
},
volumeMounts: []
};
export default class Workload extends WorkloadService {
// remove clone as yaml/edit as yaml until API supported
get _availableActions() {
@ -205,22 +207,20 @@ export default class Workload extends WorkloadService {
return this.goToEdit({ sidecar: true });
}
get showPodRestarts() {
return true;
}
get restartCount() {
const pods = this.pods;
return this.pods.reduce((total, pod) => {
const { status:{ containerStatuses = [] } } = pod;
let sum = 0;
if (containerStatuses.length) {
total += containerStatuses.reduce((tot, container) => {
tot += container.restartCount || 0;
pods.forEach((pod) => {
if (pod.status.containerStatuses) {
sum += pod.status?.containerStatuses[0].restartCount || 0;
return tot;
}, 0);
}
});
return sum;
return total;
}, 0);
}
get hasSidecars() {
@ -334,6 +334,10 @@ export default class Workload extends WorkloadService {
const type = this._type ? this._type : this.type;
const detailItem = {
restarts: {
label: this.t('resourceDetail.masthead.restartCount'),
content: this.restartCount
},
endpoint: {
label: 'Endpoints',
content: this.endpoint,
@ -400,10 +404,13 @@ export default class Workload extends WorkloadService {
});
}
out.push( {
out.push({
label: 'Image',
content: this.imageNames,
formatter: 'PodImages'
}, {
label: detailItem.restarts.label,
content: detailItem.restarts.content,
});
switch (type) {
@ -550,30 +557,56 @@ export default class Workload extends WorkloadService {
}
}
get pods() {
const relationships = this.metadata?.relationships || [];
const podRelationship = relationships.filter((relationship) => relationship.toType === POD)[0];
if (podRelationship) {
const pods = this.$getters['podsByNamespace'](this.metadata.namespace);
// Used in conjunction with `matches/match/label selectors`. Requires https://github.com/rancher/dashboard/issues/10417 to fix
return pods.filter((obj) => {
return matches(obj, podRelationship.selector);
async fetchPods() {
if (this.podMatchExpression) {
return this.$dispatch('findLabelSelector', {
type: POD,
matching: {
namespace: this.metadata.namespace,
labelSelector: { matchExpressions: this.podMatchExpression },
},
});
}
return undefined;
}
/**
* This getter expects a superset of workload pods to have been fetched already
*
* It assumes fetchPods has been called and should be used instead of the response of fetchPods
* (findAll --> findLabelSelector world results won't trigger change detection)
*/
get pods() {
if (this.podMatchExpression) {
return this.$getters['matchingLabelSelector'](POD, { matchExpressions: this.podMatchExpression }, this.metadata.namespace);
} else {
return [];
}
}
get podGauges() {
/**
* Return a string version of a matchLabel expression
*/
get podSelector() {
const relationships = this.metadata?.relationships || [];
const selector = relationships.filter((relationship) => relationship.toType === POD)[0]?.selector;
return selector;
}
get podMatchExpression() {
return this.podSelector ? parse(this.podSelector) : null;
}
calcPodGauges(pods) {
const out = { };
if (!this.pods) {
if (!pods) {
return out;
}
this.pods.map((pod) => {
pods.map((pod) => {
const { stateColor, stateDisplay } = pod;
if (out[stateDisplay]) {
@ -589,6 +622,10 @@ export default class Workload extends WorkloadService {
return out;
}
get podGauges() {
return this.calcPodGauges(this.pods);
}
// Job Specific
get jobRelationships() {
if (this.type !== WORKLOAD_TYPES.CRON_JOB) {
@ -664,13 +701,15 @@ export default class Workload extends WorkloadService {
}
async matchingPods() {
// Used in conjunction with `matches/match/label selectors`. Requires https://github.com/rancher/dashboard/issues/10417 to fix
const all = await this.$dispatch('findAll', { type: POD });
const allInNamespace = all.filter((pod) => pod.metadata.namespace === this.metadata.namespace);
const matchInfo = await matching({
labelSelector: { matchExpressions: convertSelectorObj(this.spec.selector) },
type: POD,
$store: this.$store || { getters: this.$rootGetters, dispatch: (action, args) => this.$dispatch(action.split('/')[1], args) },
inStore: this.$rootGetters['currentProduct'].inStore,
namespace: this.metadata.namespace,
});
const selector = convertSelectorObj(this.spec.selector);
return matching(allInNamespace, selector);
return matchInfo.matches;
}
cleanForSave(data) {

View File

@ -12,9 +12,9 @@ export default {
async fetch() {
if ( this.$store.getters['cluster/schemaFor'](SERVICE) ) {
this.uiServices = await this.$store.dispatch('cluster/findMatching', {
this.uiServices = await this.$store.dispatch('cluster/findLabelSelector', {
type: SERVICE,
selector: 'app=longhorn-ui'
matching: { labelSelector: { matchLabels: { app: 'longhorn-ui' } } }
});
}
},

View File

@ -175,7 +175,9 @@ describe('dashboard-store: actions', () => {
haveSelector: jest.fn(() => false),
matching: jest.fn(() => 'getters.all'),
urlFor: jest.fn(() => 'getters.urlFor'),
urlOptions: jest.fn(() => 'getters.urlOptions')
urlOptions: jest.fn(() => 'getters.urlOptions'),
haveAll: jest.fn(() => false),
all: jest.fn(() => []),
};
const rootGetters = { 'type-map/optionsFor': jest.fn() };

View File

@ -9,6 +9,8 @@ import garbageCollect from '@shell/utils/gc/gc';
import { addSchemaIndexFields } from '@shell/plugins/steve/schema.utils';
import { addParam, parse } from '@shell/utils/url';
import { conditionalDepaginate } from '@shell/store/type-map.utils';
import { FilterArgs } from '@shell/types/store/pagination.types';
import { isLabelSelectorEmpty, labelSelectorToSelector } from '@shell/utils/selector-typed';
export const _ALL = 'all';
export const _MERGE = 'merge';
@ -399,6 +401,9 @@ export default {
},
/**
* If result not already cached, make a http request to fetch a specific set of resources
*
* This accepts all the new sql-cache backed api features (sort, filter, etc)
*
* @param {*} ctx
* @param { {type: string, opt: ActionFindPageArgs} } opt
@ -478,11 +483,61 @@ export default {
garbageCollect.gcUpdateLastAccessed(ctx, type);
return opt.transient ? {
data: out.data,
data: await dispatch('createMany', out.data), // Return classified objects
pagination
} : findAllGetter(getters, type, opt);
},
/**
* Find results matching a kube `labelSelector` object
*
* If result not already cached, make a http request to fetch resources matching the selector/s
*
* This is different if vai based pagination is on
* a) Pagination Enabled - use the new sql-cache backed api - findPage
* b) Pagination Disabled - use the old 'native kube api' - findMatching
*
* Filter is defined via the kube labelSelector object (see KubeLabelSelector)
*/
async findLabelSelector(ctx, {
type,
context,
matching: {
namespace,
labelSelector
},
opt
}) {
const { getters, dispatch } = ctx;
const args = {
id: type,
context,
};
if (getters[`paginationEnabled`]?.(args)) {
if (isLabelSelectorEmpty(labelSelector)) {
throw new Error(`labelSelector must not be empty when using findLabelSelector (avoid fetching all resources)`);
}
// opt of type ActionFindPageArgs
return dispatch('findPage', {
type,
opt: {
...(opt || {}),
namespaced: namespace,
pagination: new FilterArgs({ labelSelector }),
}
});
}
return dispatch('findMatching', {
type,
selector: labelSelectorToSelector(labelSelector),
opt,
namespace,
});
},
async findMatching(ctx, {
type,
selector,
@ -500,7 +555,13 @@ export default {
if ( !getters.typeRegistered(type) ) {
commit('registerType', type);
}
if ( opt.force !== true && getters['haveSelector'](type, selector) ) {
if ( opt.force !== true && getters['haveSelector'](type, selector)) {
return getters.all(type);
}
// Optimisation - We can pretend like we've fetched a specific selectors worth instead of replacing ALL pods with only SOME
if ( opt.force !== true && getters['haveAll'](type)) {
return getters.matching( type, selector, namespace );
}
@ -508,6 +569,7 @@ export default {
opt = opt || {};
opt.labelSelector = selector;
opt.namespaced = namespace;
opt.url = getters.urlFor(type, null, opt);
opt.depaginate = conditionalDepaginate(typeOptions?.depaginate, { ctx, args: { type, opt } });
@ -536,7 +598,7 @@ export default {
garbageCollect.gcUpdateLastAccessed(ctx, type);
return getters.matching( type, selector, namespace );
return getters.all(type);
},
// opt:

View File

@ -1,5 +1,5 @@
import { SCHEMA, COUNT } from '@shell/config/types';
import { SCHEMA, COUNT, POD } from '@shell/config/types';
import { matches } from '@shell/utils/selector';
import { typeMunge, typeRef, SIMPLE_TYPES } from '@shell/utils/create-yaml';
@ -9,6 +9,7 @@ import { keyFieldFor, normalizeType } from './normalize';
import { lookup } from './model-loader';
import garbageCollect from '@shell/utils/gc/gc';
import paginationUtils from '@shell/utils/pagination-utils';
import { labelSelectorToSelector } from '@shell/utils/selector-typed';
export const urlFor = (state, getters) => (type, id, opt) => {
opt = opt || {};
@ -91,12 +92,55 @@ export default {
return state.types[type].list;
},
/**
* This is a _manual_ application of the selector against whatever is in the store
*
* The store must be populated with enough resources to make this valid
*
* It's like `matching` except
* - it accepts a kube labelSelector object (see KubeLabelSelector)
* - if the store already has the selector and only the selector just return it, otherwise run through `matching`
*/
matchingLabelSelector: (state, getters, rootState) => (type, labelSelector, namespace) => {
type = getters.normalizeType(type);
const selector = labelSelectorToSelector(labelSelector);
const page = getters['havePage'](type, selector)?.request;
// Does the store contain the result of a vai backed api request?
if (
page?.namespace === namespace &&
page?.pagination?.filters?.length === 0 &&
page?.pagination.labelSelector &&
selector === labelSelectorToSelector(page?.pagination.labelSelector
)
) {
return getters.all(type);
}
// Does the store contain the result of a specific labelSelector request?
if (getters['haveSelector'](type, selector)) {
return getters.all(type);
}
// Does the store have all and we can pretend like it contains a result of a labelSelector?
if (getters['haveAll'](type)) {
return getters.matching( type, selector, namespace );
}
return [];
},
/**
* This is a _manual_ application of the selector against whatever is in the store
*
* The store must be populated with enough resources to make this valid
*/
matching: (state, getters, rootState) => (type, selector, namespace, config = { skipSelector: false }) => {
let matching = getters['all'](type);
// Filter first by namespace if one is provided, since this is efficient
if (namespace && typeof namespace === 'string') {
matching = matching.filter((obj) => obj.namespace === namespace);
matching = type === POD ? getters['podsByNamespace'](namespace) : matching.filter((obj) => obj.namespace === namespace);
}
garbageCollect.gcUpdateLastAccessed({

View File

@ -413,13 +413,18 @@ export default {
loadSelector(state, {
type, entries, ctx, selector, revision
}) {
const keyField = ctx.getters.keyFieldForType(type);
const cache = registerType(state, type);
const cachedArgs = createLoadArgs(ctx, entries?.[0]?.type);
const proxies = reactive(entries.map((x) => classify(ctx, x)));
for ( const data of entries ) {
load(state, {
data, ctx, cachedArgs
});
clear(cache.list);
cache.map.clear();
cache.generation++;
addObjects(cache.list, proxies);
for ( let i = 0 ; i < proxies.length ; i++ ) {
cache.map.set(proxies[i][keyField], proxies[i]);
}
cache.haveSelector[selector] = true;

View File

@ -34,6 +34,7 @@ import { markRaw } from 'vue';
import { ExtensionPoint, ActionLocation } from '@shell/core/types';
import { getApplicableExtensionEnhancements } from '@shell/core/plugin-helpers';
import { parse } from '@shell/utils/selector';
export const DNS_LIKE_TYPES = ['dnsLabel', 'dnsLabelRestricted', 'hostname'];
@ -1179,7 +1180,8 @@ export default class Resource {
}
// @TODO remove this once the API maps steve _type <-> k8s type in both directions
opt.data = this.toSave() || { ...this };
// Completely disconnect the object we're going to save (and mangle in cleanForSave) and `this`
opt.data = this.toSave() || JSON.parse(JSON.stringify(this));
if (opt.data._type) {
opt.data.type = opt.data._type;
@ -1791,6 +1793,7 @@ export default class Resource {
}
if ( r.selector ) {
// A selector is a stringified version of a matchLabel (https://github.com/kubernetes/apimachinery/blob/master/pkg/labels/selector.go#L1010)
addObjects(out.selectors, {
type: r.toType,
namespace: r.toNamespace,
@ -1834,15 +1837,32 @@ export default class Resource {
}
async _findRelationship(rel, direction) {
// Find resources for this resource's metadata.relationships (steve prop)
// These will either reference a selector (stringified matchLabels) OR specific resources (ids)
const { selectors, ids } = this._relationshipsFor(rel, direction);
const out = [];
// Find all the resources that match the selector
for ( const sel of selectors ) {
const matching = await this.$dispatch('findMatching', sel);
const {
type,
selector,
namespace,
opt,
} = sel;
const matching = await this.$dispatch('findLabelSelector', {
type,
matching: {
namespace,
labelSelector: { matchExpressions: parse(selector) }
},
opts: opt
});
addObjects(out, matching.data);
addObjects(out, matching);
}
// Find all the resources that match the required id's
for ( const obj of ids ) {
const { type, id } = obj;
let matching = this.$getters['byId'](type, id);

View File

@ -15,7 +15,7 @@ import { splitObjectPath } from '@shell/utils/string';
import { parseType } from '@shell/models/schema';
import {
STEVE_AGE_COL,
STEVE_ID_COL, STEVE_LIST_GROUPS, STEVE_NAMESPACE_COL, STEVE_STATE_COL
STEVE_ID_COL, STEVE_LIST_GROUPS, STEVE_NAME_COL, STEVE_NAMESPACE_COL, STEVE_STATE_COL
} from '@shell/config/pagination-table-headers';
import { createHeaders } from '@shell/store/type-map.utils';
import paginationUtils from '@shell/utils/pagination-utils';
@ -326,6 +326,7 @@ export default {
typeOptions: typeMapGetters['optionsFor'](schema, true),
schema,
columns: {
name: STEVE_NAME_COL,
state: STEVE_STATE_COL,
namespace: STEVE_NAMESPACE_COL,
age: STEVE_AGE_COL,

View File

@ -59,4 +59,8 @@ export default class SteveModel extends HybridModel {
return val;
}
paginationEnabled() {
return this.$getters['paginationEnabled'](this.type);
}
}

View File

@ -18,6 +18,7 @@ import {
import { CAPI as CAPI_LAB_AND_ANO, CATTLE_PUBLIC_ENDPOINTS } from '@shell/config/labels-annotations';
import { Schema } from '@shell/plugins/steve/schema';
import { PaginationSettingsStore } from '@shell/types/resources/settings';
import { KubeLabelSelector, KubeLabelSelectorExpression } from '@shell/types/kube/kube-api';
/**
* This is a workaround for a ts build issue found in check-plugins-build.
@ -401,6 +402,14 @@ class StevePaginationUtils extends NamespaceProjectFilters {
}
}
if (opt.pagination.labelSelector) {
const filters = this.convertLabelSelectorPaginationParams(schema, opt.pagination.labelSelector);
if (filters) {
params.push(filters);
}
}
// Note - There is a `limit` property that is by default 100,000. This can be disabled by using `limit=-1`,
// but we shouldn't be fetching any pages big enough to exceed the default
@ -499,6 +508,111 @@ class StevePaginationUtils extends NamespaceProjectFilters {
return res;
}
/**
* Convert kube labelSelector object into steve filter params
*
* A lot of the requirements and details are taken directly from
* https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/
*/
private convertLabelSelectorPaginationParams(schema: Schema, labelSelector: KubeLabelSelector): string {
// Get a list of matchExpressions
const expressions: KubeLabelSelectorExpression[] = labelSelector.matchExpressions ? [...labelSelector.matchExpressions] : [];
// matchLabels are just simpler versions of matchExpressions, for ease convert them
if (labelSelector.matchLabels) {
Object.entries(labelSelector.matchLabels).forEach(([key, value]) => {
const expression: KubeLabelSelectorExpression = {
key,
values: [value],
operator: 'In'
};
expressions.push(expression);
});
}
// concert all matchExpressions into string params
const filters: string[] = expressions.reduce((res, exp) => {
const labelKey = `metadata.labels[${ exp.key }]`;
switch (exp.operator) {
case 'In':
if (!exp.values?.length) {
console.error(`Skipping labelSelector to API filter param conversion for ${ exp.key }(IN) as no value was supplied`); // eslint-disable-line no-console
return res;
}
// foo IN [bar] => ?filter=foo+IN+(bar)
// foo IN [bar, baz2] => ?filter=foo+IN+(bar,baz2)
res.push(`filter=${ labelKey } IN (${ exp.values.join(',') })`);
break;
case 'NotIn':
if (!exp.values?.length) {
console.error(`Skipping labelSelector to API filter param conversion for ${ exp.key }(NOTIN) as no value was supplied`); // eslint-disable-line no-console
return res;
}
// aaa NotIn [bar, baz2]=> ?filter=foo+NOTIN+(bar,baz2)
res.push(`filter=${ labelKey } NOTIN (${ exp.values.join(',') })`);
break;
case 'Exists':
if (exp.values?.length) {
console.error(`Skipping labelSelector to API filter param conversion for ${ exp.key }(Exists) as no value was supplied`); // eslint-disable-line no-console
return res;
}
// bbb Exists=> ?filter=bbb
res.push(`filter=${ labelKey }`);
break;
case 'DoesNotExist':
if (exp.values?.length) {
console.error(`Skipping labelSelector to API filter param conversion for ${ exp.key }(DoesNotExist) as no value was supplied`); // eslint-disable-line no-console
return res;
}
// ccc DoesNotExist ?filter=!bbb. # or %21bbb
res.push(`filter=!${ labelKey }`);
break;
case 'Gt':
// Currently broken - see https://github.com/rancher/rancher/issues/50057
// Only applicable to node affinity (atm) - https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#operators
if (typeof exp.values !== 'string') {
console.error(`Skipping labelSelector to API filter param conversion for ${ exp.key }(Gt) as no value was supplied`); // eslint-disable-line no-console
return res;
}
// ddd Gt 1=> ?filter=ddd+>+1
res.push(`filter=${ labelKey } > (${ exp.values })`);
break;
case 'Lt':
// Currently broken - see https://github.com/rancher/rancher/issues/50057
// Only applicable to node affinity (atm) - https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#operators
if (typeof exp.values !== 'string') {
console.error(`Skipping labelSelector to API filter param conversion for ${ exp.key }(Lt) as no value was supplied`); // eslint-disable-line no-console
return res;
}
// eee Lt 2=> ?filter=eee+<+2
res.push(`filter=${ labelKey } < (${ exp.values })`);
break;
}
return res;
}, [] as string[]);
// "All of the requirements, from both matchLabels and matchExpressions are ANDed together -- they must all be satisfied in order to match"
return filters.join('&');
}
}
export const PAGINATION_SETTINGS_STORE_DEFAULTS: PaginationSettingsStore = {

View File

@ -314,7 +314,9 @@ self.onmessage = (e) => {
if (workerActions[action]) {
workerActions[action](e?.data[action]);
} else {
console.warn('no associated action for:', action); // eslint-disable-line no-console
// This catches any window sendMessage event. We're hitting this on hot-reload of code where somehow this file is loaded
// Could be related to extensions, which have their own version of this
console.debug('no associated action for:', action); // eslint-disable-line no-console
}
});
}; // bind everything to the worker's onmessage handler via the workerActions

View File

@ -100,7 +100,7 @@ export function createHeaders(
// Always try to have an identifier
if ( !hasName ) {
insertAt(out, 1, idColumn || nameColumn);
insertAt(out, 1, nameColumn || idColumn );
if ( namespaced ) {
insertAt(out, 2, namespaceColumn);
}

View File

@ -0,0 +1,22 @@
export interface KubeLabelSelectorExpression {
key: string,
/**
* Gt and Lt are only applicable to node selectors
*/
operator: 'In' | 'NotIn' | 'Exists' | 'DoesNotExist' | 'Gt' | 'Lt',
/**
* An array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. If the operator is Gt or Lt, the values array must have a single element, which will be interpreted as an integer. This array is replaced during a strategic merge patch.
*/
values?: string[],
}
/**
* https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#labelselector-v1-meta
*/
export interface KubeLabelSelector {
matchExpressions?: KubeLabelSelectorExpression[],
/**
* matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed.
*/
matchLabels?: { [key: string]: string }
}

View File

@ -1,4 +1,5 @@
import { NAMESPACE_FILTER_NS_FULL_PREFIX, NAMESPACE_FILTER_P_FULL_PREFIX } from '@shell/utils/namespace-filter';
import { KubeLabelSelector } from '@shell/types/kube/kube-api';
// Pagination Typing
// These structures are designed to offer both convenience and flexibility based on a common structure and are
@ -304,7 +305,7 @@ export class PaginationArgs {
*/
sort: PaginationSort[];
/**
* A collection of `filter` params
* A collection of traditional `filter` params covering logic such as x is y, x is like y, x is not y
*
* For more info see {@link PaginationParamFilter}
*/
@ -316,6 +317,11 @@ export class PaginationArgs {
*/
projectsOrNamespaces: PaginationParamProjectOrNamespace[];
/**
* Traditional Kube labelSelector consisting of matchLabels and matchExpressions
*/
labelSelector?: KubeLabelSelector;
/**
* Creates an instance of PaginationArgs.
*
@ -327,6 +333,7 @@ export class PaginationArgs {
sort = [],
filters = [],
projectsOrNamespaces = [],
labelSelector = undefined,
}:
// This would be neater as just Partial<PaginationArgs> but we lose all jsdoc
{
@ -354,6 +361,10 @@ export class PaginationArgs {
* For definition see {@link PaginationArgs} `projectsOrNamespaces`
*/
projectsOrNamespaces?: PaginationParamProjectOrNamespace | PaginationParamProjectOrNamespace[],
/**
* Traditional Kube labelSelector consisting of matchLabels and matchExpressions
*/
labelSelector?: KubeLabelSelector,
}) {
this.page = page;
this.pageSize = pageSize;
@ -368,6 +379,7 @@ export class PaginationArgs {
} else {
this.projectsOrNamespaces = [];
}
this.labelSelector = labelSelector;
}
}
@ -386,6 +398,7 @@ export class FilterArgs extends PaginationArgs {
sort = [],
filters = [],
projectsOrNamespaces = [],
labelSelector = undefined,
}:
// This would be neater as just Partial<PaginationArgs> but we lose all jsdoc
{
@ -405,9 +418,13 @@ export class FilterArgs extends PaginationArgs {
* For definition see {@link PaginationArgs} `projectsOrNamespaces`
*/
projectsOrNamespaces?: PaginationParamProjectOrNamespace | PaginationParamProjectOrNamespace[],
/**
* Traditional Kube labelSelector consisting of matchLabels and matchExpressions
*/
labelSelector?: KubeLabelSelector
}) {
super({
page: null, pageSize: null, sort, filters, projectsOrNamespaces
page: null, pageSize: null, sort, filters, projectsOrNamespaces, labelSelector
});
}
}

View File

@ -0,0 +1,205 @@
import { COUNT } from '@shell/config/types';
import { KubeLabelSelector, KubeLabelSelectorExpression } from '@shell/types/kube/kube-api';
import { ActionFindPageArgs } from '@shell/types/store/dashboard-store.types';
import { FilterArgs, PaginationFilterField, PaginationParamFilter } from '@shell/types/store/pagination.types';
import { isEmpty } from '@shell/utils/object';
import { convert, matching as rootMatching } from '@shell/utils/selector';
type MatchingResponse = {
matched: number,
matches: any[],
none: boolean,
sample: any,
total: number,
}
/**
* Find resources that match a labelSelector. This behaves differently if vai based pagination is on
* a) Pagination Enabled - fetch matching resources filtered on backend - findPage
* b) Pagination Disabled - fetch all resources and then filter locally - findAll --> root `matching` fn
*
* This is a much smarter version of root matching fn `matching` from shell/utils/selector.js (which just does local filtering)
*
* If fetching all of a resource should be avoided or we don't want to mess around with the cache the action `findLabelSelector` should be used
* - sometimes some legacy code expects all resources are fetched
* - sometimes we want to fetch a resource but not override the cache
* - already have a pods list cached, don't want to overwrite that when finding pods associated with a service
*
* Resources are returned in a common format which includes metadata
*/
export async function matching({
labelSelector,
type,
inStore,
$store,
inScopeCount = undefined,
namespace = undefined,
transient = false,
}: {
/**
* Standard kube label selector object.
*
* If this is 'empty' (no matchLabels or matchExpressions) it will return all results
*
* If this is 'null' it will return no results
*/
labelSelector: KubeLabelSelector,
/**
* Resource type
*/
type: string,
/**
* Store in which resources will be cached
*/
inStore: string,
/**
* Standard vuex store object
*/
$store: any,
/**
* Number of resources that are applicable when filtering.
*
* Used to skip any potential http request if we know the result will be zero
*
* If this property is not supplied we'll try and discover it from the COUNTS resource.
*/
inScopeCount?: number
/**
* Optional namespace or namespaces to apply selector to
*
* If this is undefined then namespaces will totally be ignored
*
* If this is provided all resources must be within them. If an empty array is provided then no resources will be matched
*
*/
namespace?: string | string[],
/**
* Should the result bypass the store?
*/
transient?: boolean,
}): Promise<MatchingResponse> {
const isNamespaced = $store.getters[`${ inStore }/schemaFor`](type)?.attributes.namespaced;
const safeNamespaces = Array.isArray(namespace) ? namespace : !!namespace ? [namespace] : [];
const filterByNamespaces = isNamespaced && !!namespace ; // Result set must come from a resource in a namespace
// Determine if there's actually anything to filter on
if (typeof inScopeCount === 'undefined') {
const counts = $store.getters[`${ inStore }/all`](COUNT)?.[0]?.counts || {};
if (filterByNamespaces) {
inScopeCount = 0;
safeNamespaces.forEach((n) => {
inScopeCount += counts?.[type]?.namespaces[n]?.count || 0;
});
} else {
inScopeCount = counts?.[type]?.summary?.count || 0;
}
}
// Exit early if there are any situations that always return nothing
const noCandidates = (inScopeCount || 0) === 0;
const filterByNamespaceButNoNamespace = isNamespaced && !!namespace && (!safeNamespaces || safeNamespaces.length === 0);
const explicityNullLabelSelector = labelSelector === null || (labelSelector?.matchLabels === null && !labelSelector.matchExpressions === null);
if (noCandidates || filterByNamespaceButNoNamespace || explicityNullLabelSelector) {
return generateMatchingResponse([], inScopeCount || 0);
}
if ($store.getters[`${ inStore }/paginationEnabled`]?.({ id: type })) {
if (isLabelSelectorEmpty(labelSelector) && (!!namespace && !safeNamespaces?.length)) {
// no namespaces - ALL resources are candidates
// no labels - return all candidates
// too many to fetch...
throw new Error('Either populated labelSelector or namespace/s must be supplied in order to call findPage');
}
const findPageArgs: ActionFindPageArgs = {
pagination: new FilterArgs({
labelSelector,
filters: PaginationParamFilter.createMultipleFields(
safeNamespaces.map(
(n) => new PaginationFilterField({
field: 'metadata.namespace', // API only compatible with steve atm...
value: n,
})
)
),
}),
transient,
};
let match = await $store.dispatch(`${ inStore }/findPage`, { type, opt: findPageArgs });
if (transient) {
match = match.data;
}
return generateMatchingResponse(match, inScopeCount || 0);
} else {
// Start off with everything as a candidate
let candidates = await $store.dispatch(`${ inStore }/findAll`, { type });
inScopeCount = candidates.length;
// Filter out namespace specific stuff
if (isNamespaced && safeNamespaces?.length > 0) {
candidates = candidates.filter((e: any) => safeNamespaces.includes(e.metadata?.namespace));
inScopeCount = candidates.length;
}
// Apply labelSelector
if (labelSelector.matchLabels || labelSelector.matchExpressions) {
candidates = matches(candidates, labelSelector, 'metadata.labels');
}
return generateMatchingResponse(candidates, inScopeCount || 0);
}
}
const generateMatchingResponse = <T extends { [key: string]: any, nameDisplay: string}>(match: T[], inScopeCount: number): MatchingResponse => {
const matched = match.length || 0;
const sample = match[0]?.nameDisplay;
return {
matched,
matches: match,
none: matched === 0,
sample,
total: inScopeCount || 0,
};
};
/**
* This is similar to shell/utils/selector.js `matches`, but accepts a kube labelSelector
*/
function matches<T = any>(candidates: T[], labelSelector: KubeLabelSelector, labelKey: string): T[] {
const convertedObject = convert(labelSelector.matchLabels, labelSelector.matchExpressions);
return rootMatching(candidates, convertedObject, labelKey);
}
export function isLabelSelectorEmpty(labelSelector?: KubeLabelSelector): boolean {
return !labelSelector?.matchExpressions?.length && isEmpty(labelSelector?.matchLabels);
}
export function labelSelectorToSelector(labelSelector?: KubeLabelSelector): string {
if (isLabelSelectorEmpty(labelSelector)) {
return '';
}
const res: string[] = [];
Object.entries(labelSelector?.matchLabels || {}).forEach(([key, value]) => {
res.push(`${ key }=${ value }`);
});
(labelSelector?.matchExpressions || []).forEach((value: KubeLabelSelectorExpression) => {
if (value.operator === 'In' && value.values?.length === 1) {
res.push(`${ value.key }=${ value.values[0] }`);
} else {
throw new Error(`Unsupported matchExpression found when converting to selector string. ${ value }`);
}
});
return res.join(',');
}

View File

@ -11,7 +11,14 @@ const OP_MAP = {
'>': 'Gt',
};
// Parse a labelSelector string
/**
* Convert a string to a matchExpression object
*
* *string* is from https://github.com/kubernetes/apimachinery/blob/master/pkg/labels/selector.go#L1010.
* However... it seems to contain more operators than just `=` which the labels.Selector `String` does not use
*
* matchExpression is an array of 'key' 'operator' 'values'
*/
export function parse(labelSelector) {
// matchLabels:
// comma-separated list, all rules ANDed together
@ -32,6 +39,10 @@ export function parse(labelSelector) {
// operator: In, NotIn, Exists, or DoesNotExist
// values: [array, of, values, even, if, only, one]
if (!labelSelector) {
return [];
}
labelSelector = labelSelector.replace(/\+/g, ' ');
if ( parseCache[labelSelector] ) {
@ -101,13 +112,17 @@ export function parse(labelSelector) {
return out;
}
// Convert a Selector object to matchExpressions
/**
* Convert a Selector object to matchExpressions
*/
export function convertSelectorObj(obj) {
return convert(obj.matchLabels || {}, obj.matchExpressions || []);
}
// Convert matchLabels to matchExpressions
// Optionally combining with an existing set of matchExpressions
/**
* Convert matchLabels to matchExpressions
* Optionally combining with an existing set of matchExpressions
*/
export function convert(matchLabelsObj, matchExpressions) {
const keys = Object.keys(matchLabelsObj || {});
const out = matchExpressions || [];
@ -130,8 +145,10 @@ export function convert(matchLabelsObj, matchExpressions) {
return out;
}
// Convert matchExpressions to matchLabels when possible,
// returning the simplest combination of them.
/**
* Convert matchExpressions to matchLabels when possible,
* returning the simplest combination of them.
*/
export function simplify(matchExpressionsInput) {
const matchLabels = {};
const matchExpressions = [];
@ -163,6 +180,12 @@ export function simplify(matchExpressionsInput) {
return { matchLabels, matchExpressions };
}
/**
*
* For a fn that accepts a kube labelSelector object see shell/utils/selector-typed.ts `matches`
*
* @param {*} selector Is NOT a labelSelector object
*/
export function matches(obj, selector, labelKey = 'metadata.labels') {
let rules = [];