feat(raw): add grafana dashboard endpoints

This commit is contained in:
Paymahn Moghadasian
2026-07-06 13:03:49 -05:00
parent 2fd657e90b
commit 65d6a586f0
10 changed files with 486 additions and 15 deletions
+18
View File
@@ -0,0 +1,18 @@
# Napkin
## Corrections
| Date | Source | What Went Wrong | What To Do Instead |
|------|--------|----------------|-------------------|
| 2026-07-06 | self | Ran the HAR generator directly on a narrow dashboard HAR and it overwrote the broad raw command registry with only 24 commands | For additive HAR integrations, preserve the generated baseline and add/merge new commands instead of replacing unrelated captured endpoints |
## User Preferences
- Always commit any changes made to this napkin file.
## Patterns That Work
- (approaches that succeeded)
## Patterns That Don't Work
- (approaches that failed and why)
## Domain Notes
- (project/domain context that matters)
+4
View File
@@ -160,6 +160,10 @@ groundcover raw k8s clusters list
groundcover raw dashboards get --dashboard-id <id>
groundcover raw metrics query-range --body-file body.json
groundcover raw prometheus api query --query query='up'
groundcover raw grafana dashboards get --dashboard-uid <uid>
groundcover raw grafana dashboards save --body-file dashboard.json
groundcover raw grafana folders list
groundcover raw grafana ds query --body-file query.json
```
Raw commands support:
+1 -1
View File
@@ -44,7 +44,7 @@ func NewCommand(cfg *config.Config) *cobra.Command {
}
func printCommands(cmd *cobra.Command, filter string) {
for _, command := range Commands {
for _, command := range allCommands() {
name := command.Key()
if filter != "" && !strings.Contains(name, filter) {
continue
+28 -1
View File
@@ -2,6 +2,7 @@ package raw
import (
"encoding/json"
"sort"
"strings"
)
@@ -14,6 +15,7 @@ type Command struct {
DefaultQuery map[string]string
DefaultBody json.RawMessage
BodyContentType string
WebApp bool
}
func (c Command) Key() string {
@@ -21,7 +23,7 @@ func (c Command) Key() string {
}
func Find(tokens []string) (Command, bool) {
for _, command := range Commands {
for _, command := range allCommands() {
if len(command.Name) != len(tokens) {
continue
}
@@ -39,6 +41,31 @@ func Find(tokens []string) (Command, bool) {
return Command{}, false
}
func allCommands() []Command {
commands := make([]Command, 0, len(Commands)+len(ExtraCommands))
commands = append(commands, Commands...)
commands = append(commands, ExtraCommands...)
byName := map[string]Command{}
for _, command := range commands {
if _, exists := byName[command.Key()]; exists {
continue
}
byName[command.Key()] = command
}
keys := make([]string, 0, len(byName))
for key := range byName {
keys = append(keys, key)
}
sort.Strings(keys)
ordered := make([]Command, 0, len(keys))
for _, key := range keys {
ordered = append(ordered, byName[key])
}
return ordered
}
func kebab(value string) string {
var out strings.Builder
for i, r := range value {
+190
View File
@@ -0,0 +1,190 @@
package raw
var ExtraCommands = []Command{
{
Name: []string{"grafana", "search"},
Method: "GET",
Path: "/grafana/api/search",
Description: "GET /grafana/api/search",
DefaultQuery: map[string]string{"limit": "50", "page": "1", "type": "dash-db"},
WebApp: true,
},
{
Name: []string{"grafana", "search", "sorting"},
Method: "GET",
Path: "/grafana/api/search/sorting",
Description: "GET /grafana/api/search/sorting",
WebApp: true,
},
{
Name: []string{"grafana", "dashboards", "save"},
Method: "POST",
Path: "/grafana/api/dashboards/db",
Description: "POST /grafana/api/dashboards/db",
WebApp: true,
},
{
Name: []string{"grafana", "dashboards", "get"},
Method: "GET",
Path: "/grafana/api/dashboards/uid/:dashboardUid",
Description: "GET /grafana/api/dashboards/uid/:dashboardUid",
PathParams: []string{"dashboardUid"},
WebApp: true,
},
{
Name: []string{"grafana", "dashboards", "delete"},
Method: "DELETE",
Path: "/grafana/api/dashboards/uid/:dashboardUid",
Description: "DELETE /grafana/api/dashboards/uid/:dashboardUid",
PathParams: []string{"dashboardUid"},
WebApp: true,
},
{
Name: []string{"grafana", "dashboards", "permissions", "get"},
Method: "GET",
Path: "/grafana/api/dashboards/uid/:dashboardUid/permissions",
Description: "GET /grafana/api/dashboards/uid/:dashboardUid/permissions",
PathParams: []string{"dashboardUid"},
WebApp: true,
},
{
Name: []string{"grafana", "dashboards", "permissions", "update"},
Method: "POST",
Path: "/grafana/api/dashboards/uid/:dashboardUid/permissions",
Description: "POST /grafana/api/dashboards/uid/:dashboardUid/permissions",
PathParams: []string{"dashboardUid"},
WebApp: true,
},
{
Name: []string{"grafana", "dashboards", "versions"},
Method: "GET",
Path: "/grafana/api/dashboards/id/:dashboardId/versions",
Description: "GET /grafana/api/dashboards/id/:dashboardId/versions",
PathParams: []string{"dashboardId"},
WebApp: true,
},
{
Name: []string{"grafana", "dashboards", "versions", "get"},
Method: "GET",
Path: "/grafana/api/dashboards/id/:dashboardId/versions/:version",
Description: "GET /grafana/api/dashboards/id/:dashboardId/versions/:version",
PathParams: []string{"dashboardId", "version"},
WebApp: true,
},
{
Name: []string{"grafana", "dashboards", "versions", "restore"},
Method: "POST",
Path: "/grafana/api/dashboards/id/:dashboardId/restore",
Description: "POST /grafana/api/dashboards/id/:dashboardId/restore",
PathParams: []string{"dashboardId"},
WebApp: true,
},
{
Name: []string{"grafana", "folders", "list"},
Method: "GET",
Path: "/grafana/api/folders",
Description: "GET /grafana/api/folders",
DefaultQuery: map[string]string{"limit": "50", "page": "1"},
WebApp: true,
},
{
Name: []string{"grafana", "folders", "get"},
Method: "GET",
Path: "/grafana/api/folders/:folderUid",
Description: "GET /grafana/api/folders/:folderUid",
PathParams: []string{"folderUid"},
WebApp: true,
},
{
Name: []string{"grafana", "folders", "create"},
Method: "POST",
Path: "/grafana/api/folders",
Description: "POST /grafana/api/folders",
WebApp: true,
},
{
Name: []string{"grafana", "folders", "update"},
Method: "PUT",
Path: "/grafana/api/folders/:folderUid",
Description: "PUT /grafana/api/folders/:folderUid",
PathParams: []string{"folderUid"},
WebApp: true,
},
{
Name: []string{"grafana", "folders", "delete"},
Method: "DELETE",
Path: "/grafana/api/folders/:folderUid",
Description: "DELETE /grafana/api/folders/:folderUid",
PathParams: []string{"folderUid"},
WebApp: true,
},
{
Name: []string{"grafana", "folders", "permissions", "get"},
Method: "GET",
Path: "/grafana/api/folders/:folderUid/permissions",
Description: "GET /grafana/api/folders/:folderUid/permissions",
PathParams: []string{"folderUid"},
WebApp: true,
},
{
Name: []string{"grafana", "folders", "permissions", "update"},
Method: "POST",
Path: "/grafana/api/folders/:folderUid/permissions",
Description: "POST /grafana/api/folders/:folderUid/permissions",
PathParams: []string{"folderUid"},
WebApp: true,
},
{
Name: []string{"grafana", "prometheus", "rules"},
Method: "GET",
Path: "/grafana/api/prometheus/grafana/api/v1/rules",
Description: "GET /grafana/api/prometheus/grafana/api/v1/rules",
WebApp: true,
},
{
Name: []string{"grafana", "annotations", "list"},
Method: "GET",
Path: "/grafana/api/annotations",
Description: "GET /grafana/api/annotations",
DefaultQuery: map[string]string{"limit": "100", "matchAny": "false"},
WebApp: true,
},
{
Name: []string{"grafana", "annotations", "create"},
Method: "POST",
Path: "/grafana/api/annotations",
Description: "POST /grafana/api/annotations",
WebApp: true,
},
{
Name: []string{"grafana", "annotations", "update"},
Method: "PUT",
Path: "/grafana/api/annotations/:annotationId",
Description: "PUT /grafana/api/annotations/:annotationId",
PathParams: []string{"annotationId"},
WebApp: true,
},
{
Name: []string{"grafana", "annotations", "delete"},
Method: "DELETE",
Path: "/grafana/api/annotations/:annotationId",
Description: "DELETE /grafana/api/annotations/:annotationId",
PathParams: []string{"annotationId"},
WebApp: true,
},
{
Name: []string{"grafana", "datasources", "label-values"},
Method: "GET",
Path: "/grafana/api/datasources/uid/:datasourceUid/resources/api/v1/label/:label/values",
Description: "GET /grafana/api/datasources/uid/:datasourceUid/resources/api/v1/label/:label/values",
PathParams: []string{"datasourceUid", "label"},
WebApp: true,
},
{
Name: []string{"grafana", "ds", "query"},
Method: "POST",
Path: "/grafana/api/ds/query",
Description: "POST /grafana/api/ds/query",
WebApp: true,
},
}
+7 -1
View File
@@ -16,6 +16,8 @@ import (
"gopkg.in/yaml.v3"
)
const defaultWebAppBaseURL = "https://app.groundcover.com"
func Run(command Command, cfg config.Config, opts Options, out io.Writer) error {
if err := cfg.RequireAPIKey(); err != nil {
return err
@@ -76,7 +78,11 @@ func buildURL(command Command, cfg config.Config, opts Options) (*url.URL, error
path = strings.ReplaceAll(path, ":"+param, url.PathEscape(value))
}
base, err := url.Parse(cfg.NormalizedBaseURL())
baseURL := cfg.NormalizedBaseURL()
if command.WebApp && baseURL == config.DefaultBaseURL {
baseURL = defaultWebAppBaseURL
}
base, err := url.Parse(baseURL)
if err != nil {
return nil, err
}
+54 -1
View File
@@ -1,6 +1,10 @@
package raw
import "testing"
import (
"testing"
"github.com/paymog/groundcover-cli/internal/config"
)
func TestSetDeep(t *testing.T) {
target := map[string]any{}
@@ -28,3 +32,52 @@ func TestFind(t *testing.T) {
t.Fatalf("unexpected path %s", command.Path)
}
}
func TestFindGrafanaCommand(t *testing.T) {
command, ok := Find([]string{"grafana", "dashboards", "get"})
if !ok {
t.Fatalf("expected grafana dashboards get command")
}
if command.Path != "/grafana/api/dashboards/uid/:dashboardUid" {
t.Fatalf("unexpected path %s", command.Path)
}
if len(command.PathParams) != 1 || command.PathParams[0] != "dashboardUid" {
t.Fatalf("unexpected path params %#v", command.PathParams)
}
if !command.WebApp {
t.Fatalf("expected grafana command to target webapp host")
}
}
func TestBuildURLUsesWebAppDefault(t *testing.T) {
command, ok := Find([]string{"grafana", "dashboards", "get"})
if !ok {
t.Fatalf("expected grafana dashboards get command")
}
requestURL, err := buildURL(command, config.Config{BaseURL: config.DefaultBaseURL}, Options{
PathValues: map[string]string{"dashboardUid": "streamling-pipeline-slo"},
Query: []string{"orgId=1"},
})
if err != nil {
t.Fatalf("buildURL failed: %v", err)
}
if got, want := requestURL.String(), "https://app.groundcover.com/grafana/api/dashboards/uid/streamling-pipeline-slo?orgId=1"; got != want {
t.Fatalf("unexpected URL\n got: %s\nwant: %s", got, want)
}
}
func TestBuildURLHonorsCustomBaseURL(t *testing.T) {
command, ok := Find([]string{"grafana", "folders", "get"})
if !ok {
t.Fatalf("expected grafana folders get command")
}
requestURL, err := buildURL(command, config.Config{BaseURL: "https://groundcover.example"}, Options{
PathValues: map[string]string{"folderUid": "bend1nm1f0ruod"},
})
if err != nil {
t.Fatalf("buildURL failed: %v", err)
}
if got, want := requestURL.String(), "https://groundcover.example/grafana/api/folders/bend1nm1f0ruod"; got != want {
t.Fatalf("unexpected URL\n got: %s\nwant: %s", got, want)
}
}
+110 -11
View File
@@ -40,12 +40,19 @@ type command struct {
PathParams []string
DefaultQuery map[string]string
DefaultBody []byte
WebApp bool
}
var (
uuidPattern = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
uuidPattern = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
numericPattern = regexp.MustCompile(`^\d+$`)
versionPattern = regexp.MustCompile(`^v\d+$`)
excludedPaths = map[string]bool{"/api/track/events": true}
excludedPaths = map[string]bool{
"/api/track/events": true,
"/grafana/api/access-control/user/actions": true,
"/grafana/api/frontend/assets": true,
"/grafana/api/frontend-metrics": true,
}
)
func main() {
@@ -73,7 +80,7 @@ func main() {
if !strings.HasSuffix(parsedURL.Hostname(), "groundcover.com") {
continue
}
if !strings.HasPrefix(parsedURL.Path, "/api/") {
if !supportedPath(parsedURL.Path) {
continue
}
if excludedPaths[parsedURL.Path] || entry.Response.Status >= 400 {
@@ -99,13 +106,14 @@ func main() {
}
byEndpoint[key] = command{
Name: commandName(parsedURL.Path),
Name: commandName(method, path),
Method: method,
Path: path,
Description: method + " " + path,
PathParams: params,
DefaultQuery: defaultQuery,
DefaultBody: parseBody(entry.Request.PostData),
WebApp: strings.HasPrefix(parsedURL.Path, "/grafana/api/"),
}
}
@@ -132,15 +140,30 @@ func main() {
}
}
func commandName(path string) []string {
parts := strings.Split(strings.TrimPrefix(path, "/api/"), "/")
func supportedPath(path string) bool {
return strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/grafana/api/")
}
func commandName(method string, path string) []string {
name := []string{}
trimmed := path
if strings.HasPrefix(path, "/grafana/api/") {
name = append(name, "grafana")
trimmed = strings.TrimPrefix(path, "/grafana/api/")
} else {
trimmed = strings.TrimPrefix(path, "/api/")
}
parts := strings.Split(trimmed, "/")
for i, part := range parts {
if part == "" || versionPattern.MatchString(part) {
continue
}
if uuidPattern.MatchString(part) {
if i == 0 || len(name) == 0 || name[len(name)-1] != "get" {
if (part == "uid" || part == "id") && i+1 < len(parts) && strings.HasPrefix(parts[i+1], ":") {
continue
}
if strings.HasPrefix(part, ":") {
if method == "GET" && i == len(parts)-1 && (len(name) == 0 || name[len(name)-1] != "get") {
name = append(name, "get")
}
continue
@@ -154,11 +177,13 @@ func normalizedPath(path string) (string, []string) {
parts := strings.Split(path, "/")
params := []string{}
for i, part := range parts {
if !uuidPattern.MatchString(part) {
if !isDynamicPathPart(parts, i, part) {
continue
}
name := paramName(previousPart(parts, i))
params = append(params, name)
name := dynamicParamName(parts, i)
if !contains(params, name) {
params = append(params, name)
}
parts[i] = ":" + name
}
return strings.Join(parts, "/"), params
@@ -173,6 +198,69 @@ func previousPart(parts []string, index int) string {
return "id"
}
func isDynamicPathPart(parts []string, index int, part string) bool {
if uuidPattern.MatchString(part) {
return true
}
if !isGrafanaPath(parts) {
return false
}
previous := previousPart(parts, index)
switch previous {
case "uid", "label":
return true
case "folders":
return part != "folders"
case "annotations", "id", "versions":
return numericPattern.MatchString(part)
default:
return false
}
}
func isGrafanaPath(parts []string) bool {
return len(parts) > 3 && parts[1] == "grafana" && parts[2] == "api"
}
func dynamicParamName(parts []string, index int) string {
previous := previousPart(parts, index)
if isGrafanaPath(parts) {
switch previous {
case "uid":
return uidParamName(previousPartBefore(parts, index-1))
case "folders":
return "folderUid"
case "label":
return "label"
case "annotations":
return "annotationId"
case "id":
return paramName(previousPartBefore(parts, index-1))
case "versions":
return "version"
}
}
return paramName(previous)
}
func previousPartBefore(parts []string, index int) string {
for i := index - 1; i >= 0; i-- {
if parts[i] != "" {
return parts[i]
}
}
return "id"
}
func contains(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func paramName(previous string) string {
base := strings.TrimSuffix(previous, "s")
if base == "" {
@@ -181,6 +269,14 @@ func paramName(previous string) string {
return base + "Id"
}
func uidParamName(previous string) string {
base := strings.TrimSuffix(previous, "s")
if base == "" {
base = "id"
}
return base + "Uid"
}
func parseBody(postData *struct {
Text string `json:"text"`
MimeType string `json:"mimeType"`
@@ -254,6 +350,9 @@ func emit(commands []command) ([]byte, error) {
if len(command.DefaultBody) > 0 {
fmt.Fprintf(&b, "\t\tDefaultBody: json.RawMessage(%s),\n", strconv.Quote(string(command.DefaultBody)))
}
if command.WebApp {
b.WriteString("\t\tWebApp: true,\n")
}
b.WriteString("\t},\n")
}
b.WriteString("}\n")
+52
View File
@@ -0,0 +1,52 @@
package main
import (
"reflect"
"testing"
)
func TestNormalizedPathGrafanaDashboardUID(t *testing.T) {
path, params := normalizedPath("/grafana/api/dashboards/uid/streamling-pipeline-slo")
if path != "/grafana/api/dashboards/uid/:dashboardUid" {
t.Fatalf("unexpected path %s", path)
}
if !reflect.DeepEqual(params, []string{"dashboardUid"}) {
t.Fatalf("unexpected params %#v", params)
}
name := commandName("GET", path)
if !reflect.DeepEqual(name, []string{"grafana", "dashboards", "get"}) {
t.Fatalf("unexpected name %#v", name)
}
}
func TestNormalizedPathGrafanaFolderUID(t *testing.T) {
path, params := normalizedPath("/grafana/api/folders/bend1nm1f0ruod")
if path != "/grafana/api/folders/:folderUid" {
t.Fatalf("unexpected path %s", path)
}
if !reflect.DeepEqual(params, []string{"folderUid"}) {
t.Fatalf("unexpected params %#v", params)
}
name := commandName("GET", path)
if !reflect.DeepEqual(name, []string{"grafana", "folders", "get"}) {
t.Fatalf("unexpected name %#v", name)
}
}
func TestNormalizedPathGrafanaDatasourceLabelValues(t *testing.T) {
path, params := normalizedPath("/grafana/api/datasources/uid/aelovgen78268b/resources/api/v1/label/project_id/values")
if path != "/grafana/api/datasources/uid/:datasourceUid/resources/api/v1/label/:label/values" {
t.Fatalf("unexpected path %s", path)
}
if !reflect.DeepEqual(params, []string{"datasourceUid", "label"}) {
t.Fatalf("unexpected params %#v", params)
}
name := commandName("GET", path)
want := []string{"grafana", "datasources", "resources", "api", "label", "values"}
if !reflect.DeepEqual(name, want) {
t.Fatalf("unexpected name %#v", name)
}
}
+22
View File
@@ -74,6 +74,7 @@ Reach for `groundcover raw …` for any of these; the SDK has the *parent* resou
- **traces:** `attributes`, `details`, `errors`, `filters`, `insights`, `latencies`, `requests`, `values-distribution` (SDK only has `search`)
- **metrics:** `cardinality`, `cardinality-graph`, `discovery`, `labels-cardinality`, `query-range`, `resources errors|latencies|list|requests` (SDK has `query`, `names`, `keys`, `values`)
- **prometheus:** `prometheus api query` (raw Prom passthrough — handy for ad-hoc PromQL via `--query query='up'`)
- **Grafana dashboards:** `grafana search`, `grafana dashboards get|save|delete`, `grafana dashboards permissions get|update`, `grafana dashboards versions|get|restore`, `grafana folders list|get|create|update|delete`, `grafana folders permissions get|update`, `grafana annotations list|create|update|delete`, `grafana prometheus rules`, `grafana datasources label-values`, `grafana ds query`
- **monitors drilldowns:** `instances filters|query|timeline`, `labels keys`, `silences`, `summary filters|query`, `timeline` (SDK only has CRUD)
- **k8s drilldowns:** `configmaps|cronjob|daemonsets|deployments|jobs|pods|pvcs|replicasets|statefulsets list`, `container info`, `context events`, `namespaces info|list`, `nodes info-with-resources|list|resources|usage top10`, `pod container usage`, `pods status-over-time`, `workloads availability|events|usage top10`, `network connections|cross-az|cross-az-regions|partners|throughput|top-connections`, `events search-time-series` (SDK only has `clusters`, `workloads`, `events-search`, `events-over-time`)
- **infra:** `infra hosts info-with-resources`
@@ -205,8 +206,29 @@ groundcover raw k8s clusters list
groundcover raw dashboards get --dashboard-id <id>
groundcover raw metrics query-range --body-file body.json
groundcover raw prometheus api query --query query='up'
groundcover raw grafana dashboards get --dashboard-uid <uid>
groundcover raw grafana dashboards save --body-file dashboard.json
groundcover raw grafana folders list
groundcover raw grafana ds query --body-file query.json
```
### Grafana native dashboards
Groundcover also embeds Grafana at `/grafana`. These are **not** the same as Groundcover's first-class `dashboards` SDK resource, so use `raw grafana …` when you need native Grafana JSON dashboards, folders, permissions, annotations, datasource-backed variable values, or panel query execution.
Common commands:
```sh
groundcover raw grafana search --query query='service slo' --query folderUIDs=general
groundcover raw grafana dashboards get --dashboard-uid streamling-pipeline-slo
groundcover raw grafana dashboards save --body-file dashboard.json
groundcover raw grafana folders list
groundcover raw grafana datasources label-values --datasource-uid <uid> --label project_id --query start=<unix> --query end=<unix>
groundcover raw grafana ds query --query ds_type=prometheus --body-file query.json
```
Grafana raw commands default to `https://app.groundcover.com` (the webapp host), while normal API/SDK commands default to `https://api.groundcover.com`. Pass `--base-url` only for non-standard deployments.
Raw flags: `--body-json`, `--body-file`, `--set dotted.path=value`, `--query key=value` (repeatable), generated path flags (e.g. `--dashboard-id`), `--raw`. Many raw commands ship a default body captured from the webapp HAR, so you can run them with no `--body-*` at all and override specific fields with `--set`.
## Recipes