Commit 1102deb0 authored by Chris Baumbauer's avatar Chris Baumbauer

Initial Serverless Functions detailed view

parent 71026ffd
<script>
import PodBox from './pod_box.vue';
import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
Icon,
PodBox,
ClipboardButton,
},
props: {
func: {
type: Object,
required: true,
},
},
computed: {
name() {
return this.func.name;
},
description() {
return this.func.description;
},
funcUrl() {
return this.func.url;
},
podCount() {
return this.func.podcount || 0;
},
},
};
</script>
<template>
<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>
<h4>{{ s__('ServerlessDetails|Kubernetes Pods') }}</h4>
<div v-if="podCount > 0">
<p>
<b v-if="podCount == 1">{{ podCount }} {{ s__('ServerlessDetails|pod in use') }}</b>
<b v-else>{{ podCount }} {{ s__('ServerlessDetails|pods in use') }}</b>
</p>
<pod-box :count="podCount" />
<p>
{{
s__('ServerlessDetails|Number of Kubernetes pods in use over time based on necessity.')
}}
</p>
</div>
<div v-else><p>No pods loaded at this time.</p></div>
</section>
</template>
......@@ -15,8 +15,14 @@ export default {
name() {
return this.func.name;
},
url() {
return this.func.url;
description() {
return this.func.description;
},
detailUrl() {
return this.func.detail_url;
},
environment() {
return this.func.environment_scope;
},
image() {
return this.func.image;
......@@ -30,11 +36,20 @@ export default {
<template>
<div class="gl-responsive-table-row">
<div class="table-section section-20">{{ name }}</div>
<div class="table-section section-50">
<a :href="url">{{ url }}</a>
<div class="table-section section-20 section-wrap">
<a :href="detailUrl">{{ name }}</a>
</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>
</template>
<style>
.line-break {
white-space: pre;
}
</style>
......@@ -50,8 +50,11 @@ export default {
<div class="table-section section-20" role="rowheader">
{{ s__('Serverless|Function') }}
</div>
<div class="table-section section-50" role="rowheader">
{{ s__('Serverless|Domain') }}
<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') }}
......
<script>
export default {
props: {
count: {
type: Number,
required: true,
},
color: {
type: String,
required: false,
default: 'green',
},
},
methods: {
boxOffset(i) {
return 20 * (i - 1);
},
},
};
</script>
<template>
<svg :width="boxOffset(count + 1)" :height="20">
<rect
v-for="i in count"
:key="i"
width="15"
height="15"
rx="5"
ry="5"
:fill="color"
:x="boxOffset(i)"
y="0"
/>
</svg>
</template>
......@@ -4,23 +4,65 @@ import { s__ } from '../locale';
import Flash from '../flash';
import Poll from '../lib/utils/poll';
import ServerlessStore from './stores/serverless_store';
import ServerlessDetailsStore from './stores/serverless_details_store';
import GetFunctionsService from './services/get_functions_service';
import Functions from './components/functions.vue';
import FunctionDetails from './components/function_details.vue';
export default class Serverless {
constructor() {
const { statusPath, clustersPath, helpPath, installed } = document.querySelector(
'.js-serverless-functions-page',
).dataset;
if (document.querySelector('.js-serverless-function-details-page') != null) {
const {
serviceName,
serviceDescription,
serviceEnvironment,
serviceUrl,
serviceNamespace,
servicePodcount,
} = document.querySelector('.js-serverless-function-details-page').dataset;
const el = document.querySelector('#js-serverless-function-details');
this.store = new ServerlessDetailsStore();
const { store } = this;
this.service = new GetFunctionsService(statusPath);
this.knativeInstalled = installed !== undefined;
this.store = new ServerlessStore(this.knativeInstalled, clustersPath, helpPath);
this.initServerless();
this.functionLoadCount = 0;
const service = {
name: serviceName,
description: serviceDescription,
environment: serviceEnvironment,
url: serviceUrl,
namespace: serviceNamespace,
podcount: servicePodcount,
};
if (statusPath && this.knativeInstalled) {
this.initPolling();
this.store.updateDetailedFunction(service);
this.functionDetails = new Vue({
el,
data() {
return {
state: store.state,
};
},
render(createElement) {
return createElement(FunctionDetails, {
props: {
func: this.state.functionDetail,
},
});
},
});
} else {
const { statusPath, clustersPath, helpPath, installed } = document.querySelector(
'.js-serverless-functions-page',
).dataset;
this.service = new GetFunctionsService(statusPath);
this.knativeInstalled = installed !== undefined;
this.store = new ServerlessStore(this.knativeInstalled, clustersPath, helpPath);
this.initServerless();
this.functionLoadCount = 0;
if (statusPath && this.knativeInstalled) {
this.initPolling();
}
}
}
......@@ -55,7 +97,7 @@ export default class Serverless {
resource: this.service,
method: 'fetchData',
successCallback: data => this.handleSuccess(data),
errorCallback: () => this.handleError(),
errorCallback: () => Serverless.handleError(),
});
if (!Visibility.hidden()) {
......@@ -64,7 +106,7 @@ export default class Serverless {
this.service
.fetchData()
.then(data => this.handleSuccess(data))
.catch(() => this.handleError());
.catch(() => Serverless.handleError());
}
Visibility.change(() => {
......@@ -102,5 +144,6 @@ export default class Serverless {
}
this.functions.$destroy();
this.functionDetails.$destroy();
}
}
export default class ServerlessDetailsStore {
constructor() {
this.state = {
functionDetail: {},
};
}
updateDetailedFunction(func) {
this.state.functionDetail = func;
}
}
......@@ -7,19 +7,17 @@ module Projects
before_action :authorize_read_cluster!
INDEX_PRIMING_INTERVAL = 10_000
INDEX_POLLING_INTERVAL = 30_000
INDEX_PRIMING_INTERVAL = 15_000
INDEX_POLLING_INTERVAL = 60_000
def index
finder = Projects::Serverless::FunctionsFinder.new(project.clusters)
respond_to do |format|
format.json do
functions = finder.execute
if functions.any?
Gitlab::PollingInterval.set_header(response, interval: INDEX_POLLING_INTERVAL)
render json: Projects::Serverless::ServiceSerializer.new(current_user: @current_user).represent(functions)
render json: serialize_function(functions)
else
Gitlab::PollingInterval.set_header(response, interval: INDEX_PRIMING_INTERVAL)
head :no_content
......@@ -32,6 +30,29 @@ module Projects
end
end
end
def show
@service = serialize_function(finder.service(params[:environment_id], params[:id]))
return not_found if @service.nil?
respond_to do |format|
format.json do
render json: @service
end
format.html
end
end
private
def finder
Projects::Serverless::FunctionsFinder.new(project.clusters)
end
def serialize_function(function)
Projects::Serverless::ServiceSerializer.new(current_user: @current_user, project: project).represent(function)
end
end
end
end
......@@ -15,11 +15,40 @@ module Projects
clusters_with_knative_installed.exists?
end
def service(environment_scope, name)
knative_service(environment_scope, name)&.first
end
private
def knative_service(environment_scope, name)
clusters_with_knative_installed.preload_knative.map do |cluster|
next if environment_scope != cluster.environment_scope
services = cluster.application_knative.services_for(ns: cluster.platform_kubernetes&.actual_namespace)
.select { |svc| svc["metadata"]["name"] == name }
add_metadata(cluster, services).first unless services.nil?
end
end
def knative_services
clusters_with_knative_installed.preload_knative.map do |cluster|
cluster.application_knative.services_for(ns: cluster.platform_kubernetes&.actual_namespace)
services = cluster.application_knative.services_for(ns: cluster.platform_kubernetes&.actual_namespace)
add_metadata(cluster, services) unless services.nil?
end
end
def add_metadata(cluster, services)
services.each do |s|
s["environment_scope"] = cluster.environment_scope
s["cluster_id"] = cluster.id
if services.length == 1
s["podcount"] = cluster.application_knative.service_pod_details(
cluster.platform_kubernetes&.actual_namespace,
s["metadata"]["name"]).length
end
end
end
......
......@@ -41,6 +41,8 @@ module Clusters
scope :for_cluster, -> (cluster) { where(cluster: cluster) }
after_save :clear_reactive_cache!
def chart
'knative/knative'
end
......@@ -79,7 +81,7 @@ module Clusters
end
def calculate_reactive_cache
{ services: read_services }
{ services: read_services, pods: read_pods }
end
def ingress_service
......@@ -87,7 +89,7 @@ module Clusters
end
def services_for(ns: namespace)
return unless services
return [] unless services
return [] unless ns
services.select do |service|
......@@ -95,8 +97,22 @@ module Clusters
end
end
def service_pod_details(ns, service)
with_reactive_cache do |data|
data[:pods].select { |pod| filter_pods(pod, ns, service) }
end
end
private
def read_pods
cluster.kubeclient.core_client.get_pods.as_json
end
def filter_pods(pod, namespace, service)
pod["metadata"]["namespace"] == namespace && pod["metadata"]["labels"]["serving.knative.dev/service"] == service
end
def read_services
client.get_services.as_json
rescue Kubeclient::ResourceNotFoundError
......
......@@ -13,6 +13,25 @@ module Projects
service.dig('metadata', 'namespace')
end
expose :environment_scope do |service|
service.dig('environment_scope')
end
expose :cluster_id do |service|
service.dig('cluster_id')
end
expose :detail_url do |service|
project_serverless_path(
request.project,
service.dig('environment_scope'),
service.dig('metadata', 'name'))
end
expose :podcount do |service|
service.dig('podcount')
end
expose :created_at do |service|
service.dig('metadata', 'creationTimestamp')
end
......@@ -22,11 +41,24 @@ module Projects
end
expose :description do |service|
service.dig('spec', 'runLatest', 'configuration', 'revisionTemplate', 'metadata', 'annotations', 'Description')
service.dig(
'spec',
'runLatest',
'configuration',
'revisionTemplate',
'metadata',
'annotations',
'Description')
end
expose :image do |service|
service.dig('spec', 'runLatest', 'configuration', 'build', 'template', 'name')
service.dig(
'spec',
'runLatest',
'configuration',
'build',
'template',
'name')
end
end
end
......
- @no_container = true
- @content_class = "limit-container-width" unless fluid_layout
- add_to_breadcrumbs('Serverless', project_serverless_functions_path(@project))
- page_title @service[:name]
.serverless-function-details-page.js-serverless-function-details-page{ data: { service: @service.as_json } }
%div{ class: [container_class, ('limit-container-width' unless fluid_layout)] }
.top-area.adjust
.serverless-function-details#js-serverless-function-details
.js-serverless-function-notice
.flash-container
.function-holder.js-function-holder.input-group
---
title: Add Knative detailed view
merge_request: 23863
author: Chris Baumbauer
type: added
......@@ -247,6 +247,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
end
namespace :serverless do
get '/functions/:environment_id/:id', to: 'functions#show'
resources :functions, only: [:index]
end
......
......@@ -167,8 +167,8 @@ appear under **Operations > Serverless**.
![serverless page](img/serverless-page.png)
This page contains all functions available for the project, the URL for
accessing the function, and if available, the function's runtime information.
This page contains all functions available for the project, the description for
accessing the function, and, if available, the function's runtime information.
The details are derived from the Knative installation inside each of the project's
Kubernetes cluster.
......@@ -184,6 +184,12 @@ The sample function can now be triggered from any HTTP client using a simple `PO
Currently, the Serverless page presents all functions available in all clusters registered for the project with Knative installed.
Clicking on the function name will provide additional details such as the
function's URL as well as runtime statistics such as the number of active pods
available to service the request based on load.
![serverless function details](img/serverless-details.png)
## Deploying Serverless applications
> Introduced in GitLab 11.5.
......
......@@ -6057,13 +6057,31 @@ msgstr ""
msgid "Serverless"
msgstr ""
msgid "ServerlessDetails|Copy URL to clipboard"
msgstr ""
msgid "ServerlessDetails|Kubernetes Pods"
msgstr ""
msgid "ServerlessDetails|Number of Kubernetes pods in use over time based on necessity."
msgstr ""
msgid "ServerlessDetails|pod in use"
msgstr ""
msgid "ServerlessDetails|pods in use"
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|Domain"
msgid "Serverless|Cluster Env"
msgstr ""
msgid "Serverless|Description"
msgstr ""
msgid "Serverless|Function"
......
......@@ -45,9 +45,45 @@ describe Projects::Serverless::FunctionsController do
end
end
describe 'GET #show' do
context 'invalid data' do
it 'has a bad function name' do
get :show, params: params({ format: :json, environment_id: "*", id: "foo" })
expect(response).to have_gitlab_http_status(404)
end
end
context 'valid data', :use_clean_rails_memory_store_caching do
before do
stub_kubeclient_service_pods
stub_reactive_cache(knative,
{
services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"],
pods: kube_knative_pods_body(cluster.project.name, namespace.namespace)["items"]
})
end
it 'has a valid function name' do
get :show, params: params({ format: :json, environment_id: "*", id: cluster.project.name })
expect(response).to have_gitlab_http_status(200)
expect(json_response).to include(
"name" => project.name,
"url" => "http://#{project.name}.#{namespace.namespace}.example.com",
"podcount" => 1
)
end
end
end
describe 'GET #index with data', :use_clean_rails_memory_store_caching do
before do
stub_reactive_cache(knative, services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"])
stub_kubeclient_service_pods
stub_reactive_cache(knative,
{
services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"],
pods: kube_knative_pods_body(cluster.project.name, namespace.namespace)["items"]
})
end
it 'has data' do
......
......@@ -29,15 +29,34 @@ describe Projects::Serverless::FunctionsFinder do
context 'has knative installed' do
let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) }
let(:finder) { described_class.new(project.clusters) }
it 'there are no functions' do
expect(described_class.new(project.clusters).execute).to be_empty
expect(finder.execute).to be_empty
end
it 'there are functions', :use_clean_rails_memory_store_caching do
stub_reactive_cache(knative, services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"])
stub_kubeclient_service_pods
stub_reactive_cache(knative,
{
services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"],
pods: kube_knative_pods_body(cluster.project.name, namespace.namespace)["items"]
})
expect(described_class.new(project.clusters).execute).not_to be_empty
expect(finder.execute).not_to be_empty
end
it 'has a function', :use_clean_rails_memory_store_caching do
stub_kubeclient_service_pods
stub_reactive_cache(knative,
{
services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"],
pods: kube_knative_pods_body(cluster.project.name, namespace.namespace)["items"]
})
result = finder.service(cluster.environment_scope, cluster.project.name)
expect(result).not_to be_empty
expect(result["metadata"]["name"]).to be_eql(cluster.project.name)
end
end
end
......
......@@ -149,6 +149,35 @@ describe Clusters::Applications::Knative do
it { is_expected.to validate_presence_of(:hostname) }
end
describe '#service_pod_details' do
let(:cluster) { create(:cluster, :project, :provided_by_gcp) }
let(:service) { cluster.platform_kubernetes }
let(:knative) { create(:clusters_applications_knative, cluster: cluster) }
let(:namespace) do
create(:cluster_kubernetes_namespace,
cluster: cluster,
cluster_project: cluster.cluster_project,
project: cluster.cluster_project.project)
end
before do
stub_kubeclient_discover(service.api_url)
stub_kubeclient_knative_services
stub_kubeclient_service_pods
stub_reactive_cache(knative,
{
services: kube_response(kube_knative_services_body),
pods: kube_response(kube_knative_pods_body(cluster.cluster_project.project.name, namespace.namespace))
})
synchronous_reactive_cache(knative)
end
it 'should be able k8s core for pod details' do
expect(knative.service_pod_details(namespace.namespace, cluster.cluster_project.project.name)).not_to be_nil
end
end
describe '#services' do
let(:cluster) { create(:cluster, :project, :provided_by_gcp) }
let(:service) { cluster.platform_kubernetes }
......@@ -166,6 +195,7 @@ describe Clusters::Applications::Knative do
before do
stub_kubeclient_discover(service.api_url)
stub_kubeclient_knative_services
stub_kubeclient_service_pods
end
it 'should have an unintialized cache' do
......@@ -174,7 +204,11 @@ describe Clusters::Applications::Knative do
context 'when using synchronous reactive cache' do
before do