Skip to content

Develop a Plugin

In the following you find some instructions and recommendations which will help you to develop your own plugins for kobs.

Notes

Please read the following notes, before you start with the development of your own plugins:

  • Adding a plugin to the kobsio/kobs repository: When you want to create a plugin, which should be added as official plugin to the kobsio/kobs repository, create a new issue first. The issue should contain a description for the plugin you want to develop and an explanation why it makes sens to add it as official plugin.
  • Maintaining a plugin in your own repository: If you develop a public plugin in your own repository it would be nice to add a markdown file for your plugin to the docs/community-plugins folder. You can also add a kobs-plugin label so that users can find your plugin via GitHub.
  • Private plugins: You can also extend kobs with private plugins. More recommendations on how to maintain and use private plugins can be found in the Using the kobsio/app section of the documentation.

Structure

Each plugin contains a backend and a frontend part. The backend part is written in Go and provides the API for the frontend. The frontend part is written in TypeScript and will become part of the React UI of kobs.

In the following we show the backend and frontend code, which is required for a plugin named helloworld.

Backend Code

For each plugin you develop your should create a new Go package with the name of your plugin. In our case the package will be named helloworld:

package helloworld

Each plugin should export a Route constant. This constant can then be used in the plugins.go file to mount your plugin API routes under the give route.

const Route = "/helloworld"

If your plugin requires a configuration, which should be provided by a user via a config.yaml file your Go package should export a Config struct with all the fields a user should provide via the configuration.

This struct can then be added to the Config struct in the plugins.go file with the name of your plugin as json key, e.g. HelloWorld helloworld.Config json:"helloworld".

type Config struct {
    Name           string `json:"name"`
    DisplayName    string `json:"displayName"`
    Description    string `json:"description"`
    HelloWorldName string `json:"helloWorldName"`
}

Each plugin must export chi.Router router interface, so that the router can be mounted by kobs. To be able to use your configuration within your plugin routes we recommend, that you implement a Router struct. This struct should contain your configuration and each instance of your plugin (e.g. prometheus.go).

type Router struct {
    *chi.Mux
    clustersClient clusters.Client
    config   Config
}

With the Router struct you can then create your APIs as follows, where you have access to the clustersClient, config, etc.

func (router *Router) getName(w http.ResponseWriter, r *http.Request) {}

Finally your plugin should export a Register function, which returns the chi.Router interface. This function should be used to initialize your plugin and to mount all the API routes for your plugin.

You have to add an entry to the plugins slice for each instance of your plugin, so that the React UI is aware of the plugin. Then you can create your router object, which will then be mounted under the before specified Route.

func Register(clustersClient clusters.Client, plugins *plugin.Plugins, config Config) chi.Router {
    plugins.Append(plugin.Plugin{
        Name:        config.Name,
        DisplayName: config.DisplayName,
        Description: config.Description,
        Type:        "helloworld",
    })

    router := Router{
        chi.NewRouter(),
        clustersClient,
        config,
    }

    router.Get("/name", router.getName)

    return router
}
Complete Go Code

The complete Go code for our helloworld plugin looks as follows:

package helloworld

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/pkg/log"

    "github.com/go-chi/chi/v5"
    "github.com/go-chi/render"
    "go.uber.org/zap"
)

const Route = "/helloworld"

type Config struct {
    Name           string `json:"name"`
    DisplayName    string `json:"displayName"`
    Description    string `json:"description"`
    HelloWorldName string `json:"helloWorldName"`
}

type Router struct {
    *chi.Mux
    clustersClient clusters.Client
    config   Config
}

// getName returns the name form the configuration.
func (router *Router) getName(w http.ResponseWriter, r *http.Request) {
    if router.config.HelloWorldName == "" {
        log.Error(r.Context(), "Name is missing")
        errresponse.Render(w, r, nil, http.StatusInternalServerError, "Name is missing")
        return
    }

    data := struct {
        Name string `json:"name"`
    }{
        router.config.HelloWorldName,
    }

    log.Debug(r.Context(), "Get name result", zap.String("name", data.Name))
    render.JSON(w, r, data)
}

// Register returns a new router which can be used in the router for the kobs rest api.
func Register(clustersClient clusters.Client, plugins *plugin.Plugins, config Config) chi.Router {
    plugins.Append(plugin.Plugin{
        Name:        config.Name,
        DisplayName: config.DisplayName,
        Description: config.Description,
        Type:        "helloworld",
    })

    router := Router{
        chi.NewRouter(),
        clustersClient,
        config,
    }

    router.Get("/name", router.getName)

    return router
}

Frontend Code

For each plugin you develop you should create a new NPM package, which exports the IPluginComponents interface with the name of your plugin as key:

export interface IPluginComponents {
  [key: string]: IPluginComponent;
}

