Commit 17bae7c7 authored by Chris Baumbauer's avatar Chris Baumbauer

Modified Knative list view to provide more details

parent d0187de2
<script>
import FunctionRow from './function_row.vue';
import ItemCaret from '~/groups/components/item_caret.vue';
export default {
components: {
ItemCaret,
FunctionRow,
},
props: {
env: {
type: Array,
required: true,
},
envName: {
type: String,
required: true,
},
},
data() {
return {
isOpen: true,
};
},
computed: {
envId() {
if (this.envName === '*') {
return 'env-global';
}
return `env-${this.envName}`;
},
isOpenClass() {
return {
'is-open': this.isOpen,
};
},
},
methods: {
toggleOpen() {
this.isOpen = !this.isOpen;
},
},
};
</script>
<template>
<li :id="envId" :class="isOpenClass" class="group-row has-children">
<div
class="group-row-contents d-flex justify-content-end align-items-center"
role="button"
@click.stop="toggleOpen"
>
<div class="folder-toggle-wrap d-flex align-items-center">
<item-caret :is-group-open="isOpen" />
</div>
<div class="group-text flex-grow title namespace-title prepend-left-default">
{{ envName }}
</div>
</div>
<ul v-if="isOpen" class="content-list group-list-tree">
<function-row v-for="(f, index) in env" :key="f.name" :index="index" :func="f" />
</ul>
</li>
</template>
<script> <script>
import PodBox from './pod_box.vue'; import PodBox from './pod_box.vue';
import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; import Url from './url.vue';
import Icon from '~/vue_shared/components/icon.vue';
export default { export default {
components: { components: {
Icon,
PodBox, PodBox,
ClipboardButton, Url,
}, },
props: { props: {
func: { func: {
...@@ -36,24 +34,9 @@ export default { ...@@ -36,24 +34,9 @@ export default {
<section id="serverless-function-details"> <section id="serverless-function-details">
<h3>{{ name }}</h3> <h3>{{ name }}</h3>
<div class="append-bottom-default"> <div class="append-bottom-default">
<div v-for="line in description.split('\n')" :key="line">{{ line }}<br /></div> <div v-for="(line, index) in description.split('\n')" :key="index">{{ line }}</div>
</div>
<div class="clipboard-group append-bottom-default">
<div class="label label-monospace">{{ funcUrl }}</div>
<clipboard-button
:text="String(funcUrl)"
:title="s__('ServerlessDetails|Copy URL to clipboard')"
class="input-group-text js-clipboard-btn"
/>
<a
:href="funcUrl"
target="_blank"
rel="noopener noreferrer nofollow"
class="input-group-text btn btn-default"
>
<icon name="external-link" />
</a>
</div> </div>
<url :uri="funcUrl" />
<h4>{{ s__('ServerlessDetails|Kubernetes Pods') }}</h4> <h4>{{ s__('ServerlessDetails|Kubernetes Pods') }}</h4>
<div v-if="podCount > 0"> <div v-if="podCount > 0">
......
<script> <script>
import Timeago from '~/vue_shared/components/time_ago_tooltip.vue'; import Timeago from '~/vue_shared/components/time_ago_tooltip.vue';
import Url from './url.vue';
import { visitUrl } from '~/lib/utils/url_utility';
export default { export default {
components: { components: {
Timeago, Timeago,
Url,
}, },
props: { props: {
func: { func: {
...@@ -16,13 +19,18 @@ export default { ...@@ -16,13 +19,18 @@ export default {
return this.func.name; return this.func.name;
}, },
description() { description() {
return this.func.description; const desc = this.func.description.split('\n');
if (desc.length > 1) {
return desc[1];
}
return desc[0];
}, },
detailUrl() { detailUrl() {
return this.func.detail_url; return this.func.detail_url;
}, },
environment() { targetUrl() {
return this.func.environment_scope; return this.func.url;
}, },
image() { image() {
return this.func.image; return this.func.image;
...@@ -31,25 +39,34 @@ export default { ...@@ -31,25 +39,34 @@ export default {
return this.func.created_at; return this.func.created_at;
}, },
}, },
methods: {
checkClass(element) {
if (element.closest('.no-expand') === null) {
return true;
}
return false;
},
openDetails(e) {
if (this.checkClass(e.target)) {
visitUrl(this.detailUrl);
}
},
},
}; };
</script> </script>
<template> <template>
<div class="gl-responsive-table-row"> <li :id="name" class="group-row">
<div class="table-section section-20 section-wrap"> <div class="group-row-contents" role="button" @click="openDetails">
<a :href="detailUrl">{{ name }}</a> <p class="float-right text-right">
</div> <span>{{ image }}</span
<div class="table-section section-10">{{ environment }}</div> ><br />
<div class="table-section section-40 section-wrap"> <timeago :time="timestamp" />
<span class="line-break">{{ description }}</span> </p>
<b>{{ name }}</b>
<div v-for="line in description.split('\n')" :key="line">{{ line }}</div>
<url :uri="targetUrl" class="prepend-top-8 no-expand" />
</div> </div>
<div class="table-section section-20">{{ image }}</div> </li>
<div class="table-section section-10"><timeago :time="timestamp" /></div>
</div>
</template> </template>
<style>
.line-break {
white-space: pre;
}
</style>
<script> <script>
import { GlSkeletonLoading } from '@gitlab/ui'; import { GlSkeletonLoading } from '@gitlab/ui';
import FunctionRow from './function_row.vue'; import FunctionRow from './function_row.vue';
import EnvironmentRow from './environment_row.vue';
import EmptyState from './empty_state.vue'; import EmptyState from './empty_state.vue';
export default { export default {
components: { components: {
EnvironmentRow,
FunctionRow, FunctionRow,
EmptyState, EmptyState,
GlSkeletonLoading, GlSkeletonLoading,
}, },
props: { props: {
functions: { functions: {
type: Array, type: Object,
required: true, required: true,
default: () => [], default: () => ({}),
}, },
installed: { installed: {
type: Boolean, type: Boolean,
...@@ -45,33 +47,21 @@ export default { ...@@ -45,33 +47,21 @@ export default {
<section id="serverless-functions"> <section id="serverless-functions">
<div v-if="installed"> <div v-if="installed">
<div v-if="hasFunctionData"> <div v-if="hasFunctionData">
<div class="ci-table js-services-list function-element"> <template v-if="loadingData">
<div class="gl-responsive-table-row table-row-header" role="row"> <div v-for="j in 3" :key="j" class="gl-responsive-table-row"><gl-skeleton-loading /></div>
<div class="table-section section-20" role="rowheader"> </template>
{{ s__('Serverless|Function') }} <template v-else>
</div> <div class="groups-list-tree-container">
<div class="table-section section-10" role="rowheader"> <ul class="content-list group-list-tree">
{{ s__('Serverless|Cluster Env') }} <environment-row
</div> v-for="(env, index) in functions"
<div class="table-section section-40" role="rowheader"> :key="index"
{{ s__('Serverless|Description') }} :env="env"
</div> :env-name="index"
<div class="table-section section-20" role="rowheader"> />
{{ s__('Serverless|Runtime') }} </ul>
</div>
<div class="table-section section-10" role="rowheader">
{{ s__('Serverless|Last Update') }}
</div>
</div> </div>
<template v-if="loadingData"> </template>
<div v-for="j in 3" :key="j" class="gl-responsive-table-row">
<gl-skeleton-loading />
</div>
</template>
<template v-else>
<function-row v-for="f in functions" :key="f.name" :func="f" />
</template>
</div>
</div> </div>
<div v-else class="empty-state js-empty-state"> <div v-else class="empty-state js-empty-state">
<div class="text-content"> <div class="text-content">
...@@ -111,16 +101,3 @@ export default { ...@@ -111,16 +101,3 @@ export default {
<empty-state v-else :clusters-path="clustersPath" :help-path="helpPath" /> <empty-state v-else :clusters-path="clustersPath" :help-path="helpPath" />
</section> </section>
</template> </template>
<style>
.top-area {
border-bottom: 0;
}
.function-element {
border-bottom: 1px solid #e5e5e5;
border-bottom-color: rgb(229, 229, 229);
border-bottom-style: solid;
border-bottom-width: 1px;
}
</style>
<script>
import { GlButton } from '@gitlab/ui';
import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
Icon,
GlButton,
ClipboardButton,
},
props: {
uri: {
type: String,
required: true,
},
},
};
</script>
<template>
<div class="clipboard-group">
<div class="url-text-field label label-monospace">{{ uri }}</div>
<clipboard-button
:text="uri"
:title="s__('ServerlessURL|Copy URL to clipboard')"
class="input-group-text js-clipboard-btn"
/>
<gl-button
:href="uri"
target="_blank"
rel="noopener noreferrer nofollow"
class="input-group-text btn btn-default"
>
<icon name="external-link" />
</gl-button>
</div>
</template>
export default class ServerlessStore { export default class ServerlessStore {
constructor(knativeInstalled = false, clustersPath, helpPath) { constructor(knativeInstalled = false, clustersPath, helpPath) {
this.state = { this.state = {
functions: [], functions: {},
hasFunctionData: true, hasFunctionData: true,
loadingData: true, loadingData: true,
installed: knativeInstalled, installed: knativeInstalled,
...@@ -10,8 +10,13 @@ export default class ServerlessStore { ...@@ -10,8 +10,13 @@ export default class ServerlessStore {
}; };
} }
updateFunctionsFromServer(functions = []) { updateFunctionsFromServer(upstreamFunctions = []) {
this.state.functions = functions; this.state.functions = upstreamFunctions.reduce((rv, func) => {
const envs = rv;
envs[func.environment_scope] = (rv[func.environment_scope] || []).concat([func]);
return envs;
}, {});
} }
updateLoadingState(loadingData) { updateLoadingState(loadingData) {
......
.url-text-field {
cursor: text;
}
---
title: Modified Knative list view to provide more details
merge_request: 24072
author: Chris Baumbauer
type: changed
...@@ -6360,9 +6360,6 @@ msgstr "" ...@@ -6360,9 +6360,6 @@ msgstr ""
msgid "Serverless" msgid "Serverless"
msgstr "" msgstr ""
msgid "ServerlessDetails|Copy URL to clipboard"
msgstr ""
msgid "ServerlessDetails|Kubernetes Pods" msgid "ServerlessDetails|Kubernetes Pods"
msgstr "" msgstr ""
...@@ -6375,19 +6372,13 @@ msgstr "" ...@@ -6375,19 +6372,13 @@ msgstr ""
msgid "ServerlessDetails|pods in use" msgid "ServerlessDetails|pods in use"
msgstr "" msgstr ""
msgid "Serverless| In order to start using functions as a service, you must first install Knative on your Kubernetes cluster." msgid "ServerlessURL|Copy URL to clipboard"
msgstr ""
msgid "Serverless|An error occurred while retrieving serverless components"
msgstr ""
msgid "Serverless|Cluster Env"
msgstr "" msgstr ""
msgid "Serverless|Description" msgid "Serverless| In order to start using functions as a service, you must first install Knative on your Kubernetes cluster."
msgstr "" msgstr ""
msgid "Serverless|Function" msgid "Serverless|An error occurred while retrieving serverless components"
msgstr "" msgstr ""
msgid "Serverless|Getting started with serverless" msgid "Serverless|Getting started with serverless"
...@@ -6399,18 +6390,12 @@ msgstr "" ...@@ -6399,18 +6390,12 @@ msgstr ""
msgid "Serverless|Install Knative" msgid "Serverless|Install Knative"
msgstr "" msgstr ""
msgid "Serverless|Last Update"
msgstr ""
msgid "Serverless|Learn more about Serverless" msgid "Serverless|Learn more about Serverless"
msgstr "" msgstr ""
msgid "Serverless|No functions available" msgid "Serverless|No functions available"
msgstr "" msgstr ""
msgid "Serverless|Runtime"
msgstr ""
msgid "Serverless|There is currently no function data available from Knative. This could be for a variety of reasons including:" msgid "Serverless|There is currently no function data available from Knative. This could be for a variety of reasons including:"
msgstr "" msgstr ""
......
# frozen_string_literal: true
require 'spec_helper' require 'spec_helper'
describe 'Functions', :js do describe 'Functions', :js do
include KubernetesHelpers
let(:project) { create(:project) } let(:project) { create(:project) }
let(:user) { create(:user) } let(:user) { create(:user) }
...@@ -34,11 +38,14 @@ describe 'Functions', :js do ...@@ -34,11 +38,14 @@ describe 'Functions', :js do
end end
context 'when the user has a cluster and knative installed and visits the serverless page' do context 'when the user has a cluster and knative installed and visits the serverless page' do
let!(:cluster) { create(:cluster, :project, :provided_by_gcp) } let(:cluster) { create(:cluster, :project, :provided_by_gcp) }
let(:service) { cluster.platform_kubernetes }
let(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) } let(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) }
let(:project) { knative.cluster.project } let(:project) { knative.cluster.project }
before do before do
stub_kubeclient_knative_services
stub_kubeclient_service_pods
visit project_serverless_functions_path(project) visit project_serverless_functions_path(project)
end end
......
import Vue from 'vue';
import environmentRowComponent from '~/serverless/components/environment_row.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import ServerlessStore from '~/serverless/stores/serverless_store';
import { mockServerlessFunctions, mockServerlessFunctionsDiffEnv } from '../mock_data';
const createComponent = (env, envName) =>
mountComponent(Vue.extend(environmentRowComponent), { env, envName });
describe('environment row component', () => {
describe('default global cluster case', () => {
let vm;
beforeEach(() => {
const store = new ServerlessStore(false, '/cluster_path', 'help_path');
store.updateFunctionsFromServer(mockServerlessFunctions);
vm = createComponent(store.state.functions['*'], '*');
});
it('has the correct envId', () => {
expect(vm.envId).toEqual('env-global');
vm.$destroy();
});
it('is open by default', () => {
expect(vm.isOpenClass).toEqual({ 'is-open': true });
vm.$destroy();
});
it('generates correct output', () => {
expect(vm.$el.querySelectorAll('li').length).toEqual(2);
expect(vm.$el.id).toEqual('env-global');
expect(vm.$el.classList.contains('is-open')).toBe(true);
expect(vm.$el.querySelector('div.title').innerHTML.trim()).toEqual('*');
vm.$destroy();
});
it('opens and closes correctly', () => {
expect(vm.isOpen).toBe(true);
vm.toggleOpen();
Vue.nextTick(() => {
expect(vm.isOpen).toBe(false);
});
vm.$destroy();
});
});
describe('default named cluster case', () => {
let vm;
beforeEach(() => {
const store = new ServerlessStore(false, '/cluster_path', 'help_path');
store.updateFunctionsFromServer(mockServerlessFunctionsDiffEnv);
vm = createComponent(store.state.functions.test, 'test');
});
it('has the correct envId', () => {
expect(vm.envId).toEqual('env-test');
vm.$destroy();
});
it('is open by default', () => {
expect(vm.isOpenClass).toEqual({ 'is-open': true });
vm.$destroy();
});
it('generates correct output', () => {
expect(vm.$el.querySelectorAll('li').length).toEqual(1);
expect(vm.$el.id).toEqual('env-test');
expect(vm.$el.classList.contains('is-open')).toBe(true);
expect(vm.$el.querySelector('div.title').innerHTML.trim()).toEqual('test');
vm.$destroy();
});
});
});
import Vue from 'vue';
import functionRowComponent from '~/serverless/components/function_row.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockServerlessFunction } from '../mock_data';
const createComponent = func => mountComponent(Vue.extend(functionRowComponent), { func });
describe('functionRowComponent', () => {
it('Parses the function details correctly', () => {
const vm = createComponent(mockServerlessFunction);
expect(vm.$el.querySelector('b').innerHTML).toEqual(mockServerlessFunction.name);
expect(vm.$el.querySelector('span').innerHTML).toEqual(mockServerlessFunction.image);
expect(vm.$el.querySelector('time').getAttribute('data-original-title')).not.toBe(null);
expect(vm.$el.querySelector('div.url-text-field').innerHTML).toEqual(
mockServerlessFunction.url,
);
vm.$destroy();
});
it('handles clicks correctly', () => {
const vm = createComponent(mockServerlessFunction);
expect(vm.checkClass(vm.$el.querySelector('p'))).toBe(true); // check somewhere inside the row
expect(vm.checkClass(vm.$el.querySelector('svg'))).toBe(false); // check a button image
expect(vm.checkClass(vm.$el.querySelector('div.url-text-field'))).toBe(false); // check the url bar
vm.$destroy();
});
});
import Vue from 'vue';
import functionsComponent from '~/serverless/components/functions.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import ServerlessStore from '~/serverless/stores/serverless_store';
import { mockServerlessFunctions } from '../mock_data';
const createComponent = (
functions,
installed = true,
loadingData = true,
hasFunctionData = true,
) => {
const component = Vue.extend(functionsComponent);
return mountComponent(component, {
functions,
installed,
clustersPath: '/testClusterPath',
helpPath: '/helpPath',
loadingData,
hasFunctionData,
});
};
describe('functionsComponent', () => {
it('should render empty state when Knative is not installed', () => {
const vm = createComponent({}, false);
expect(vm.$el.querySelector('div.row').classList.contains('js-empty-state')).toBe(true);
expect(vm.$el.querySelector('h4.state-title').innerHTML.trim()).toEqual(
'Getting started with serverless',
);
vm.$destroy();
});
it('should render a loading component', () => {
const vm = createComponent({});
expect(vm.$el.querySelector('.gl-responsive-table-row')).not.toBe(null);
expect(vm.$el.querySelector('div.animation-container')).not.toBe(null);
});
it('should render empty state when there is no function data', () => {
const vm = createComponent({}, true, false, false);
expect(
vm.$el.querySelector('.empty-state, .js-empty-state').classList.contains('js-empty-state'),
).toBe(true);
expect(vm.$el.querySelector('h4.state-title').innerHTML.trim()).toEqual(
'No functions available',
);
vm.$destroy();
});
it('should render the functions list', () => {
const store = new ServerlessStore(false, '/cluster_path', 'help_path');
store.updateFunctionsFromServer(mockServerlessFunctions);
const vm = createComponent(store.state.functions, true, false);
expect(vm.$el.querySelector('div.groups-list-tree-container')).not.toBe(null);
expect(vm.$el.querySelector('#env-global').classList.contains('has-children')).toBe(true);
});
});
import Vue from 'vue';
import urlComponent from '~/serverless/components/url.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
const createComponent = uri => {
const component = Vue.extend(urlComponent);
return mountComponent(component, {
uri,
});
};
describe('urlComponent', () => {
it('should render correctly', () => {
const uri = 'http://testfunc.apps.example.com';
const vm = createComponent(uri);
expect(vm.$el.classList.contains('clipboard-group')).toBe(true);
expect(vm.$el.querySelector('.js-clipboard-btn').getAttribute('data-clipboard-text')).toEqual(
uri,
);
expect(vm.$el.querySelector('.url-text-field').innerHTML).toEqual(uri);
vm.$destroy();
});
});
export const mockServerlessFunctions = [
{
name: 'testfunc1',
namespace: 'tm-example',
environment_scope: '*',
cluster_id: 46,
detail_url: '/testuser/testproj/serverless/functions/*/testfunc1',
podcount: null,
created_at: '2019-02-05T01:01:23Z',
url: 'http://testfunc1.tm-example.apps.example.com',
description: 'A test service',
image: 'knative-test-container-buildtemplate',
},
{
name: 'testfunc2',
namespace: 'tm-example',
environment_scope: '*',
cluster_id: 46,
detail_url: '/testuser/testproj/serverless/functions/*/testfunc2',
podcount: null,
created_at: '2019-02-05T01:01:23Z',
url: 'http://testfunc2.tm-example.apps.example.com',
description: 'A second test service\nThis one with additional descriptions',
image: 'knative-test-echo-buildtemplate',
},
];
export const mockServerlessFunctionsDiffEnv = [
{
name: 'testfunc1',
namespace: 'tm-example',
environment_scope: '*',
cluster_id: 46,
detail_url: '/testuser/testproj/serverless/functions/*/testfunc1',
podcount: null,
created_at: '2019-02-05T01:01:23Z',
url: 'http://testfunc1.tm-example.apps.example.com',
description: 'A test service',
image: 'knative-test-container-buildtemplate',
},
{
name: 'testfunc2',
namespace: 'tm-example',
environment_scope: 'test',
cluster_id: 46,
detail_url: '/testuser/testproj/serverless/functions/*/testfunc2',
podcount: null,
created_at: '2019-02-05T01:01:23Z',
url: 'http://testfunc2.tm-example.apps.example.com',
description: 'A second test service\nThis one with additional descriptions',
image: 'knative-test-echo-buildtemplate',
},
];
export const mockServerlessFunction = {
name: 'testfunc1',
namespace: 'tm-example',
environment_scope: '*',
cluster_id: 46,
detail_url: '/testuser/testproj/serverless/functions/*/testfunc1',
podcount: '3',
created_at: '2019-02-05T01:01:23Z',
url: 'http://testfunc1.tm-example.apps.example.com',
description: 'A test service',
image: 'knative-test-container-buildtemplate',
};
export const mockMultilineServerlessFunction = {
name: 'testfunc1',
namespace: 'tm-example',
environment_scope: '*',
cluster_id: 46,
detail_url: '/testuser/testproj/serverless/functions/*/testfunc1',
podcount: '3',
created_at: '2019-02-05T01:01:23Z',
url: 'http://testfunc1.tm-example.apps.example.com',
description: 'testfunc1\nA test service line\\nWith additional services',
image: 'knative-test-container-buildtemplate',
};
import ServerlessStore from '~/serverless/stores/serverless_store';
import { mockServerlessFunctions, mockServerlessFunctionsDiffEnv } from '../mock_data';
describe('Serverless Functions Store', () => {
let store;
beforeEach(() => {
store = new ServerlessStore(false, '/cluster_path', 'help_path');
});
describe('#updateFunctionsFromServer', () => {
it('should pass an empty hash object', () => {
store.updateFunctionsFromServer();
expect(store.state.functions).toEqual({});
});
it('should group functions to one global environment', () => {
const mockServerlessData = mockServerlessFunctions;
store.updateFunctionsFromServer(mockServerlessData);
expect(Object.keys(store.state.functions)).toEqual(jasmine.objectContaining(['*']));
expect(store.state.functions['*'].length).toEqual(2);
});
it('should group functions to multiple environments', () => {
const mockServerlessData = mockServerlessFunctionsDiffEnv;
store.updateFunctionsFromServer(mockServerlessData);
expect(Object.keys(store.state.functions)).toEqual(jasmine.objectContaining(['*']));
expect(store.state.functions['*'].length).toEqual(1);
expect(store.state.functions.test.length).toEqual(1);
expect(store.state.functions.test[0].name).toEqual('testfunc2');
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment