dashboard/shell/components/nav/TopLevelMenu.vue

782 lines
18 KiB
Vue

<script>
import BrandImage from '@shell/components/BrandImage';
import ClusterProviderIcon from '@shell/components/ClusterProviderIcon';
import IconOrSvg from '../IconOrSvg';
import { mapGetters } from 'vuex';
import $ from 'jquery';
import { CAPI, MANAGEMENT } from '@shell/config/types';
import { mapPref, MENU_MAX_CLUSTERS } from '@shell/store/prefs';
import { sortBy } from '@shell/utils/sort';
import { ucFirst } from '@shell/utils/string';
import { KEY } from '@shell/utils/platform';
import { getVersionInfo } from '@shell/utils/version';
import { LEGACY } from '@shell/store/features';
import { SETTING } from '@shell/config/settings';
import { filterOnlyKubernetesClusters, filterHiddenLocalCluster } from '@shell/utils/cluster';
import { isRancherPrime } from '@shell/config/version';
export default {
components: {
BrandImage,
ClusterProviderIcon,
IconOrSvg
},
data() {
const { displayVersion, fullVersion } = getVersionInfo(this.$store);
const hasProvCluster = this.$store.getters[`management/schemaFor`](CAPI.RANCHER_CLUSTER);
return {
shown: false,
displayVersion,
fullVersion,
clusterFilter: '',
hasProvCluster,
};
},
fetch() {
if (this.hasProvCluster) {
this.$store.dispatch('management/findAll', { type: CAPI.RANCHER_CLUSTER });
}
},
computed: {
...mapGetters(['clusterId']),
...mapGetters(['clusterReady', 'isRancher', 'currentCluster', 'currentProduct']),
...mapGetters('type-map', ['activeProducts']),
...mapGetters({ features: 'features/get' }),
value: {
get() {
return this.$store.getters['productId'];
},
},
legacyEnabled() {
return this.features(LEGACY);
},
showClusterSearch() {
return this.clusters.length > this.maxClustersToShow;
},
clusters() {
const all = this.$store.getters['management/all'](MANAGEMENT.CLUSTER);
let kubeClusters = filterHiddenLocalCluster(filterOnlyKubernetesClusters(all), this.$store);
let pClusters = null;
if (this.hasProvCluster) {
pClusters = this.$store.getters['management/all'](CAPI.RANCHER_CLUSTER);
const available = pClusters.reduce((p, c) => {
p[c.mgmt] = true;
return p;
}, {});
// Filter to only show mgmt clusters that exist for the available provisionning clusters
// Addresses issue where a mgmt cluster can take some time to get cleaned up after the corresponding
// provisionning cluster has been deleted
kubeClusters = kubeClusters.filter(c => !!available[c]);
}
return kubeClusters.map((x) => {
const pCluster = pClusters?.find(c => c.mgmt.id === x.id);
return {
id: x.id,
label: x.nameDisplay,
ready: x.isReady && !pCluster?.hasError,
osLogo: x.providerOsLogo,
providerNavLogo: x.providerMenuLogo,
badge: x.badge,
isLocal: x.isLocal
};
});
},
clustersFiltered() {
const search = (this.clusterFilter || '').toLowerCase();
const out = search ? this.clusters.filter(item => item.label.toLowerCase().includes(search)) : this.clusters;
const sorted = sortBy(out, ['name:desc', 'label']);
return sorted;
},
maxClustersToShow: mapPref(MENU_MAX_CLUSTERS),
multiClusterApps() {
const options = this.options;
return options.filter(opt => (opt.inStore === 'management' || opt.isMultiClusterApp) && opt.category !== 'configuration' && opt.category !== 'legacy');
},
legacyApps() {
const options = this.options;
return options.filter(opt => opt.inStore === 'management' && opt.category === 'legacy');
},
configurationApps() {
const options = this.options;
return options.filter(opt => opt.category === 'configuration');
},
options() {
const cluster = this.clusterId || this.$store.getters['defaultClusterId'];
// TODO plugin routes
const entries = this.activeProducts.map((p) => {
// Try product-specific index first
const to = p.to || {
name: `c-cluster-${ p.name }`,
params: { cluster }
};
if ( !this.$router.getMatchedComponents(to).length ) {
to.name = 'c-cluster-product';
to.params.product = p.name;
}
return {
label: this.$store.getters['i18n/withFallback'](`product."${ p.name }"`, null, ucFirst(p.name)),
icon: `icon-${ p.icon || 'copy' }`,
svg: p.svg,
value: p.name,
removable: p.removable !== false,
inStore: p.inStore || 'cluster',
weight: p.weight || 1,
category: p.category || 'none',
to,
isMultiClusterApp: p.isMultiClusterApp,
};
});
return sortBy(entries, ['weight']);
},
canEditSettings() {
return (this.$store.getters['management/schemaFor'](MANAGEMENT.SETTING)?.resourceMethods || []).includes('PUT');
},
hasSupport() {
return isRancherPrime() || this.$store.getters['management/byId'](MANAGEMENT.SETTING, SETTING.SUPPORTED )?.value === 'true';
},
},
watch: {
$route() {
this.shown = false;
}
},
mounted() {
document.addEventListener('keyup', this.handler);
},
beforeDestroy() {
document.removeEventListener('keyup', this.handler);
},
methods: {
// Cluster list number of items shown is configurable via user preference
setClusterListHeight(maxToShow) {
const el = this.$refs.clusterList;
const max = Math.min(maxToShow, this.clusters.length);
if (el) {
const $el = $(el);
const h = 33 * max;
$el.css('min-height', `${ h }px`);
$el.css('max-height', `${ h }px`);
}
},
handler(e) {
if (e.keyCode === KEY.ESCAPE ) {
this.hide();
}
},
hide() {
this.shown = false;
},
toggle() {
this.shown = !this.shown;
this.$nextTick(() => {
this.setClusterListHeight(this.maxClustersToShow);
});
},
}
};
</script>
<template>
<div>
<div
data-testid="top-level-menu"
class="menu"
:class="{'raised': shown, 'unraised':!shown}"
@click="toggle()"
>
<svg
class="menu-icon"
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
><path
d="M0 0h24v24H0z"
fill="none"
/><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z" /></svg>
</div>
<div
v-if="shown"
class="side-menu-glass"
@click="hide()"
/>
<transition name="fade">
<div
v-if="shown"
data-testid="side-menu"
class="side-menu"
tabindex="-1"
>
<div class="title">
<div class="menu-spacer" />
<div class="side-menu-logo">
<BrandImage file-name="rancher-logo.svg" />
</div>
</div>
<div class="body">
<div @click="hide()">
<nuxt-link
class="option cluster selector home"
:to="{ name: 'home' }"
>
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
><path
d="M0 0h24v24H0z"
fill="none"
/><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" /></svg>
<div>
{{ t('nav.home') }}
</div>
</nuxt-link>
</div>
<template v-if="clusters && !!clusters.length">
<div class="category">
{{ t('nav.categories.explore') }}
</div>
<div
v-if="showClusterSearch"
class="search"
>
<input
ref="clusterFilter"
v-model="clusterFilter"
:placeholder="t('nav.search.placeholder')"
>
<i
v-if="clusterFilter"
class="icon icon-close"
@click="clusterFilter=''"
/>
</div>
<div
ref="clusterList"
class="clusters"
>
<div
v-for="c in clustersFiltered"
:key="c.id"
@click="hide()"
>
<nuxt-link
v-if="c.ready"
class="cluster selector option"
:to="{ name: 'c-cluster', params: { cluster: c.id } }"
>
<ClusterProviderIcon
:small="true"
:cluster="c"
class="rancher-provider-icon mr-10"
/>
<div class="cluster-name">
{{ c.label }}
</div>
</nuxt-link>
<span
v-else
class="option-disabled cluster selector disabled"
>
<ClusterProviderIcon
:small="true"
:cluster="c"
class="rancher-provider-icon mr-10"
/>
<div class="cluster-name">{{ c.label }}</div>
</span>
</div>
<div
v-if="clustersFiltered.length === 0"
class="none-matching"
>
{{ t('nav.search.noResults') }}
</div>
</div>
</template>
<template v-if="multiClusterApps.length">
<div class="category">
{{ t('nav.categories.multiCluster') }}
</div>
<div
v-for="a in multiClusterApps"
:key="a.label"
@click="hide()"
>
<nuxt-link
class="option"
:to="a.to"
>
<IconOrSvg
:icon="a.icon"
:src="a.svg"
/>
<div>{{ a.label }}</div>
</nuxt-link>
</div>
</template>
<template v-if="legacyEnabled">
<div class="category">
{{ t('nav.categories.legacy') }}
</div>
<div
v-for="a in legacyApps"
:key="a.label"
@click="hide()"
>
<nuxt-link
class="option"
:to="a.to"
>
<IconOrSvg
:icon="a.icon"
:src="a.svg"
/>
<div>{{ a.label }}</div>
</nuxt-link>
</div>
</template>
<template v-if="configurationApps.length">
<div class="category">
{{ t('nav.categories.configuration') }}
</div>
<div
v-for="a in configurationApps"
:key="a.label"
@click="hide()"
>
<nuxt-link
class="option"
:to="a.to"
>
<IconOrSvg
:icon="a.icon"
:src="a.svg"
/>
<div>{{ a.label }}</div>
</nuxt-link>
</div>
</template>
<div class="pad" />
</div>
<div class="footer">
<div
v-if="canEditSettings"
@click="hide()"
>
<nuxt-link :to="{name: 'support'}">
{{ t('nav.support', {hasSupport}) }}
</nuxt-link>
</div>
<div @click="hide()">
<nuxt-link
:to="{ name: 'about' }"
class="version"
>
{{ t('about.title') }}
</nuxt-link>
</div>
</div>
</div>
</transition>
</div>
</template>
<style scoped>
.cluster.disabled > * {
cursor: not-allowed;
filter: grayscale(1);
color: var(--muted);
}
</style>
<style lang="scss">
.localeSelector, .footer-tooltip {
z-index: 1000;
}
.cluster {
&.selector:not(.disabled):hover {
color: var(--primary-hover-text);
background: var(--primary-hover-bg);
border-radius: 5px;
text-decoration: none;
.rancher-provider-icon {
.rancher-icon-fill {
fill: var(--primary-hover-text);;
}
}
}
.rancher-provider-icon {
.rancher-icon-fill {
// Should match .option color
fill: var(--link);
}
}
}
.localeSelector {
.popover-inner {
padding: 10px 0;
}
.popover-arrow {
display: none;
}
.popover:focus {
outline: 0;
}
}
</style>
<style lang="scss" scoped>
$clear-search-size: 20px;
$icon-size: 25px;
$option-padding: 4px;
$option-height: $icon-size + $option-padding + $option-padding;
.option {
align-items: center;
cursor: pointer;
display: flex;
color: var(--link);
&:hover {
text-decoration: none;
}
&:focus {
outline: 0;
> div {
text-decoration: underline;
}
}
> i {
width: $icon-size;
font-size: $icon-size;
margin-right: 8px;
}
svg {
margin-right: 8px;
fill: var(--link);
}
img {
margin-right: 8px;
}
> div {
color: var(--link);
}
&:hover {
color: var(--primary-hover-text);
background: var(--primary-hover-bg);
border-radius: 5px;
> div {
color: var(--primary-hover-text);
}
svg {
fill: var(--primary-hover-text);
}
div {
color: var(--primary-hover-text);
}
}
}
.option, .option-disabled {
padding: $option-padding 0 $option-padding 10px;
}
.menu {
position: absolute;
left: 0;
width: 55px;
height: 54px;
top: 0;
grid-area: menu;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background-color: var(--topmost-light-hover);
}
.menu-icon {
width: 24px;
height: 24px;
fill: var(--header-btn-text);
}
&.raised {
z-index: 200;
}
}
.side-menu {
position: absolute;
top: 0;
left: 0px;
bottom: 0;
width: 280px;
background-color: var(--topmenu-bg);
z-index: 100;
border-right: 1px solid var(--topmost-border);
box-shadow: 0 0 15px 4px var(--topmost-shadow);
display: flex;
flex-direction: column;
padding: 0;
&:focus {
outline: 0;
}
.title {
display: flex;
height: 55px;
flex: 0 0 55px;
width: 100%;
border-bottom: 1px solid var(--nav-border);
justify-content: flex-start;
align-items: center;
.menu {
display: flex;
justify-content: center;
width: 55px;
margin-right: 10px;
}
.menu-icon {
width: 24px;
height: 24px;
}
.menu-spacer {
width: 55px;
}
}
.body {
flex: 1;
display: flex;
flex-direction: column;
margin: 10px 20px;
overflow-y: auto;
.category {
padding: 10px 0;
text-transform: uppercase;
opacity: 0.8;
margin-top: 10px;
}
.home {
color: var(--link);
}
.home:focus {
outline: 0;
}
.cluster {
align-items: center;
display: flex;
height: $option-height;
white-space: nowrap;
&:focus {
outline: 0;
}
.cluster-name {
text-overflow: ellipsis;
overflow: hidden;
}
> img {
max-height: $icon-size;
max-width: $icon-size;
margin-right: 8px;
}
}
.pad {
flex: 1;
}
.search {
position: relative;
> input {
background-color: transparent;
margin-bottom: 8px;
padding-right: 34px;
}
> i {
position: absolute;
font-size: $clear-search-size;
top: 9px;
right: 8px;
opacity: 0.7;
cursor: pointer;
&:hover {
color: var(--disabled-bg);
}
}
}
.clusters {
overflow-y: auto;
overflow-x: hidden;
}
.none-matching {
padding: 8px
}
}
.footer {
margin: 20px;
display: flex;
flex: 0;
flex-direction: row;
> * {
flex: 1;
color: var(--link);
&:first-child {
text-align: left;
}
&:last-child {
text-align: right;
}
text-align: center;
}
.version {
cursor: pointer;
}
}
}
.side-menu-glass {
background-color: transparent;
position: absolute;
top: 0;
left: 0px;
bottom: 0;
width: 100vw;
z-index: 99;
opacity: 1;
}
.side-menu-logo {
align-items: center;
display: flex;
margin-left: 10px;
opacity: 1;
transition: opacity 1.2s;
transition-delay: 0s;
height: 55px;
max-width: 200px;
& IMG {
object-fit: contain;
height: 21px;
max-width: 200px;
}
}
.fade-enter-active {
.side-menu-logo {
opacity: 0;
}
}
.fade-enter-active, .fade-leave-active {
transition: all 0.2s;
transition-timing-function: ease;
}
.fade-leave-active {
transition: all 0.4s;
}
.fade-leave-to {
left: -300px;
}
.fade-enter {
left: -300px;
.side-menu-logo {
opacity: 0;
}
}
.locale-chooser {
cursor: pointer;
}
.localeSelector {
::v-deep .popover-inner {
padding: 50px 0;
}
::v-deep .popover-arrow {
display: none;
}
::v-deep .popover:focus {
outline: 0;
}
li {
padding: 8px 20px;
&:hover {
background-color: var(--primary-hover-bg);
color: var(--primary-hover-text);
text-decoration: none;
}
}
}
</style>