export interface IPluginComponent {
  home?: React.FunctionComponent<IPluginPageProps>;
  icon: string;
  page?: React.FunctionComponent<IPluginPageProps>;
  panel: React.FunctionComponent<IPluginPanelProps>;
  preview?: React.FunctionComponent<IPluginPreviewProps>;
  variables?: (variable: IDashboardVariableValues, variables: IDashboardVariableValues[], times: IPluginTimes) => Promise<IDashboardVariableValues>;
}

In our helloworld example the index.ts file would then look as follows:

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 helloworldPlugin: IPluginComponents = {
  helloworld: {
    icon: icon,
    page: Page,
    panel: Panel,
  },
};

export default helloworldPlugin;

As you can see each plugin should contain a icon, panel and an optional page component. The icon will be displayed on the plugins page of kobs, next to the DisplayName and Description specified in the Go code of the plugin.

The panel component is used to implement a Dashboard panel for your plugin. The component receives all the properties defined by the IPluginPanelProps interface.

The options field contains the complete JSON structure defined by a user in a Dashboard, so you should make sure to validate these options before blindly using them. The reason for this is that Kubernetes can not validate these options while a CR is applied and so the user can pass what every he wants as options.

export interface IPluginPanelProps {
  defaults: IPluginDefaults;
  times?: IPluginTimes;
  name: string;
  title: string;
  description?: string;
  pluginOptions?: IPluginDataOptions;
  options?: any;
  setDetails?: (details: React.ReactNode) => void;
}
Example Panel

The following example shows a panel component for the helloworld plugin.

import React from 'react';
import { IPluginPanelProps, PluginOptionsMissing, PluginCard } from '@kobsio/plugin-core';

import HelloWorld from './HelloWorld';
import { IPanelOptions } from '../../utils/interfaces';

interface IPanelProps extends IPluginPanelProps {
  options?: IPanelOptions;
}

export const Panel: React.FunctionComponent<IPanelProps> = ({ title, description, options }: IPanelProps) => {
  if (!options || !options.name) {
    return (
      <PluginOptionsMissing
        title={title}
        message="Options for Hello World panel are missing or invalid"
        details="The panel doesn't contain the required options to get hello world or the provided options are invalid."
        documentation="https://kobs.io/"
      />
    );
  }

  return (
    <PluginCard title={title} description={description}>
      <HelloWorld name={options.name} />
    </PluginCard>
  );
};

export default Panel;

The page compinent is shown when a user selects your plugin on the plugins page of kobs. The component receives all the properties defined by the IPluginPageProps interface.

The options field can be used to pass options from the Go code to the TypeScript code. These options can be set by adding a map[string]interface{} to the options field in the plugins slice in the Go code (e.g. opsgenie.go).

export interface IPluginPageProps {
  name: string;
  displayName: string;
  description: string;
  options?: IPluginDataOptions;
}
Example Page

The following example shows a page component for the helloworld plugin.

import {
  Alert,
  AlertActionLink,
  AlertVariant,
  PageSection,
  PageSectionVariants,
  Spinner,
  Title,
} from '@patternfly/react-core';
import { QueryObserverResult, useQuery } from 'react-query';
import { IPluginPageProps } from '@kobsio/plugin-core';
import React from 'react';
import { useHistory } from 'react-router-dom';

import { IHelloWorld } from '../../utils/interfaces';
import HelloWorld from '../panel/HelloWorld';

const Page: React.FunctionComponent<IPluginPageProps> = ({ name, displayName, description }: IPluginPageProps) => {
  const history = useHistory();

  const { isError, isLoading, error, data, refetch } = useQuery<IHelloWorld, Error>(['helloworld/helloworld', name], async () => {
    try {
      const response = await fetch(`/api/plugins/${name}/name`, { 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;
    }
  });

  return (
    <React.Fragment>
      <PageSection variant={PageSectionVariants.light}>
        <Title headingLevel="h6" size="xl">
          {displayName}
        </Title>
        <p>{description}</p>
      </PageSection>

      <PageSection style={{ height: '100%', minHeight: '100%' }} variant={PageSectionVariants.default}>
        {isLoading ? (
          <div className="pf-u-text-align-center">
            <Spinner />
          </div>
        ) : isError ? (
          <Alert
            variant={AlertVariant.danger}
            title="Could not get teams"
            actionLinks={
              <React.Fragment>
                <AlertActionLink onClick={(): void => history.push('/')}>Home</AlertActionLink>
                <AlertActionLink onClick={(): Promise<QueryObserverResult<IHelloWorld, Error>> => refetch()}>
                  Retry
                </AlertActionLink>
              </React.Fragment>
            }
          >
            <p>{error?.message}</p>
          </Alert>
        ) : data ?
            <HelloWorld name={data.name} /> : null}
      </PageSection>
    </React.Fragment>
  );
};

export default Page;