Initial dashboard view (#7519)

* Dashboard view with quick access and details about resources.

---------

Co-authored-by: Richard Cox <richard.cox@suse.com>
This commit is contained in:
Sorin 2023-02-10 14:23:35 +01:00 committed by GitHub
parent 072cc00286
commit 93381d9cf3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 598 additions and 13 deletions

9
.vscode/launch.json vendored
View File

@ -12,6 +12,15 @@
"webRoot": "${workspaceFolder}",
"envFile": "${workspaceFolder}/.env",
},
{
"name": "Debug in Brave - MacOS",
"request": "launch",
"type": "pwa-chrome",
"url": "https://localhost:8005",
"runtimeExecutable": "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
"webRoot": "${workspaceFolder}",
"envFile": "${workspaceFolder}/.env",
},
{
"type": "node",
"request": "launch",

View File

@ -20,6 +20,7 @@
"cacerts",
"chainable",
"Codecov",
"createapp",
"epinio",
"hevi",
"kube",

View File

@ -99,7 +99,7 @@
"nuxt": "2.15.8",
"papaparse": "5.3.0",
"portal-vue": "2.1.7",
"rancher-icons": "rancher/icons#v2.0.9",
"rancher-icons": "rancher/icons#v2.0.12",
"require-extension-hooks": "0.3.3",
"require-extension-hooks-babel": "1.0.0",
"require-extension-hooks-vue": "3.0.0",

View File

@ -0,0 +1,195 @@
<script>
export default {
name: 'DashboardCard',
props: {
isLoaded: { type: Boolean, required: true },
title: { type: String, required: true },
icon: { type: String, required: true },
cta: { type: Object, required: true },
link: { type: Object, required: true },
linkText: { type: String, required: true },
description: { type: String, required: true },
slotTitle: {
type: String,
required: false,
default: null,
},
},
computed: {
setLoading() {
return !this.isLoaded ? 'loading' : '';
},
},
};
</script>
<template>
<div
v-if="!isLoaded"
:class="setLoading"
>
<i class="icon-spinner animate-spin" />
</div>
<div
v-else
class="d-main"
:class="setLoading"
>
<div class="d-header">
<i
class="icon icon-fw"
:class="icon"
/>
<n-link :to="link">
<h1>
{{ title }}
</h1>
</n-link>
</div>
<p>
{{ description }}
</p>
<n-link
class="btn role-secondary"
:to="cta"
>
{{ linkText }}
</n-link>
<hr>
<div
class="d-slot"
>
<h2 v-if="slotTitle">
{{ slotTitle }}
</h2>
<slot />
</div>
</div>
</template>
<style lang="scss" scoped>
.d-main, .loading {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: $space-m;
grid-auto-rows: 1fr;
gap: $space-m;
outline: 1px solid var(--border);
border-radius: var(--border-radius);
height: 100%;
// Header's style
.d-header {
display: flex;
align-items: center;
i {
margin-right: 5px ;
width: auto;
text-decoration: none;
}
h1 {
margin: 0;
font-size: 18px;
}
}
p {
min-height: 48px;
}
.d-slot {
width: 100%;
display: flex;
flex-direction: column;
h2 {
min-height: 18px;
font-size: 16px;
}
ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: $space-s;
li, .link {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
font-size: 14px;
&:not(:last-child) {
border-bottom: 1px solid var(--border);
padding-bottom: $space-s;
}
}
li > .disabled {
color: var(--disabled-text);
}
.disabled {
cursor: not-allowed;
}
}
}
}
.loading {
min-height: 325px;
overflow: hidden;
position: relative;
display: flex;
align-items: center;
place-content: center;
// INFO: Disable shimmer effect for now
// &::after {
// position: absolute;
// top: 0;
// right: 0;
// bottom: 0;
// left: 0;
// opacity: 0.1;
// transform: translateX(-100%);
// background-image: linear-gradient(
// 90deg,
// rgba(#fff, 0) 0,
// rgba(#fff, 0.2) 20%,
// rgba(#fff, 0.5) 60%,
// rgba(#fff, 0)
// );
// animation: shimmer 4s infinite;
// content: '';
// }
// @keyframes shimmer {
// 100% {
// transform: translateX(100%);
// }
// }
.animate-spin {
opacity: 0.5;
font-size: 24px;
animation: spin 5s linear infinite;
}
@keyframes spin {
100% {
transform: rotate(360deg);
}
}
}
</style>

