Merge branch 'knative-list' into 'master'

Modify Serverless Listing

See merge request gitlab-org/gitlab-ce!24072
This commit is contained in:
Phil Hughes 2019-02-06 09:45:31 +00:00
commit c7c5492cf4
16 changed files with 513 additions and 103 deletions

View file

@ -0,0 +1,65 @@
<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>

View file

@ -1,13 +1,11 @@
<script>
import PodBox from './pod_box.vue';
import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
import Url from './url.vue';
export default {
components: {
Icon,
PodBox,
ClipboardButton,
Url,
},
props: {
func: {
@ -36,24 +34,9 @@ export default {
<section id="serverless-function-details">
<h3>{{ name }}</h3>
<div class="append-bottom-default">
<div v-for="line in description.split('\n')" :key="line">{{ line }}<br /></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 v-for="(line, index) in description.split('\n')" :key="index">{{ line }}</div>
</div>
<url :uri="funcUrl" />
<h4>{{ s__('ServerlessDetails|Kubernetes Pods') }}</h4>
<div v-if="podCount > 0">

View file

@ -1,9 +1,12 @@
<script>
import Timeago from '~/vue_shared/components/time_ago_tooltip.vue';
import Url from './url.vue';
import { visitUrl } from '~/lib/utils/url_utility';
export default {
components: {
Timeago,
Url,
},
props: {
func: {
@ -16,13 +19,18 @@ export default {
return this.func.name;
},
description() {
return this.func.description;
const desc = this.func.description.split('\n');
if (desc.length > 1) {
return desc[1];
}
return desc[0];
},
detailUrl() {
return this.func.detail_url;
},
environment() {
return this.func.environment_scope;
targetUrl() {
return this.func.url;
},
image() {
return this.func.image;
@ -31,25 +39,34 @@ export default {
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>
<template>
<div class="gl-responsive-table-row">
<div class="table-section section-20 section-wrap">
<a :href="detailUrl">{{ name }}</a>
<li :id="name" class="group-row">
<div class="group-row-contents" role="button" @click="openDetails">
<p class="float-right text-right">
<span>{{ image }}</span
><br />
<timeago :time="timestamp" />
</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 class="table-section section-10">{{ environment }}</div>
<div class="table-section section-40 section-wrap">
<span class="line-break">{{ description }}</span>
</div>
<div class="table-section section-20">{{ image }}</div>
<div class="table-section section-10"><timeago :time="timestamp" /></div>
</div>
</li>
</template>
<style>
.line-break {
white-space: pre;
}
</style>

View file

@ -1,19 +1,21 @@
<script>
import { GlSkeletonLoading } from '@gitlab/ui';
import FunctionRow from './function_row.vue';
import EnvironmentRow from './environment_row.vue';
import EmptyState from './empty_state.vue';
export default {
components: {
EnvironmentRow,
FunctionRow,
EmptyState,
GlSkeletonLoading,
},
props: {
functions: {
type: Array,
type: Object,
required: true,
default: () => [],
default: () => ({}),
},
installed: {
type: Boolean,
@ -45,33 +47,21 @@ export default {
<section id="serverless-functions">
<div v-if="installed">
<div v-if="hasFunctionData">
<div class="ci-table js-services-list function-element">
<div class="gl-responsive-table-row table-row-header" role="row">
<div class="table-section section-20" role="rowheader">
{{ s__('Serverless|Function') }}
</div>
<div class="table-section section-10" role="rowheader">
{{ s__('Serverless|Cluster Env') }}
</div>
<div class="table-section section-40" role="rowheader">
{{ s__('Serverless|Description') }}
</div>
<div class="table-section section-20" role="rowheader">
{{ s__('Serverless|Runtime') }}
</div>
<div class="table-section section-10" role="rowheader">
{{ s__('Serverless|Last Update') }}
</div>
<template v-if="loadingData">
<div v-for="j in 3" :key="j" class="gl-responsive-table-row"><gl-skeleton-loading /></div>
</template>
<template v-else>
<div class="groups-list-tree-container">
<ul class="content-list group-list-tree">
<environment-row
v-for="(env, index) in functions"
:key="index"
:env="env"
:env-name="index"
/>
</ul>
</div>
<template v-if="loadingData">
<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>
</template>
</div>
<div v-else class="empty-state js-empty-state">
<div class="text-content">
@ -111,16 +101,3 @@ export default {
<empty-state v-else :clusters-path="clustersPath" :help-path="helpPath" />
</section>
</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>

View file

@ -0,0 +1,38 @@
<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>

View file

@ -1,7 +1,7 @@
export default class ServerlessStore {
constructor(knativeInstalled = false, clustersPath, helpPath) {
this.state = {
functions: [],
functions: {},
hasFunctionData: true,
loadingData: true,
installed: knativeInstalled,
@ -10,8 +10,13 @@ export default class ServerlessStore {
};
}
updateFunctionsFromServer(functions = []) {
this.state.functions = functions;
updateFunctionsFromServer(upstreamFunctions = []) {
this.state.functions = upstreamFunctions.reduce((rv, func) => {
const envs = rv;
envs[func.environment_scope] = (rv[func.environment_scope] || []).concat([func]);
return envs;
}, {});
}
updateLoadingState(loadingData) {

View file

@ -0,0 +1,3 @@
.url-text-field {
cursor: text;
}

View file

@ -0,0 +1,5 @@
---
title: Modified Knative list view to provide more details
merge_request: 24072
author: Chris Baumbauer
type: changed

View file

@ -6360,9 +6360,6 @@ msgstr ""
msgid "Serverless"
msgstr ""
msgid "ServerlessDetails|Copy URL to clipboard"
msgstr ""
msgid "ServerlessDetails|Kubernetes Pods"
msgstr ""
@ -6375,21 +6372,15 @@ msgstr ""
msgid "ServerlessDetails|pods in use"
msgstr ""
msgid "ServerlessURL|Copy URL to clipboard"
msgstr ""
msgid "Serverless| In order to start using functions as a service, you must first install Knative on your Kubernetes cluster."
msgstr ""
msgid "Serverless|An error occurred while retrieving serverless components"
msgstr ""
msgid "Serverless|Cluster Env"
msgstr ""
msgid "Serverless|Description"
msgstr ""
msgid "Serverless|Function"
msgstr ""
msgid "Serverless|Getting started with serverless"
msgstr ""
@ -6399,18 +6390,12 @@ msgstr ""
msgid "Serverless|Install Knative"
msgstr ""
msgid "Serverless|Last Update"
msgstr ""
msgid "Serverless|Learn more about Serverless"
msgstr ""
msgid "Serverless|No functions available"
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:"
msgstr ""

View file

@ -1,6 +1,10 @@
# frozen_string_literal: true
require 'spec_helper'
describe 'Functions', :js do
include KubernetesHelpers
let(:project) { create(:project) }
let(:user) { create(:user) }
@ -34,11 +38,14 @@ describe 'Functions', :js do
end
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(:project) { knative.cluster.project }
before do
stub_kubeclient_knative_services
stub_kubeclient_service_pods
visit project_serverless_functions_path(project)
end

View file

@ -0,0 +1,81 @@
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();
});
});
});

View file

@ -0,0 +1,33 @@
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();
});
});

View file

@ -0,0 +1,68 @@
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);
});
});

View file

@ -0,0 +1,28 @@
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();
});
});

View file

@ -0,0 +1,79 @@
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',
};

View file

@ -0,0 +1,36 @@
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');
});
});
});