dashboard/shell/detail/workload/index.vue

552 lines
16 KiB
Vue

<script>
import CreateEditView from '@shell/mixins/create-edit-view';
import { NAMESPACE as NAMESPACE_COL } from '@shell/config/table-headers';
import {
POD, WORKLOAD_TYPES, SCALABLE_WORKLOAD_TYPES, SERVICE, INGRESS, NODE, NAMESPACE, WORKLOAD_TYPE_TO_KIND_MAPPING, METRICS_SUPPORTED_KINDS
} from '@shell/config/types';
import ResourceTable from '@shell/components/ResourceTable';
import Tab from '@shell/components/Tabbed/Tab';
import Loading from '@shell/components/Loading';
import ResourceTabs from '@shell/components/form/ResourceTabs';
import CountGauge from '@shell/components/CountGauge';
import { allHash } from '@shell/utils/promise';
import DashboardMetrics from '@shell/components/DashboardMetrics';
import { mapGetters } from 'vuex';
import { allDashboardsExist } from '@shell/utils/grafana';
import PlusMinus from '@shell/components/form/PlusMinus';
import { matches } from '@shell/utils/selector';
import { PROJECT } from '@shell/config/labels-annotations';
const SCALABLE_TYPES = Object.values(SCALABLE_WORKLOAD_TYPES);
const WORKLOAD_METRICS_DETAIL_URL = '/api/v1/namespaces/cattle-monitoring-system/services/http:rancher-monitoring-grafana:80/proxy/d/rancher-workload-pods-1/rancher-workload-pods?orgId=1';
const WORKLOAD_METRICS_SUMMARY_URL = '/api/v1/namespaces/cattle-monitoring-system/services/http:rancher-monitoring-grafana:80/proxy/d/rancher-workload-1/rancher-workload?orgId=1';
export default {
components: {
DashboardMetrics,
Tab,
Loading,
ResourceTabs,
CountGauge,
ResourceTable,
PlusMinus
},
mixins: [CreateEditView],
async fetch() {
let hasNodes = false;
try {
const inStore = this.$store.getters['currentStore']();
const schema = this.$store.getters[`${ inStore }/schemaFor`](NODE);
if (schema) {
hasNodes = true;
}
} catch {}
const hash = {
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();
}
const res = await allHash(hash);
for ( const k in res ) {
this[k] = res[k];
}
const isMetricsSupportedKind = METRICS_SUPPORTED_KINDS.includes(this.value.type);
this.showMetrics = isMetricsSupportedKind && await allDashboardsExist(this.$store, this.currentCluster.id, [WORKLOAD_METRICS_DETAIL_URL, WORKLOAD_METRICS_SUMMARY_URL]);
if (!this.showMetrics) {
const namespace = await this.$store.dispatch('cluster/find', { type: NAMESPACE, id: this.value.metadata.namespace });
const projectId = namespace?.metadata?.labels[PROJECT];
if (projectId) {
this.WORKLOAD_PROJECT_METRICS_DETAIL_URL = `/api/v1/namespaces/cattle-project-${ projectId }-monitoring/services/http:cattle-project-${ projectId }-monitoring-grafana:80/proxy/d/rancher-pod-containers-1/rancher-workload-pods?orgId=1'`;
this.WORKLOAD_PROJECT_METRICS_SUMMARY_URL = `/api/v1/namespaces/cattle-project-${ projectId }-monitoring/services/http:cattle-project-${ projectId }-monitoring-grafana:80/proxy/d/rancher-pod-1/rancher-workload?orgId=1`;
this.showProjectMetrics = await allDashboardsExist(this.$store, this.currentCluster.id, [this.WORKLOAD_PROJECT_METRICS_DETAIL_URL, this.WORKLOAD_PROJECT_METRICS_SUMMARY_URL], 'cluster', projectId);
}
}
this.findMatchingServices();
this.findMatchingIngresses();
},
async unmounted() {
if (this.podSchema) {
await this.value.unWatchPods();
}
},
data() {
return {
servicesInNamespace: [],
allIngresses: [],
matchingServices: [],
matchingIngresses: [],
allNodes: [],
WORKLOAD_METRICS_DETAIL_URL,
WORKLOAD_METRICS_SUMMARY_URL,
POD_PROJECT_METRICS_DETAIL_URL: '',
POD_PROJECT_METRICS_SUMMARY_URL: '',
showMetrics: false,
showProjectMetrics: false,
};
},
computed: {
...mapGetters(['currentCluster']),
isScalable() {
return this.value?.canUpdate;
},
isJob() {
return this.value.type === WORKLOAD_TYPES.JOB;
},
isCronJob() {
return this.value.type === WORKLOAD_TYPES.CRON_JOB;
},
isPod() {
return this.value.type === POD;
},
podSchema() {
return this.$store.getters['cluster/schemaFor'](POD);
},
ingressSchema() {
return this.$store.getters['cluster/schemaFor'](INGRESS);
},
serviceSchema() {
return this.$store.getters['cluster/schemaFor'](SERVICE);
},
podTemplateSpec() {
if ( this.value.type === WORKLOAD_TYPES.CRON_JOB ) {
return this.value.spec.jobTemplate.spec.template.spec;
}
// This is for viewing
if ( this.value.type === POD ) {
return this.value;
}
return this.value.spec?.template?.spec;
},
container() {
return this.podTemplateSpec?.containers[0];
},
jobSchema() {
return this.$store.getters['cluster/schemaFor'](WORKLOAD_TYPES.JOB);
},
jobHeaders() {
return this.$store.getters['type-map/headersFor'](this.jobSchema).filter((h) => !h.name || h.name !== NAMESPACE_COL.name);
},
ingressHeaders() {
return this.$store.getters['type-map/headersFor'](this.ingressSchema).filter((h) => !h.name || h.name !== NAMESPACE_COL.name);
},
serviceHeaders() {
return this.$store.getters['type-map/headersFor'](this.serviceSchema).filter((h) => !h.name || h.name !== NAMESPACE_COL.name);
},
totalRuns() {
if (!this.value.jobs) {
return;
}
return this.value.jobs.reduce((total, job) => {
const { status = {} } = job;
total += (status.active || 0);
total += (status.succeeded || 0);
total += (status.failed || 0);
return total;
}, 0);
},
podHeaders() {
return this.$store.getters['type-map/headersFor'](this.podSchema).filter((h) => !h.name || h.name !== NAMESPACE_COL.name);
},
graphVarsWorkload() {
return this.value.type === WORKLOAD_TYPES.DEPLOYMENT ? this.value.replicaSetId : this.value.shortId;
},
graphVars() {
return {
namespace: this.value.namespace,
kind: WORKLOAD_TYPE_TO_KIND_MAPPING[this.value.type],
workload: this.graphVarsWorkload
};
},
showPodGaugeCircles() {
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;
return !jobGauges.find((jg) => jg.count === total);
},
canScale() {
return !!SCALABLE_TYPES.includes(this.value.type) && this.value.canUpdate;
},
},
methods: {
async scale(isUp) {
try {
if (isUp) {
await this.value.scaleUp();
} else {
await this.value.scaleDown();
}
} catch (err) {
this.$store.dispatch('growl/fromError', {
title: this.t('workload.list.errorCannotScale', { direction: isUp ? 'up' : 'down', workloadName: this.value.name }),
err
},
{ root: true });
}
},
async scaleDown() {
await this.scale(false);
},
async scaleUp() {
await this.scale(true);
},
findMatchingServices() {
if (!this.serviceSchema) {
return [];
}
// 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 < this.value.pods.length; i++) {
const pod = this.value.pods[i];
if (service.metadata?.namespace === this.value.metadata?.namespace && matches(pod, selector)) {
return true;
}
}
return false;
});
},
findMatchingIngresses() {
if (!this.ingressSchema) {
return [];
}
// Find Ingresses that forward traffic to Services
// that select this workload.
const matchingIngresses = this.allIngresses.filter((ingress) => {
try {
const rules = ingress.spec.rules;
if (!rules || !Array.isArray(rules)) return false;
for (let i = 0; i < rules.length; i++) {
const paths = rules[i]?.http?.paths;
if (!paths || !Array.isArray(paths)) continue;
// For each Ingress, check if any Services that match
// this workload are also target backends for the Ingress.
for (let j = 0; j < paths.length; j++) {
const pathData = paths[j];
const targetServiceName = pathData?.backend?.service?.name;
if (!targetServiceName) continue;
for (let k = 0; k < this.matchingServices.length; k++) {
const service = this.matchingServices[k];
const matchingServiceName = service?.metadata?.name;
if (ingress.metadata?.namespace === this.value.metadata?.namespace && matchingServiceName === targetServiceName) {
return true;
}
}
}
}
} catch (err) {
return false;
}
});
this.matchingIngresses = matchingIngresses;
}
},
watch: {
async 'value.jobRelationships.length'(neu, old) {
// If there are MORE jobs ensure we go out and fetch them (changes and removals are tracked by watches)
if (neu > old) {
// We don't need to worry about spam, this won't be called often and it will be infrequent
await this.value.matchingJobs();
}
}
}
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<div v-else>
<div
v-if="canScale"
class="right-align flex"
>
<PlusMinus
class="text-right"
:label="t('tableHeaders.scale')"
:value="value.spec.replicas"
:disabled="!isScalable"
@minus="scaleDown"
@plus="scaleUp"
/>
</div>
<h3>
{{ isJob || isCronJob ? t('workload.detailTop.runs') :t('workload.detailTop.pods') }}
</h3>
<div
v-if="value.pods || value.jobGauges"
class="gauges mb-20"
:class="{'gauges__pods': !!value.pods}"
>
<template v-if="value.jobGauges">
<CountGauge
v-for="(group, key) in value.jobGauges"
:key="key"
:total="isCronJob? totalRuns : value.pods.length"
:useful="group.count || 0"
:graphical="showJobGaugeCircles"
:primary-color-var="`--sizzle-${group.color}`"
:name="t(`workload.gaugeStates.${key}`)"
/>
</template>
<template v-else>
<CountGauge
v-for="(group, key) in podGauges"
:key="key"
:total="value.pods.length"
:useful="group.count || 0"
:graphical="showPodGaugeCircles"
:primary-color-var="`--sizzle-${group.color}`"
:name="key"
/>
</template>
</div>
<ResourceTabs
:value="value"
>
<Tab
v-if="isCronJob"
name="jobs"
:label="t('tableHeaders.jobs')"
:weight="4"
>
<ResourceTable
:rows="value.jobs"
:headers="jobHeaders"
key-field="id"
:schema="jobSchema"
:namespaced="false"
:groupable="false"
:search="false"
/>
</Tab>
<Tab
v-else-if="value.podMatchExpression"
name="pods"
:label="t('tableHeaders.pods')"
:weight="4"
>
<ResourceTable
:rows="value.pods"
:headers="podHeaders"
key-field="id"
:schema="podSchema"
:namespaced="false"
:groupable="false"
:search="false"
/>
</Tab>
<Tab
v-if="showMetrics"
:label="t('workload.container.titles.metrics')"
name="workload-metrics"
:weight="3"
>
<template #default="props">
<DashboardMetrics
v-if="props.active"
:detail-url="WORKLOAD_METRICS_DETAIL_URL"
:summary-url="WORKLOAD_METRICS_SUMMARY_URL"
:vars="graphVars"
graph-height="600px"
/>
</template>
</Tab>
<Tab
v-if="showProjectMetrics"
:label="t('workload.container.titles.metrics')"
name="workload-metrics"
:weight="3"
>
<template #default="props">
<DashboardMetrics
v-if="props.active"
:detail-url="WORKLOAD_PROJECT_METRICS_DETAIL_URL"
:summary-url="WORKLOAD_PROJECT_METRICS_SUMMARY_URL"
:vars="graphVars"
graph-height="600px"
/>
</template>
</Tab>
<Tab
v-if="!isJob && !isCronJob"
name="services"
:label="t('workload.detail.services')"
:weight="3"
>
<p
v-if="!serviceSchema"
class="caption"
>
{{ t('workload.detail.cannotViewServices') }}
</p>
<p
v-else-if="matchingServices.length === 0"
class="caption"
>
{{ t('workload.detail.cannotFindServices') }}
</p>
<p
v-else
class="caption"
>
{{ t('workload.detail.serviceListCaption') }}
</p>
<ResourceTable
v-if="serviceSchema && matchingServices.length > 0"
:rows="matchingServices"
:headers="serviceHeaders"
key-field="id"
:schema="serviceSchema"
:namespaced="false"
:groupable="false"
:search="false"
:table-actions="false"
/>
</Tab>
<Tab
v-if="!isJob && !isCronJob"
name="ingresses"
:label="t('workload.detail.ingresses')"
:weight="2"
>
<p
v-if="!serviceSchema"
class="caption"
>
{{ t('workload.detail.cannotViewIngressesBecauseCannotViewServices') }}
</p>
<p
v-else-if="!ingressSchema"
class="caption"
>
{{ t('workload.detail.cannotViewIngresses') }}
</p>
<p
v-else-if="matchingIngresses.length === 0"
class="caption"
>
{{ t('workload.detail.cannotFindIngresses') }}
</p>
<p
v-else
class="caption"
>
{{ t('workload.detail.ingressListCaption') }}
</p>
<ResourceTable
v-if="ingressSchema && matchingIngresses.length > 0"
:rows="matchingIngresses"
:headers="ingressHeaders"
key-field="id"
:schema="ingressSchema"
:namespaced="false"
:groupable="false"
:search="false"
:table-actions="false"
/>
</Tab>
</ResourceTabs>
</div>
</template>
<style lang='scss' scoped>
.right-align {
float: right;
}
.gauges {
display: flex;
justify-content: space-around;
&>*{
flex: 1;
margin-right: $column-gutter;
}
&__pods {
flex-wrap: wrap;
justify-content: left;
.count-gauge {
width: 23%;
margin-bottom: 10px;
flex: initial;
}
}
}
.caption {
margin-bottom: .5em;
}
</style>