diff --git a/cmd/obs-mcp/main.go b/cmd/obs-mcp/main.go index 334ecc3..a5a1fcb 100644 --- a/cmd/obs-mcp/main.go +++ b/cmd/obs-mcp/main.go @@ -30,6 +30,7 @@ func main() { var guardrails = flag.String("guardrails", "all", "Guardrails configuration: 'all' (default), 'none', or comma-separated list of guardrails to enable (disallow-explicit-name-label, require-label-matcher, disallow-blanket-regex)") var maxMetricCardinality = flag.Uint64("guardrails.max-metric-cardinality", 20000, "Maximum allowed series count per metric (0 = disabled)") var maxLabelCardinality = flag.Uint64("guardrails.max-label-cardinality", 500, "Maximum allowed label value count for blanket regex (0 = always disallow blanket regex). Only takes effect if disallow-blanket-regex is enabled.") + flag.Parse() // Configure slog with specified log level diff --git a/go.mod b/go.mod index 33fca80..c23bb9c 100644 --- a/go.mod +++ b/go.mod @@ -4,26 +4,38 @@ go 1.24.6 require ( github.com/mark3labs/mcp-go v0.39.1 + github.com/perses/perses-operator v0.2.0 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/common v0.67.1 github.com/prometheus/prometheus v0.307.3 + k8s.io/apimachinery v0.34.1 k8s.io/client-go v0.34.1 + sigs.k8s.io/controller-runtime v0.22.4 ) require ( + cel.dev/expr v0.24.0 // indirect + github.com/PaesslerAG/gval v1.2.4 // indirect + github.com/PaesslerAG/jsonpath v0.1.2-0.20240726212847-3a740cf7976f // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/brunoga/deep v1.2.5 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dennwc/varint v1.0.0 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/cel-go v0.26.0 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 // indirect @@ -31,34 +43,52 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/jpillora/backoff v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/labstack/echo/v4 v4.13.4 // indirect + github.com/labstack/gommon v0.4.2 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/muhlemmer/gu v0.3.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect + github.com/nexucis/lamenv v0.5.2 // indirect + github.com/perses/common v0.27.1-0.20250326140707-96e439b14e0e // indirect + github.com/perses/perses v0.51.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/procfs v0.16.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect + github.com/stoewer/go-strcase v1.3.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + github.com/zitadel/oidc/v3 v3.38.1 // indirect + github.com/zitadel/schema v1.3.1 // indirect go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.42.0 // indirect + golang.org/x/exp v0.0.0-20250808145144-a408d31f581a // indirect golang.org/x/net v0.44.0 // indirect golang.org/x/oauth2 v0.31.0 // indirect golang.org/x/sys v0.36.0 // indirect golang.org/x/term v0.35.0 // indirect golang.org/x/text v0.29.0 // indirect golang.org/x/time v0.13.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.34.1 // indirect - k8s.io/apimachinery v0.34.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect diff --git a/go.sum b/go.sum index 246fcf8..dce4297 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cloud.google.com/go/auth v0.16.5 h1:mFWNQ2FEVWAliEQWpAdH80omXFokmrnbDhUS9cBywsI= cloud.google.com/go/auth v0.16.5/go.mod h1:utzRfHMP+Vv0mpOkTRQoWD2q3BatTOoWbA7gCc2dUhQ= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= @@ -12,8 +14,16 @@ github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDo github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 h1:XkkQbfMyuH2jTSjQjSoihryI8GINRcs4xp8lNawg0FI= github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/PaesslerAG/gval v1.2.2/go.mod h1:XRFLwvmkTEdYziLdaCeCa5ImcGVrfQbeNUbVR+C6xac= +github.com/PaesslerAG/gval v1.2.4 h1:rhX7MpjJlcxYwL2eTTYIOBUyEKZ+A96T9vQySWkVUiU= +github.com/PaesslerAG/gval v1.2.4/go.mod h1:XRFLwvmkTEdYziLdaCeCa5ImcGVrfQbeNUbVR+C6xac= +github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8= +github.com/PaesslerAG/jsonpath v0.1.2-0.20240726212847-3a740cf7976f h1:TxDCeKRCgHea2hUiMOjWwqzWmrIGqSOZYkEPuClXzDo= +github.com/PaesslerAG/jsonpath v0.1.2-0.20240726212847-3a740cf7976f/go.mod h1:zTyVtYhYjcHpfCtqnCMxejgp0pEEwb/xJzhn05NrkJk= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/aws/aws-sdk-go-v2 v1.39.2 h1:EJLg8IdbzgeD7xgvZ+I8M1e0fL0ptn/M47lianzth0I= github.com/aws/aws-sdk-go-v2 v1.39.2/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= github.com/aws/aws-sdk-go-v2/config v1.31.12 h1:pYM1Qgy0dKZLHX2cXslNacbcEFMkDMl+Bcj5ROuS6p8= @@ -46,6 +56,8 @@ github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3 h1:6df1vn4bBlDDo github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3/go.mod h1:CIWtjkly68+yqLPbvwwR/fjNJA/idrtULjZWh2v1ys0= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/brunoga/deep v1.2.5 h1:bigq4eooqbeJXfvTfZBn3AH3B1iW+rtetxVeh0GiLrg= +github.com/brunoga/deep v1.2.5/go.mod h1:GDV6dnXqn80ezsLSZ5Wlv1PdKAWAO4L5PnKYtv2dgaI= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -58,16 +70,24 @@ github.com/dennwc/varint v1.0.0 h1:kGNFFSSw8ToIy3obO/kKr8U9GZYUAxQEVuix4zfDWzE= github.com/dennwc/varint v1.0.0/go.mod h1:hnItb35rvZvJrbTALZtY/iQfDs48JKRG1RPpgziApxA= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY= +github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= @@ -82,8 +102,11 @@ github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9v github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= +github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -117,27 +140,46 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= +github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mark3labs/mcp-go v0.39.1 h1:2oPxk7aDbQhouakkYyKl2T4hKFU1c6FDaubWyGyVE1k= github.com/mark3labs/mcp-go v0.39.1/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= +github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nexucis/lamenv v0.5.2 h1:tK/u3XGhCq9qIoVNcXsK9LZb8fKopm0A5weqSRvHd7M= +github.com/nexucis/lamenv v0.5.2/go.mod h1:HusJm6ltmmT7FMG8A750mOLuME6SHCsr2iFYxp5fFi0= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= -github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= -github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= -github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= -github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= +github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= +github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= +github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= +github.com/perses/common v0.27.1-0.20250326140707-96e439b14e0e h1:AormqtWdtHdoQyGO90U1fRoElR0XQHmP0W9oJUsCOZY= +github.com/perses/common v0.27.1-0.20250326140707-96e439b14e0e/go.mod h1:CMTbKu0uWCFKgo4oDVoT8GcMC0bKyDH4cNG3GVfi+rA= +github.com/perses/perses v0.51.0 h1:lLssvsMjxFg2oP+vKX6pz2SFTfrUyso/A2/A/6oFens= +github.com/perses/perses v0.51.0/go.mod h1:DrGiL+itTLl2mwEvNa0wGokELfZTsqOc3TEg+2B0uwY= +github.com/perses/perses-operator v0.2.0 h1:gIhKUWca8ncaxyvOk2USaGfQ32eNcXzjDN97UlQAP0M= +github.com/perses/perses-operator v0.2.0/go.mod h1:91gFy0XicXrWSYSr4ChkMp16GSOkeXjKdkXlfEECw5g= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -159,18 +201,35 @@ github.com/prometheus/prometheus v0.307.3 h1:zGIN3EpiKacbMatcUL2i6wC26eRWXdoXfNP github.com/prometheus/prometheus v0.307.3/go.mod h1:sPbNW+KTS7WmzFIafC3Inzb6oZVaGLnSvwqTdz2jxRQ= github.com/prometheus/sigv4 v0.2.1 h1:hl8D3+QEzU9rRmbKIRwMKRwaFGyLkbPdH5ZerglRHY0= github.com/prometheus/sigv4 v0.2.1/go.mod h1:ySk6TahIlsR2sxADuHy4IBFhwEjRGGsfbbLGhFYFj6Q= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= +github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= @@ -179,6 +238,10 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/zitadel/oidc/v3 v3.38.1 h1:VTf1Bv/33UbSwJnIWbfEIdpUGYKfoHetuBNIqVTcjvA= +github.com/zitadel/oidc/v3 v3.38.1/go.mod h1:muukzAasaWmn3vBwEVMglJfuTE0PKCvLJGombPwXIRw= +github.com/zitadel/schema v1.3.1 h1:QT3kwiRIRXXLVAs6gCK/u044WmUVh6IlbLXUsn6yRQU= +github.com/zitadel/schema v1.3.1/go.mod h1:071u7D2LQacy1HAN+YnMd/mx1qVE2isb0Mjeqg46xnU= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= @@ -191,8 +254,14 @@ go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJr go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= @@ -222,6 +291,8 @@ golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= @@ -244,6 +315,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.250.0 h1:qvkwrf/raASj82UegU2RSDGWi/89WkLckn4LuO4lVXM= google.golang.org/api v0.250.0/go.mod h1:Y9Uup8bDLJJtMzJyQnu+rLRJLA0wn+wTtc6vTlOvfXo= +google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU= +google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk= google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9 h1:V1jCN2HBa8sySkR5vLcCSqJSTMv093Rw9EJefhQGP7M= google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= @@ -257,10 +330,13 @@ gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSP gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= +k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI= +k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= @@ -271,6 +347,8 @@ k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOP k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= +sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= diff --git a/hack/e2e/manifests/perses-crd.yaml b/hack/e2e/manifests/perses-crd.yaml new file mode 100644 index 0000000..4b136d5 --- /dev/null +++ b/hack/e2e/manifests/perses-crd.yaml @@ -0,0 +1,261 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + cert-manager.io/inject-ca-from: perses-operator-system/perses-operator-serving-cert + controller-gen.kubebuilder.io/version: v0.19.0 + name: persesdashboards.perses.dev +spec: + group: perses.dev + names: + kind: PersesDashboard + listKind: PersesDashboardList + plural: persesdashboards + shortNames: + - perdb + singular: persesdashboard + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: PersesDashboard is the Schema for the persesdashboards API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + datasources: + additionalProperties: + properties: + default: + type: boolean + display: + properties: + description: + type: string + name: + type: string + type: object + plugin: + description: |- + Plugin will contain the datasource configuration. + The data typed is available in Cue. + properties: + kind: + type: string + spec: + x-kubernetes-preserve-unknown-fields: true + required: + - kind + - spec + type: object + required: + - default + - plugin + type: object + description: Datasources is an optional list of datasource definition. + type: object + display: + properties: + description: + type: string + name: + type: string + type: object + duration: + description: Duration is the default time range to use when getting data to fill the dashboard + format: duration + pattern: ^(([0-9]+)y)?(([0-9]+)w)?(([0-9]+)d)?(([0-9]+)h)?(([0-9]+)m)?(([0-9]+)s)?(([0-9]+)ms)?$ + type: string + layouts: + items: + properties: + kind: + type: string + spec: + x-kubernetes-preserve-unknown-fields: true + required: + - kind + - spec + type: object + type: array + panels: + additionalProperties: + properties: + kind: + type: string + spec: + properties: + display: + properties: + description: + type: string + name: + type: string + required: + - name + type: object + links: + items: + properties: + name: + type: string + renderVariables: + type: boolean + targetBlank: + type: boolean + tooltip: + type: string + url: + type: string + required: + - url + type: object + type: array + plugin: + properties: + kind: + type: string + spec: + x-kubernetes-preserve-unknown-fields: true + required: + - kind + - spec + type: object + queries: + items: + properties: + kind: + type: string + spec: + properties: + plugin: + properties: + kind: + type: string + spec: + x-kubernetes-preserve-unknown-fields: true + required: + - kind + - spec + type: object + required: + - plugin + type: object + required: + - kind + - spec + type: object + type: array + required: + - display + - plugin + type: object + required: + - kind + - spec + type: object + type: object + refreshInterval: + description: RefreshInterval is the default refresh interval to use when landing on the dashboard + format: duration + pattern: ^(([0-9]+)y)?(([0-9]+)w)?(([0-9]+)d)?(([0-9]+)h)?(([0-9]+)m)?(([0-9]+)s)?(([0-9]+)ms)?$ + type: string + variables: + items: + properties: + kind: + description: Kind is the type of the variable. Depending on the value of Kind, it will change the content of Spec. + type: string + spec: + x-kubernetes-preserve-unknown-fields: true + required: + - kind + - spec + type: object + type: array + required: + - duration + - layouts + - panels + type: object + status: + description: PersesDashboardStatus defines the observed state of PersesDashboard + properties: + conditions: + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/hack/e2e/manifests/perses-sample-dashboard.yaml b/hack/e2e/manifests/perses-sample-dashboard.yaml new file mode 100644 index 0000000..bf67744 --- /dev/null +++ b/hack/e2e/manifests/perses-sample-dashboard.yaml @@ -0,0 +1,1041 @@ +# Taken from https://github.com/openshift/monitoring-plugin/blob/ea66fe4cfeb0f41b62278bca55f6f856480752c3/web/cypress/fixtures/coo/coo121_perses_dashboards/openshift-cluster-sample-dashboard.yaml +apiVersion: perses.dev/v1alpha1 +kind: PersesDashboard +metadata: + name: sample-dashboard + namespace: monitoring +spec: + display: + name: Kubernetes / Compute Resources / Cluster + variables: + - kind: ListVariable + spec: + display: + hidden: false + allowAllValue: false + allowMultiple: false + sort: alphabetical-asc + plugin: + kind: PrometheusLabelValuesVariable + spec: + labelName: cluster + matchers: + - up{job="kubelet", metrics_path="/metrics/cadvisor"} + name: cluster + panels: + "0_0": + kind: Panel + spec: + display: + name: CPU Utilisation + plugin: + kind: StatChart + spec: + calculation: mean + format: + unit: percent-decimal + thresholds: + steps: + - color: green + value: 0 + - color: red + value: 80 + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: cluster:node_cpu:ratio_rate5m{cluster="$cluster"} + "0_1": + kind: Panel + spec: + display: + name: CPU Requests Commitment + plugin: + kind: StatChart + spec: + calculation: mean + format: + unit: percent-decimal + thresholds: + steps: + - color: green + value: 0 + - color: red + value: 80 + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(namespace_cpu:kube_pod_container_resource_requests:sum{cluster="$cluster"}) / sum(kube_node_status_allocatable{job="kube-state-metrics",resource="cpu",cluster="$cluster"}) + "0_2": + kind: Panel + spec: + display: + name: CPU Limits Commitment + plugin: + kind: StatChart + spec: + calculation: mean + format: + unit: percent-decimal + thresholds: + steps: + - color: green + value: 0 + - color: red + value: 80 + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(namespace_cpu:kube_pod_container_resource_limits:sum{cluster="$cluster"}) / sum(kube_node_status_allocatable{job="kube-state-metrics",resource="cpu",cluster="$cluster"}) + "0_3": + kind: Panel + spec: + display: + name: Memory Utilisation + plugin: + kind: StatChart + spec: + calculation: mean + format: + unit: percent-decimal + thresholds: + steps: + - color: green + value: 0 + - color: red + value: 80 + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: 1 - sum(:node_memory_MemAvailable_bytes:sum{cluster="$cluster"}) / sum(node_memory_MemTotal_bytes{job="node-exporter",cluster="$cluster"}) + "0_4": + kind: Panel + spec: + display: + name: Memory Requests Commitment + plugin: + kind: StatChart + spec: + calculation: mean + format: + unit: percent-decimal + thresholds: + steps: + - color: green + value: 0 + - color: red + value: 80 + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(namespace_memory:kube_pod_container_resource_requests:sum{cluster="$cluster"}) / sum(kube_node_status_allocatable{job="kube-state-metrics",resource="memory",cluster="$cluster"}) + "0_5": + kind: Panel + spec: + display: + name: Memory Limits Commitment + plugin: + kind: StatChart + spec: + calculation: mean + format: + unit: percent-decimal + thresholds: + steps: + - color: green + value: 0 + - color: red + value: 80 + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(namespace_memory:kube_pod_container_resource_limits:sum{cluster="$cluster"}) / sum(kube_node_status_allocatable{job="kube-state-metrics",resource="memory",cluster="$cluster"}) + "1_0": + kind: Panel + spec: + display: + name: CPU Usage + plugin: + kind: TimeSeriesChart + spec: + legend: + mode: list + position: bottom + values: [] + visual: + areaOpacity: 1 + connectNulls: false + display: line + lineWidth: 0.25 + stack: all + yAxis: + format: + unit: decimal + min: 0 + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(node_namespace_pod_container:container_cpu_usage_seconds_total:sum_irate{cluster="$cluster"}) by (namespace) + seriesNameFormat: "{{namespace}}" + "2_0": + kind: Panel + spec: + display: + name: CPU Quota + plugin: + kind: Table + spec: + columnSettings: + - header: Time + hide: true + name: Time + - header: Pods + name: "Value #A" + - header: Workloads + name: "Value #B" + - header: CPU Usage + name: "Value #C" + - header: CPU Requests + name: "Value #D" + - header: CPU Requests % + name: "Value #E" + - header: CPU Limits + name: "Value #F" + - header: CPU Limits % + name: "Value #G" + - header: Namespace + name: namespace + - header: "" + name: /.*/ + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(kube_pod_owner{job="kube-state-metrics", cluster="$cluster"}) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: count(avg(namespace_workload_pod:kube_pod_owner:relabel{cluster="$cluster"}) by (workload, namespace)) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(node_namespace_pod_container:container_cpu_usage_seconds_total:sum_irate{cluster="$cluster"}) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(namespace_cpu:kube_pod_container_resource_requests:sum{cluster="$cluster"}) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(node_namespace_pod_container:container_cpu_usage_seconds_total:sum_irate{cluster="$cluster"}) by (namespace) / sum(namespace_cpu:kube_pod_container_resource_requests:sum{cluster="$cluster"}) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(namespace_cpu:kube_pod_container_resource_limits:sum{cluster="$cluster"}) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(node_namespace_pod_container:container_cpu_usage_seconds_total:sum_irate{cluster="$cluster"}) by (namespace) / sum(namespace_cpu:kube_pod_container_resource_limits:sum{cluster="$cluster"}) by (namespace) + seriesNameFormat: "" + "3_0": + kind: Panel + spec: + display: + name: Memory Usage (w/o cache) + plugin: + kind: TimeSeriesChart + spec: + legend: + mode: list + position: bottom + values: [] + visual: + areaOpacity: 1 + connectNulls: false + display: line + lineWidth: 0.25 + stack: all + yAxis: + format: + unit: bytes + min: 0 + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(container_memory_rss{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", container!=""}) by (namespace) + seriesNameFormat: "{{namespace}}" + "4_0": + kind: Panel + spec: + display: + name: Requests by Namespace + plugin: + kind: Table + spec: + columnSettings: + - header: Time + hide: true + name: Time + - header: Pods + name: "Value #A" + - header: Workloads + name: "Value #B" + - header: Memory Usage + name: "Value #C" + - header: Memory Requests + name: "Value #D" + - header: Memory Requests % + name: "Value #E" + - header: Memory Limits + name: "Value #F" + - header: Memory Limits % + name: "Value #G" + - header: Namespace + name: namespace + - header: "" + name: /.*/ + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(kube_pod_owner{job="kube-state-metrics", cluster="$cluster"}) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: count(avg(namespace_workload_pod:kube_pod_owner:relabel{cluster="$cluster"}) by (workload, namespace)) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(container_memory_rss{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", container!=""}) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(namespace_memory:kube_pod_container_resource_requests:sum{cluster="$cluster"}) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(container_memory_rss{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", container!=""}) by (namespace) / sum(namespace_memory:kube_pod_container_resource_requests:sum{cluster="$cluster"}) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(namespace_memory:kube_pod_container_resource_limits:sum{cluster="$cluster"}) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(container_memory_rss{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", container!=""}) by (namespace) / sum(namespace_memory:kube_pod_container_resource_limits:sum{cluster="$cluster"}) by (namespace) + seriesNameFormat: "" + "5_0": + kind: Panel + spec: + display: + name: Current Network Usage + plugin: + kind: Table + spec: + columnSettings: + - header: Time + hide: true + name: Time + - header: Current Receive Bandwidth + name: "Value #A" + - header: Current Transmit Bandwidth + name: "Value #B" + - header: Rate of Received Packets + name: "Value #C" + - header: Rate of Transmitted Packets + name: "Value #D" + - header: Rate of Received Packets Dropped + name: "Value #E" + - header: Rate of Transmitted Packets Dropped + name: "Value #F" + - header: Namespace + name: namespace + - header: "" + name: /.*/ + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(irate(container_network_receive_bytes_total{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", namespace=~".+"}[$__rate_interval])) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(irate(container_network_transmit_bytes_total{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", namespace=~".+"}[$__rate_interval])) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(irate(container_network_receive_packets_total{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", namespace=~".+"}[$__rate_interval])) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(irate(container_network_transmit_packets_total{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", namespace=~".+"}[$__rate_interval])) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(irate(container_network_receive_packets_dropped_total{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", namespace=~".+"}[$__rate_interval])) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(irate(container_network_transmit_packets_dropped_total{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", namespace=~".+"}[$__rate_interval])) by (namespace) + seriesNameFormat: "" + "6_0": + kind: Panel + spec: + display: + name: Receive Bandwidth + plugin: + kind: TimeSeriesChart + spec: {} + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(irate(container_network_receive_bytes_total{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", namespace=~".+"}[$__rate_interval])) by (namespace) + seriesNameFormat: "{{namespace}}" + "6_1": + kind: Panel + spec: + display: + name: Transmit Bandwidth + plugin: + kind: TimeSeriesChart + spec: {} + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(irate(container_network_transmit_bytes_total{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", namespace=~".+"}[$__rate_interval])) by (namespace) + seriesNameFormat: "{{namespace}}" + "7_0": + kind: Panel + spec: + display: + name: "Average Container Bandwidth by Namespace: Received" + plugin: + kind: TimeSeriesChart + spec: {} + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: avg(irate(container_network_receive_bytes_total{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", namespace=~".+"}[$__rate_interval])) by (namespace) + seriesNameFormat: "{{namespace}}" + "7_1": + kind: Panel + spec: + display: + name: "Average Container Bandwidth by Namespace: Transmitted" + plugin: + kind: TimeSeriesChart + spec: {} + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: avg(irate(container_network_transmit_bytes_total{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", namespace=~".+"}[$__rate_interval])) by (namespace) + seriesNameFormat: "{{namespace}}" + "8_0": + kind: Panel + spec: + display: + name: Rate of Received Packets + plugin: + kind: TimeSeriesChart + spec: {} + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(irate(container_network_receive_packets_total{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", namespace=~".+"}[$__rate_interval])) by (namespace) + seriesNameFormat: "{{namespace}}" + "8_1": + kind: Panel + spec: + display: + name: Rate of Transmitted Packets + plugin: + kind: TimeSeriesChart + spec: {} + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(irate(container_network_transmit_packets_total{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", namespace=~".+"}[$__rate_interval])) by (namespace) + seriesNameFormat: "{{namespace}}" + "9_0": + kind: Panel + spec: + display: + name: Rate of Received Packets Dropped + plugin: + kind: TimeSeriesChart + spec: {} + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(irate(container_network_receive_packets_dropped_total{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", namespace=~".+"}[$__rate_interval])) by (namespace) + seriesNameFormat: "{{namespace}}" + "9_1": + kind: Panel + spec: + display: + name: Rate of Transmitted Packets Dropped + plugin: + kind: TimeSeriesChart + spec: {} + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(irate(container_network_transmit_packets_dropped_total{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", namespace=~".+"}[$__rate_interval])) by (namespace) + seriesNameFormat: "{{namespace}}" + "10_0": + kind: Panel + spec: + display: + name: IOPS(Reads+Writes) + plugin: + kind: TimeSeriesChart + spec: {} + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: ceil(sum by(namespace) (rate(container_fs_reads_total{job="kubelet", metrics_path="/metrics/cadvisor", id!="", device=~"(/dev.+)|mmcblk.p.+|nvme.+|rbd.+|sd.+|vd.+|xvd.+|dm-.+|dasd.+", cluster="$cluster", namespace!=""}[$__rate_interval]) + rate(container_fs_writes_total{job="kubelet", metrics_path="/metrics/cadvisor", id!="", cluster="$cluster", namespace!=""}[$__rate_interval]))) + seriesNameFormat: "{{namespace}}" + "10_1": + kind: Panel + spec: + display: + name: ThroughPut(Read+Write) + plugin: + kind: TimeSeriesChart + spec: {} + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum by(namespace) (rate(container_fs_reads_bytes_total{job="kubelet", metrics_path="/metrics/cadvisor", id!="", device=~"(/dev.+)|mmcblk.p.+|nvme.+|rbd.+|sd.+|vd.+|xvd.+|dm-.+|dasd.+", cluster="$cluster", namespace!=""}[$__rate_interval]) + rate(container_fs_writes_bytes_total{job="kubelet", metrics_path="/metrics/cadvisor", id!="", cluster="$cluster", namespace!=""}[$__rate_interval])) + seriesNameFormat: "{{namespace}}" + "11_0": + kind: Panel + spec: + display: + name: Current Storage IO + plugin: + kind: Table + spec: + columnSettings: + - header: Time + hide: true + name: Time + - header: IOPS(Reads) + name: "Value #A" + - header: IOPS(Writes) + name: "Value #B" + - header: IOPS(Reads + Writes) + name: "Value #C" + - header: Throughput(Read) + name: "Value #D" + - header: Throughput(Write) + name: "Value #E" + - header: Throughput(Read + Write) + name: "Value #F" + - header: Namespace + name: namespace + - header: "" + name: /.*/ + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum by(namespace) (rate(container_fs_reads_total{job="kubelet", metrics_path="/metrics/cadvisor", device=~"(/dev.+)|mmcblk.p.+|nvme.+|rbd.+|sd.+|vd.+|xvd.+|dm-.+|dasd.+", id!="", cluster="$cluster", namespace!=""}[$__rate_interval])) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum by(namespace) (rate(container_fs_writes_total{job="kubelet", metrics_path="/metrics/cadvisor", device=~"(/dev.+)|mmcblk.p.+|nvme.+|rbd.+|sd.+|vd.+|xvd.+|dm-.+|dasd.+", id!="", cluster="$cluster", namespace!=""}[$__rate_interval])) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum by(namespace) (rate(container_fs_reads_total{job="kubelet", metrics_path="/metrics/cadvisor", device=~"(/dev.+)|mmcblk.p.+|nvme.+|rbd.+|sd.+|vd.+|xvd.+|dm-.+|dasd.+", id!="", cluster="$cluster", namespace!=""}[$__rate_interval]) + rate(container_fs_writes_total{job="kubelet", metrics_path="/metrics/cadvisor", device=~"(/dev.+)|mmcblk.p.+|nvme.+|rbd.+|sd.+|vd.+|xvd.+|dm-.+|dasd.+", id!="", cluster="$cluster", namespace!=""}[$__rate_interval])) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum by(namespace) (rate(container_fs_reads_bytes_total{job="kubelet", metrics_path="/metrics/cadvisor", device=~"(/dev.+)|mmcblk.p.+|nvme.+|rbd.+|sd.+|vd.+|xvd.+|dm-.+|dasd.+", id!="", cluster="$cluster", namespace!=""}[$__rate_interval])) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum by(namespace) (rate(container_fs_writes_bytes_total{job="kubelet", metrics_path="/metrics/cadvisor", device=~"(/dev.+)|mmcblk.p.+|nvme.+|rbd.+|sd.+|vd.+|xvd.+|dm-.+|dasd.+", id!="", cluster="$cluster", namespace!=""}[$__rate_interval])) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum by(namespace) (rate(container_fs_reads_bytes_total{job="kubelet", metrics_path="/metrics/cadvisor", device=~"(/dev.+)|mmcblk.p.+|nvme.+|rbd.+|sd.+|vd.+|xvd.+|dm-.+|dasd.+", id!="", cluster="$cluster", namespace!=""}[$__rate_interval]) + rate(container_fs_writes_bytes_total{job="kubelet", metrics_path="/metrics/cadvisor", device=~"(/dev.+)|mmcblk.p.+|nvme.+|rbd.+|sd.+|vd.+|xvd.+|dm-.+|dasd.+", id!="", cluster="$cluster", namespace!=""}[$__rate_interval])) + seriesNameFormat: "" + layouts: + - kind: Grid + spec: + display: + title: Headlines + collapse: + open: true + items: + - x: 0 + "y": 1 + width: 4 + height: 3 + content: + $ref: "#/spec/panels/0_0" + - x: 4 + "y": 1 + width: 4 + height: 3 + content: + $ref: "#/spec/panels/0_1" + - x: 8 + "y": 1 + width: 4 + height: 3 + content: + $ref: "#/spec/panels/0_2" + - x: 12 + "y": 1 + width: 4 + height: 3 + content: + $ref: "#/spec/panels/0_3" + - x: 16 + "y": 1 + width: 4 + height: 3 + content: + $ref: "#/spec/panels/0_4" + - x: 20 + "y": 1 + width: 4 + height: 3 + content: + $ref: "#/spec/panels/0_5" + - kind: Grid + spec: + display: + title: CPU + collapse: + open: true + items: + - x: 0 + "y": 5 + width: 24 + height: 7 + content: + $ref: "#/spec/panels/1_0" + - kind: Grid + spec: + display: + title: CPU Quota + collapse: + open: true + items: + - x: 0 + "y": 13 + width: 24 + height: 7 + content: + $ref: "#/spec/panels/2_0" + - kind: Grid + spec: + display: + title: Memory + collapse: + open: true + items: + - x: 0 + "y": 21 + width: 24 + height: 7 + content: + $ref: "#/spec/panels/3_0" + - kind: Grid + spec: + display: + title: Memory Requests + collapse: + open: true + items: + - x: 0 + "y": 29 + width: 24 + height: 7 + content: + $ref: "#/spec/panels/4_0" + - kind: Grid + spec: + display: + title: Current Network Usage + collapse: + open: true + items: + - x: 0 + "y": 37 + width: 24 + height: 7 + content: + $ref: "#/spec/panels/5_0" + - kind: Grid + spec: + display: + title: Bandwidth + collapse: + open: true + items: + - x: 0 + "y": 45 + width: 12 + height: 7 + content: + $ref: "#/spec/panels/6_0" + - x: 12 + "y": 45 + width: 12 + height: 7 + content: + $ref: "#/spec/panels/6_1" + - kind: Grid + spec: + display: + title: Average Container Bandwidth by Namespace + collapse: + open: true + items: + - x: 0 + "y": 53 + width: 12 + height: 7 + content: + $ref: "#/spec/panels/7_0" + - x: 12 + "y": 53 + width: 12 + height: 7 + content: + $ref: "#/spec/panels/7_1" + - kind: Grid + spec: + display: + title: Rate of Packets + collapse: + open: true + items: + - x: 0 + "y": 61 + width: 12 + height: 7 + content: + $ref: "#/spec/panels/8_0" + - x: 12 + "y": 61 + width: 12 + height: 7 + content: + $ref: "#/spec/panels/8_1" + - kind: Grid + spec: + display: + title: Rate of Packets Dropped + collapse: + open: true + items: + - x: 0 + "y": 69 + width: 12 + height: 7 + content: + $ref: "#/spec/panels/9_0" + - x: 12 + "y": 69 + width: 12 + height: 7 + content: + $ref: "#/spec/panels/9_1" + - kind: Grid + spec: + display: + title: Storage IO + collapse: + open: true + items: + - x: 0 + "y": 77 + width: 12 + height: 7 + content: + $ref: "#/spec/panels/10_0" + - x: 12 + "y": 77 + width: 12 + height: 7 + content: + $ref: "#/spec/panels/10_1" + - kind: Grid + spec: + display: + title: Storage IO - Distribution + collapse: + open: true + items: + - x: 0 + "y": 85 + width: 24 + height: 7 + content: + $ref: "#/spec/panels/11_0" + duration: 1h diff --git a/hack/e2e/setup-cluster.sh b/hack/e2e/setup-cluster.sh index 28413e2..41e6a1c 100755 --- a/hack/e2e/setup-cluster.sh +++ b/hack/e2e/setup-cluster.sh @@ -26,6 +26,10 @@ fi # Apply CRDs and namespace setup first kubectl apply --server-side -f "${KUBE_PROMETHEUS_DIR}/manifests/setup" + +echo "==> Installing Perses CRD..." +kubectl apply -f "${ROOT_DIR}/hack/e2e/manifests/perses-crd.yaml" + echo "==> Waiting for CRDs to be established..." kubectl wait --for condition=Established --all CustomResourceDefinition --namespace=monitoring --timeout=5m @@ -44,6 +48,9 @@ for f in "${KUBE_PROMETHEUS_DIR}"/manifests/alertmanager-*.yaml; do kubectl apply -f "$f" done +echo "==> Installing Perses sample dashboard..." +kubectl apply -f "${ROOT_DIR}/hack/e2e/manifests/perses-sample-dashboard.yaml" + echo "==> Waiting for Prometheus Operator to be ready..." kubectl -n monitoring rollout status deployment/prometheus-operator --timeout=5m diff --git a/hack/lightspeed-stack/lightspeed-stack.yaml b/hack/lightspeed-stack/lightspeed-stack.yaml index 0dc68b5..53728a0 100644 --- a/hack/lightspeed-stack/lightspeed-stack.yaml +++ b/hack/lightspeed-stack/lightspeed-stack.yaml @@ -4,7 +4,6 @@ name: Lightspeed Core Service (LCS) service: host: localhost port: 8080 - auth_enabled: false workers: 1 color_log: true @@ -30,7 +29,9 @@ mcp_servers: - name: "obs" provider_id: "model-context-protocol" url: "http://localhost:9100/mcp" - + # - name: "layout-manager" + # provider_id: "model-context-protocol" + # url: "http://localhost:9081/mcp" customization: system_prompt: |- Always use available tools. diff --git a/manifests/kubernetes/01_service_account.yaml b/manifests/kubernetes/01_service_account.yaml index 2eba402..4bcb500 100644 --- a/manifests/kubernetes/01_service_account.yaml +++ b/manifests/kubernetes/01_service_account.yaml @@ -19,6 +19,9 @@ rules: - apiGroups: [""] resources: ["namespaces"] verbs: ["get", "list"] + - apiGroups: ["perses.dev"] + resources: ["persesdashboards"] + verbs: ["get", "list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding diff --git a/pkg/k8s/perses.go b/pkg/k8s/perses.go new file mode 100644 index 0000000..9e60bc8 --- /dev/null +++ b/pkg/k8s/perses.go @@ -0,0 +1,109 @@ +package k8s + +import ( + "context" + "encoding/json" + "fmt" + + persesv1alpha1 "github.com/perses/perses-operator/api/v1alpha1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + // MCPHelpAnnotation is the annotation key for MCP help description + MCPHelpAnnotation = "operator.perses.dev/mcp-help" +) + +// GetPersesClient returns a controller-runtime client with types registered +func GetPersesClient() (client.Client, error) { + config, err := GetClientConfig() + if err != nil { + return nil, err + } + + scheme := runtime.NewScheme() + if err := persesv1alpha1.AddToScheme(scheme); err != nil { + return nil, fmt.Errorf("failed to add perses scheme: %w", err) + } + + c, err := client.New(config, client.Options{Scheme: scheme}) + if err != nil { + return nil, fmt.Errorf("failed to create controller-runtime client: %w", err) + } + + return c, nil +} + +// ListDashboards lists all Dashboard objects across all namespaces or in a specific namespace. +// Uses types from github.com/perses/perses-operator/api/v1alpha1 +// The labelSelector parameter accepts Kubernetes label selector syntax (e.g., "app=myapp,env=prod"). +func ListDashboards(ctx context.Context, namespace, labelSelector string) (dashboardPointers []*persesv1alpha1.PersesDashboard, err error) { + c, err := GetPersesClient() + if err != nil { + return nil, fmt.Errorf("failed to get perses client: %w", err) + } + + // Build list options + listOpts := &client.ListOptions{} + if namespace != "" { + listOpts.Namespace = namespace + } + if labelSelector != "" { + selector, err := labels.Parse(labelSelector) + if err != nil { + return nil, fmt.Errorf("invalid label selector: %w", err) + } + listOpts.LabelSelector = selector + } + + var dashboardList persesv1alpha1.PersesDashboardList + if err := c.List(ctx, &dashboardList, listOpts); err != nil { + return nil, fmt.Errorf("failed to list Dashboards: %w", err) + } + + for i := range dashboardList.Items { + dashboardPointers = append(dashboardPointers, &dashboardList.Items[i]) + } + + return +} + +// GetDashboard retrieves a specific Dashboard by name and namespace. +// Returns the dashboard name, namespace, and full spec as a map for JSON serialization. +func GetDashboard(ctx context.Context, namespace, name string) (specMap map[string]any, err error) { + c, err := GetPersesClient() + if err != nil { + return nil, fmt.Errorf("failed to get perses client: %w", err) + } + + var dashboard persesv1alpha1.PersesDashboard + key := client.ObjectKey{Namespace: namespace, Name: name} + if err := c.Get(ctx, key, &dashboard); err != nil { + return nil, fmt.Errorf("failed to get Dashboard %s/%s: %w", namespace, name, err) + } + + // Convert spec to map[string]interface{} for JSON serialization + specMap, err = specToMap(&dashboard.Spec) + if err != nil { + return nil, fmt.Errorf("failed to convert spec to map: %w", err) + } + + return +} + +// specToMap converts a DashboardSpec to a map[string]interface{} for JSON serialization +func specToMap(spec *persesv1alpha1.Dashboard) (map[string]any, error) { + data, err := json.Marshal(spec) + if err != nil { + return nil, err + } + + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + + return result, nil +} diff --git a/pkg/mcp/auth.go b/pkg/mcp/auth.go index fdcefe6..d37a546 100644 --- a/pkg/mcp/auth.go +++ b/pkg/mcp/auth.go @@ -22,9 +22,12 @@ import ( type AuthMode string const ( - AuthModeKubeConfig AuthMode = "kubeconfig" + // AuthModeKubeConfig uses kubeconfig for authentication + AuthModeKubeConfig AuthMode = "kubeconfig" + // AuthModeServiceAccount uses in-cluster service account token for authentication AuthModeServiceAccount AuthMode = "serviceaccount" - AuthModeHeader AuthMode = "header" + // AuthModeHeader uses token from context header for authentication + AuthModeHeader AuthMode = "header" ) const ( @@ -32,6 +35,7 @@ const ( defaultServiceAccountCAPath = "/var/run/secrets/kubernetes.io/serviceaccount/service-ca.crt" ) +// ContextKey defines keys for context values type ContextKey string const ( diff --git a/pkg/mcp/handlers.go b/pkg/mcp/handlers.go index e012506..4697cd5 100644 --- a/pkg/mcp/handlers.go +++ b/pkg/mcp/handlers.go @@ -10,6 +10,8 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/prometheus/common/model" + "github.com/rhobs/obs-mcp/pkg/k8s" + "github.com/rhobs/obs-mcp/pkg/perses" "github.com/rhobs/obs-mcp/pkg/prometheus" ) @@ -170,3 +172,326 @@ func ExecuteRangeQueryHandler(opts ObsMCPOptions) func(context.Context, mcp.Call return mcp.NewToolResultStructured(output, string(jsonResult)), nil } } + +// DashboardsHandler handles returning all dashboards from the cluster. +// Returns all Dashboard resources to provide maximum context for LLM selection. +func DashboardsHandler(_ ObsMCPOptions) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + slog.Info("DashboardsHandler called") + + // TODO: add a label selectors flag when more dashboards start annotating themselves? + dashboards, err := k8s.ListDashboards(ctx, "", "") + if err != nil { + return errorResult(fmt.Sprintf("failed to list dashboards: %s", err.Error())) + } + + // Convert to DashboardInfo + dashboardInfos := make([]perses.DashboardInfo, 0, len(dashboards)) + for _, dashboard := range dashboards { + info := perses.DashboardInfo{ + Name: dashboard.Name, + Namespace: dashboard.Namespace, + Labels: dashboard.Labels, + } + + // Extract description from annotation + if dashboard.Annotations != nil { + if desc, ok := dashboard.Annotations[k8s.MCPHelpAnnotation /* TODO: currently no such annotation is curated across openshift */]; ok { + info.Description = desc + } + } + + dashboardInfos = append(dashboardInfos, info) + } + + slog.Info("DashboardsHandler executed successfully", "dashboardCount", len(dashboardInfos)) + slog.Debug("DashboardsHandler results", "results", dashboardInfos) + + output := DashboardsOutput{Dashboards: dashboardInfos} + + result, err := json.Marshal(output) + if err != nil { + return errorResult(fmt.Sprintf("failed to marshal dashboards: %s", err.Error())) + } + + return mcp.NewToolResultStructured(output, string(result)), nil + } +} + +// GetDashboardHandler handles getting a specific dashboard by name and namespace. +func GetDashboardHandler(_ ObsMCPOptions) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + slog.Info("GetDashboardHandler called") + slog.Debug("GetDashboardHandler params", "params", req.Params) + + name, err := req.RequireString("name") + if err != nil { + return errorResult("name parameter is required and must be a string") + } + + namespace, err := req.RequireString("namespace") + if err != nil { + return errorResult("namespace parameter is required and must be a string") + } + + spec, err := k8s.GetDashboard(ctx, namespace, name) + if err != nil { + return errorResult(fmt.Sprintf("failed to get Dashboard: %s", err.Error())) + } + + slog.Info("GetDashboardHandler executed successfully", "name", name, "namespace", namespace) + slog.Debug("GetDashboardHandler spec", "spec", spec) + + output := GetDashboardOutput{ + Name: name, + Namespace: namespace, + Spec: spec, + } + + result, err := json.Marshal(output) + if err != nil { + return errorResult(fmt.Sprintf("failed to marshal dashboard: %s", err.Error())) + } + + return mcp.NewToolResultStructured(output, string(result)), nil + } +} + +// GetDashboardPanelsHandler handles getting panel metadata from a dashboard for LLM selection. +func GetDashboardPanelsHandler(_ ObsMCPOptions) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + slog.Info("GetDashboardPanelsHandler called") + slog.Debug("GetDashboardPanelsHandler params", "params", req.Params) + + name, err := req.RequireString("name") + if err != nil { + return errorResult("name parameter is required and must be a string") + } + + namespace, err := req.RequireString("namespace") + if err != nil { + return errorResult("namespace parameter is required and must be a string") + } + + // Optional panel IDs filter + panelIDsStr := req.GetString("panel_ids", "") + var panelIDs []string + if panelIDsStr != "" { + for _, part := range splitByComma(panelIDsStr) { + if part != "" { + panelIDs = append(panelIDs, part) + } + } + } + + spec, err := k8s.GetDashboard(ctx, namespace, name) + if err != nil { + return errorResult(fmt.Sprintf("failed to get dashboard: %s", err.Error())) + } + + // Extract panel metadata (with optional filtering) + panels := perses.ExtractPanels(name, namespace, spec, false, panelIDs) + + duration := "1h" + if d, ok := spec["duration"].(string); ok { + duration = d + } + + slog.Info("GetDashboardPanelsHandler executed successfully", + "name", name, + "namespace", namespace, + "requested", len(panelIDs), + "returned", len(panels)) + + output := GetDashboardPanelsOutput{ + Name: name, + Namespace: namespace, + Duration: duration, + Panels: panels, + } + + result, err := json.Marshal(output) + if err != nil { + return errorResult(fmt.Sprintf("failed to marshal panels: %s", err.Error())) + } + + return mcp.NewToolResultStructured(output, string(result)), nil + } +} + +// FormatPanelsForUIHandler handles formatting selected panels for UI rendering. +func FormatPanelsForUIHandler(_ ObsMCPOptions) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + slog.Info("FormatPanelsForUIHandler called") + slog.Debug("FormatPanelsForUIHandler params", "params", req.Params) + + name, err := req.RequireString("name") + if err != nil { + return errorResult("name parameter is required and must be a string") + } + + namespace, err := req.RequireString("namespace") + if err != nil { + return errorResult("namespace parameter is required and must be a string") + } + + panelIDsStr, err := req.RequireString("panel_ids") + if err != nil { + return errorResult("panel_ids parameter is required and must be a string") + } + + // Parse comma-separated panel IDs + var panelIDs []string + if panelIDsStr != "" { + for _, part := range splitByComma(panelIDsStr) { + if part != "" { + panelIDs = append(panelIDs, part) + } + } + } + + spec, err := k8s.GetDashboard(ctx, namespace, name) + if err != nil { + return errorResult(fmt.Sprintf("failed to get Dashboard: %s", err.Error())) + } + + // Extract full panel details for UI + panels := perses.ExtractPanels(name, namespace, spec, true, panelIDs) + + // Convert panels to DashboardWidget format + widgets := convertPanelsToDashboardWidgets(panels) + + slog.Info("FormatPanelsForUIHandler executed successfully", + "dashboard", name, + "namespace", namespace, + "requestedPanels", len(panelIDs), + "formattedWidgets", len(widgets)) + + output := FormatPanelsForUIOutput{ + Widgets: widgets, + } + + result, err := json.Marshal(output) + if err != nil { + return errorResult(fmt.Sprintf("failed to marshal panels: %s", err.Error())) + } + + return mcp.NewToolResultStructured(output, string(result)), nil + } +} + +func splitByComma(s string) []string { + var parts []string + current := "" + for i := 0; i < len(s); i++ { + if s[i] == ',' { + trimmed := trimWhitespace(current) + if trimmed != "" { + parts = append(parts, trimmed) + } + current = "" + } else { + current += string(s[i]) + } + } + trimmed := trimWhitespace(current) + if trimmed != "" { + parts = append(parts, trimmed) + } + return parts +} + +func trimWhitespace(s string) string { + start := 0 + end := len(s) + + for start < end && isWhitespace(s[start]) { + start++ + } + + for end > start && isWhitespace(s[end-1]) { + end-- + } + + return s[start:end] +} + +func isWhitespace(b byte) bool { + return b == ' ' || b == '\t' || b == '\n' || b == '\r' +} + +// convertPanelsToDashboardWidgets converts DashboardPanel objects to DashboardWidget format expected by UI. +func convertPanelsToDashboardWidgets(panels []*perses.DashboardPanel) []perses.DashboardWidget { + widgets := make([]perses.DashboardWidget, 0, len(panels)) + + for _, panel := range panels { + // Set defaults for required fields + step := panel.Step + if step == "" { + step = "15s" // default step for Prometheus queries + } + duration := panel.Duration + if duration == "" { + duration = "1h" // default duration + } + + // Infer breakpoint from panel width if available + breakpoint := "lg" // default + if panel.Position != nil { + breakpoint = inferBreakpointFromWidth(panel.Position.W) + } + + widget := perses.DashboardWidget{ + ID: panel.ID, + ComponentType: mapChartTypeToComponent(panel.ChartType), + Breakpoint: breakpoint, + Props: perses.DashboardWidgetProps{ + Query: panel.Query, + Duration: duration, + Start: panel.Start, + End: panel.End, + Step: step, + }, + } + + // Add position if available + if panel.Position != nil { + widget.Position = *panel.Position + } + + widgets = append(widgets, widget) + } + + return widgets +} + +// mapChartTypeToComponent maps Perses chart types to component names +func mapChartTypeToComponent(chartType string) string { + switch chartType { + case "TimeSeriesChart": + return "PersesTimeSeries" + case "PieChart", "StatChart": + return "PersesPieChart" + case "Table": + return "PersesTable" + default: + return "PersesTimeSeries" // default fallback + } +} + +// inferBreakpointFromWidth maps panel width to responsive breakpoint. +// Perses uses a 24-column grid, so we infer breakpoints based on width. +func inferBreakpointFromWidth(width int) string { + switch { + case width >= 18: + return "xl" + case width >= 12: + return "lg" + case width >= 6: + return "md" + case width >= 1: + return "sm" + default: // 0 + return "lg" + } +} diff --git a/pkg/mcp/handlers_test.go b/pkg/mcp/handlers_test.go index 67765ff..179dfb4 100644 --- a/pkg/mcp/handlers_test.go +++ b/pkg/mcp/handlers_test.go @@ -3,6 +3,7 @@ package mcp import ( "context" "fmt" + "strings" "testing" "time" @@ -68,7 +69,7 @@ func TestExecuteRangeQueryHandler_ExplicitTimeRange_RFC3339(t *testing.T) { expectedEnd, _ := prometheus.ParseTimestamp("2024-01-01T01:00:00Z") mockClient := &MockedLoader{ - ExecuteRangeQueryFunc: func(ctx context.Context, query string, start, end time.Time, step time.Duration) (map[string]any, error) { + ExecuteRangeQueryFunc: func(_ context.Context, query string, start, end time.Time, step time.Duration) (map[string]any, error) { if query != "up{job=\"api\"}" { t.Errorf("expected query 'up{job=\"api\"}', got %q", query) } @@ -105,7 +106,7 @@ func TestExecuteRangeQueryHandler_ExplicitTimeRange_RFC3339(t *testing.T) { func TestExecuteRangeQueryHandler_StepParsing_ValidSteps(t *testing.T) { mockClient := &MockedLoader{ - ExecuteRangeQueryFunc: func(ctx context.Context, query string, start, end time.Time, step time.Duration) (map[string]any, error) { + ExecuteRangeQueryFunc: func(_ context.Context, _ string, _, _ time.Time, _ time.Duration) (map[string]any, error) { return map[string]any{"resultType": "matrix", "result": []any{}}, nil }, } @@ -246,7 +247,7 @@ func TestExecuteRangeQueryHandler_RequiredParameters(t *testing.T) { func TestExecuteRangeQueryHandler_DurationMode_DefaultOneHour(t *testing.T) { mockClient := &MockedLoader{ - ExecuteRangeQueryFunc: func(ctx context.Context, query string, start, end time.Time, step time.Duration) (map[string]any, error) { + ExecuteRangeQueryFunc: func(_ context.Context, query string, start, end time.Time, step time.Duration) (map[string]any, error) { if query != "up{job=\"api\"}" { t.Errorf("expected query 'up{job=\"api\"}', got %q", query) } @@ -282,7 +283,7 @@ func TestExecuteRangeQueryHandler_DurationMode_DefaultOneHour(t *testing.T) { func TestExecuteRangeQueryHandler_DurationMode_CustomDuration(t *testing.T) { mockClient := &MockedLoader{ - ExecuteRangeQueryFunc: func(ctx context.Context, query string, start, end time.Time, step time.Duration) (map[string]any, error) { + ExecuteRangeQueryFunc: func(_ context.Context, query string, start, end time.Time, step time.Duration) (map[string]any, error) { if query != "rate(http_requests_total{job=\"api\"}[5m])" { t.Errorf("expected query 'rate(http_requests_total{job=\"api\"}[5m])', got %q", query) } @@ -316,7 +317,7 @@ func TestExecuteRangeQueryHandler_DurationMode_CustomDuration(t *testing.T) { func TestExecuteRangeQueryHandler_DurationMode_NOWKeyword(t *testing.T) { mockClient := &MockedLoader{ - ExecuteRangeQueryFunc: func(ctx context.Context, query string, start, end time.Time, step time.Duration) (map[string]any, error) { + ExecuteRangeQueryFunc: func(_ context.Context, _ string, start, end time.Time, _ time.Duration) (map[string]any, error) { duration := end.Sub(start) if duration < 59*time.Minute || duration > 61*time.Minute { t.Errorf("expected duration ~1h when NOW is used, got %v", duration) @@ -357,3 +358,154 @@ func getErrorMessage(t *testing.T, result *mcp.CallToolResult) string { return fmt.Sprintf("%v", content) } } + +func TestGetDashboardPanelsHandler_RequiredParameters(t *testing.T) { + tests := []struct { + name string + params map[string]any + expectedError string + }{ + { + name: "missing name parameter", + params: map[string]any{"namespace": "default"}, + expectedError: "name parameter is required and must be a string", + }, + { + name: "missing namespace parameter", + params: map[string]any{"name": "test-dashboard"}, + expectedError: "namespace parameter is required and must be a string", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := GetDashboardPanelsHandler(ObsMCPOptions{}) + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "get_dashboard_panels", + Arguments: tt.params, + }, + } + + result, _ := handler(context.Background(), req) + errorMsg := getErrorMessage(t, result) + if errorMsg != tt.expectedError { + t.Errorf("expected error %q, got %q", tt.expectedError, errorMsg) + } + }) + } +} + +func TestFormatPanelsForUIHandler_RequiredParameters(t *testing.T) { + tests := []struct { + name string + params map[string]any + expectedError string + }{ + { + name: "missing name", + params: map[string]any{ + "namespace": "default", + "panel_ids": "0_0", + }, + expectedError: "name parameter is required and must be a string", + }, + { + name: "missing namespace", + params: map[string]any{ + "name": "test-dashboard", + "panel_ids": "0_0", + }, + expectedError: "namespace parameter is required and must be a string", + }, + { + name: "missing panel_ids", + params: map[string]any{ + "name": "test-dashboard", + "namespace": "default", + }, + expectedError: "panel_ids parameter is required and must be a string", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := FormatPanelsForUIHandler(ObsMCPOptions{}) + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "format_panels_for_ui", + Arguments: tt.params, + }, + } + + result, _ := handler(context.Background(), req) + errorMsg := getErrorMessage(t, result) + if errorMsg != tt.expectedError { + t.Errorf("expected error %q, got %q", tt.expectedError, errorMsg) + } + }) + } +} + +func TestGetDashboardPanelsHandler_OptionalPanelIDs(t *testing.T) { + // This test verifies that panel_ids is optional + handler := GetDashboardPanelsHandler(ObsMCPOptions{}) + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "get_dashboard_panels", + Arguments: map[string]any{ + "name": "test-dashboard", + "namespace": "default", + // panel_ids is optional - not provided + }, + }, + } + + // Should fail with dashboard not found, not parameter error + result, _ := handler(context.Background(), req) + errorMsg := getErrorMessage(t, result) + if errorMsg == "panel_ids parameter is required and must be a string" { + t.Errorf("panel_ids should be optional, but got required parameter error") + } + // Expected to fail with "failed to get dashboard" since we don't have a real cluster + if errorMsg != "" && strings.HasPrefix(errorMsg, "failed to get Dashboard") { + t.Logf("Got expected error: %s", errorMsg) + } +} + +func TestGetDashboardHandler_RequiredParameters(t *testing.T) { + tests := []struct { + name string + params map[string]any + expectedError string + }{ + { + name: "missing name parameter", + params: map[string]any{"namespace": "default"}, + expectedError: "name parameter is required and must be a string", + }, + { + name: "missing namespace parameter", + params: map[string]any{"name": "test-dashboard"}, + expectedError: "namespace parameter is required and must be a string", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := GetDashboardHandler(ObsMCPOptions{}) + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "get_perses_dashboard", + Arguments: tt.params, + }, + } + + result, _ := handler(context.Background(), req) + errorMsg := getErrorMessage(t, result) + if errorMsg != tt.expectedError { + t.Errorf("expected error %q, got %q", tt.expectedError, errorMsg) + } + }) + } +} diff --git a/pkg/mcp/server.go b/pkg/mcp/server.go index 49af9fe..ac6ee70 100644 --- a/pkg/mcp/server.go +++ b/pkg/mcp/server.go @@ -32,6 +32,7 @@ const ( defaultShutdownTimeout = 10 * time.Second ) +// NewMCPServer creates and configures a new MCP server instance func NewMCPServer(opts ObsMCPOptions) (*server.MCPServer, error) { mcpServer := server.NewMCPServer( serverName, @@ -47,18 +48,31 @@ func NewMCPServer(opts ObsMCPOptions) (*server.MCPServer, error) { return mcpServer, nil } +// SetupTools registers all MCP tools and their handlers with the server func SetupTools(mcpServer *server.MCPServer, opts ObsMCPOptions) error { // Create tool definitions listMetricsTool := CreateListMetricsTool() executeRangeQueryTool := CreateExecuteRangeQueryTool() + listDashboardsTool := CreateListDashboardsTool() + getDashboardTool := CreateGetDashboardTool() + getDashboardPanelsTool := CreateGetDashboardPanelsTool() + formatPanelsForUITool := CreateFormatPanelsForUITool() // Create handlers listMetricsHandler := ListMetricsHandler(opts) executeRangeQueryHandler := ExecuteRangeQueryHandler(opts) + dashboardsHandler := DashboardsHandler(opts) + getDashboardHandler := GetDashboardHandler(opts) + getDashboardPanelsHandler := GetDashboardPanelsHandler(opts) + formatPanelsForUIHandler := FormatPanelsForUIHandler(opts) // Add tools to server mcpServer.AddTool(listMetricsTool, listMetricsHandler) mcpServer.AddTool(executeRangeQueryTool, executeRangeQueryHandler) + mcpServer.AddTool(listDashboardsTool, dashboardsHandler) + mcpServer.AddTool(getDashboardTool, getDashboardHandler) + mcpServer.AddTool(getDashboardPanelsTool, getDashboardPanelsHandler) + mcpServer.AddTool(formatPanelsForUITool, formatPanelsForUIHandler) return nil } @@ -83,6 +97,7 @@ func loggingMiddleware(next http.Handler) http.Handler { }) } +// Serve starts the MCP server and listens for incoming HTTP requests func Serve(ctx context.Context, mcpServer *server.MCPServer, listenAddr string) error { mux := http.NewServeMux() @@ -100,7 +115,7 @@ func Serve(ctx context.Context, mcpServer *server.MCPServer, listenAddr string) mux.Handle("/", streamableHTTPServer) - mux.HandleFunc(healthEndpoint, func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc(healthEndpoint, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("OK")) }) diff --git a/pkg/mcp/tools.go b/pkg/mcp/tools.go index 7900334..ed93deb 100644 --- a/pkg/mcp/tools.go +++ b/pkg/mcp/tools.go @@ -2,6 +2,8 @@ package mcp import ( "github.com/mark3labs/mcp-go/mcp" + + "github.com/rhobs/obs-mcp/pkg/perses" ) // ListMetricsOutput defines the output schema for the list_metrics tool. @@ -18,10 +20,11 @@ type RangeQueryOutput struct { // SeriesResult represents a single time series result from a range query. type SeriesResult struct { - Metric map[string]string `json:"metric" jsonschema:"description=The metric labels"` - Values [][]any `json:"values" jsonschema:"description=Array of [timestamp, value] pairs"` + Metric map[string]string `json:"metric" jsonschema:"description=The metric labels as key-value pairs"` + Values [][]any `json:"values" jsonschema:"description=Array of [timestamp, value] pairs where timestamp is Unix epoch in seconds and value is the metric value"` } +// CreateListMetricsTool creates the list_metrics tool definition. func CreateListMetricsTool() mcp.Tool { tool := mcp.NewTool("list_metrics", mcp.WithDescription("List all available metrics in Prometheus"), @@ -34,6 +37,7 @@ func CreateListMetricsTool() mcp.Tool { return tool } +// CreateExecuteRangeQueryTool creates the execute_range_query tool definition. func CreateExecuteRangeQueryTool() mcp.Tool { return mcp.NewTool("execute_range_query", mcp.WithDescription(`Execute a PromQL range query with flexible time specification. @@ -66,3 +70,144 @@ For historical data queries, use explicit 'start' and 'end' times. mcp.WithOutputSchema[RangeQueryOutput](), ) } + +// DashboardsOutput defines the output schema for the list_perses_dashboards tool. +type DashboardsOutput struct { + Dashboards []perses.DashboardInfo `json:"dashboards" jsonschema:"description=List of all PersesDashboard resources from the cluster with their metadata"` +} + +// GetDashboardOutput defines the output schema for the get_perses_dashboard tool. +type GetDashboardOutput struct { + Name string `json:"name" jsonschema:"description=Name of the Dashboard"` + Namespace string `json:"namespace" jsonschema:"description=Namespace where the Dashboard is located"` + Spec map[string]any `json:"spec" jsonschema:"description=The full dashboard specification including panels, layouts, variables, and datasources"` +} + +// CreateListDashboardsTool creates the list_perses_dashboards tool definition. +func CreateListDashboardsTool() mcp.Tool { + // "list_dashboards" conflicts with the same tool in layout-manager, and makes LCS throw duplicate tool name errors + tool := mcp.NewTool("list_perses_dashboards", + mcp.WithDescription(`List all PersesDashboard resources from the cluster. + +Start here when there is a need to visualize metrics. + +Returns dashboard summaries with name, namespace, labels, and descriptions. + +Use the descriptions to identify dashboards relevant to the user's question. + +In the case that there is insufficient information in the description, use get_perses_dashboard to fetch the full dashboard spec for more context. Doing so is an expensive operation, so only do this when necessary. + +Follow up with get_dashboard_panels to see what panels are available in the relevant dashboard(s). +`), + mcp.WithOutputSchema[DashboardsOutput](), + ) + // workaround for tool with no parameter + tool.InputSchema = mcp.ToolInputSchema{} + tool.RawInputSchema = []byte(`{"type":"object","properties":{}}`) + return tool +} + +// CreateGetDashboardTool creates the get_perses_dashboard tool definition. +func CreateGetDashboardTool() mcp.Tool { + // "get_dashboard" conflicts with the same tool in layout-manager, and makes LCS throw duplicate tool name errors + return mcp.NewTool("get_perses_dashboard", + mcp.WithDescription(`Get a specific Dashboard by name and namespace. This tool is used to get the dashboard's panels and configuration. + +Use the list_perses_dashboards tool first to find available dashboards, then use this tool to get the full specification of a specific dashboard, if needed (to gather more context). + +The intended use of this tool is only to gather more context on one or more dashboards when the description from list_perses_dashboards is insufficient. + +Information about panels themselves should be gathered using get_dashboard_panels instead (e.g., looking at a "kind: Markdown" panel to gather more context). + +Returns the dashboard's full specification including panels, layouts, variables, and datasources in JSON format. + +For most use cases, you will want to follow up with get_dashboard_panels to extract panel metadata for selection. +`), + mcp.WithString("name", + mcp.Required(), + mcp.Description("Name of the Dashboard"), + ), + mcp.WithString("namespace", + mcp.Required(), + mcp.Description("Namespace of the Dashboard"), + ), + mcp.WithOutputSchema[GetDashboardOutput](), + ) +} + +// GetDashboardPanelsOutput defines the output schema for the get_dashboard_panels tool. +type GetDashboardPanelsOutput struct { + Name string `json:"name" jsonschema:"description=Name of the dashboard"` + Namespace string `json:"namespace" jsonschema:"description=Namespace of the dashboard"` + Duration string `json:"duration,omitempty" jsonschema:"description=Default time duration for queries extracted from dashboard spec (e.g. 1h, 24h)"` + Panels []*perses.DashboardPanel `json:"panels" jsonschema:"description=List of panel metadata including IDs, titles, queries, and chart types for LLM selection"` +} + +// CreateGetDashboardPanelsTool creates the get_dashboard_panels tool definition. +func CreateGetDashboardPanelsTool() mcp.Tool { + return mcp.NewTool("get_dashboard_panels", + mcp.WithDescription(`Get panel(s) information from a specific Dashboard. + +After finding a relevant dashboard (using list_perses_dashboards and conditionally, get_perses_dashboard), use this to see what panels it contains. + +Returns panel metadata including: +- Panel IDs (format: 'panelName' or 'panelName-N' for multi-query panels) +- Titles and descriptions +- PromQL queries (may contain variables like $namespace) +- Chart types (TimeSeriesChart, PieChart, Table) + +You can optionally provide specific panel IDs to fetch only those panels. This is useful when you remember panel IDs from earlier calls and want to re-fetch just their metadata without retrieving the entire dashboard's panels. + +Use this information to identify which panels answer the user's question, then use format_panels_for_ui with the selected panel IDs to prepare them for display. +`), + mcp.WithString("name", + mcp.Required(), + mcp.Description("Name of the Dashboard"), + ), + mcp.WithString("namespace", + mcp.Required(), + mcp.Description("Namespace of the Dashboard"), + ), + mcp.WithString("panel_ids", + mcp.Description("Optional comma-separated list of panel IDs to filter. Panel IDs follow the format 'panelName' or 'panelName-N' where N is the query index (e.g. 'cpuUsage,memoryUsage-0,networkTraffic-1'). Use this to fetch metadata for specific panels you've seen in earlier calls. Leave empty to get all panels."), + ), + mcp.WithOutputSchema[GetDashboardPanelsOutput](), + ) +} + +// FormatPanelsForUIOutput defines the output schema for the format_panels_for_ui tool. +type FormatPanelsForUIOutput struct { + Widgets []perses.DashboardWidget `json:"widgets" jsonschema:"description=Dashboard widgets in DashboardWidget format ready for direct rendering by genie-plugin UI"` +} + +// CreateFormatPanelsForUITool creates the format_panels_for_ui tool definition. +func CreateFormatPanelsForUITool() mcp.Tool { + return mcp.NewTool("format_panels_for_ui", + mcp.WithDescription(`Format selected dashboard panels for UI rendering in DashboardWidget format. + +After choosing relevant panels, use this to prepare them for display. + +Returns an array of DashboardWidget objects ready for direct rendering, with: +- id: Unique panel identifier +- componentType: Perses component name (PersesTimeSeries, PersesPieChart, PersesTable) +- position: Grid layout coordinates (x, y, w, h) in 24-column grid +- breakpoint: Responsive grid breakpoint (xl/lg/md/sm) inferred from panel width +- props: Component properties (query, duration, step, start, end) + +Panel IDs (fetched using get_dashboard_panels) must be provided to specify which panels to format. +`), + mcp.WithString("name", + mcp.Required(), + mcp.Description("Name of the dashboard containing the panels"), + ), + mcp.WithString("namespace", + mcp.Required(), + mcp.Description("Namespace of the dashboard"), + ), + mcp.WithString("panel_ids", + mcp.Required(), // Panel IDs are not required in get_dashboard_panels, but are required here to specify which panels to format + mcp.Description("Comma-separated list of panel IDs to format (e.g. 'myPanelID-1,0_1-2')"), + ), + mcp.WithOutputSchema[FormatPanelsForUIOutput](), + ) +} diff --git a/pkg/mcp/tools_test.go b/pkg/mcp/tools_test.go index 9d8b9cc..9bcc10d 100644 --- a/pkg/mcp/tools_test.go +++ b/pkg/mcp/tools_test.go @@ -314,6 +314,11 @@ func TestToolsHaveOutputSchema(t *testing.T) { tools := []mcp.Tool{ CreateListMetricsTool(), CreateExecuteRangeQueryTool(), + CreateListDashboardsTool(), + CreateListDashboardsTool(), + CreateGetDashboardTool(), + CreateGetDashboardPanelsTool(), + CreateFormatPanelsForUITool(), } if len(tools) == 0 { diff --git a/pkg/perses/dashboard.go b/pkg/perses/dashboard.go new file mode 100644 index 0000000..c07011d --- /dev/null +++ b/pkg/perses/dashboard.go @@ -0,0 +1,55 @@ +package perses + +// DashboardInfo contains metadata about a Perses Dashboard. +type DashboardInfo struct { + Name string `json:"name" jsonschema:"description=Name of the Dashboard"` + Namespace string `json:"namespace" jsonschema:"description=Namespace where the Dashboard is located"` + Labels map[string]string `json:"labels,omitempty" jsonschema:"description=Labels attached to the Dashboard"` + Description string `json:"description,omitempty" jsonschema:"description=Human-readable description of the dashboard and what information it contains (from operator.perses.dev/mcp-help annotation)"` +} + +// DashboardPanel is an intermediary representation of a dashboard panel's metadata and query information. +// We maintain this alongside DashboardWidget to separate concerns between data extraction and UI rendering. +// More importantly, this helps curb the blast radius of changes if the DashboardWidget interface evolves, which we expect it will (during the genie-plugin to genie-web-client migration). +type DashboardPanel struct { + // Needed to build context for identification and selection + ID string `json:"id" jsonschema:"description=Unique identifier for the panel in format 'panelName' or 'panelName-N' where N is the query index for multi-query panels"` + Title string `json:"title,omitempty" jsonschema:"description=Human-readable title of the panel extracted from panel.display.name"` + Description string `json:"description,omitempty" jsonschema:"description=Description of what the panel displays extracted from panel.display.description"` + Query string `json:"query" jsonschema:"description=PromQL query string for fetching data"` + ChartType string `json:"chartType,omitempty" jsonschema:"description=Type of chart to render (TimeSeriesChart, PieChart, StatChart, Table, etc)"` + + // Needed for UI rendering (only populated when fullDetails=true in ExtractPanels) + Duration string `json:"duration,omitempty" jsonschema:"description=Time duration for the query (e.g. 1h, 24h, 7d), extracted from dashboard spec or defaults to 1h"` + Start string `json:"start,omitempty" jsonschema:"description=Optional explicit start time as RFC3339 or Unix timestamp"` + End string `json:"end,omitempty" jsonschema:"description=Optional explicit end time as RFC3339 or Unix timestamp"` + Step string `json:"step,omitempty" jsonschema:"description=Query resolution step width (e.g. 15s, 1m, 5m), extracted from query spec if available"` + Position *PanelPosition `json:"position,omitempty" jsonschema:"description=Layout position information extracted from dashboard layout spec (only when fullDetails=true)"` +} + +// PanelPosition defines the layout position of a panel in a 24-column grid system. +type PanelPosition struct { + X int `json:"x" jsonschema:"description=X coordinate in 24-column grid"` + Y int `json:"y" jsonschema:"description=Y coordinate in grid"` + W int `json:"w" jsonschema:"description=Width in grid units (out of 24 columns)"` + H int `json:"h" jsonschema:"description=Height in grid units"` +} + +// DashboardWidget represents a dashboard widget in the format expected by genie-plugin UI. +// This matches the DashboardWidget interface from jhadvig/genie-plugin. +type DashboardWidget struct { + ID string `json:"id" jsonschema:"description=Unique identifier for the widget"` + ComponentType string `json:"componentType" jsonschema:"description=Type of Perses component to render (PersesTimeSeries, PersesPieChart, PersesTable)"` + Position PanelPosition `json:"position" jsonschema:"description=Layout position in 24-column grid (optional, included when available from dashboard layout)"` + Props DashboardWidgetProps `json:"props" jsonschema:"description=Properties passed to the Perses component"` + Breakpoint string `json:"breakpoint" jsonschema:"description=Responsive grid breakpoint (xl/lg/md/sm) inferred from panel width, defaults to lg if position unavailable"` +} + +// DashboardWidgetProps contains the properties passed to Perses components. +type DashboardWidgetProps struct { + Query string `json:"query" jsonschema:"description=PromQL query string"` + Duration string `json:"duration" jsonschema:"description=Time duration for the query (e.g. 1h, 24h), defaults to 1h if not specified in dashboard"` + Start string `json:"start,omitempty" jsonschema:"description=Optional explicit start time as RFC3339 or Unix timestamp"` + End string `json:"end,omitempty" jsonschema:"description=Optional explicit end time as RFC3339 or Unix timestamp"` + Step string `json:"step" jsonschema:"description=Query resolution step width (e.g. 15s, 1m, 5m), defaults to 15s if not specified in dashboard"` +} diff --git a/pkg/perses/panels.go b/pkg/perses/panels.go new file mode 100644 index 0000000..bf2a52a --- /dev/null +++ b/pkg/perses/panels.go @@ -0,0 +1,296 @@ +package perses + +import ( + "fmt" + "log/slog" +) + +// ExtractPanels extracts panel information from a dashboard spec. +// If fullDetails is true, includes position, step, and duration for UI rendering. +// If panelIDs is provided, only extracts those specific panels. +func ExtractPanels(dashboardName, dashboardNamespace string, spec map[string]any, fullDetails bool, panelIDs []string) (panels []*DashboardPanel) { + panelsMap, ok := spec["panels"].(map[string]any) + if !ok { + slog.Debug("No panels found in dashboard spec", "dashboard", dashboardName) + return panels + } + + // Build a lookup set for requested panel IDs + requestedIDs := make(map[string]bool) + for _, id := range panelIDs { + requestedIDs[id] = true + } + + // Extract additional details only if needed + var defaultDuration string + var layoutMap map[string]PanelPosition + if fullDetails { + defaultDuration = extractDefaultDuration(spec) + layoutMap = extractLayoutPositions(spec) + } + + // Process each panel + for panelName, panelData := range panelsMap { + panelMap, ok := panelData.(map[string]any) + if !ok { + continue + } + + // Extract the spec from the Panel wrapper + // Structure: { kind: "Panel", spec: { display, plugin, queries } } + spec, ok := panelMap["spec"].(map[string]any) + if !ok { + // Try without wrapper for backwards compatibility + spec = panelMap + } + + // Get basic panel info + title, description := extractDisplayInfo(spec) + chartType := extractChartType(spec) + queries := extractQueries(spec) + + // Create a panel for each query + for i, query := range queries { + panelID := panelName + if len(queries) > 1 { + panelID = fmt.Sprintf("%s-%d", panelName, i) + } + + // Skip if filtering and this panel wasn't requested + if len(requestedIDs) > 0 && !requestedIDs[panelID] { + continue + } + + panel := &DashboardPanel{ + ID: panelID, + Title: title, + Description: description, + Query: query.Query, + ChartType: chartType, + } + + // Add full details only if requested + if fullDetails { + panel.Duration = defaultDuration + panel.Step = query.Step + if pos, ok := layoutMap[panelName]; ok { + panel.Position = &pos + } + } + + panels = append(panels, panel) + } + } + + slog.Debug("Extracted panels from dashboard", + "dashboard", dashboardName, + "namespace", dashboardNamespace, + "fullDetails", fullDetails, + "panelCount", len(panels)) + + return panels +} + +// panelQuery represents a query extracted from a panel +type panelQuery struct { + Query string + Step string +} + +// extractDisplayInfo extracts title and description from a panel's display section +func extractDisplayInfo(panelMap map[string]any) (title, description string) { + display, ok := panelMap["display"].(map[string]any) + if !ok { + return "", "" + } + + if name, ok := display["name"].(string); ok { + title = name + } + if desc, ok := display["description"].(string); ok { + description = desc + } + return title, description +} + +// extractChartType extracts and maps the chart type from a panel's plugin section +func extractChartType(panelMap map[string]any) string { + plugin, ok := panelMap["plugin"].(map[string]any) + if !ok { + return "" + } + + kind, ok := plugin["kind"].(string) + if !ok { + return "" + } + + return mapKindToChartType(kind) +} + +// extractQueries extracts all queries from a panel +func extractQueries(panelMap map[string]any) []panelQuery { + var queries []panelQuery + + queriesArray, ok := panelMap["queries"].([]any) + if !ok { + return queries + } + + for _, queryData := range queriesArray { + queryMap, ok := queryData.(map[string]any) + if !ok { + continue + } + + if pq := extractSingleQuery(queryMap); pq.Query != "" { + queries = append(queries, pq) + } + } + + return queries +} + +// extractSingleQuery extracts query and step from a query spec. +// +// structure: { spec: { plugin: { spec: { query: "...", step: "..." } } } } +func extractSingleQuery(queryMap map[string]any) panelQuery { + spec, ok := queryMap["spec"].(map[string]any) + if !ok { + return panelQuery{} + } + + plugin, ok := spec["plugin"].(map[string]any) + if !ok { + return panelQuery{} + } + + pluginSpec, ok := plugin["spec"].(map[string]any) + if !ok { + return panelQuery{} + } + + pq := panelQuery{} + if query, ok := pluginSpec["query"].(string); ok { + pq.Query = query + } + if step, ok := pluginSpec["step"].(string); ok { + pq.Step = step + } + + return pq +} + +// extractDefaultDuration extracts the default duration from dashboard spec +func extractDefaultDuration(spec map[string]any) string { + if duration, ok := spec["duration"].(string); ok { + return duration + } + return "1h" +} + +// extractLayoutPositions extracts panel positions from the dashboard layout +func extractLayoutPositions(spec map[string]any) map[string]PanelPosition { + positions := make(map[string]PanelPosition) + + layouts, ok := spec["layouts"].([]any) + if !ok { + return positions + } + + for _, layoutData := range layouts { + layoutMap, ok := layoutData.(map[string]any) + if !ok { + continue + } + + layoutSpec, ok := layoutMap["spec"].(map[string]any) + if !ok { + continue + } + + items, ok := layoutSpec["items"].([]any) + if !ok { + continue + } + + for _, itemData := range items { + itemMap, ok := itemData.(map[string]any) + if !ok { + continue + } + + x, xOk := getInt(itemMap["x"]) + y, yOk := getInt(itemMap["y"]) + w, wOk := getInt(itemMap["width"]) + h, hOk := getInt(itemMap["height"]) + + if !xOk || !yOk || !wOk || !hOk { + continue + } + + // Extract panel reference from content.$ref + content, ok := itemMap["content"].(map[string]any) + if !ok { + continue + } + + ref, ok := content["$ref"].(string) + if !ok { + continue + } + + // - x: 4 + // "y": 1 + // width: 4 + // height: 3 + // content: + // $ref: "#/spec/panels/0_1" + panelName := extractPanelNameFromRef(ref) + if panelName != "" { + positions[panelName] = PanelPosition{X: x, Y: y, W: w, H: h} + } + } + } + + return positions +} + +// extractPanelNameFromRef extracts panel name from a JSON reference +// Example: "#/spec/panels/panelName" -> "panelName" +func extractPanelNameFromRef(ref string) string { + const prefix = "#/spec/panels/" + if len(ref) > len(prefix) && ref[:len(prefix)] == prefix { + return ref[len(prefix):] + } + return "" +} + +// mapKindToChartType maps plugin kinds to UI chart types +func mapKindToChartType(persesKind string) string { + switch persesKind { + case "TimeSeriesChart", "BarChart": + return "TimeSeriesChart" + case "StatChart", "GaugeChart": + return "PieChart" + case "Table": + return "Table" + default: + return persesKind + } +} + +func getInt(value any) (int, bool) { + switch v := value.(type) { + case int: + return v, true + case int64: + return int(v), true + case float64: + return int(v), true + case float32: + return int(v), true + default: + return 0, false + } +} diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index dd7af5b..05b02b8 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -148,6 +148,116 @@ func sendMCPRequest(t *testing.T, req MCPRequest) (*MCPResponse, error) { return &mcpResp, nil } +func getFirstDashboard(t *testing.T) (name, namespace string) { + req := MCPRequest{ + JSONRPC: "2.0", + ID: 1, + Method: "tools/call", + Params: map[string]any{ + "name": "list_perses_dashboards", + "arguments": map[string]any{}, + }, + } + + resp, err := sendMCPRequest(t, req) + if err != nil { + t.Fatalf("Failed to call list_perses_dashboards: %v", err) + } + + if resp.Error != nil { + t.Fatalf("MCP error getting dashboards: %s", resp.Error.Message) + } + + content, ok := resp.Result["content"].([]any) + if !ok || len(content) == 0 { + t.Fatalf("No dashboards available") + } + + firstContent, ok := content[0].(map[string]any) + if !ok { + t.Fatalf("Unexpected content structure") + } + + text, ok := firstContent["text"].(string) + if !ok { + t.Fatalf("No text field in content") + } + + var dashboardData struct { + Dashboards []struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + } `json:"dashboards"` + } + + if err := json.Unmarshal([]byte(text), &dashboardData); err != nil { + t.Fatalf("Failed to parse dashboard data: %v", err) + } + + if len(dashboardData.Dashboards) == 0 { + t.Fatalf("No dashboards found") + } + + return dashboardData.Dashboards[0].Name, dashboardData.Dashboards[0].Namespace +} + +func getDashboardPanelIDs(t *testing.T, dashboardName, dashboardNamespace string) []string { + t.Helper() + + req := MCPRequest{ + JSONRPC: "2.0", + ID: 2, + Method: "tools/call", + Params: map[string]any{ + "name": "get_dashboard_panels", + "arguments": map[string]any{ + "name": dashboardName, + "namespace": dashboardNamespace, + }, + }, + } + + resp, err := sendMCPRequest(t, req) + if err != nil { + t.Fatalf("Failed to call get_dashboard_panels: %v", err) + } + + if resp.Error != nil { + t.Fatalf("MCP error: %s", resp.Error.Message) + } + + content, ok := resp.Result["content"].([]any) + if !ok || len(content) == 0 { + t.Fatalf("No panel content available") + } + + text, ok := content[0].(map[string]any)["text"].(string) + if !ok { + t.Fatalf("No text field in panel content") + } + + var panelsData struct { + Panels []struct { + ID string `json:"id"` + } `json:"panels"` + } + + if err := json.Unmarshal([]byte(text), &panelsData); err != nil { + t.Fatalf("Failed to parse panels data: %v", err) + } + + if len(panelsData.Panels) == 0 { + t.Fatalf("No panels found in dashboard") + } + + panelIDs := make([]string, len(panelsData.Panels)) + for i, panel := range panelsData.Panels { + panelIDs[i] = panel.ID + } + + return panelIDs +} + func TestHealthEndpoint(t *testing.T) { resp, err := http.Get(obsMCPURL + "/health") if err != nil { @@ -185,7 +295,6 @@ func TestListMetrics(t *testing.T) { t.Error("Expected result, got nil") } - t.Logf("list_metrics returned successfully") } func TestListMetricsReturnsKnownMetrics(t *testing.T) { @@ -245,7 +354,6 @@ func TestExecuteRangeQuery(t *testing.T) { t.Errorf("MCP error: %s", resp.Error.Message) } - t.Logf("execute_range_query returned successfully") } func TestRangeQueryWithInvalidPromQL(t *testing.T) { @@ -374,3 +482,176 @@ func TestGuardrailsBlockDangerousQuery(t *testing.T) { t.Error("Expected guardrails to block the dangerous query") } } + +func TestListDashboards(t *testing.T) { + req := MCPRequest{ + JSONRPC: "2.0", + ID: 8, + Method: "tools/call", + Params: map[string]any{ + "name": "list_perses_dashboards", + "arguments": map[string]any{}, + }, + } + + resp, err := sendMCPRequest(t, req) + if err != nil { + t.Fatalf("Failed to call list_perses_dashboards: %v", err) + } + + if resp.Error != nil { + t.Fatalf("MCP error: %s", resp.Error.Message) + } + + if resp.Result == nil { + t.Fatal("Expected result, got nil") + } + + // Verify the result structure contains dashboards + resultJSON, _ := json.Marshal(resp.Result) + resultStr := string(resultJSON) + + if !strings.Contains(resultStr, "dashboards") { + t.Error("Expected 'dashboards' field in result") + } +} + +func TestGetDashboardPanels(t *testing.T) { + t.Run("WithListDashboards", func(t *testing.T) { + dashboardName, dashboardNamespace := getFirstDashboard(t) + + req := MCPRequest{ + JSONRPC: "2.0", + ID: 12, + Method: "tools/call", + Params: map[string]any{ + "name": "get_dashboard_panels", + "arguments": map[string]any{ + "name": dashboardName, + "namespace": dashboardNamespace, + }, + }, + } + + resp, err := sendMCPRequest(t, req) + if err != nil { + t.Fatalf("Failed to call get_dashboard_panels: %v", err) + } + + if resp.Error != nil { + t.Fatalf("MCP error: %s", resp.Error.Message) + } + + if resp.Result == nil { + t.Fatal("Expected result, got nil") + } + }) + + t.Run("WithPanelIDsFilter", func(t *testing.T) { + dashboardName, dashboardNamespace := getFirstDashboard(t) + // Get panel IDs without filter first + panelIDs := getDashboardPanelIDs(t, dashboardName, dashboardNamespace) + + req := MCPRequest{ + JSONRPC: "2.0", + ID: 14, + Method: "tools/call", + Params: map[string]any{ + "name": "get_dashboard_panels", + "arguments": map[string]any{ + "name": dashboardName, + "namespace": dashboardNamespace, + "panel_ids": fmt.Sprintf("%s,%s", panelIDs[0], panelIDs[1]), + }, + }, + } + + resp, err := sendMCPRequest(t, req) + if err != nil { + t.Fatalf("Failed to call get_dashboard_panels: %v", err) + } + + if resp.Error != nil { + t.Errorf("Unexpected error: %s", resp.Error.Message) + } + + t.Log("get_dashboard_panels with panel_ids filter handled correctly") + }) +} + +func TestFormatPanelsForUI(t *testing.T) { + t.Run("SinglePanel", func(t *testing.T) { + dashboardName, dashboardNamespace := getFirstDashboard(t) + panelIDs := getDashboardPanelIDs(t, dashboardName, dashboardNamespace) + + req := MCPRequest{ + JSONRPC: "2.0", + ID: 18, + Method: "tools/call", + Params: map[string]any{ + "name": "format_panels_for_ui", + "arguments": map[string]any{ + "name": dashboardName, + "namespace": dashboardNamespace, + "panel_ids": panelIDs[0], + }, + }, + } + + resp, err := sendMCPRequest(t, req) + if err != nil { + t.Fatalf("Failed to call format_panels_for_ui: %v", err) + } + + if resp.Error != nil { + t.Fatalf("MCP error: %s", resp.Error.Message) + } + + if resp.Result == nil { + t.Fatal("Expected result, got nil") + } + + resultJSON, _ := json.Marshal(resp.Result) + resultStr := string(resultJSON) + + if !strings.Contains(resultStr, "widgets") { + t.Error("Expected 'widgets' field in result") + } + + t.Log("format_panels_for_ui returned successfully") + }) + + t.Run("MultiplePanels", func(t *testing.T) { + dashboardName, dashboardNamespace := getFirstDashboard(t) + panelIDs := getDashboardPanelIDs(t, dashboardName, dashboardNamespace) + + if len(panelIDs) < 2 { + t.Skip("Need at least 2 panels for this test") + } + + req := MCPRequest{ + JSONRPC: "2.0", + ID: 21, + Method: "tools/call", + Params: map[string]any{ + "name": "format_panels_for_ui", + "arguments": map[string]any{ + "name": dashboardName, + "namespace": dashboardNamespace, + "panel_ids": fmt.Sprintf("%s,%s", panelIDs[0], panelIDs[1]), + }, + }, + } + + resp, err := sendMCPRequest(t, req) + if err != nil { + t.Fatalf("Failed to call format_panels_for_ui: %v", err) + } + + if resp.Error != nil { + t.Fatalf("MCP error: %s", resp.Error.Message) + } + + t.Log("format_panels_for_ui with multiple panel IDs handled correctly") + }) +}