dashboard/layouts/default.vue

697 lines
18 KiB
Vue

<script>
import debounce from 'lodash/debounce';
import { mapState, mapGetters } from 'vuex';
import { mapPref, DEV, FAVORITE_TYPES, AFTER_LOGIN_ROUTE } from '@/store/prefs';
import ActionMenu from '@/components/ActionMenu';
import GrowlManager from '@/components/GrowlManager';
import WindowManager from '@/components/nav/WindowManager';
import PromptRemove from '@/components/PromptRemove';
import PromptRestore from '@/components/PromptRestore';
import PromptModal from '@/components/PromptModal';
import AssignTo from '@/components/AssignTo';
import Group from '@/components/nav/Group';
import Header from '@/components/nav/Header';
import Brand from '@/mixins/brand';
import FixedBanner from '@/components/FixedBanner';
import {
COUNT, SCHEMA, MANAGEMENT, UI, CATALOG
} from '@/config/types';
import { BASIC, FAVORITE, USED } from '@/store/type-map';
import { addObjects, replaceWith, clear, addObject } from '@/utils/array';
import { NAME as EXPLORER } from '@/config/product/explorer';
import { NAME as NAVLINKS } from '@/config/product/navlinks';
import isEqual from 'lodash/isEqual';
import { ucFirst } from '@/utils/string';
import { getVersionInfo, markSeenReleaseNotes } from '@/utils/version';
import { sortBy } from '@/utils/sort';
import PageHeaderActions from '@/mixins/page-actions';
const SET_LOGIN_ACTION = 'set-as-login';
export default {
components: {
PromptRemove,
PromptRestore,
AssignTo,
PromptModal,
Header,
ActionMenu,
Group,
GrowlManager,
WindowManager,
FixedBanner
},
mixins: [PageHeaderActions, Brand],
data() {
const { displayVersion } = getVersionInfo(this.$store);
return {
groups: [],
displayVersion,
wantNavSync: false
};
},
middleware: ['authenticated'],
computed: {
...mapState(['managementReady', 'clusterReady']),
...mapGetters(['productId', 'clusterId', 'namespaceMode', 'isExplorer', 'currentProduct']),
...mapGetters({ locale: 'i18n/selectedLocaleLabel' }),
...mapGetters('type-map', ['activeProducts']),
afterLoginRoute: mapPref(AFTER_LOGIN_ROUTE),
namespaces() {
return this.$store.getters['namespaces']();
},
dev: mapPref(DEV),
favoriteTypes: mapPref(FAVORITE_TYPES),
pageActions() {
const pageActions = [];
const product = this.$store.getters['currentProduct'];
if ( !product ) {
return [];
}
// Only show for Cluster Explorer or Global Apps (not configuration)
const canSetAsHome = product.inStore === 'cluster' || (product.inStore === 'management' && product.category !== 'configuration');
if (canSetAsHome) {
pageActions.push({
labelKey: 'nav.header.setLoginPage',
action: SET_LOGIN_ACTION
});
}
return pageActions;
},
allSchemas() {
const managementReady = this.$store.getters['managementReady'];
const product = this.$store.getters['currentProduct'];
if ( !managementReady || !product ) {
return [];
}
return this.$store.getters[`${ product.inStore }/all`](SCHEMA);
},
allNavLinks() {
if ( !this.clusterId || !this.$store.getters['cluster/schemaFor'](UI.NAV_LINK) ) {
return [];
}
return this.$store.getters['cluster/all'](UI.NAV_LINK);
},
counts() {
const managementReady = this.$store.getters['managementReady'];
const product = this.$store.getters['currentProduct'];
if ( !managementReady || !product ) {
return {};
}
const inStore = product.inStore;
// So that there's something to watch for updates
if ( this.$store.getters[`${ inStore }/haveAll`](COUNT) ) {
const counts = this.$store.getters[`${ inStore }/all`](COUNT)[0].counts;
return counts;
}
return {};
},
showClusterTools() {
return this.isExplorer &&
this.$store.getters['cluster/canList'](CATALOG.CLUSTER_REPO) &&
this.$store.getters['cluster/canList'](CATALOG.APP);
}
},
watch: {
counts() {
this.queueUpdate();
},
allSchemas() {
this.queueUpdate();
},
allNavLinks() {
this.queueUpdate();
},
favoriteTypes() {
this.queueUpdate();
},
locale(a, b) {
if ( !isEqual(a, b) ) {
this.getGroups();
}
},
productId(a, b) {
if ( !isEqual(a, b) ) {
// Immediately update because you'll see it come in later
this.getGroups();
this.wantNavSync = true;
}
},
clusterId(a, b) {
if ( !isEqual(a, b) ) {
// Store the last visited route when the cluster changes
this.setClusterAsLastRoute();
}
},
namespaceMode(a, b) {
if ( !isEqual(a, b) ) {
// Immediately update because you'll see it come in later
this.getGroups();
this.wantNavSync = true;
}
},
namespaces(a, b) {
if ( !isEqual(a, b) ) {
// Immediately update because you'll see it come in later
this.getGroups();
}
},
clusterReady(a, b) {
if ( !isEqual(a, b) ) {
// Immediately update because you'll see it come in later
this.getGroups();
}
},
product(a, b) {
if ( !isEqual(a, b) ) {
// Immediately update because you'll see it come in later
this.getGroups();
}
},
async currentProduct(a, b) {
if ( !isEqual(a, b) ) {
if (a.inStore !== b.inStore || a.inStore !== 'cluster' ) {
const route = {
name: 'c-cluster-product',
params: {
cluster: this.clusterId,
product: a.name,
}
};
await this.$store.dispatch('prefs/setLastVisited', route);
}
}
},
$route(a, b) {
if (this.wantNavSync && !isEqual(a, b)) {
this.wantNavSync = false;
this.$nextTick(() => this.syncNav());
}
}
},
async created() {
this.queueUpdate = debounce(this.getGroups, 500);
this.getGroups();
await this.$store.dispatch('prefs/setLastVisited', this.$route);
},
mounted() {
// Sync the navigation tree on fresh load
this.$nextTick(() => this.syncNav());
},
methods: {
async setClusterAsLastRoute() {
const route = {
name: this.$route.name,
params: {
...this.$route.params,
cluster: this.clusterId,
}
};
await this.$store.dispatch('prefs/setLastVisited', route);
},
handlePageAction(action) {
if (action.action === SET_LOGIN_ACTION) {
this.afterLoginRoute = this.getLoginRoute();
// Mark release notes as seen, so that the login route is honoured
markSeenReleaseNotes(this.$store);
}
},
getLoginRoute() {
// Cluster Explorer
if (this.currentProduct.inStore === 'cluster') {
return {
name: 'c-cluster-explorer',
params: { cluster: this.clusterId }
};
}
return this.$route;
},
collapseAll() {
this.$refs.groups.forEach((grp) => {
grp.isExpanded = false;
});
},
getGroups() {
if ( !this.clusterReady ) {
clear(this.groups);
return;
}
const clusterId = this.$store.getters['clusterId'];
const currentProduct = this.$store.getters['productId'];
const currentType = this.$route.params.resource || '';
let namespaces = null;
if ( !this.$store.getters['isAllNamespaces'] ) {
namespaces = Object.keys(this.namespaces);
}
const namespaceMode = this.$store.getters['namespaceMode'];
const out = [];
const loadProducts = this.isExplorer ? [EXPLORER] : [];
const productMap = this.activeProducts.reduce((acc, p) => {
return { ...acc, [p.name]: p };
}, {});
if ( this.isExplorer ) {
for ( const product of this.activeProducts ) {
if ( product.inStore === 'cluster' ) {
addObject(loadProducts, product.name);
}
}
}
// This should already have come into the list from above, but in case it hasn't...
addObject(loadProducts, currentProduct);
for ( const productId of loadProducts ) {
const modes = [BASIC];
if ( productId === NAVLINKS ) {
// Navlinks produce their own top-level nav items so don't need to show it as a product.
continue;
}
if ( productId === EXPLORER ) {
modes.push(FAVORITE);
modes.push(USED);
}
for ( const mode of modes ) {
const types = this.$store.getters['type-map/allTypes'](productId, mode) || {};
const more = this.$store.getters['type-map/getTree'](productId, mode, types, clusterId, namespaceMode, namespaces, currentType);
if ( productId === EXPLORER || !this.isExplorer ) {
addObjects(out, more);
} else {
const root = more.find(x => x.name === 'root');
const other = more.filter(x => x.name !== 'root');
const group = {
name: productId,
label: this.$store.getters['i18n/withFallback'](`product.${ productId }`, null, ucFirst(productId)),
children: [...(root?.children || []), ...other],
weight: productMap[productId]?.weight || 0,
};
addObject(out, group);
}
}
}
if ( this.isExplorer ) {
const allNavLinks = this.allNavLinks;
const toAdd = [];
const haveGroup = {};
for ( const obj of allNavLinks ) {
if ( !obj.link ) {
continue;
}
const groupLabel = obj.spec.group;
const groupSlug = obj.normalizedGroup;
const entry = {
name: `link-${ obj._key }`,
link: obj.link,
target: obj.actualTarget,
label: obj.labelDisplay,
sideLabel: obj.spec.sideLabel,
iconSrc: obj.spec.iconSrc,
description: obj.spec.description,
};
// If there's a spec.group (groupLabel), all entries with that name go under one nav group
if ( groupSlug ) {
if ( haveGroup[groupSlug] ) {
continue;
}
haveGroup[groupSlug] = true;
toAdd.push({
name: `navlink-group-${ groupSlug }`,
label: groupLabel,
isRoot: true,
// This is the item that actually shows up in the nav, since this outer group will be invisible
children: [
{
name: `navlink-child-${ groupSlug }`,
label: groupLabel,
route: {
name: 'c-cluster-navlinks-group',
params: {
cluster: this.clusterId,
group: groupSlug,
}
},
}
],
weight: -100,
});
} else {
toAdd.push({
name: `navlink-${ entry.name }`,
label: entry.label,
isRoot: true,
// This is the item that actually shows up in the nav, since this outer group will be invisible
children: [entry],
weight: -100,
});
}
}
addObjects(out, toAdd);
}
replaceWith(this.groups, ...sortBy(out, ['weight:desc', 'label']));
},
toggleNoneLocale() {
this.$store.dispatch('i18n/toggleNone');
},
toggleTheme() {
this.$store.dispatch('prefs/toggleTheme');
},
groupSelected(selected) {
this.$refs.groups.forEach((grp) => {
if (grp.canCollapse) {
grp.isExpanded = (grp.group.name === selected.name);
}
});
},
wheresMyDebugger() {
// vue-shortkey is preventing F8 from passing through to the browser... this works for now.
// eslint-disable-next-line no-debugger
debugger;
},
async toggleShell() {
const clusterId = this.$route.params.cluster;
if ( !clusterId ) {
return;
}
const cluster = await this.$store.dispatch('management/find', {
type: MANAGEMENT.CLUSTER,
id: clusterId,
});
if (!cluster ) {
return;
}
cluster.openShell();
},
syncNav() {
const refs = this.$refs.groups;
if (refs) {
// Only expand one group - so after the first has been expanded, no more will
// This prevents the 'More Resources' group being expanded in addition to the normal group
let canExpand = true;
refs.forEach((grp) => {
if (!grp.group.isRoot) {
grp.isExpanded = false;
if (canExpand) {
const isActive = grp.hasActiveRoute();
if (isActive) {
grp.isExpanded = true;
canExpand = false;
this.$nextTick(() => grp.syncNav());
}
}
}
});
}
},
}
};
</script>
<template>
<div class="dashboard-root">
<FixedBanner />
<div v-if="managementReady" class="dashboard-content">
<Header />
<nav v-if="clusterReady" class="side-nav">
<div class="nav">
<template v-for="(g, idx) in groups">
<Group
ref="groups"
:key="idx"
id-prefix=""
class="package"
:group="g"
:can-collapse="!g.isRoot"
:show-header="!g.isRoot"
@selected="groupSelected($event)"
@expand="groupSelected($event)"
>
<template #header>
<h6>{{ g.label }}</h6>
</template>
</Group>
</template>
</div>
<n-link v-if="showClusterTools" tag="div" class="tools" :to="{name: 'c-cluster-explorer-tools', params: {cluster: clusterId}}">
<a class="tools-button" @click="collapseAll()">
<i class="icon icon-gear" />
<span>{{ t('nav.clusterTools') }}</span>
</a>
</n-link>
<div class="version text-muted">
{{ displayVersion }}
</div>
</nav>
<main v-if="clusterReady">
<nuxt class="outlet" />
<ActionMenu />
<PromptRemove />
<PromptRestore />
<AssignTo />
<PromptModal />
<button v-if="dev" v-shortkey.once="['shift','l']" class="hide" @shortkey="toggleNoneLocale()" />
<button v-if="dev" v-shortkey.once="['shift','t']" class="hide" @shortkey="toggleTheme()" />
<button v-shortkey.once="['f8']" class="hide" @shortkey="wheresMyDebugger()" />
<button v-shortkey.once="['`']" class="hide" @shortkey="toggleShell" />
</main>
<div class="wm">
<WindowManager />
</div>
</div>
<FixedBanner :footer="true" />
<GrowlManager />
</div>
</template>
<style lang="scss" scoped>
.side-nav {
display: flex;
flex-direction: column;
.nav {
flex: 1;
overflow-y: auto;
}
}
</style>
<style lang="scss">
.dashboard-root{
display: flex;
flex-direction: column;
height: 100vh;
}
.dashboard-content {
display: grid;
position: relative;
flex: 1 1 auto;
overflow-y: auto;
min-height: 0px;
grid-template-areas:
"header header"
"nav main"
"wm wm";
grid-template-columns: var(--nav-width) auto;
grid-template-rows: var(--header-height) auto var(--wm-height, 0px);
> HEADER {
grid-area: header;
}
NAV {
grid-area: nav;
position: relative;
background-color: var(--nav-bg);
border-right: var(--nav-border-size) solid var(--nav-border);
overflow-y: auto;
H6, .root.child .label {
margin: 0;
letter-spacing: normal;
line-height: initial;
A { padding-left: 0; }
}
}
NAV .tools {
display: flex;
margin: 10px;
text-align: center;
A {
align-items: center;
border: 1px solid var(--border);
border-radius: 5px;
color: var(--body-text);
display: flex;
justify-content: center;
outline: 0;
flex: 1;
padding: 10px;
&:hover {
background: var(--nav-hover);
text-decoration: none;
}
> I {
margin-right: 4px;
}
}
&.nuxt-link-active:not(:hover) {
A {
background-color: var(--nav-active);
}
}
}
NAV .version {
cursor: default;
margin: 0 10px 10px 10px;
}
}
MAIN {
grid-area: main;
overflow: auto;
.outlet {
display: flex;
flex-direction: column;
padding: 20px;
min-height: 100%;
}
FOOTER {
background-color: var(--nav-bg);
height: var(--footer-height);
}
HEADER {
display: grid;
grid-template-areas: "type-banner type-banner"
"title actions"
"state-banner state-banner";
grid-template-columns: auto auto;
margin-bottom: 20px;
align-content: center;
min-height: 48px;
.type-banner {
grid-area: type-banner;
}
.state-banner {
grid-area: state-banner;
}
.title {
grid-area: title;
align-self: center;
}
.actions-container {
grid-area: actions;
margin-left: 8px;
align-self: center;
text-align: right;
}
.role-multi-action {
padding: 0 $input-padding-sm;
}
}
}
.wm {
grid-area: wm;
overflow-y: hidden;
z-index: 1;
}
</style>