Since this is a fresh project, we can take advantage of the CLI's tooling to set up boilerplate code for us which we can then extend on. Note that this is not strictly necessary for writing an application (whereas running the CUE codegen is something you'll likely want for every project), but it makes initial project bootstrapping simpler, and will help us move along here faster. If you decide in future projects you want to handle your routing, storage, or front-end framework differently, you can eschew some or all of the things laid out in this section.
Earlier, we used the CLI's project
command with project init
, initializing our project with some very basic stuff. Now, we can again use the project
command, this time to add boilerplate components to our app. These are added using the project component add
command, with the name of one or more components you wish to add to the project. To see the list of possible components, you can run it sans arguments, like so:
$ grafana-app-sdk project component add
Usage: grafana-app-sdk project component add [options] <components>
where <components> are one or more of:
backend
frontend
operator
We can also get information on what flags this (or any other) command takes with --help
:
$ grafana-app-sdk project component add --help
Usage:
grafana-app-sdk project component add [flags]
Flags:
--grouping string Kind go package grouping.
Allowed values are 'group' and 'kind'. This should match the flag used in the 'generate' command (default "kind")
-h, --help help for add
Global Flags:
-f, --format string Format in which kinds are written for this project (currently allowed values are 'cue') (default "cue")
--manifest string Path selector to use for the manifest (default "manifest")
--overwrite Overwrite existing files instead of prompting
-p, --path string Path to project directory
-s, --source string Path to directory with your codegen source files (such as a CUE module) (default "kinds")
We can leave all these flags empty, as we're fine with the defaults, but it's good to know how we can find information about the CLI commands.
Since we're building out everything we can as part of this tutorial, let's go ahead and add all three project components.
grafana-app-sdk project component add frontend backend operator
Just like with any other command that writes files, the output is a list of all written files, though the front-end files are created with yarn
and are not listed.
$ grafana-app-sdk project component add frontend backend operator
Creating plugin frontend using `yarn create @grafana/plugin` (this may take a moment)...
* Writing file plugin/src/plugin.json
* Writing file plugin/src/constants.ts
* Writing file plugin/pkg/main.go
* Writing file pkg/plugin/handler_issue.go
* Writing file pkg/plugin/plugin.go
* Writing file pkg/plugin/secure/data.go
* Writing file pkg/plugin/secure/middleware.go
* Writing file pkg/plugin/secure/retriever.go
* Writing file plugin/Magefile.go
* Writing file plugin/src/plugin.json
* Writing file cmd/operator/config.go
* Writing file cmd/operator/kubeconfig.go
* Writing file cmd/operator/main.go
* Writing file pkg/app/app.go
* Writing file pkg/watchers/watcher_issue.go
* Writing file cmd/operator/Dockerfile
Let's take a look at the tree to get a better picture of everything:
$ tree -I "generated|definitions|kinds|local" .
.
├── Makefile
├── cmd
│ └── operator
│ ├── Dockerfile
│ ├── config.go
│ ├── kubeconfig.go
│ └── main.go
├── go.mod
├── go.sum
├── pkg
│ ├── app
│ │ └── app.go
│ ├── plugin
│ │ ├── handler_issue.go
│ │ ├── plugin.go
│ │ └── secure
│ │ ├── data.go
│ │ ├── middleware.go
│ │ └── retriever.go
│ └── watchers
│ └── watcher_issue.go
└── plugin
├── CHANGELOG.md
├── LICENSE
├── Magefile.go
├── README.md
├── docker-compose.yaml
├── jest-setup.js
├── jest.config.js
├── package.json
├── pkg
│ └── main.go
├── playwright.config.ts
├── provisioning
│ └── plugins
│ ├── README.md
│ └── apps.yaml
├── src
│ ├── README.md
│ ├── components
│ │ ├── App
│ │ │ ├── App.test.tsx
│ │ │ └── App.tsx
│ │ ├── AppConfig
│ │ │ ├── AppConfig.test.tsx
│ │ │ └── AppConfig.tsx
│ │ └── testIds.ts
│ ├── constants.ts
│ ├── img
│ │ └── logo.svg
│ ├── module.tsx
│ ├── pages
│ │ ├── PageFour.tsx
│ │ ├── PageOne.tsx
│ │ ├── PageThree.tsx
│ │ └── PageTwo.tsx
│ ├── plugin.json
│ └── utils
│ └── utils.routing.ts
├── tests
│ ├── appConfig.spec.ts
│ ├── appNavigation.spec.ts
│ └── fixtures.ts
└── tsconfig.json
20 directories, 45 files
Excluding our previously-generated files, we can see that we have a few new go packages (pkg/app
, pkg/watchers
, pkg/plugin
, and pkg/plugin/secure
), some go files and a Dockerfile in cmd/operator
, and a bunch of new stuff in the plugin
directory.
If we had split up our project component add
into project component add backend
, we'd only get our generated go files in pkg/plugin
, project component add frontend
would only give us the non-plugin/pkg
plugin files, and project component add operator
would give us the pkg/watchers
and cmd/operator
files. As we can see, none of these project component add
components have overlapping code, which is deliberate. If you prefer to not use boilerplate for a given component, you can simply not add it and not worry that another component will depend on boilerplate from it.
So, what are these new bits of code doing?
Important note: the back-end part of the plugin is primarily used as a proxy to the app API server, in order to allow the user to use grafana auth to make the request to the grafana resource API, and let the plugin make the request to the API server using credentials for the API server. The final state of app platform will allow for grafana auth to be used with the API server, and direct access to the API server from outside of the back-end, so the eventual goal is to both allow and encourage the front-end to directly interact with the API server and kubernetes-style APIs.
The project component add
didn't actually generate too many files for our back-end boilerplate, just a couple of go files in pkg/plugin
and then some code in pkg/plugin/secure
:
$ tree pkg/plugin
pkg/plugin
├── handler_issue.go
├── plugin.go
└── secure
├── data.go
├── middleware.go
└── retriever.go
1 directory, 5 files
The code in the pkg/plugin/secure
package is focused around defining the shape of your SecureJSONData
, which is encrypted data shared between the front-end and back-end of the plugin. For more information on data jsonData/secureJSONData, see this section of grafana's plugin docs (it refers to data source plugins, but the concept is the same for all plugins that have a back-end component).
For our purposes, we care about the secureJSONData because we're going to store the details on how to access our storage medium in there: since we're going to be using kubernetes to store our data, we'll have a kubeconfig embedded in the secure JSON data. In your own development, you may store things such as user keys for a third-party service in this data if the back-end needs to reach out to them.
The code in pkg/plugin
is split into two files:
plugin.go
, which defines ourPlugin
type we'll run everything from, which embeds a router and defines routes.handler_issue.go
, which defines the handlers for theissue
routes defined inplugin.go
. If we had more Kinds, we'd have a handler go file for each one, with boilerplate CRUDL code for each Kind.
The first thing defined in plugin.go
is a Service
interface:
type Service interface {
GetIssueService(context.Context) (IssueService, error)
}
Getting ahead of ourselves, we have a Service
which returns the actual services our plugin will use (such as IssueService
), because we have to lazy-instantiate our Schema-specific services. This is because we need data from that secureJSONData
mentioned above, and we only get that data from a request made to the back-end of the plugin through grafana, so we don't have it at start-up time. We'll take a look at the implementation of Service
with that lazy-initialization later.
Our boilerplate Plugin
creates a router and registers routes when created with New
:
func New(namespace string, service Service) (*Plugin, error) {
p := &Plugin{
router: router.NewJSONRouter(log.DefaultLogger),
namespace: namespace,
service: service,
}
p.router.Use(
kubeconfig.LoadingMiddleware(),
router.MiddlewareFunc(secure.Middleware))
// V1 Routes
v1Subrouter := p.router.Subroute("v1/")
// Issue subrouter
issueSubrouter := v1Subrouter.Subroute("issues/")
v1Subrouter.Handle("issues", p.handleIssueList, http.MethodGet)
issueSubrouter.Handle("{name}", p.handleIssueGet, http.MethodGet)
issueSubrouter.HandleWithCode("", p.handleIssueCreate, http.StatusCreated, http.MethodPost)
issueSubrouter.Handle("{name}", p.handleIssueUpdate, http.MethodPut)
issueSubrouter.HandleWithCode("{name}", p.handleIssueDelete, http.StatusNoContent, http.MethodDelete)
return p, nil
}
We can see that this router isn't a standard go http router. Requests that come to the back-end of our plugin are sent through grafana's Resource API, which then passes along a subset of that data to the plugin with gRPC. The router.JSONRouter
abstracts away that implementation detail (and there are other router flavors in the router
package), and gives us a router where we can define normal HTTP routes, with handlers that will consume a router.JSONRequest
(which pulls together all the data we get from the forwarded grafana request), and return either some object which can (and will) be marshaled into JSON, or an error (which will be marshaled into an error response).
There are also several pieces of middleware in use:
p.router.Use(
router.NewTracingMiddleware(otel.GetTracerProvider().Tracer("tracing-middleware")),
router.NewLoggingMiddleware(logging.DefaultLogger),
kubeconfig.LoadingMiddleware(),
router.MiddlewareFunc(secure.Middleware))
router.NewTracingMiddleware(otel.GetTracerProvider().Tracer("tracing-middleware"))
is a middleware that adds a tracing span for every request. The span lasts during the duration of the request's handle time and includes HTTP request and response attributes.router.NewLoggingMiddleware(logging.DefaultLogger)
is a middleware that logs an INFO level message for each request.kubeconfig.LoadingMiddleware()
is middleware managed by the grafana-app-sdk which will pull kube config details from the secureJSONData and place it into the context. We'll see the other side, where we use that kube config, later on.router.MiddlewareFunc(secure.Middleware)
is that secureJSONData middleware we just talked about in our boilerplatepkg/plugin/secure
package.
The last bits in the boilerplate code here are just creating a subrouter for our issue
Kind and adding routes and handlers for all standard Create/Read/Update/Delete/List endpoints.
The handler functions themselves are defined in pkg/plugin/handler_issue.go
, though we can see that the first thing defined is our IssueService
:
type IssueService interface {
List(ctx context.Context, namespace string, filters ...string) (*resource.TypedStoreList[*issue.Object], error)
Get(ctx context.Context, id resource.Identifier) (*issue.Object, error)
Add(ctx context.Context, obj *issue.Object) (*issue.Object, error)
Update(ctx context.Context, id resource.Identifier, obj *issue.Object) (*issue.Object, error)
Delete(ctx context.Context, id resource.Identifier) error
}
This service is what we'll have to actually implement later when we start writing code, but it's what the handlers are going to try to use to do what they're supposed to do. To see this, let's take a look at the list handler (defined first):
func (p *Plugin) handleIssueList(ctx context.Context, req router.JSONRequest) (router.JSONResponse, error) {
ctx, span := tracing.DefaultTracer().Start(ctx, "issue-list")
defer span.End()
filtersRaw := req.URL.Query().Get("filters")
filters := make([]string, 0)
if len(filtersRaw) > 0 {
filters = strings.Split(filtersRaw, ",")
}
svc, err := p.service.GetIssueService(ctx)
if err != nil {
log.DefaultLogger.With("traceID", span.SpanContext().TraceID()).Error("Error getting IssueService: "+err.Error(), "error", err)
return nil, plugin.NewError(http.StatusInternalServerError, err.Error())
}
return svc.List(ctx, resource.StoreListOptions{Namespace: p.namespace, PerPage: 500, Filters: filters})
}
It satisfies the router.JSONHandlerFunc
function type, so that we can use it as a handler. The first parameter, ctx
, is somewhat self-explanatory as the go context (if you're unfamiliar with go contexts, the godoc is a good place to start). The second parameter is a router.JSONRequest
. This is a sort of plugin equivalent of the http.Request
, though with some differences, most of which we won't cover here. The important one to know is that it doesn't have all the request data you might have in an http.Request
, such as the hostname, or all the headers. The url.URL
we get with req.URL
contains a URL which begins at the entrypoint to our API, so the first part will be the first part of the path in our route (no protocol, host, or initial grafana resource API path).
We return a router.JSONResponse
, which is any JSON-marshalable object, and a possible error
. The router.JSONRouter
will handle response marshaling and writing for us, so rather than needing to write out data like in a http
handler, we just return as we would a normal function.
In our list handler boilerplate, we can see we grab filters from the query, if present, and then we call List
on our IssueService
we attempt to retrive from our Service
implementation. Overall, the handler functions in this file should be pretty straightforward, and you're encouraged to change them as you see fit once we have a working application (this code isn't something that you'll be re-generating, like the pkg/generated
code).
plugin/pkg
is where the main
package lives for our plugin, it's what will be compiled for the back-end. This is also where the boilerplate has the most gaps that need to be filled to make things functional, but let's take a look at what's given to us first.
Let's ignore PluginService
for now, as we'll be replacing that code later with our own. main
function contains mostly boilerplate code, but most importantly it calls app.Manage
to handle the plugin lifecycle.
app.Manage(pluginID, newInstanceFactory(logger), app.ManageOpts{
TracingOpts: tracing.Opts{
CustomAttributes: []attribute.KeyValue{
attribute.String("plugin.id", pluginID),
},
},
})
app.Manage
works on the result of newInstanceFactory
, which loads the supplied configuration, creates store for storing our issues, and then creates a plugin instance using plugin.New
, which is then returned.
A lot of files were generated in plugin
:
$ tree plugin
plugin
├── CHANGELOG.md
├── LICENSE
├── Magefile.go
├── README.md
├── docker-compose.yaml
├── jest-setup.js
├── jest.config.js
├── package.json
├── pkg
│ └── main.go
├── playwright.config.ts
├── provisioning
│ └── plugins
│ ├── README.md
│ └── apps.yaml
├── src
│ ├── README.md
│ ├── components
│ │ ├── App
│ │ │ ├── App.test.tsx
│ │ │ └── App.tsx
│ │ ├── AppConfig
│ │ │ ├── AppConfig.test.tsx
│ │ │ └── AppConfig.tsx
│ │ └── testIds.ts
│ ├── constants.ts
│ ├── generated
│ │ └── issue
│ │ └── v1
│ │ ├── issue_object_gen.ts
│ │ ├── types.metadata.gen.ts
│ │ ├── types.spec.gen.ts
│ │ └── types.status.gen.ts
│ ├── img
│ │ └── logo.svg
│ ├── module.tsx
│ ├── pages
│ │ ├── PageFour.tsx
│ │ ├── PageOne.tsx
│ │ ├── PageThree.tsx
│ │ └── PageTwo.tsx
│ ├── plugin.json
│ └── utils
│ └── utils.routing.ts
├── tests
│ ├── appConfig.spec.ts
│ ├── appNavigation.spec.ts
│ └── fixtures.ts
└── tsconfig.json
15 directories, 35 files
We can also safely ignore a lot of this generation. If you create a grafana plugin, there's a certain amount of metadata that needs to be created, and, likewise, when you create a react app (which front-end plugins are), there's some other data that needs to exist. So basically everything in the root plugin
directory is something we can ignore for the moment, as it's just things telling either grafana how to handle this app, or react how to compile it. But, as a quick breakdown, here's what each file does that we're going to ignore:
File | Purpose |
---|---|
jest.config.js |
Jest test configuration |
Magefile.go |
Mage build configuration |
package.json |
React app configuration |
README.md |
Plugin README (required by grafana) |
tsconfig.json |
TypeScript config |
src/plugin.json |
Grafana plugin information |
That leaves us with just our varying TypeScript files.
pages/
contains the actual front-end pages to be displayed for the app. PageOne.tsx
is your main plugin page, which by default just contains a simple statement declaring it your main landing page:
function PageOne() {
const s = useStyles2(getStyles);
return (
<PluginPage>
<div data-testid={testIds.pageOne.container}>
This is page one.
<div className={s.marginTop}>
<LinkButton data-testid={testIds.pageOne.navigateToFour} href={prefixRoute(ROUTES.Four)}>
Full-width page example
</LinkButton>
</div>
</div>
</PluginPage>
);
}
There are other pages generated here as examples from the output of yarn create @grafana/plugin
. The routing for these pages is in components/App/App.tsx
.
MainPage
is used by the router when displaying pages--you can add more by creating other exported functions and registering them in the router.
components
contains your front-end App declaration and AppConfig.
generated/issue/v1
contains the types for our v1 Issue
kind, which we can use to interact with the plugin backend (and API server).
This is the code for the app itself. The app (business logic) and the way it is run (an operator) are treated as separate concepts by the grafana-app-sdk to allow you to run the same app multiple ways based on your needs.
pkg/app/app.go
contains two exported methods: Provider
and New
. New
creates a new grafana-app-sdk app.App
-implementing instance (in our case, we use simple.App
for this),
and Provider
returns a new app.Provider
which packs in your manifest, app-specific config, and the ability to call New
.
app.Provider
is what is used by runners such as the operator runner we created with component add operator
.
Here is where the main
code to run the operator lives, and the docker file to package it as an image for deployment.
cmd/operator/main.go
has a straightforward main
function that:
- Loads the kube config, assuming that it's running in the cluster that it will work with.
- Creates the operator runner
- Runs the operator runner using the
Provider
we generated in theapp
package, stopping on SIGTERM or SIGINT
Here we only have one file, created for our Issue kind. If we had more kinds, we'd have more files here, as the project component add operator
command generates a boilerplate watcher for each kind in CUE with a target: "resource"
. This file defines a simple watcher object which implements operator.ResourceWatcher
, with an additional Sync
function which is used in conjunction with a resource.OpinionatedWatcher
. All this boilerplate watcher does is check that it can cast the provided resource(s) into the issue.Object
type, and then print a line to the console with the event type and details.
Next, now that we have minimal functioning code, we can try, deploying our project locally