From 894379b7f70df4eb8d7c4e6b7be59aee7c3f05de Mon Sep 17 00:00:00 2001 From: ricoberger Date: Thu, 1 Apr 2021 21:14:33 +0200 Subject: [PATCH] Add preview for Applications via plugins This commit adds a new "plugin" field to the details section of the Application CR. This allows a user to specify a plugin, which is then used as preview for the Application in the Application overview page. At the moment a user can specify a Prometheus or Elasticsearch query for the preview. When this is specified the corresponding Prometheus chart with the metrics for the query, or the number of hits and the buckets chart will be displayed in the Application card. If the user doesn't specify a plugin, we will display the provided description of the Application. This commit also fixes a small bug, where the list of resources wasn't updated correctly, because we missed to compare the properties in the "React.memo" function. --- CHANGELOG.md | 1 + .../applications/ApplicationItem.tsx | 9 +- app/src/components/plugins/Preview.tsx | 29 ++++ .../resources/ResourcesListItem.tsx | 13 +- .../elasticsearch/ElasticsearchPreview.tsx | 19 +++ .../ElasticsearchPreviewChart.tsx | 93 +++++++++++++ .../plugins/prometheus/PrometheusPreview.tsx | 19 +++ .../prometheus/PrometheusPreviewChart.tsx | 96 +++++++++++++ app/src/proto/application_pb.d.ts | 6 + app/src/proto/application_pb.js | 53 ++++++- app/src/utils/plugins.tsx | 5 + .../kustomize/crds/kobs.io_applications.yaml | 130 ++++++++++++++++++ .../application/proto/application.pb.go | 51 ++++--- proto/application.proto | 1 + 14 files changed, 501 insertions(+), 24 deletions(-) create mode 100644 app/src/components/plugins/Preview.tsx create mode 100644 app/src/plugins/elasticsearch/ElasticsearchPreview.tsx create mode 100644 app/src/plugins/elasticsearch/ElasticsearchPreviewChart.tsx create mode 100644 app/src/plugins/prometheus/PrometheusPreview.tsx create mode 100644 app/src/plugins/prometheus/PrometheusPreviewChart.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 384b70556..c21bc96bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ NOTE: As semantic versioning states all 0.y.z releases can contain breaking chan - [#16](https://github.com/kobsio/kobs/pull/16): Add support for multiple queries in the Prometheus plugin page. - [#18](https://github.com/kobsio/kobs/pull/18): Add metrics and logs for the gRPC server. - [#19](https://github.com/kobsio/kobs/pull/19): Use multiple colors in the Jaeger plugin. Each service in a trace has a unique color now, which is used for the charts. +- [#21](https://github.com/kobsio/kobs/pull/21): Add preview for Applications via plugins. ### Fixed diff --git a/app/src/components/applications/ApplicationItem.tsx b/app/src/components/applications/ApplicationItem.tsx index 4d6eeeaee..7f6039f63 100644 --- a/app/src/components/applications/ApplicationItem.tsx +++ b/app/src/components/applications/ApplicationItem.tsx @@ -2,6 +2,7 @@ import { Card, CardBody, CardTitle } from '@patternfly/react-core'; import React from 'react'; import { Application } from 'proto/application_pb'; +import Preview from 'components/plugins/Preview'; interface IApplicationItemProps { application: Application.AsObject; @@ -19,7 +20,13 @@ const ApplicationItem: React.FunctionComponent = ({ selectApplication(application)}> {application.name} - {application.details ? application.details.description : `${application.namespace} (${application.cluster})`} + {application.details && application.details.plugin ? ( + + ) : application.details ? ( + application.details.description + ) : ( + `${application.namespace} (${application.cluster})` + )} ); diff --git a/app/src/components/plugins/Preview.tsx b/app/src/components/plugins/Preview.tsx new file mode 100644 index 000000000..04ea9ceb0 --- /dev/null +++ b/app/src/components/plugins/Preview.tsx @@ -0,0 +1,29 @@ +import { Alert, AlertVariant } from '@patternfly/react-core'; +import React, { useContext } from 'react'; + +import { IPluginsContext, PluginsContext } from 'context/PluginsContext'; +import { Plugin as IPlugin } from 'proto/plugins_grpc_web_pb'; +import { plugins } from 'utils/plugins'; + +interface IPreviewProps { + plugin: IPlugin.AsObject; +} + +const Preview: React.FunctionComponent = ({ plugin }: IPreviewProps) => { + const pluginsContext = useContext(PluginsContext); + const pluginDetails = pluginsContext.getPluginDetails(plugin.name); + + if (!pluginDetails || !plugins.hasOwnProperty(pluginDetails.type)) { + return ; + } + + const Component = plugins[pluginDetails.type].preview; + + if (!Component) { + return ; + } + + return ; +}; + +export default Preview; diff --git a/app/src/components/resources/ResourcesListItem.tsx b/app/src/components/resources/ResourcesListItem.tsx index a57291fe0..a1e2fc93f 100644 --- a/app/src/components/resources/ResourcesListItem.tsx +++ b/app/src/components/resources/ResourcesListItem.tsx @@ -77,6 +77,15 @@ const ResourcesListItem: React.FunctionComponent = ({ ); }; -export default memo(ResourcesListItem, () => { - return true; +export default memo(ResourcesListItem, (prevProps, nextProps) => { + if ( + JSON.stringify(prevProps.clusters) === JSON.stringify(nextProps.clusters) && + JSON.stringify(prevProps.namespaces) === JSON.stringify(nextProps.namespaces) && + JSON.stringify(prevProps.resource) === JSON.stringify(nextProps.resource) && + prevProps.selector === nextProps.selector + ) { + return true; + } + + return false; }); diff --git a/app/src/plugins/elasticsearch/ElasticsearchPreview.tsx b/app/src/plugins/elasticsearch/ElasticsearchPreview.tsx new file mode 100644 index 000000000..915284b63 --- /dev/null +++ b/app/src/plugins/elasticsearch/ElasticsearchPreview.tsx @@ -0,0 +1,19 @@ +import { Alert, AlertVariant } from '@patternfly/react-core'; +import React from 'react'; + +import ElasticsearchPreviewChart from 'plugins/elasticsearch/ElasticsearchPreviewChart'; +import { IPluginProps } from 'utils/plugins'; + +const ElasticsearchPreview: React.FunctionComponent = ({ name, description, plugin }: IPluginProps) => { + if ( + !plugin.elasticsearch || + plugin.elasticsearch.queriesList.length !== 1 || + plugin.elasticsearch.queriesList[0].query === '' + ) { + return ; + } + + return ; +}; + +export default ElasticsearchPreview; diff --git a/app/src/plugins/elasticsearch/ElasticsearchPreviewChart.tsx b/app/src/plugins/elasticsearch/ElasticsearchPreviewChart.tsx new file mode 100644 index 000000000..428decb4b --- /dev/null +++ b/app/src/plugins/elasticsearch/ElasticsearchPreviewChart.tsx @@ -0,0 +1,93 @@ +import { Alert, AlertVariant } from '@patternfly/react-core'; +import { ChartBar, ChartGroup } from '@patternfly/react-charts'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import { + Bucket, + ElasticsearchPromiseClient, + GetLogsRequest, + GetLogsResponse, + Query, +} from 'proto/elasticsearch_grpc_web_pb'; +import { apiURL } from 'utils/constants'; + +// elasticsearchService is the gRPC service to get the number of documents and buckets for the defined query. +const elasticsearchService = new ElasticsearchPromiseClient(apiURL, null, null); + +interface IDataState { + buckets: Bucket.AsObject[]; + error: string; + hits: number; +} + +interface IElasticsearchPreviewChartProps { + name: string; + query: Query.AsObject; +} + +const ElasticsearchPreviewChart: React.FunctionComponent = ({ + name, + query, +}: IElasticsearchPreviewChartProps) => { + const refChart = useRef(null); + const [width, setWidth] = useState(0); + const [height, setHeight] = useState(0); + const [data, setData] = useState({ buckets: [], error: '', hits: 0 }); + + const fetchData = useCallback(async () => { + try { + const q = new Query(); + q.setQuery(query.query); + + const getLogsRequest = new GetLogsRequest(); + getLogsRequest.setName(name); + getLogsRequest.setScrollid(''); + getLogsRequest.setTimeend(Math.floor(Date.now() / 1000)); + getLogsRequest.setTimestart(Math.floor(Date.now() / 1000) - 900); + getLogsRequest.setQuery(q); + + const getLogsResponse: GetLogsResponse = await elasticsearchService.getLogs(getLogsRequest, null); + const tmpLogsResponse = getLogsResponse.toObject(); + + setData({ buckets: tmpLogsResponse.bucketsList, error: '', hits: tmpLogsResponse.hits }); + } catch (err) { + setData({ buckets: [], error: err.message, hits: 0 }); + } + }, [name, query]); + + // useEffect is executed on every render of this component. This is needed, so that we are able to use a width of 100% + // and a static height for the chart. + useEffect(() => { + if (refChart && refChart.current) { + setWidth(refChart.current.getBoundingClientRect().width); + setHeight(refChart.current.getBoundingClientRect().height); + } + }, [data.buckets]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + if (data.error) { + return ( + + ); + } + + return ( + +
{data.hits} Hits
+ {query.name ? ( +
{query.name}
+ ) : null} + +
+ + + +
+
+ ); +}; + +export default ElasticsearchPreviewChart; diff --git a/app/src/plugins/prometheus/PrometheusPreview.tsx b/app/src/plugins/prometheus/PrometheusPreview.tsx new file mode 100644 index 000000000..fe9f7fe5f --- /dev/null +++ b/app/src/plugins/prometheus/PrometheusPreview.tsx @@ -0,0 +1,19 @@ +import { Alert, AlertVariant } from '@patternfly/react-core'; +import React from 'react'; + +import { IPluginProps } from 'utils/plugins'; +import PrometheusPreviewChart from 'plugins/prometheus/PrometheusPreviewChart'; + +const PrometheusPreview: React.FunctionComponent = ({ name, description, plugin }: IPluginProps) => { + if ( + !plugin.prometheus || + plugin.prometheus.chartsList.length !== 1 || + plugin.prometheus.chartsList[0].queriesList.length !== 1 + ) { + return ; + } + + return ; +}; + +export default PrometheusPreview; diff --git a/app/src/plugins/prometheus/PrometheusPreviewChart.tsx b/app/src/plugins/prometheus/PrometheusPreviewChart.tsx new file mode 100644 index 000000000..7f317c251 --- /dev/null +++ b/app/src/plugins/prometheus/PrometheusPreviewChart.tsx @@ -0,0 +1,96 @@ +import { Alert, AlertVariant } from '@patternfly/react-core'; +import { ChartArea, ChartGroup } from '@patternfly/react-charts'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import { + Chart, + GetMetricsRequest, + GetMetricsResponse, + Metrics, + PrometheusPromiseClient, + Query, +} from 'proto/prometheus_grpc_web_pb'; +import { apiURL } from 'utils/constants'; + +// prometheusService is the gRPC service to get the metrics for the defined query in a chart. +const prometheusService = new PrometheusPromiseClient(apiURL, null, null); + +interface IDataState { + error: string; + metrics: Metrics.AsObject[]; +} + +interface IPrometheusPreviewChartProps { + name: string; + chart: Chart.AsObject; +} + +const PrometheusPreviewChart: React.FunctionComponent = ({ + name, + chart, +}: IPrometheusPreviewChartProps) => { + const refChart = useRef(null); + const [width, setWidth] = useState(0); + const [height, setHeight] = useState(0); + const [data, setData] = useState({ error: '', metrics: [] }); + + const fetchData = useCallback(async () => { + try { + const query = new Query(); + query.setQuery(chart.queriesList[0].query); + query.setLabel(chart.queriesList[0].label); + + const getMetricsRequest = new GetMetricsRequest(); + getMetricsRequest.setName(name); + getMetricsRequest.setTimeend(Math.floor(Date.now() / 1000)); + getMetricsRequest.setTimestart(Math.floor(Date.now() / 1000) - 900); + getMetricsRequest.setQueriesList([query]); + getMetricsRequest.setVariablesList([]); + + const getMetricsResponse: GetMetricsResponse = await prometheusService.getMetrics(getMetricsRequest, null); + setData({ error: '', metrics: getMetricsResponse.toObject().metricsList }); + } catch (err) { + setData({ error: err.message, metrics: [] }); + } + }, [name, chart]); + + // useEffect is executed on every render of this component. This is needed, so that we are able to use a width of 100% + // and a static height for the chart. + useEffect(() => { + if (refChart && refChart.current) { + setWidth(refChart.current.getBoundingClientRect().width); + setHeight(refChart.current.getBoundingClientRect().height); + } + }, [data.metrics]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + if (data.error || data.metrics.length === 0) { + return ( + + ); + } + + return ( + +
+ {data.metrics[0].dataList[data.metrics[0].dataList.length - 1].y.toFixed(2)} {chart.unit} +
+ {chart.title ? ( +
{chart.title}
+ ) : null} + +
+ + {data.metrics.map((metric, index) => ( + + ))} + +
+
+ ); +}; + +export default PrometheusPreviewChart; diff --git a/app/src/proto/application_pb.d.ts b/app/src/proto/application_pb.d.ts index 7d9a12186..2eee84798 100644 --- a/app/src/proto/application_pb.d.ts +++ b/app/src/proto/application_pb.d.ts @@ -59,6 +59,11 @@ export class Details extends jspb.Message { setLinksList(value: Array): void; addLinks(value?: Link, index?: number): Link; + hasPlugin(): boolean; + clearPlugin(): void; + getPlugin(): plugins_pb.Plugin | undefined; + setPlugin(value?: plugins_pb.Plugin): void; + serializeBinary(): Uint8Array; toObject(includeInstance?: boolean): Details.AsObject; static toObject(includeInstance: boolean, msg: Details): Details.AsObject; @@ -73,6 +78,7 @@ export namespace Details { export type AsObject = { description: string, linksList: Array, + plugin?: plugins_pb.Plugin.AsObject, } } diff --git a/app/src/proto/application_pb.js b/app/src/proto/application_pb.js index b9b83345d..b1bb6a01e 100644 --- a/app/src/proto/application_pb.js +++ b/app/src/proto/application_pb.js @@ -499,7 +499,8 @@ proto.application.Details.toObject = function(includeInstance, msg) { var f, obj = { description: jspb.Message.getFieldWithDefault(msg, 1, ""), linksList: jspb.Message.toObjectList(msg.getLinksList(), - proto.application.Link.toObject, includeInstance) + proto.application.Link.toObject, includeInstance), + plugin: (f = msg.getPlugin()) && plugins_pb.Plugin.toObject(includeInstance, f) }; if (includeInstance) { @@ -545,6 +546,11 @@ proto.application.Details.deserializeBinaryFromReader = function(msg, reader) { reader.readMessage(value,proto.application.Link.deserializeBinaryFromReader); msg.addLinks(value); break; + case 3: + var value = new plugins_pb.Plugin; + reader.readMessage(value,plugins_pb.Plugin.deserializeBinaryFromReader); + msg.setPlugin(value); + break; default: reader.skipField(); break; @@ -589,6 +595,14 @@ proto.application.Details.serializeBinaryToWriter = function(message, writer) { proto.application.Link.serializeBinaryToWriter ); } + f = message.getPlugin(); + if (f != null) { + writer.writeMessage( + 3, + f, + plugins_pb.Plugin.serializeBinaryToWriter + ); + } }; @@ -648,6 +662,43 @@ proto.application.Details.prototype.clearLinksList = function() { }; +/** + * optional plugins.Plugin plugin = 3; + * @return {?proto.plugins.Plugin} + */ +proto.application.Details.prototype.getPlugin = function() { + return /** @type{?proto.plugins.Plugin} */ ( + jspb.Message.getWrapperField(this, plugins_pb.Plugin, 3)); +}; + + +/** + * @param {?proto.plugins.Plugin|undefined} value + * @return {!proto.application.Details} returns this +*/ +proto.application.Details.prototype.setPlugin = function(value) { + return jspb.Message.setWrapperField(this, 3, value); +}; + + +/** + * Clears the message field making it undefined. + * @return {!proto.application.Details} returns this + */ +proto.application.Details.prototype.clearPlugin = function() { + return this.setPlugin(undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.application.Details.prototype.hasPlugin = function() { + return jspb.Message.getField(this, 3) != null; +}; + + diff --git a/app/src/utils/plugins.tsx b/app/src/utils/plugins.tsx index 91292f278..6af8c45fa 100644 --- a/app/src/utils/plugins.tsx +++ b/app/src/utils/plugins.tsx @@ -2,10 +2,12 @@ import React from 'react'; import ElasticsearchPage from 'plugins/elasticsearch/ElasticsearchPage'; import ElasticsearchPlugin from 'plugins/elasticsearch/ElasticsearchPlugin'; +import ElasticsearchPreview from 'plugins/elasticsearch/ElasticsearchPreview'; import JaegerPage from 'plugins/jaeger/JaegerPage'; import JaegerPlugin from 'plugins/jaeger/JaegerPlugin'; import PrometheusPage from 'plugins/prometheus/PrometheusPage'; import PrometheusPlugin from 'plugins/prometheus/PrometheusPlugin'; +import PrometheusPreview from 'plugins/prometheus/PrometheusPreview'; import { Plugin as IProtoPlugin } from 'proto/plugins_grpc_web_pb'; @@ -31,6 +33,7 @@ export interface IPluginProps { export interface IPlugin { page: React.FunctionComponent; plugin: React.FunctionComponent; + preview?: React.FunctionComponent; } // IPlugins is the interface for a list of plugins. The key of this interface is the plugin type and must correspond @@ -44,6 +47,7 @@ export const plugins: IPlugins = { elasticsearch: { page: ElasticsearchPage, plugin: ElasticsearchPlugin, + preview: ElasticsearchPreview, }, jaeger: { page: JaegerPage, @@ -52,5 +56,6 @@ export const plugins: IPlugins = { prometheus: { page: PrometheusPage, plugin: PrometheusPlugin, + preview: PrometheusPreview, }, }; diff --git a/deploy/kustomize/crds/kobs.io_applications.yaml b/deploy/kustomize/crds/kobs.io_applications.yaml index b148925a3..5ad324e6f 100644 --- a/deploy/kustomize/crds/kobs.io_applications.yaml +++ b/deploy/kustomize/crds/kobs.io_applications.yaml @@ -63,6 +63,136 @@ spec: type: string type: object type: array + plugin: + description: Plugin is the plugin formate, which can be used within + the Application CR. Each plugin requires a name. The plugin specific + fields like "prometheus", "elasticsearch" and "jaeger" are mutually + exclusive and containing the data, which is needed to use the + plugin within a Application CR. + properties: + elasticsearch: + description: Spec implements the specification for an application. + This field is then used in the Application CR and contains, + all possible fields, which can be used by a user to work with + their logs in Elasticsaerch. + properties: + queries: + items: + description: Query represents a single query for an application. + A query is identified by a name, a query and a list + of fields, which should be shown in the results table. + If the fields list is empty, we show the complete document + in the table. + properties: + fields: + items: + type: string + type: array + name: + type: string + query: + type: string + type: object + type: array + type: object + jaeger: + properties: + queries: + items: + properties: + name: + type: string + operation: + type: string + service: + type: string + tags: + type: string + type: object + type: array + type: object + name: + type: string + prometheus: + description: Spec implements the specification for an application. + This field is then used in the Application CR and contains, + all possible fields, which can be used by a user to work with + variables and charts for their data in Prometheus. + properties: + charts: + items: + description: Chart represents a chart for the metrics + view. A chart must contain a title, a type (line, area, bar + chart, etc.). It can also contain a unit for the y axis. + If the stacked option is set to true all series for + the chart will be stacked. The size parameter can be + used to define the width of a chart for large screens. + We are using a 12 column grid to display the charts, + so the number must be between 1 and 12. The last option + is a list of queries, which are executed against the + datasource (e.g. For Prometheus this will be a list + of PromQL queries). + properties: + queries: + items: + description: Query presents a single query to get + the data, which should be shown in the chart for + the metrics section. A query consists of a query + string (e.g. PromQL) and a lable. The query and + the label can contain variables via Go templating + syntax (e.g. {{ .VARIABLE-NAME }}). For Prometheus + the label can also contain a label from the returned + series with the same syntax (e.g. {{ .SERIES-LABEL + }}). + properties: + label: + type: string + query: + type: string + type: object + type: array + size: + format: int64 + type: integer + stacked: + type: boolean + title: + type: string + type: + type: string + unit: + type: string + type: object + type: array + variables: + items: + description: Variable specifies a variable, which can + be used within the charts. A variable must contain a + name, a label and a query. It also can set the allowAll + field to true, which will include an "All" option in + the variables values. The values and value field must + not be provided by the user. These fields will be set + by the GetVariables call. If a user provide a "value", + we will try to use it as the selected value. + properties: + allowAll: + type: boolean + label: + type: string + name: + type: string + query: + type: string + value: + type: string + values: + items: + type: string + type: array + type: object + type: array + type: object + type: object type: object name: type: string diff --git a/pkg/api/plugins/application/proto/application.pb.go b/pkg/api/plugins/application/proto/application.pb.go index eb9b39a25..383509dbd 100644 --- a/pkg/api/plugins/application/proto/application.pb.go +++ b/pkg/api/plugins/application/proto/application.pb.go @@ -125,8 +125,9 @@ type Details struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Description string `protobuf:"bytes,1,opt,name=description,proto3" json:"description,omitempty"` - Links []*Link `protobuf:"bytes,2,rep,name=links,proto3" json:"links,omitempty"` + Description string `protobuf:"bytes,1,opt,name=description,proto3" json:"description,omitempty"` + Links []*Link `protobuf:"bytes,2,rep,name=links,proto3" json:"links,omitempty"` + Plugin *proto1.Plugin `protobuf:"bytes,3,opt,name=plugin,proto3" json:"plugin,omitempty"` } func (x *Details) Reset() { @@ -175,6 +176,13 @@ func (x *Details) GetLinks() []*Link { return nil } +func (x *Details) GetPlugin() *proto1.Plugin { + if x != nil { + return x.Plugin + } + return nil +} + // Link is the format of a link, which can be provided within an Application. A link consists of a title, which is // displayed in the frontend and the link, which is used within the href attribute (title=Example, // link=https://example.com). @@ -312,24 +320,26 @@ var file_application_proto_rawDesc = []byte{ 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x52, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x29, 0x0a, 0x07, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x73, 0x2e, 0x50, 0x6c, 0x75, - 0x67, 0x69, 0x6e, 0x52, 0x07, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x73, 0x22, 0x54, 0x0a, 0x07, + 0x67, 0x69, 0x6e, 0x52, 0x07, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x73, 0x22, 0x7d, 0x0a, 0x07, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x27, 0x0a, 0x05, 0x6c, 0x69, 0x6e, 0x6b, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x4c, 0x69, 0x6e, 0x6b, 0x52, 0x05, 0x6c, 0x69, 0x6e, - 0x6b, 0x73, 0x22, 0x30, 0x0a, 0x04, 0x4c, 0x69, 0x6e, 0x6b, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x69, - 0x74, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, - 0x12, 0x12, 0x0a, 0x04, 0x6c, 0x69, 0x6e, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, - 0x6c, 0x69, 0x6e, 0x6b, 0x22, 0x3d, 0x0a, 0x09, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x73, 0x12, 0x14, 0x0a, 0x05, 0x6b, 0x69, 0x6e, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, - 0x52, 0x05, 0x6b, 0x69, 0x6e, 0x64, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x65, 0x6c, 0x65, 0x63, - 0x74, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x65, 0x6c, 0x65, 0x63, - 0x74, 0x6f, 0x72, 0x42, 0x3a, 0x5a, 0x38, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, - 0x6d, 0x2f, 0x6b, 0x6f, 0x62, 0x73, 0x69, 0x6f, 0x2f, 0x6b, 0x6f, 0x62, 0x73, 0x2f, 0x70, 0x6b, - 0x67, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x73, 0x2f, 0x61, 0x70, - 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, - 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x6b, 0x73, 0x12, 0x27, 0x0a, 0x06, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x73, 0x2e, 0x50, 0x6c, 0x75, + 0x67, 0x69, 0x6e, 0x52, 0x06, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x22, 0x30, 0x0a, 0x04, 0x4c, + 0x69, 0x6e, 0x6b, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6c, 0x69, 0x6e, + 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6c, 0x69, 0x6e, 0x6b, 0x22, 0x3d, 0x0a, + 0x09, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x6b, 0x69, + 0x6e, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x6b, 0x69, 0x6e, 0x64, 0x73, + 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x08, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x42, 0x3a, 0x5a, 0x38, + 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6b, 0x6f, 0x62, 0x73, 0x69, + 0x6f, 0x2f, 0x6b, 0x6f, 0x62, 0x73, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, + 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x73, 0x2f, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -357,11 +367,12 @@ var file_application_proto_depIdxs = []int32{ 3, // 1: application.Application.resources:type_name -> application.Resources 4, // 2: application.Application.plugins:type_name -> plugins.Plugin 2, // 3: application.Details.links:type_name -> application.Link - 4, // [4:4] is the sub-list for method output_type - 4, // [4:4] is the sub-list for method input_type - 4, // [4:4] is the sub-list for extension type_name - 4, // [4:4] is the sub-list for extension extendee - 0, // [0:4] is the sub-list for field type_name + 4, // 4: application.Details.plugin:type_name -> plugins.Plugin + 5, // [5:5] is the sub-list for method output_type + 5, // [5:5] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name } func init() { file_application_proto_init() } diff --git a/proto/application.proto b/proto/application.proto index 7dcf4ac9f..4fa4ae308 100644 --- a/proto/application.proto +++ b/proto/application.proto @@ -24,6 +24,7 @@ message Application { message Details { string description = 1; repeated Link links = 2; + plugins.Plugin plugin = 3; } // Link is the format of a link, which can be provided within an Application. A link consists of a title, which is