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