View File

@ -14,6 +14,7 @@ export function init($plugin: any, store: any) {
configureType,
spoofedType,
weightType,
virtualType,
weightGroup
} = $plugin.DSL(store, $plugin.name);
@ -24,8 +25,8 @@ export function init($plugin: any, store: any) {
logo: require(`../assets/logo-epinio.svg`),
productNameKey: 'epinio.label',
aboutPage: createEpinioRoute('about', { cluster: EPINIO_STANDALONE_CLUSTER_NAME }),
afterLoginRoute: createEpinioRoute('c-cluster-applications', { cluster: EPINIO_STANDALONE_CLUSTER_NAME }),
logoRoute: createEpinioRoute('c-cluster-applications', { cluster: EPINIO_STANDALONE_CLUSTER_NAME }),
afterLoginRoute: createEpinioRoute('c-cluster-dashboard', { cluster: EPINIO_STANDALONE_CLUSTER_NAME }),
logoRoute: createEpinioRoute('c-cluster-dashboard', { cluster: EPINIO_STANDALONE_CLUSTER_NAME }),
disableSteveSockets: true,
});
}
@ -79,6 +80,15 @@ export function init($plugin: any, store: any) {
customRoute: createEpinioRoute('c-cluster-applications', { }),
});
virtualType({
label: store.getters['i18n/t']('epinio.intro.dashboard'),
icon: 'dashboard',
group: 'Root',
namespaced: false,
name: EPINIO_TYPES.DASHBOARD,
route: createEpinioRoute('c-cluster-dashboard', { })
});
// App Chart resource
configureType(EPINIO_TYPES.APP_CHARTS, {
isCreatable: false,
@ -149,11 +159,13 @@ export function init($plugin: any, store: any) {
EPINIO_TYPES.APP_CHARTS
], ADVANCED_GROUP);
weightType(EPINIO_TYPES.APP, 300, true);
weightType(EPINIO_TYPES.DASHBOARD, 300, true);
weightType(EPINIO_TYPES.APP, 250, true);
weightGroup(SERVICE_GROUP, 2, true);
weightType(EPINIO_TYPES.NAMESPACE, 100, true);
weightGroup(ADVANCED_GROUP, 1, true);
basicType([
EPINIO_TYPES.DASHBOARD,
EPINIO_TYPES.APP,
SERVICE_GROUP,
EPINIO_TYPES.NAMESPACE,

View File

@ -1,4 +1,8 @@
typeLabel:
withCount:
namespaces: Namespaces {n}
applications: Applications {n}
services: Services {n}
namespaces: |-
{count, plural,
one { Namespaces }
@ -42,12 +46,27 @@ epinio:
allPackages: See all packages
getBinaries: Get binaries
intro:
dashboard: Dashboard
welcome: Welcome to Epinio
blurb: The Application Development Engine for Kubernetes
description: Epinio takes your application from source code to deployment and allow for Developers and Operators to work better together!
learnMoreLink: https://epinio.io/
learnMore: Learn more
noNamespaces: Create a Namespace, then create your Applications
getStarted: Get started
issues: Issues
cards:
namespaces:
linkText: Create Namespace
slotTitle: Newest Namespaces
applications:
linkText: Deploy Application
running: Running
services:
title: Services
description: Create instances of your services. Instances can be bound to your Applications to provide data.
linkText: Create Instance
slotTitle: Quick start with
tableHeaders:
namespace: Namespace
instances:

View File

@ -45,7 +45,12 @@ export default {
},
...mapState('action-menu', ['showPromptRemove']),
},
mounted() {
// Opens the create namespace modal if the query is passed as query param
if (this.$route.query.mode === 'openModal') {
this.openCreateModal();
}
},
props: {
schema: {
type: Object,

View File

@ -0,0 +1,321 @@
<script lang="ts">
import { getVersionInfo } from '@shell/utils/version';
import Vue from 'vue';
import DashboardCard from '../../../components/dashboard/Cards.vue';
import { createEpinioRoute } from '@/pkg/epinio/utils/custom-routing';
import { EpinioApplicationResource, EpinioCatalogService, EPINIO_TYPES } from '@/pkg/epinio/types';
import ConsumptionGauge from '@shell/components/ConsumptionGauge.vue';
import Namespace from '~/shell/models/namespace';
import EpinioServiceModel from '~/pkg/epinio/models/services';
import isEqual from 'lodash/isEqual';
import { sortBy } from 'lodash';
export default Vue.extend<any, any, any, any>({
components: { DashboardCard, ConsumptionGauge },
async fetch() {
await Promise.all([
this.$store.dispatch(`epinio/findAll`, { type: EPINIO_TYPES.CATALOG_SERVICE }),
this.$store.dispatch(`epinio/findAll`, { type: EPINIO_TYPES.NAMESPACE }),
this.$store.dispatch(`epinio/findAll`, { type: EPINIO_TYPES.SERVICE_INSTANCE })
]);
},
data() {
return {
sectionContent: [
{
isEnable: true,
isLoaded: false,
icon: 'icon-namespace',
cta: createEpinioRoute('c-cluster-resource', { resource: EPINIO_TYPES.NAMESPACE }, { query: { mode: 'openModal' } }),
link: createEpinioRoute('c-cluster-resource', { resource: EPINIO_TYPES.NAMESPACE }),
linkText: this.t('epinio.intro.cards.namespaces.linkText'),
description: this.t('typeDescription.namespaces', {}, true),
slotTitle: this.t('epinio.intro.cards.namespaces.slotTitle')
},
{
isEnable: true,
isLoaded: false,
icon: 'icon-application',
cta: createEpinioRoute('c-cluster-applications-createapp', { resource: EPINIO_TYPES.APP }),
link: createEpinioRoute('c-cluster-applications', { resource: EPINIO_TYPES.APP }),
linkText: this.t('epinio.intro.cards.applications.linkText'),
description: this.t('typeDescription.applications'),
slotTitle: '',
},
{
isEnable: true,
isLoaded: false,
icon: 'icon-service',
cta: createEpinioRoute('c-cluster-resource-create', { resource: EPINIO_TYPES.SERVICE_INSTANCE }),
link: createEpinioRoute('c-cluster-resource', { resource: EPINIO_TYPES.SERVICE_INSTANCE }),
linkText: this.t('epinio.intro.cards.services.linkText'),
description: this.t('epinio.intro.cards.services.description'), // INFO: typeDescription to long for the dashboard card.
slotTitle: this.t('epinio.intro.cards.services.slotTitle')
}],
colorStops: {
0: '--info', 30: '--info', 70: '--info'
}
};
},
created() {
this.redoCards();
},
watch: {
namespaces(old, neu) {
if (isEqual(old, neu)) {
return;
}
this.redoCards();
},
apps(old, neu) {
if (isEqual(old, neu)) {
return;
}
this.redoCards();
},
services(old, neu) {
if (isEqual(old, neu)) {
return;
}
this.redoCards();
}
},
methods: {
redoCards() {
// Handles titles
this.sectionContent[0].title = this.t('typeLabel.withCount.namespaces', { n: this.namespaces.totalNamespaces });
this.sectionContent[1].title = this.t('typeLabel.withCount.applications', { n: this.apps?.totalApps });
this.sectionContent[2].title = this.t('typeLabel.withCount.services', { n: this.services?.servicesInstances });
// Handles descriptions
if (this.namespaces?.totalNamespaces ) {
this.sectionContent[0].isLoaded = true;
}
if (this.apps?.totalApps || this.apps?.totalApps === 0) {
this.sectionContent[1].isLoaded = true;
}
if (this.services?.servicesCatalog.length) {
this.sectionContent[2].isLoaded = true;
this.sectionContent[2].isEnable = true;
} else {
this.sectionContent[2].isLoaded = false;
this.sectionContent[2].isEnable = false;
}
}
},
computed: {
services() {
const fetchServicesInstances: EpinioServiceModel[] = this.$store.getters['epinio/all'](EPINIO_TYPES.SERVICE_INSTANCE);
const fetchServices: EpinioCatalogService[] = this.$store.getters['epinio/all'](EPINIO_TYPES.CATALOG_SERVICE);
// Try to find the desired services
const findDesiredServices = fetchServices?.filter(service => service?.shortId === 'mysql-dev' || service?.shortId === 'redis-dev');
// if not found, return the first two services from the catalog
const services: EpinioCatalogService[] | any =
findDesiredServices.length ? findDesiredServices : fetchServices.slice(0, 2);
const s = services.reduce((acc: any[], service: { shortId: string; }) => {
acc.push({
link: createEpinioRoute('c-cluster-resource-create', { resource: EPINIO_TYPES.SERVICE_INSTANCE, name: service?.shortId }, { query: { service: service?.shortId } }),
shortId: service?.shortId,
isEnabled: true
});
return acc;
}, []);
return {
servicesInstances: fetchServicesInstances.length,
servicesCatalog: s,
};
},
version() {
const { displayVersion } = getVersionInfo(this.$store);
return displayVersion;
},
apps() {
const allApps = this.$store.getters['epinio/all'](EPINIO_TYPES.APP) as EpinioApplicationResource[];
return allApps.reduce((acc, item) => {
return {
runningApps: acc.runningApps + (item.status === 'running' ? 1 : 0),
stoppedApps: acc.stoppedApps + (item.status === 'error' ? 1 : 0),
totalApps: acc.totalApps + 1,
};
}, {
runningApps: 0, stoppedApps: 0, totalApps: 0
});
},
namespaces() {
const allNamespaces: Namespace[] = this.$store.getters['epinio/all'](EPINIO_TYPES.NAMESPACE);
return { totalNamespaces: allNamespaces.length, latestNamespaces: sortBy(allNamespaces, 'metadata.createdAt').reverse().slice(0, 2) };
},
},
});
</script>
<template>
<div class="dashboard">
<div class="head">
<div class="head-title">
<h1>{{ t('epinio.intro.welcome') }}</h1>
<span>{{ version }}</span>
</div>
<p class="head-subheader">
{{ t('epinio.intro.blurb') }}
</p>
<p>
{{ t('epinio.intro.description') }}
</p>
<div class="head-links">
<a
href="https://epinio.io/"
target="_blank"
rel="noopener noreferrer nofollow"
>{{ t('epinio.intro.getStarted') }}</a>
<a
href="https://github.com/epinio/epinio/issues"
target="_blank"
rel="noopener noreferrer nofollow"
>{{ t('epinio.intro.issues') }}</a>
</div>
</div>
<div class="get-started">
<div
v-for="(card, index) in sectionContent"
:key="index"
>
<DashboardCard
v-if="card.isEnable"
:is-loaded="card.isLoaded"
:title="card.title"
:icon="card.icon"
:cta="card.cta"
:link="card.link"
:link-text="card.linkText"
:description="card.description"
:slot-title="card.slotTitle"
>
<span v-if="index === 0">
<slot>
<ul>
<li
v-for="(ns, i) in namespaces.latestNamespaces"
:key="i"
>
{{ ns.id }}
</li>
</ul>
</slot>
</span>
<span v-if="index === 1">
<slot>
<ConsumptionGauge
:resource-name="t('epinio.intro.cards.applications.running')"
:capacity="apps.totalApps"
:used-as-resource-name="true"
:color-stops="colorStops"
:used="apps.runningApps"
units="Apps"
/>
</slot>
</span>
<span v-if="index === 2">
<slot>
<ul>
<li
v-for="(service, i) in services.servicesCatalog"
:key="i"
>
<n-link
v-if="service.isEnabled"
:to="service.link"
class="link"
>
{{ service.shortId }}
<span>+</span>
</n-link>
<span
v-if="!service.isEnabled"
class="link disabled"
>
{{ service.shortId }}
<span>+</span>
</span>
</li>
</ul>
</slot>
</span>
</DashboardCard>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.dashboard {
display: flex;
flex-direction: column;
.head {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: $space-m;
outline: 1px solid var(--border);
border-radius: var(--border-radius);
margin: 0 0 20px 0;
padding: $space-m;
gap: $space-m;
&-title {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
h1 {
margin: 0;
}
span {
background: var(--primary);
color: var(--default);
border-radius: var(--border-radius);
padding: 4px 8px;
}
}
&-subheader {
font-size: 1.2rem;
font-weight: 500;
color: var(--text-secondary);
}
&-links {
display: flex;
gap: 10px;
}
}
.get-started {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
grid-gap: 20px;
}
}
</style>

View File

@ -5,6 +5,7 @@ import { EPINIO_PRODUCT_NAME } from '../types';
import CreateApp from '../pages/c/_cluster/applications/createapp/index.vue';
import ListApp from '../pages/c/_cluster/applications/index.vue';
import ListEpinio from '../pages/index.vue';
import Dashboard from '../pages/c/_cluster/dashboard.vue';
import AboutEpinio from '../pages/about.vue';
import ListEpinioResource from '../pages/c/_cluster/_resource/index.vue';
import CreateEpinioResource from '../pages/c/_cluster/_resource/create.vue';
@ -12,6 +13,11 @@ import ViewEpinioResource from '../pages/c/_cluster/_resource/_id.vue';
import ViewEpinioNsResource from '../pages/c/_cluster/_resource/_namespace/_id.vue';
const routes: RouteConfig[] = [{
name: `${ EPINIO_PRODUCT_NAME }-c-cluster-dashboard`,
path: `/:product/c/:cluster/dashboard`,
component: Dashboard,
},
{
name: `${ EPINIO_PRODUCT_NAME }-c-cluster-applications-createapp`,
path: `/:product/c/:cluster/applications/createapp`,
component: CreateApp,
@ -20,8 +26,8 @@ const routes: RouteConfig[] = [{
path: `/:product/c/:cluster/applications`,
component: ListApp,
}, {
name: `${ EPINIO_PRODUCT_NAME }`,
path: `/:product`,
name: `${ EPINIO_PRODUCT_NAME }-applications`,
path: `/:product/application`,
component: ListEpinio,
}, {
name: `${ EPINIO_PRODUCT_NAME }-about`,

View File

@ -20,6 +20,7 @@ export const EPINIO_TYPES = {
CATALOG_SERVICE: 'catalogservices',
SERVICE_INSTANCE: 'services',
// Internal
DASHBOARD: 'dashboard',
INSTANCE: 'instance',
APP_ACTION: 'application-action',
APP_INSTANCE: 'application-instance',

View File

@ -5,10 +5,11 @@ export const rootEpinioRoute = () => ({
params: { product: EPINIO_PRODUCT_NAME }
});
export const createEpinioRoute = (name: string, params: Object) => ({
export const createEpinioRoute = (name: string, params: Object, query?: Object) => ({
name: `${ rootEpinioRoute().name }-${ name }`,
params: {
...rootEpinioRoute().params,
...params
}
},
...query
});

View File

@ -106,7 +106,10 @@ export default {
{{ resourceName }}
</h4>
<span v-else>{{ t('node.detail.glance.consumptionGauge.used') }}</span>
<span>{{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }} <span class="ml-10 percentage">/&nbsp;{{ formattedPercentage }}</span></span>
<span class="numbers-stats">
{{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }}
<span class="percentage"><i>/&nbsp;</i>{{ formattedPercentage }}</span>
</span>
</slot>
</div>
<div class="mt-10">
@ -125,8 +128,20 @@ export default {
flex-direction: row;
justify-content: space-between;
&-stats {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
align-self: baseline;
}
.percentage {
font-weight: bold;
i {
margin-right: 10px;
}
}
}
}

View File

@ -14515,9 +14515,9 @@ queue-microtask@^1.2.2:
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
rancher-icons@rancher/icons#v2.0.9:
version "2.0.9"
resolved "https://codeload.github.com/rancher/icons/tar.gz/09048239918c9eccf49f7770948efef63fb04f24"
rancher-icons@rancher/icons#v2.0.12:
version "2.0.12"
resolved "https://codeload.github.com/rancher/icons/tar.gz/fa899fecced43835926ad6607b10a9f0c9c163da"
randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5, randombytes@^2.1.0:
version "2.1.0"