diff --git a/CHANGELOG.md b/CHANGELOG.md index b182a1275..22f2b92bf 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 - [#193](https://github.com/kobsio/kobs/pull/193): [elasticsearch] Adjust selected time range via logs chart and allow filtering via fields. - [#201](https://github.com/kobsio/kobs/pull/201): [sonarqube] Add SonarQube plugin to view projects and their measures within kobs. - [#202](https://github.com/kobsio/kobs/pull/202): [core] Add tooltip to refresh button to show selected time interval. +- [#204](https://github.com/kobsio/kobs/pull/204): [grafana] Add Grafana plugin, to show dashboards from a Grafana instance and to embed Grafana panels into kobs dashboards. ### Fixed diff --git a/app/src/index.tsx b/app/src/index.tsx index 8de625f58..56515968d 100644 --- a/app/src/index.tsx +++ b/app/src/index.tsx @@ -11,6 +11,7 @@ import applicationsPlugin from '@kobsio/plugin-applications'; import dashboardsPlugin from '@kobsio/plugin-dashboards'; import elasticsearchPlugin from '@kobsio/plugin-elasticsearch'; import fluxPlugin from '@kobsio/plugin-flux'; +import grafanaPlugin from '@kobsio/plugin-grafana'; import istioPlugin from '@kobsio/plugin-istio'; import jaegerPlugin from '@kobsio/plugin-jaeger'; import kialiPlugin from '@kobsio/plugin-kiali'; @@ -32,6 +33,7 @@ ReactDOM.render( ...dashboardsPlugin, ...elasticsearchPlugin, ...fluxPlugin, + ...grafanaPlugin, ...istioPlugin, ...jaegerPlugin, ...kialiPlugin, diff --git a/cmd/kobs/plugins/plugins.go b/cmd/kobs/plugins/plugins.go index f6b1f573b..cc36485a5 100644 --- a/cmd/kobs/plugins/plugins.go +++ b/cmd/kobs/plugins/plugins.go @@ -16,6 +16,7 @@ import ( "github.com/kobsio/kobs/plugins/dashboards" "github.com/kobsio/kobs/plugins/elasticsearch" "github.com/kobsio/kobs/plugins/flux" + "github.com/kobsio/kobs/plugins/grafana" "github.com/kobsio/kobs/plugins/istio" "github.com/kobsio/kobs/plugins/jaeger" "github.com/kobsio/kobs/plugins/kiali" @@ -37,6 +38,7 @@ type Config struct { Dashboards dashboards.Config `json:"dashboards"` Elasticsearch elasticsearch.Config `json:"elasticsearch"` Flux flux.Config `json:"flux"` + Grafana grafana.Config `json:"grafana"` Istio istio.Config `json:"istio"` Jaeger jaeger.Config `json:"jaeger"` Kiali kiali.Config `json:"kiali"` @@ -84,6 +86,7 @@ func Register(clusters *clusters.Clusters, config Config) chi.Router { jaegerRouter := jaeger.Register(clusters, router.plugins, config.Jaeger) kialiRouter := kiali.Register(clusters, router.plugins, config.Kiali) istioRouter := istio.Register(clusters, router.plugins, config.Istio, prometheusInstances, clickhouseInstances) + grafanaRouter := grafana.Register(clusters, router.plugins, config.Grafana) fluxRouter := flux.Register(clusters, router.plugins, config.Flux) opsgenieRouter := opsgenie.Register(clusters, router.plugins, config.Opsgenie) sonarqubeRouter := sonarqube.Register(clusters, router.plugins, config.Sonarqube) @@ -103,6 +106,7 @@ func Register(clusters *clusters.Clusters, config Config) chi.Router { router.Mount(jaeger.Route, jaegerRouter) router.Mount(kiali.Route, kialiRouter) router.Mount(istio.Route, istioRouter) + router.Mount(grafana.Route, grafanaRouter) router.Mount(flux.Route, fluxRouter) router.Mount(opsgenie.Route, opsgenieRouter) router.Mount(sonarqube.Route, sonarqubeRouter) diff --git a/docs/configuration/plugins.md b/docs/configuration/plugins.md index c5335e89a..cc667a9f1 100644 --- a/docs/configuration/plugins.md +++ b/docs/configuration/plugins.md @@ -7,6 +7,7 @@ Plugins can be used to extend the functions of kobs. They can be configured usin | applications | [Applications](#applications) | Configure the caching behaviour for the applications plugin. | No | | clickhouse | [[]ClickHouse](#clickhouse) | Configure multiple ClickHouse instances, which can be used within kobs. | No | | elasticsearch | [[]Elasticsearch](#elasticsearch) | Configure multiple Elasticsearch instances, which can be used within kobs. | No | +| grafana | [[]Grafana](#grafana) | Configure multiple Grafana instances, which can be used within kobs. | No | | istio | [[]Istio](#istio) | Configure multiple Istio instances, which can be used within kobs. | No | | jaeger | [[]Jaeger](#jaeger) | Configure multiple Jaeger instances, which can be used within kobs. | No | | kiali | [[]Kiali](#kiali) | Configure multiple Kiali instances, which can be used within kobs. | No | @@ -83,6 +84,30 @@ plugins: | password | string | Password to access an Elasticsearch instance via basic authentication. | No | | token | string | Token to access an Elasticsearch instance via token based authentication. | No | +## Grafana + +The following config can be used to grant kobs access to a Grafana instance running on `grafana.kobs.io`. + +```yaml +plugins: + grafana: + - name: Grafana + description: Query, visualize, alert on, and understand your data no matter where it’s stored. With Grafana you can create, explore and share all of your data through beautiful, flexible dashboards. + internalAddress: http://grafana.monitoring.svc.cluster.local:3000 + publicAddress: https://grafana.kobs.io +``` + +| Field | Type | Description | Required | +| ----- | ---- | ----------- | -------- | +| name | string | Name of the Grafana instance. | Yes | +| displayName | string | Name of the Grafana as it is shown in the UI. | Yes | +| descriptions | string | Description of the Grafana instance. | No | +| internalAddress | string | The cluster internal address of the Grafana instance. | Yes | +| publicAddress | string | The public address of the Grafana instance. | Yes | +| username | string | Username to access an Grafana instance via basic authentication. | No | +| password | string | Password to access an Grafana instance via basic authentication. | No | +| token | string | Token to access an Grafana instance via token based authentication. | No | + ## Istio The following configuration can be used to access a Istio instances using a Prometheus plugin named `prometheus` and an Clickhouse plugin named `clickhouse`. diff --git a/docs/plugins/assets/grafana.png b/docs/plugins/assets/grafana.png new file mode 100644 index 000000000..4461305a5 Binary files /dev/null and b/docs/plugins/assets/grafana.png differ diff --git a/docs/plugins/grafana.md b/docs/plugins/grafana.md new file mode 100644 index 000000000..d340f1998 --- /dev/null +++ b/docs/plugins/grafana.md @@ -0,0 +1,165 @@ +# Grafana + +The Grafana plugin can be used to search through all your Grafana dashboards and to show a list of dashboards or embed a Grafana panel within a kobs dashboard. + +![Grafana](assets/grafana.png) + +## Options + +The following options can be used for a panel with the Elasticsearch plugin: + +| Field | Type | Description | Required | +| ----- | ---- | ----------- | -------- | +| type | string | The panel type. This could be `dashboards` or `panel`. | No | +| dashboards | []string | A list of dashboard ids to show, when the type is `dashboards`. | Yes | +| panel | [Panel](#panel) | The panel which should be displayed, when the type is `panel`. | Yes | + +### Panel + +| Field | Type | Description | Required | +| ----- | ---- | ----------- | -------- | +| dashboardID | string | The id of the dashboard. | Yes | +| panelID | string | The id of the panel. | Yes | +| variables | map | A map of variables, with the name of the variable as key and the value of the variable as value. | No | + +## Example + +The following dashboards shows some panels from a Grafana plugin and a list of dashboards from this instance. The dashboard also uses some variables, which are then passed to the panels from Grafana. + +```yaml +--- +apiVersion: kobs.io/v1beta1 +kind: Dashboard +metadata: + name: istio-overview + namespace: kobs +spec: + title: Istio Overview + variables: + - name: var_namespace + label: Namespace + plugin: + name: core + options: + type: static + items: + - bookinfo + - name: var_workload + label: Workload + plugin: + name: core + options: + type: static + items: + - productpage + - details + - ratings + - reviews + rows: + - size: 1 + panels: + - title: Global Request Volume + colSpan: 3 + plugin: + name: grafana + options: + type: panel + panel: + dashboardID: G8wLrJIZk + panelID: "20" + - title: Global Success Rate + colSpan: 3 + plugin: + name: grafana + options: + type: panel + panel: + dashboardID: G8wLrJIZk + panelID: "21" + - title: 4xx + colSpan: 3 + plugin: + name: grafana + options: + type: panel + panel: + dashboardID: G8wLrJIZk + panelID: "22" + - title: 5xx + colSpan: 3 + plugin: + name: grafana + options: + type: panel + panel: + dashboardID: G8wLrJIZk + panelID: "23" + + - size: 1 + panels: + - title: Incoming Request Volume + colSpan: 4 + plugin: + name: grafana + options: + type: panel + panel: + dashboardID: UbsSZTDik + panelID: "12" + variables: + var-datasource: default + var-namespace: "{% .var_namespace %}" + var-workload: "{% .var_workload %}" + var-qrep: destination + var-srcns: All + var-srcwl: All + var-dstsvc: All + - title: Incoming Success Rate + colSpan: 4 + plugin: + name: grafana + options: + type: panel + panel: + dashboardID: UbsSZTDik + panelID: "14" + variables: + var-datasource: default + var-namespace: "{% .var_namespace %}" + var-workload: "{% .var_workload %}" + var-qrep: destination + var-srcns: All + var-srcwl: All + var-dstsvc: All + - title: Request Duration + colSpan: 4 + plugin: + name: grafana + options: + type: panel + panel: + dashboardID: UbsSZTDik + panelID: "87" + variables: + var-datasource: default + var-namespace: "{% .var_namespace %}" + var-workload: "{% .var_workload %}" + var-qrep: destination + var-srcns: All + var-srcwl: All + var-dstsvc: All + + - size: 3 + panels: + - title: Dashboards + plugin: + name: grafana + options: + type: dashboards + dashboards: + - 3--MLVZZk + - G8wLrJIZk + - vu8e0VWZk + - LJ_uJAvmk + - UbsSZTDik +``` diff --git a/mkdocs.yml b/mkdocs.yml index c5ff36038..df94f8241 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -51,6 +51,7 @@ nav: - Dashboards: plugins/dashboards.md - Elasticsearch: plugins/elasticsearch.md - Flux: plugins/flux.md + - Grafana: plugins/grafana.md - Istio: plugins/istio.md - Jaeger: plugins/jaeger.md - Kiali: plugins/kiali.md diff --git a/plugins/grafana/grafana.go b/plugins/grafana/grafana.go new file mode 100644 index 000000000..60f682da5 --- /dev/null +++ b/plugins/grafana/grafana.go @@ -0,0 +1,118 @@ +package grafana + +import ( + "net/http" + + "github.com/kobsio/kobs/pkg/api/clusters" + "github.com/kobsio/kobs/pkg/api/middleware/errresponse" + "github.com/kobsio/kobs/pkg/api/plugins/plugin" + "github.com/kobsio/kobs/plugins/grafana/pkg/instance" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + "github.com/sirupsen/logrus" +) + +// Route is the route under which the plugin should be registered in our router for the rest api. +const ( + Route = "/grafana" +) + +var ( + log = logrus.WithFields(logrus.Fields{"package": "grafana"}) +) + +// Config is the structure of the configuration for the grafana plugin. +type Config []instance.Config + +// Router implements the router for the resources plugin, which can be registered in the router for our rest api. +type Router struct { + *chi.Mux + clusters *clusters.Clusters + instances []*instance.Instance +} + +func (router *Router) getInstance(name string) *instance.Instance { + for _, i := range router.instances { + if i.Name == name { + return i + } + } + + return nil +} + +func (router *Router) getDashboards(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + query := r.URL.Query().Get("query") + uids := r.URL.Query()["uid"] + + log.WithFields(logrus.Fields{"name": name, "query": query, "uids": uids}).Tracef("getDashboards") + + i := router.getInstance(name) + if i == nil { + errresponse.Render(w, r, nil, http.StatusBadRequest, "Could not find instance name") + return + } + + if uids != nil { + var dashboards []instance.Dashboard + for _, uid := range uids { + dashboard, err := i.GetDashboard(r.Context(), uid) + if err != nil { + errresponse.Render(w, r, err, http.StatusInternalServerError, "Could not get dashboard") + return + } + + dashboards = append(dashboards, *dashboard) + } + + render.JSON(w, r, dashboards) + return + } + + dashboards, err := i.GetDashboards(r.Context(), query) + if err != nil { + errresponse.Render(w, r, err, http.StatusInternalServerError, "Could not get dashboards") + return + } + + render.JSON(w, r, dashboards) +} + +// Register returns a new router which can be used in the router for the kobs rest api. +func Register(clusters *clusters.Clusters, plugins *plugin.Plugins, config Config) chi.Router { + var instances []*instance.Instance + + for _, cfg := range config { + instance, err := instance.New(cfg) + if err != nil { + log.WithError(err).WithFields(logrus.Fields{"name": cfg.Name}).Fatalf("Could not create Grafana instance") + } + + instances = append(instances, instance) + + var options map[string]interface{} + options = make(map[string]interface{}) + options["internalAddress"] = cfg.InternalAddress + options["publicAddress"] = cfg.PublicAddress + + plugins.Append(plugin.Plugin{ + Name: cfg.Name, + DisplayName: cfg.DisplayName, + Description: cfg.Description, + Type: "grafana", + Options: options, + }) + } + + router := Router{ + chi.NewRouter(), + clusters, + instances, + } + + router.Get("/dashboards/{name}", router.getDashboards) + + return router +} diff --git a/plugins/grafana/package.json b/plugins/grafana/package.json new file mode 100644 index 000000000..8c6437810 --- /dev/null +++ b/plugins/grafana/package.json @@ -0,0 +1,24 @@ +{ + "name": "@kobsio/plugin-grafana", + "version": "0.0.0", + "license": "MIT", + "private": false, + "main": "./lib/index.js", + "module": "./lib-esm/index.js", + "types": "./lib/index.d.ts", + "scripts": { + "plugin": "tsc && tsc --build tsconfig.esm.json && cp -r src/assets lib && cp -r src/assets lib-esm" + }, + "dependencies": { + "@kobsio/plugin-core": "*", + "@patternfly/react-core": "^4.128.2", + "@types/react": "^17.0.0", + "@types/react-dom": "^17.0.0", + "@types/react-router-dom": "^5.1.7", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-query": "^3.17.2", + "react-router-dom": "^5.2.0", + "typescript": "^4.3.4" + } +} diff --git a/plugins/grafana/pkg/instance/instance.go b/plugins/grafana/pkg/instance/instance.go new file mode 100644 index 000000000..f180e0423 --- /dev/null +++ b/plugins/grafana/pkg/instance/instance.go @@ -0,0 +1,132 @@ +package instance + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + + "github.com/kobsio/kobs/pkg/api/middleware/roundtripper" + + "github.com/sirupsen/logrus" +) + +var ( + log = logrus.WithFields(logrus.Fields{"package": "grafana"}) +) + +// Config is the structure of the configuration for a single Grafana instance. +type Config struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + InternalAddress string `json:"internalAddress"` + PublicAddress string `json:"publicAddress"` + Username string `json:"username"` + Password string `json:"password"` + Token string `json:"token"` +} + +// Instance represents a single Grafana instance, which can be added via the configuration file. +type Instance struct { + Name string + address string + client *http.Client +} + +// doRequest is a helper function to run a request against a Grafana instance for the given path. It returns the body +// or if the request failed the error message. +func (i *Instance) doRequest(ctx context.Context, url string) ([]byte, error) { + log.WithFields(logrus.Fields{"url": i.address + url}).Tracef("request url") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s%s", i.address, url), nil) + if err != nil { + return nil, err + } + + resp, err := i.client.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return ioutil.ReadAll(resp.Body) + } + + var res ResponseError + + err = json.NewDecoder(resp.Body).Decode(&res) + if err != nil { + return nil, err + } + + if res.Message != "" { + return nil, fmt.Errorf(res.Message) + } + + return nil, fmt.Errorf("an unknown error occured") +} + +// GetDashboards returns a list of dashboards for the given query. +func (i *Instance) GetDashboards(ctx context.Context, query string) ([]Dashboard, error) { + body, err := i.doRequest(ctx, fmt.Sprintf("/api/search?query=%s&limit=100", query)) + if err != nil { + return nil, err + } + + var dashboards []Dashboard + if err := json.Unmarshal(body, &dashboards); err != nil { + return nil, err + } + + return dashboards, nil +} + +// GetDashboard returns a single dashboard by it's uid. +func (i *Instance) GetDashboard(ctx context.Context, uid string) (*Dashboard, error) { + body, err := i.doRequest(ctx, fmt.Sprintf("/api/dashboards/uid/%s", uid)) + if err != nil { + return nil, err + } + + var dashboard SingleDashboardResponse + if err := json.Unmarshal(body, &dashboard); err != nil { + return nil, err + } + + return &Dashboard{ + DashboardData: dashboard.Dashboard, + DashboardMetadata: dashboard.Metadata, + }, nil +} + +// New returns a new Elasticsearch instance for the given configuration. +func New(config Config) (*Instance, error) { + roundTripper := roundtripper.DefaultRoundTripper + + if config.Username != "" && config.Password != "" { + roundTripper = roundtripper.BasicAuthTransport{ + Transport: roundTripper, + Username: config.Username, + Password: config.Password, + } + } + + if config.Token != "" { + roundTripper = roundtripper.TokenAuthTransporter{ + Transport: roundTripper, + Token: config.Token, + } + } + + return &Instance{ + Name: config.Name, + address: config.InternalAddress, + client: &http.Client{ + Transport: roundTripper, + }, + }, nil +} diff --git a/plugins/grafana/pkg/instance/structs.go b/plugins/grafana/pkg/instance/structs.go new file mode 100644 index 000000000..1a38cde3b --- /dev/null +++ b/plugins/grafana/pkg/instance/structs.go @@ -0,0 +1,37 @@ +package instance + +// ResponseError is the structure of failed Grafana API call. +type ResponseError struct { + Message string `json:"message"` +} + +// Dashboard is the structure of a single Grafana dashboard. It contains the dashboard data and metadata. If this is +// used via the search API it also contains a type field, because the API also returns folders. +type Dashboard struct { + DashboardData + DashboardMetadata + Type string `json:"type,omitempty"` +} + +// SingleDashboardResponse is the structure of the data returned by the Grafana API to get a dashboard by its uid. +type SingleDashboardResponse struct { + Dashboard DashboardData `json:"dashboard"` + Metadata DashboardMetadata `json:"meta"` +} + +// DashboardData is the structure for the dashbord data returned by the Grafana API. +type DashboardData struct { + ID int `json:"id"` + UID string `json:"uid"` + Title string `json:"title"` + Tags []string `json:"tags"` +} + +// DashboardMetadata contains the metadata of a dashboard returned by the Grafana API. +type DashboardMetadata struct { + URL string `json:"url"` + FolderID int `json:"folderId"` + FolderUID string `json:"folderUid"` + FolderTitle string `json:"folderTitle,omitempty"` + FolderURL string `json:"folderUrl,omitempty"` +} diff --git a/plugins/grafana/src/assets/icon.png b/plugins/grafana/src/assets/icon.png new file mode 100644 index 000000000..24ef32637 Binary files /dev/null and b/plugins/grafana/src/assets/icon.png differ diff --git a/plugins/grafana/src/components/page/Dashboards.tsx b/plugins/grafana/src/components/page/Dashboards.tsx new file mode 100644 index 000000000..e0731cc39 --- /dev/null +++ b/plugins/grafana/src/components/page/Dashboards.tsx @@ -0,0 +1,84 @@ +import { Alert, AlertActionLink, AlertVariant, Menu, MenuContent, MenuList, Spinner } from '@patternfly/react-core'; +import { QueryObserverResult, useQuery } from 'react-query'; +import React from 'react'; + +import DashboardsItem from './DashboardsItem'; +import { IDashboard } from '../../utils/interfaces'; + +interface IDashboardsProps { + name: string; + query: string; + publicAddress: string; +} + +const Dashboards: React.FunctionComponent = ({ name, query, publicAddress }: IDashboardsProps) => { + const { isError, isLoading, error, data, refetch } = useQuery( + ['grafana/dashboards', name, query], + async () => { + try { + const response = await fetch(`/api/plugins/grafana/dashboards/${name}?query=${encodeURIComponent(query)}`, { + method: 'get', + }); + const json = await response.json(); + + if (response.status >= 200 && response.status < 300) { + return json; + } else { + if (json.error) { + throw new Error(json.error); + } else { + throw new Error('An unknown error occured'); + } + } + } catch (err) { + throw err; + } + }, + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) { + return ( + + > => refetch()}> + Retry + + + } + > +

{error?.message}

+
+ ); + } + + if (!data || data.length === 0) { + return null; + } + + return ( + + + + {data + .filter((dashboard) => dashboard.type !== 'dash-folder') + .map((dashboard, index) => ( + + ))} + + + + ); +}; + +export default Dashboards; diff --git a/plugins/grafana/src/components/page/DashboardsItem.tsx b/plugins/grafana/src/components/page/DashboardsItem.tsx new file mode 100644 index 000000000..1ae59b74c --- /dev/null +++ b/plugins/grafana/src/components/page/DashboardsItem.tsx @@ -0,0 +1,38 @@ +import { MenuItem } from '@patternfly/react-core'; +import React from 'react'; + +import { IDashboard } from '../../utils/interfaces'; + +interface IDashboardItemProps { + dashboard: IDashboard; + publicAddress: string; +} + +const DashboardItem: React.FunctionComponent = ({ + dashboard, + publicAddress, +}: IDashboardItemProps) => { + return ( + + + Folder: + {dashboard.folderTitle || '-'} + + + Tags: + + {dashboard.tags && dashboard.tags.length > 0 ? dashboard.tags.join(', ') : '-'} + + + + } + > + {dashboard.title} + + ); +}; + +export default DashboardItem; diff --git a/plugins/grafana/src/components/page/Page.tsx b/plugins/grafana/src/components/page/Page.tsx new file mode 100644 index 000000000..c1d6d5bf8 --- /dev/null +++ b/plugins/grafana/src/components/page/Page.tsx @@ -0,0 +1,70 @@ +import { + Drawer, + DrawerContent, + DrawerContentBody, + PageSection, + PageSectionVariants, + Title, +} from '@patternfly/react-core'; +import React, { useEffect, useState } from 'react'; +import { useHistory, useLocation } from 'react-router-dom'; + +import Dashboards from './Dashboards'; +import { IOptions } from '../../utils/interfaces'; +import { IPluginPageProps } from '@kobsio/plugin-core'; +import PageToolbar from './PageToolbar'; +import { getOptionsFromSearch } from '../../utils/helpers'; + +const Page: React.FunctionComponent = ({ + name, + displayName, + description, + options, +}: IPluginPageProps) => { + const location = useLocation(); + const history = useHistory(); + const [pageOptions, setPageOptions] = useState(getOptionsFromSearch(location.search)); + + // changePageOptions is used to change the options to get a list of dashboards from Grafna. Instead of directly + // modifying the options state we change the URL parameters. + const changePageOptions = (opts: IOptions): void => { + history.push({ + pathname: location.pathname, + search: `?query=${encodeURIComponent(opts.query)}`, + }); + }; + + // useEffect is used to set the options every time the search location for the current URL changes. The URL is changed + // via the changePageOptions function. When the search location is changed we modify the options state. + useEffect(() => { + setPageOptions(getOptionsFromSearch(location.search)); + }, [location.search]); + + return ( + + + + {displayName} + +

{description}

+ +
+ + + + + + + + + + +
+ ); +}; + +export default Page; diff --git a/plugins/grafana/src/components/page/PageToolbar.tsx b/plugins/grafana/src/components/page/PageToolbar.tsx new file mode 100644 index 000000000..04257fa5e --- /dev/null +++ b/plugins/grafana/src/components/page/PageToolbar.tsx @@ -0,0 +1,58 @@ +import { + Button, + ButtonVariant, + TextInput, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, + ToolbarToggleGroup, +} from '@patternfly/react-core'; +import { FilterIcon, SearchIcon } from '@patternfly/react-icons'; +import React, { useState } from 'react'; + +import { IOptions } from '../../utils/interfaces'; + +interface IPageToolbarProps extends IOptions { + name: string; + setOptions: (data: IOptions) => void; +} + +const PageToolbar: React.FunctionComponent = ({ name, query, setOptions }: IPageToolbarProps) => { + const [data, setData] = useState({ query: query }); + + // changeQuery changes the value of a query. + const changeQuery = (value: string): void => { + setData({ ...data, query: value }); + }; + + // onEnter is used to detect if the user pressed the "ENTER" key. If this is the case we are calling the setOptions + // function to trigger the search. + // use "SHIFT" + "ENTER". + const onEnter = (e: React.KeyboardEvent | undefined): void => { + if (e?.key === 'Enter' && !e.shiftKey) { + setOptions(data); + } + }; + + return ( + + + } breakpoint="lg"> + + + + + + + + + + + + ); +}; + +export default PageToolbar; diff --git a/plugins/grafana/src/components/panel/Dashboards.tsx b/plugins/grafana/src/components/panel/Dashboards.tsx new file mode 100644 index 000000000..e764f63bc --- /dev/null +++ b/plugins/grafana/src/components/panel/Dashboards.tsx @@ -0,0 +1,90 @@ +import { Alert, AlertActionLink, AlertVariant, Menu, MenuContent, MenuList, Spinner } from '@patternfly/react-core'; +import { QueryObserverResult, useQuery } from 'react-query'; +import React from 'react'; + +import DashboardsItem from './DashboardsItem'; +import { IDashboard } from '../../utils/interfaces'; + +interface IDashboardsProps { + name: string; + dashboardIDs: string[]; + publicAddress: string; +} + +const Dashboards: React.FunctionComponent = ({ + name, + dashboardIDs, + publicAddress, +}: IDashboardsProps) => { + const { isError, isLoading, error, data, refetch } = useQuery( + ['grafana/dashboards', name, dashboardIDs], + async () => { + try { + const uidParams = dashboardIDs.map((dashboardID) => `uid=${dashboardID}`).join('&'); + + const response = await fetch(`/api/plugins/grafana/dashboards/${name}?${uidParams}`, { + method: 'get', + }); + const json = await response.json(); + + if (response.status >= 200 && response.status < 300) { + return json; + } else { + if (json.error) { + throw new Error(json.error); + } else { + throw new Error('An unknown error occured'); + } + } + } catch (err) { + throw err; + } + }, + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) { + return ( + + > => refetch()}> + Retry + + + } + > +

{error?.message}

+
+ ); + } + + if (!data || data.length === 0) { + return null; + } + + return ( + + + + {data + .filter((dashboard) => dashboard.type !== 'dash-folder') + .map((dashboard, index) => ( + + ))} + + + + ); +}; + +export default Dashboards; diff --git a/plugins/grafana/src/components/panel/DashboardsItem.tsx b/plugins/grafana/src/components/panel/DashboardsItem.tsx new file mode 100644 index 000000000..5e2617001 --- /dev/null +++ b/plugins/grafana/src/components/panel/DashboardsItem.tsx @@ -0,0 +1,34 @@ +import { MenuItem } from '@patternfly/react-core'; +import React from 'react'; + +import { IDashboard } from '../../utils/interfaces'; + +interface IDashboardItemProps { + dashboard: IDashboard; + publicAddress: string; +} + +const DashboardItem: React.FunctionComponent = ({ + dashboard, + publicAddress, +}: IDashboardItemProps) => { + return ( + + + Tags: + + {dashboard.tags && dashboard.tags.length > 0 ? dashboard.tags.join(', ') : '-'} + + + + } + > + {dashboard.title} + + ); +}; + +export default DashboardItem; diff --git a/plugins/grafana/src/components/panel/GrafanaPanel.tsx b/plugins/grafana/src/components/panel/GrafanaPanel.tsx new file mode 100644 index 000000000..bc250a24b --- /dev/null +++ b/plugins/grafana/src/components/panel/GrafanaPanel.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import { IGrafanaPanelVariables } from '../../utils/interfaces'; +import { IPluginTimes } from '@kobsio/plugin-core'; + +interface IGrafanaPanelProps { + title: string; + dashboardID: string; + panelID: string; + variables?: IGrafanaPanelVariables; + internalAddress: string; + times: IPluginTimes; +} + +const GrafanaPanel: React.FunctionComponent = ({ + title, + dashboardID, + panelID, + internalAddress, + variables, + times, +}: IGrafanaPanelProps) => { + const variableParams = variables + ? Object.keys(variables) + .map((key) => `${key}=${variables[key]}`) + .join('&') + : ''; + + return ( + + ); +}; + +export default GrafanaPanel; diff --git a/plugins/grafana/src/components/panel/Panel.tsx b/plugins/grafana/src/components/panel/Panel.tsx new file mode 100644 index 000000000..cfe2a6137 --- /dev/null +++ b/plugins/grafana/src/components/panel/Panel.tsx @@ -0,0 +1,74 @@ +import React, { memo } from 'react'; + +import { IPluginPanelProps, PluginCard, PluginOptionsMissing } from '@kobsio/plugin-core'; +import Dashboards from './Dashboards'; +import GrafanaPanel from './GrafanaPanel'; +import { IPanelOptions } from '../../utils/interfaces'; + +interface IPanelProps extends IPluginPanelProps { + options?: IPanelOptions; +} + +export const Panel: React.FunctionComponent = ({ + name, + title, + description, + times, + pluginOptions, + options, +}: IPanelProps) => { + if ( + options && + options.type === 'panel' && + options.panel && + options.panel.dashboardID && + options.panel.panelID && + times + ) { + return ( + + ); + } + + if ( + options && + options.type === 'dashboards' && + options.dashboards && + Array.isArray(options.dashboards) && + options.dashboards.length > 0 + ) { + return ( + + + + ); + } + + return ( + + ); +}; + +export default memo(Panel, (prevProps, nextProps) => { + if (JSON.stringify(prevProps) === JSON.stringify(nextProps)) { + return true; + } + + return false; +}); diff --git a/plugins/grafana/src/index.ts b/plugins/grafana/src/index.ts new file mode 100644 index 000000000..c51d429c0 --- /dev/null +++ b/plugins/grafana/src/index.ts @@ -0,0 +1,16 @@ +import { IPluginComponents } from '@kobsio/plugin-core'; + +import icon from './assets/icon.png'; + +import Page from './components/page/Page'; +import Panel from './components/panel/Panel'; + +const grafanaPlugin: IPluginComponents = { + grafana: { + icon: icon, + page: Page, + panel: Panel, + }, +}; + +export default grafanaPlugin; diff --git a/plugins/grafana/src/utils/helpers.ts b/plugins/grafana/src/utils/helpers.ts new file mode 100644 index 000000000..de741dfd7 --- /dev/null +++ b/plugins/grafana/src/utils/helpers.ts @@ -0,0 +1,11 @@ +import { IOptions } from './interfaces'; + +// getOptionsFromSearch is used to get the Jaeger options from a given search location. +export const getOptionsFromSearch = (search: string): IOptions => { + const params = new URLSearchParams(search); + const query = params.get('query'); + + return { + query: query === null ? '' : query, + }; +}; diff --git a/plugins/grafana/src/utils/interfaces.ts b/plugins/grafana/src/utils/interfaces.ts new file mode 100644 index 000000000..d9fec0501 --- /dev/null +++ b/plugins/grafana/src/utils/interfaces.ts @@ -0,0 +1,34 @@ +// IOptions is the interface for the options on the Grafana page. +export interface IOptions { + query: string; +} + +export interface IPanelOptions { + type?: string; + dashboards?: string[]; + panel?: IGrafanaPanelOptions; +} + +export interface IGrafanaPanelOptions { + dashboardID?: string; + panelID?: string; + variables?: IGrafanaPanelVariables; +} + +export interface IGrafanaPanelVariables { + [key: string]: string; +} + +// IDashboard is the interface of a single dashboard. +export interface IDashboard { + id: number; + uid: string; + title: string; + url: string; + type?: string; + tags: string[]; + folderId: number; + folderUid: string; + folderTitle?: string; + folderUrl?: string; +} diff --git a/plugins/grafana/tsconfig.esm.json b/plugins/grafana/tsconfig.esm.json new file mode 100644 index 000000000..acbc1eff8 --- /dev/null +++ b/plugins/grafana/tsconfig.esm.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "include": ["src"], + "compilerOptions": { + "outDir": "lib-esm", + "module": "esnext", + "target": "esnext", + "moduleResolution": "node", + "lib": ["dom", "esnext"], + "declaration": false + } +} diff --git a/plugins/grafana/tsconfig.json b/plugins/grafana/tsconfig.json new file mode 100644 index 000000000..09365d619 --- /dev/null +++ b/plugins/grafana/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.json", + "exclude": ["node_modules", "lib-esm", "lib"], + "include": ["src"], + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "outDir": "lib", + "declaration": true + } +}