Swagger: Add a custom swagger/api page (#91785)

Co-authored-by: Kristian Bremberg <kristian.bremberg@grafana.com>
This commit is contained in:
Ryan McKinley 2024-08-14 09:03:00 +03:00 committed by GitHub
parent dacf11b048
commit 427dad26a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 3838 additions and 1751 deletions

View File

@ -7300,6 +7300,23 @@ exports[`better eslint`] = {
"public/app/types/unified-alerting-dto.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/swagger/K8sNameLookup.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/swagger/SwaggerPage.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/swagger/index.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Do not use any type assertions.", "3"]
],
"public/swagger/plugins.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"public/test/core/redux/reduxTester.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],

3
.github/CODEOWNERS vendored
View File

@ -506,7 +506,8 @@ playwright.config.ts @grafana/plugins-platform-frontend
/public/test/ @grafana/grafana-frontend-platform
/public/test/helpers/alertingRuleEditor.tsx @grafana/alerting-frontend
/public/views/ @grafana/grafana-frontend-platform
/public/views/swagger.html @grafana/grafana-backend-group
/public/views/swagger.html @grafana/grafana-app-platform-squad
/public/swagger/ @grafana/grafana-app-platform-squad
/public/app/features/explore/Logs/ @grafana/observability-logs

View File

@ -143,6 +143,7 @@
"@types/slate": "0.47.11",
"@types/slate-plain-serializer": "0.7.5",
"@types/slate-react": "0.22.9",
"@types/swagger-ui-react": "4.18.3",
"@types/systemjs": "6.13.5",
"@types/testing-library__jest-dom": "5.14.9",
"@types/tinycolor2": "1.4.6",
@ -394,6 +395,7 @@
"slate": "0.47.9",
"slate-plain-serializer": "0.7.13",
"slate-react": "0.22.10",
"swagger-ui-react": "5.17.14",
"symbol-observable": "4.0.0",
"systemjs": "6.15.1",
"systemjs-cjs-extra": "0.2.1",

View File

@ -220,7 +220,7 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/public/plugins/:pluginId/*", hs.getPluginAssets)
// add swagger support
registerSwaggerUI(r)
hs.registerSwaggerUI(r)
r.Post("/api/user/auth-tokens/rotate", routing.Wrap(hs.RotateUserAuthToken))
r.Get("/user/auth-tokens/rotate", routing.Wrap(hs.RotateUserAuthTokenRedirect))

View File

@ -41,6 +41,7 @@ type EntryPointAssets struct {
JSFiles []EntryPointAsset `json:"jsFiles"`
Dark string `json:"dark"`
Light string `json:"light"`
Swagger []EntryPointAsset `json:"swagger"`
}
type EntryPointAsset struct {
@ -58,4 +59,7 @@ func (a *EntryPointAssets) SetContentDeliveryURL(prefix string) {
for i, p := range a.JSFiles {
a.JSFiles[i].FilePath = prefix + p.FilePath
}
for i, p := range a.Swagger {
a.Swagger[i].FilePath = prefix + p.FilePath
}
}

View File

@ -2,31 +2,42 @@ package api
import (
"net/http"
"strings"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/api/webassets"
"github.com/grafana/grafana/pkg/middleware"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/util/errhttp"
)
func registerSwaggerUI(r routing.RouteRegister) {
func (hs *HTTPServer) registerSwaggerUI(r routing.RouteRegister) {
// Deprecated
r.Get("/swagger-ui", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "swagger", http.StatusMovedPermanently)
})
// Deprecated
r.Get("/openapi3", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "swagger?show=v3", http.StatusMovedPermanently)
http.Redirect(w, r, "swagger", http.StatusMovedPermanently)
})
// The swagger based api navigator
r.Get("/swagger", func(c *contextmodel.ReqContext) {
data := map[string]any{
"Nonce": c.RequestNonce,
ctx := c.Context.Req.Context()
assets, err := webassets.GetWebAssets(ctx, hs.Cfg, hs.License)
if err != nil {
errhttp.Write(ctx, err, c.Resp)
return
}
// Add CSP for unpkg.com to allow loading of Swagger UI assets
if existingCSP := c.Resp.Header().Get("Content-Security-Policy"); existingCSP != "" {
newCSP := strings.Replace(existingCSP, "style-src", "style-src https://unpkg.com/", 1)
c.Resp.Header().Set("Content-Security-Policy", newCSP)
data := map[string]any{
"Nonce": c.RequestNonce,
"Assets": assets,
"FavIcon": "public/img/fav32.png",
"AppleTouchIcon": "public/img/apple-touch-icon.png",
}
if hs.Cfg.CSPEnabled {
data["CSPEnabled"] = true
data["CSPContent"] = middleware.ReplacePolicyVariables(hs.Cfg.CSPTemplate, hs.Cfg.AppURL, c.RequestNonce)
}
c.HTML(http.StatusOK, "swagger", data)

File diff suppressed because it is too large Load Diff

View File

@ -23,6 +23,7 @@ type ManifestInfo struct {
App *EntryPointInfo `json:"app,omitempty"`
Dark *EntryPointInfo `json:"dark,omitempty"`
Light *EntryPointInfo `json:"light,omitempty"`
Swagger *EntryPointInfo `json:"swagger,omitempty"`
}
type EntryPointInfo struct {
@ -128,18 +129,28 @@ func readWebAssets(r io.Reader) (*dtos.EntryPointAssets, error) {
if entryPoints.Light == nil || len(entryPoints.Light.Assets.CSS) == 0 {
return nil, fmt.Errorf("missing light entry")
}
// if entryPoints.Swagger == nil || len(entryPoints.Swagger.Assets.JS) == 0 {
// return nil, fmt.Errorf("missing swagger entry")
// }
rsp := &dtos.EntryPointAssets{
JSFiles: make([]dtos.EntryPointAsset, 0, len(entryPoints.App.Assets.JS)),
Dark: entryPoints.Dark.Assets.CSS[0],
Light: entryPoints.Light.Assets.CSS[0],
Swagger: make([]dtos.EntryPointAsset, 0, len(entryPoints.Swagger.Assets.JS)),
}
entryPointJSAssets := make([]dtos.EntryPointAsset, 0)
for _, entry := range entryPoints.App.Assets.JS {
entryPointJSAssets = append(entryPointJSAssets, dtos.EntryPointAsset{
rsp.JSFiles = append(rsp.JSFiles, dtos.EntryPointAsset{
FilePath: entry,
Integrity: integrity[entry],
})
}
return &dtos.EntryPointAssets{
JSFiles: entryPointJSAssets,
Dark: entryPoints.Dark.Assets.CSS[0],
Light: entryPoints.Light.Assets.CSS[0],
}, nil
for _, entry := range entryPoints.Swagger.Assets.JS {
rsp.Swagger = append(rsp.Swagger, dtos.EntryPointAsset{
FilePath: entry,
Integrity: integrity[entry],
})
}
return rsp, nil
}

View File

@ -3,6 +3,7 @@ package webassets
import (
"context"
"encoding/json"
"fmt"
"testing"
"github.com/stretchr/testify/require"
@ -14,75 +15,63 @@ func TestReadWebassets(t *testing.T) {
dto, err := json.MarshalIndent(assets, "", " ")
require.NoError(t, err)
//fmt.Printf("%s\n", string(dto))
// fmt.Printf("%s\n", string(dto))
require.JSONEq(t, `{
"jsFiles": [
{
"filePath": "public/build/runtime.20ed8c01880b812ed29f.js",
"integrity": "sha256-rcdxIHk6cWgu4jiFa1a+pWlileYD/R72GaS8ZACBUdw= sha384-I/VJZQkt+TuJTvu61ihdWPds7EHfLrW5CxeQ0x9gtSqoPg9Z17Uawz1yoYaTdxqQ sha512-4CPAbh4KdTmGxHoQw4pgpYmgAquupVfwfo6UBV2cGU3vGFnEwkhq320037ETwWs+n9xB/bAMOvrdabp1SA1+8g=="
"filePath": "public/build/runtime~app.1140e4861852d9e6f2e2.js",
"integrity": "sha256-HwsNc3NYC6Ee9cwVjOiEvhAmcaoe8HIwlwiVu/gc+D4= sha384-tt6bizrzSNPJH+uQGvcBokWaWp34sh0jp015BO7ZAO7HujvBjlduOid/30hz64DK sha512-hC1VmDBaqkULR+iPfPyAY4ZwAa5BXrahre0UrvO7+gLIpv810eu3uCGm5YTm+lFb+YFuBPlJDC4X2DarrEc5sg=="
},
{
"filePath": "public/build/3951.4e474348841d792ab1ba.js",
"integrity": "sha256-dHqXXTRA3osYhHr9rol8hOV0nC4VP0pr5tbMp5VD95Q= sha384-4QJaSTibnxdYeYsLnmXtd1+If6IkAmXlLR0uYHN5+N+fS0FegHRH7MIFaRGjiO1B sha512-vRLEeEGbxBCx0z+l/m14fSK49reqWGA9zQzsCrD+TQQBmP07YIoRPwopMMyxtKljbbRFV0bW2bUZ7ZvzOZYoIQ=="
},
{
"filePath": "public/build/3651.4e8f7603e9778e1e9b59.js",
"integrity": "sha256-+N7caL91pVANd7C/aquAneRTjBQenCwaEKqj+3qkjxc= sha384-GQR7GyHPEwwEVph9gGYWEWvMYxkITwcOjieehbPidXZrybuQyw9cpDkjnWo1tj/w sha512-zyPM+8AxyLuECEXjb9w6Z2Sy8zmJdkfTWQphcvAb8AU4ZdkCqLmyjmOs/QQlpfKDe0wdOLyR3V9QgTDDlxtVlQ=="
},
{
"filePath": "public/build/1272.8c79fc44bf7cd993c953.js",
"integrity": "sha256-d7MRVimV83v4YQ5rdURfTaaFtiedXP3EMLT06gvvBuQ= sha384-8tRpYHQ+sEkZ8ptiIbKAbKPpHTJVnmaWDN56vJoWWUCzV1Q2w034wcJNKDJDJdAs sha512-cIZWoJHusF8qODBOj2j4b18ewcLLMo/92YQSwYQjln2G5e3o1bSO476ox2I2iecJ/tnhQK5j01h9BzTt3dNTrA=="
},
{
"filePath": "public/build/6902.070074e8f5a989b8f4c3.js",
"integrity": "sha256-TMo/uTZueyEHtkBzlLZzhwYKWF0epE4qbouo5xcwZkU= sha384-xylZJMtJ7+EsUBBdQZvPh+BeHJ3BnfclqI2vx/8QC9jvfYe/lhRsWW9OMJsxE/Aq sha512-EOmf+KZQMFPoTWAROL8bBLFfHhgvDH8ONycq37JaV7lz+sQOTaWBN2ZD0F/mMdOD5zueTg/Y1RAUP6apoEcHNQ=="
},
{
"filePath": "public/build/app.0439db6f56ee4aa501b2.js",
"integrity": "sha256-q6muaKY7BuN2Ff+00aw69628MXatcFnLNzWRnAD98DI= sha384-gv6lAbkngOHR05bvyOR8dm/J3wIjQQWSjyxK7W8vt2rG9uxcjvvDQV7aI6YbUhfX sha512-o/0mSlJ/OoqrpGdOIWCE3ZCe8n+qqLbgNCERtx9G8FIzsv++CvIWSGbbILjOTGfnEfEQWcKMH0macVpVBSe1Og=="
"filePath": "public/build/app.5e6b0a8f6800cdea4a51.js",
"integrity": "sha256-xLLqXfNJ3+ybFt0Y3519SRZ8BkZvYm30epIE2y5EaIs= sha384-A/LoksVaN1WL6LO53SVm4KcBciyP6leDAdLLrxk13g0iPRMqOPEF+r7qEl0QnHp1 sha512-amLUBch//DibrSurQ2Zt5oUDvdhl48z3Scw7gqp2ASR0jcsLeev3RHcUJKrpW0GDTMRbUEAhIqFL9he2iYzg9g=="
}
],
"dark": "public/build/grafana.dark.a28b24b45b2bbcc628cc.css",
"light": "public/build/grafana.light.3572f6d5f8b7daa8d8d0.css"
"dark": "public/build/grafana.dark.d27cc3e33cf31ab577e7.css",
"light": "public/build/grafana.light.e6be3c7d879fd499516e.css",
"swagger": [
{
"filePath": "public/build/runtime~swagger.793c8e6ede4824f9b730.js",
"integrity": "sha256-cXK7bq3M16fgI1WgELNFabHXVvn84rPN960QwrmTKiQ= sha384-Ysrs6mXl6RUK5nyttGRFQh4ABbpqxnsckFdJFI7FTU9ZQnyXl04DCam0ADj6t43G sha512-l5lei1D6HlvLrxCVovMdwNhMLDc0zO7u/fJwIvASbi49HjuJRsn6cTw/ZBfeghtqBJF0mZeg+fXo0ZKriY8Wlg=="
},
{
"filePath": "public/build/swagger.789d2ab30a8124d2a92c.js",
"integrity": "sha256-zvXf9SHyzCHFvuOGphlImo8zbJ6jnJVXQOzUFNMPMW8= sha384-GLZ2Z9nV1opFHaQXC6pehHmy4XOBImvDk1w7b8QdYeyobarSrA93CzxjfrtCnA2Y sha512-CZ2oIrYuWGaGU+EZr4/KEFerOZU9XS2NELNNsIwtzs7Zcw/rroa4fU0PiZvXbeT+B2kUy1rGLAQ5nwgR0sQlNA=="
}
]
}`, string(dto))
assets.SetContentDeliveryURL("https://grafana-assets.grafana.net/grafana/10.3.0-64123/")
dto, err = json.MarshalIndent(assets, "", " ")
require.NoError(t, err)
//fmt.Printf("%s\n", string(dto))
fmt.Printf("%s\n", string(dto))
require.JSONEq(t, `{
"cdn": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/",
"jsFiles": [
{
"filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/runtime.20ed8c01880b812ed29f.js",
"integrity": "sha256-rcdxIHk6cWgu4jiFa1a+pWlileYD/R72GaS8ZACBUdw= sha384-I/VJZQkt+TuJTvu61ihdWPds7EHfLrW5CxeQ0x9gtSqoPg9Z17Uawz1yoYaTdxqQ sha512-4CPAbh4KdTmGxHoQw4pgpYmgAquupVfwfo6UBV2cGU3vGFnEwkhq320037ETwWs+n9xB/bAMOvrdabp1SA1+8g=="
"filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/runtime~app.1140e4861852d9e6f2e2.js",
"integrity": "sha256-HwsNc3NYC6Ee9cwVjOiEvhAmcaoe8HIwlwiVu/gc+D4= sha384-tt6bizrzSNPJH+uQGvcBokWaWp34sh0jp015BO7ZAO7HujvBjlduOid/30hz64DK sha512-hC1VmDBaqkULR+iPfPyAY4ZwAa5BXrahre0UrvO7+gLIpv810eu3uCGm5YTm+lFb+YFuBPlJDC4X2DarrEc5sg=="
},
{
"filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/3951.4e474348841d792ab1ba.js",
"integrity": "sha256-dHqXXTRA3osYhHr9rol8hOV0nC4VP0pr5tbMp5VD95Q= sha384-4QJaSTibnxdYeYsLnmXtd1+If6IkAmXlLR0uYHN5+N+fS0FegHRH7MIFaRGjiO1B sha512-vRLEeEGbxBCx0z+l/m14fSK49reqWGA9zQzsCrD+TQQBmP07YIoRPwopMMyxtKljbbRFV0bW2bUZ7ZvzOZYoIQ=="
},
{
"filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/3651.4e8f7603e9778e1e9b59.js",
"integrity": "sha256-+N7caL91pVANd7C/aquAneRTjBQenCwaEKqj+3qkjxc= sha384-GQR7GyHPEwwEVph9gGYWEWvMYxkITwcOjieehbPidXZrybuQyw9cpDkjnWo1tj/w sha512-zyPM+8AxyLuECEXjb9w6Z2Sy8zmJdkfTWQphcvAb8AU4ZdkCqLmyjmOs/QQlpfKDe0wdOLyR3V9QgTDDlxtVlQ=="
},
{
"filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/1272.8c79fc44bf7cd993c953.js",
"integrity": "sha256-d7MRVimV83v4YQ5rdURfTaaFtiedXP3EMLT06gvvBuQ= sha384-8tRpYHQ+sEkZ8ptiIbKAbKPpHTJVnmaWDN56vJoWWUCzV1Q2w034wcJNKDJDJdAs sha512-cIZWoJHusF8qODBOj2j4b18ewcLLMo/92YQSwYQjln2G5e3o1bSO476ox2I2iecJ/tnhQK5j01h9BzTt3dNTrA=="
},
{
"filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/6902.070074e8f5a989b8f4c3.js",
"integrity": "sha256-TMo/uTZueyEHtkBzlLZzhwYKWF0epE4qbouo5xcwZkU= sha384-xylZJMtJ7+EsUBBdQZvPh+BeHJ3BnfclqI2vx/8QC9jvfYe/lhRsWW9OMJsxE/Aq sha512-EOmf+KZQMFPoTWAROL8bBLFfHhgvDH8ONycq37JaV7lz+sQOTaWBN2ZD0F/mMdOD5zueTg/Y1RAUP6apoEcHNQ=="
},
{
"filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/app.0439db6f56ee4aa501b2.js",
"integrity": "sha256-q6muaKY7BuN2Ff+00aw69628MXatcFnLNzWRnAD98DI= sha384-gv6lAbkngOHR05bvyOR8dm/J3wIjQQWSjyxK7W8vt2rG9uxcjvvDQV7aI6YbUhfX sha512-o/0mSlJ/OoqrpGdOIWCE3ZCe8n+qqLbgNCERtx9G8FIzsv++CvIWSGbbILjOTGfnEfEQWcKMH0macVpVBSe1Og=="
"filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/app.5e6b0a8f6800cdea4a51.js",
"integrity": "sha256-xLLqXfNJ3+ybFt0Y3519SRZ8BkZvYm30epIE2y5EaIs= sha384-A/LoksVaN1WL6LO53SVm4KcBciyP6leDAdLLrxk13g0iPRMqOPEF+r7qEl0QnHp1 sha512-amLUBch//DibrSurQ2Zt5oUDvdhl48z3Scw7gqp2ASR0jcsLeev3RHcUJKrpW0GDTMRbUEAhIqFL9he2iYzg9g=="
}
],
"dark": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/grafana.dark.a28b24b45b2bbcc628cc.css",
"light": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/grafana.light.3572f6d5f8b7daa8d8d0.css"
"dark": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/grafana.dark.d27cc3e33cf31ab577e7.css",
"light": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/grafana.light.e6be3c7d879fd499516e.css",
"swagger": [
{
"filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/runtime~swagger.793c8e6ede4824f9b730.js",
"integrity": "sha256-cXK7bq3M16fgI1WgELNFabHXVvn84rPN960QwrmTKiQ= sha384-Ysrs6mXl6RUK5nyttGRFQh4ABbpqxnsckFdJFI7FTU9ZQnyXl04DCam0ADj6t43G sha512-l5lei1D6HlvLrxCVovMdwNhMLDc0zO7u/fJwIvASbi49HjuJRsn6cTw/ZBfeghtqBJF0mZeg+fXo0ZKriY8Wlg=="
},
{
"filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/swagger.789d2ab30a8124d2a92c.js",
"integrity": "sha256-zvXf9SHyzCHFvuOGphlImo8zbJ6jnJVXQOzUFNMPMW8= sha384-GLZ2Z9nV1opFHaQXC6pehHmy4XOBImvDk1w7b8QdYeyobarSrA93CzxjfrtCnA2Y sha512-CZ2oIrYuWGaGU+EZr4/KEFerOZU9XS2NELNNsIwtzs7Zcw/rroa4fU0PiZvXbeT+B2kUy1rGLAQ5nwgR0sQlNA=="
}
]
}`, string(dto))
}

View File

@ -155,6 +155,11 @@ func CreateGrafDir(t *testing.T, opts ...GrafanaOpts) (string, string) {
"js": ["public/build/runtime.XYZ.js"]
}
},
"swagger": {
"assets": {
"js": ["public/build/runtime.XYZ.js"]
}
},
"dark": {
"assets": {
"css": ["public/build/dark.css"]

View File

@ -0,0 +1,116 @@
import { useEffect, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { Select } from '@grafana/ui';
import { NamespaceContext, ResourceContext } from './plugins';
type Props = {
value?: string;
onChange: (v?: string) => void;
// The wrapped element
Original: React.ElementType;
props: Record<string, unknown>;
};
export function K8sNameLookup(props: Props) {
const [focused, setFocus] = useState(false);
const [group, setGroup] = useState<string>();
const [version, setVersion] = useState<string>();
const [resource, setResource] = useState<string>();
const [namespace, setNamespace] = useState<string>();
const [namespaced, setNamespaced] = useState<boolean>();
const [loading, setLoading] = useState(false);
const [options, setOptions] = useState<Array<SelectableValue<string>>>();
const [placeholder, setPlaceholder] = useState<string>('Enter kubernetes name');
useEffect(() => {
if (focused && group && version && resource) {
setLoading(true);
setPlaceholder('Enter kubernetes name');
const fn = async () => {
const url = namespaced
? `apis/${group}/${version}/namespaces/${namespace}/${resource}`
: `apis/${group}/${version}/${resource}`;
const response = await fetch(url + '?limit=100', {
headers: {
Accept:
'application/json;as=Table;v=v1;g=meta.k8s.io,application/json;as=Table;v=v1beta1;g=meta.k8s.io,application/jso',
},
});
if (!response.ok) {
console.warn('error loading names');
setLoading(false);
return;
}
const table = await response.json();
console.log('LIST', url, table);
const options: Array<SelectableValue<string>> = [];
if (table.rows?.length) {
table.rows.forEach((row: any) => {
const n = row.object?.metadata?.name;
if (n) {
options.push({ label: n, value: n });
}
});
} else {
setPlaceholder('No items found');
}
setLoading(false);
setOptions(options);
};
fn();
}
}, [focused, namespace, group, version, resource, namespaced]);
return (
<NamespaceContext.Consumer>
{(namespace) => {
return (
<ResourceContext.Consumer>
{(info) => {
// delay avoids Cannot update a component
setTimeout(() => {
setNamespace(namespace);
setGroup(info?.group);
setVersion(info?.version);
setResource(info?.resource);
setNamespaced(info?.namespaced);
}, 200);
if (info) {
const value = props.value ? { label: props.value, value: props.value } : undefined;
return (
<Select
allowCreateWhileLoading={true}
allowCustomValue={true}
placeholder={placeholder}
loadingMessage="Loading kubernetes names..."
formatCreateLabel={(v) => `Use: ${v}`}
onFocus={() => {
// Delay loading until we click on the name
setFocus(true);
}}
options={options}
isLoading={loading}
isClearable={true}
defaultOptions
value={value}
onChange={(v: SelectableValue<string>) => {
props.onChange(v?.value ?? '');
}}
onCreateOption={(v) => {
props.onChange(v);
}}
/>
);
}
return <props.Original {...props.props} />;
}}
</ResourceContext.Consumer>
);
}}
</NamespaceContext.Consumer>
);
}

View File

@ -0,0 +1,105 @@
import getDefaultMonacoLanguages from 'lib/monaco-languages';
import { useState } from 'react';
import { useAsync } from 'react-use';
import SwaggerUI from 'swagger-ui-react';
import { createTheme, monacoLanguageRegistry, SelectableValue } from '@grafana/data';
import { Stack, Select } from '@grafana/ui';
import { setMonacoEnv } from 'app/core/monacoEnv';
import { ThemeProvider } from 'app/core/utils/ConfigProvider';
import { NamespaceContext, WrappedPlugins } from './plugins';
export const Page = () => {
const theme = createTheme({ colors: { mode: 'light' } });
const [url, setURL] = useState<SelectableValue<string>>();
const urls = useAsync(async () => {
const v2 = { label: 'Grafana API (OpenAPI v2)', key: 'openapi2', value: 'public/api-merged.json' };
const v3 = { label: 'Grafana API (OpenAPI v3)', key: 'openapi3', value: 'public/openapi3.json' };
const urls: Array<SelectableValue<string>> = [v2, v3];
const rsp = await fetch('openapi/v3');
const apis = await rsp.json();
for (const [key, val] of Object.entries<any>(apis.paths)) {
const parts = key.split('/');
if (parts.length === 3) {
urls.push({
key: `${parts[1]}-${parts[2]}`,
label: `${parts[1]}/${parts[2]}`,
value: val.serverRelativeURL.substring(1), // remove initial slash
});
}
}
let idx = 0;
const urlParams = new URLSearchParams(window.location.search);
const api = urlParams.get('api');
if (api) {
urls.forEach((url, i) => {
if (url.key === api) {
idx = i;
}
});
}
monacoLanguageRegistry.setInit(getDefaultMonacoLanguages);
setMonacoEnv();
setURL(urls[idx]); // Remove to start at the generic landing page
return urls;
});
const namespace = useAsync(async () => {
const response = await fetch('api/frontend/settings');
if (!response.ok) {
console.warn('No settings found');
return '';
}
const val = await response.json();
return val.namespace;
});
return (
<div>
<ThemeProvider value={theme}>
<NamespaceContext.Provider value={namespace.value}>
<div style={{ backgroundColor: '#000', padding: '10px' }}>
<Stack justifyContent={'space-between'}>
<img height="40" src="public/img/grafana_icon.svg" alt="Grafana" />
<Select
options={urls.value}
isClearable={false /* TODO -- when we allow a landing page, this can be true */}
onChange={(v) => {
const url = new URL(window.location.href);
url.hash = '';
if (v?.key) {
url.searchParams.set('api', v.key);
} else {
url.searchParams.delete('api');
}
history.pushState(null, '', url);
setURL(v);
}}
value={url}
isLoading={urls.loading}
/>
</Stack>
</div>
{url?.value && (
<SwaggerUI
url={url.value}
presets={[WrappedPlugins]}
deepLinking={true}
tryItOutEnabled={true}
queryConfigEnabled={false}
persistAuthorization={false}
/>
)}
{!url?.value && <div>...{/** TODO, we can make an api docs loading page here */}</div>}
</NamespaceContext.Provider>
</ThemeProvider>
</div>
);
};

52
public/swagger/index.tsx Normal file
View File

@ -0,0 +1,52 @@
import '../app/core/trustedTypePolicies';
declare let __webpack_public_path__: string;
declare let __webpack_nonce__: string;
// Check if we are hosting files on cdn and set webpack public path
if (window.public_cdn_path) {
__webpack_public_path__ = window.public_cdn_path;
}
// This is a path to the public folder without '/build'
window.__grafana_public_path__ =
__webpack_public_path__.substring(0, __webpack_public_path__.lastIndexOf('build/')) || __webpack_public_path__;
if (window.nonce) {
__webpack_nonce__ = window.nonce;
}
import 'swagger-ui-react/swagger-ui.css';
import DOMPurify from 'dompurify';
import { createRoot } from 'react-dom/client';
import { textUtil } from '@grafana/data';
import { Page } from './SwaggerPage';
// Use dom purify for the default policy
const tt = window.trustedTypes;
if (tt?.createPolicy) {
tt.createPolicy('default', {
createHTML: (string, sink) => DOMPurify.sanitize(string, { RETURN_TRUSTED_TYPE: true }) as unknown as string,
createScriptURL: (url, sink) => textUtil.sanitizeUrl(url) as unknown as string,
createScript: (script, sink) => script,
});
}
window.onload = () => {
// the trailing slash breaks relative URL loading
if (window.location.pathname.endsWith('/')) {
const idx = window.location.href.lastIndexOf('/');
window.location.href = window.location.href.substring(0, idx);
return;
}
const rootElement = document.getElementById('root');
if (!rootElement) {
alert('unable to find root element');
return;
}
const root = createRoot(rootElement);
root.render(<Page />);
};

163
public/swagger/plugins.tsx Normal file
View File

@ -0,0 +1,163 @@
import { createContext } from 'react';
import { CodeEditor, Monaco } from '@grafana/ui';
import { K8sNameLookup } from './K8sNameLookup';
// swagger does not have types
interface UntypedProps {
[k: string]: any;
}
export type SchemaType = Record<string, any> | undefined;
export type ResourceInfo = {
group: string;
version: string;
resource: string;
namespaced: boolean;
};
// Use react contexts to stash settings
export const SchemaContext = createContext<SchemaType>(undefined);
export const NamespaceContext = createContext<string | undefined>(undefined);
export const ResourceContext = createContext<ResourceInfo | undefined>(undefined);
/* eslint-disable react/display-name */
export const WrappedPlugins = function () {
return {
wrapComponents: {
parameterRow: (Original: React.ElementType) => (props: UntypedProps) => {
// When the parameter name is in the path, lets make it a drop down
const name = props.param.get('name');
const where = props.param.get('in');
if (name === 'name' && where === 'path') {
const path = props.specPath.get(1).split('/');
if (path.length > 4 && path[1] === 'apis') {
const info: ResourceInfo = {
group: path[2],
version: path[3],
resource: path[4],
namespaced: path[4] === 'namespaces',
};
if (info.namespaced) {
info.resource = path[6];
}
// console.log('NAME (in path)', path, info);
return (
<ResourceContext.Provider value={info}>
<Original {...props} />
</ResourceContext.Provider>
);
}
}
return <Original {...props} />;
},
// https://github.com/swagger-api/swagger-ui/blob/v5.17.14/src/core/components/parameters/parameters.jsx#L235
// https://github.com/swagger-api/swagger-ui/blob/v5.17.14/src/core/plugins/oas3/components/request-body.jsx#L35
RequestBody: (Original: React.ElementType) => (props: UntypedProps) => {
let v: SchemaType = undefined;
const content = props.requestBody.get('content');
if (content) {
let mime = content.get('application/json') ?? content.get('*/*');
if (mime) {
v = mime.get('schema').toJS();
}
console.log('RequestBody', v, mime, props);
}
// console.log('RequestBody PROPS', props);
return (
<SchemaContext.Provider value={v}>
<Original {...props} />
</SchemaContext.Provider>
);
},
modelExample: (Original: React.ElementType) => (props: UntypedProps) => {
if (props.isExecute && props.schema) {
console.log('modelExample PROPS', props);
return (
<SchemaContext.Provider value={props.schema.toJS()}>
<Original {...props} />
</SchemaContext.Provider>
);
}
return <Original {...props} />;
},
JsonSchemaForm: (Original: React.ElementType) => (props: UntypedProps) => {
const { description, disabled, required, onChange, value } = props;
if (!disabled && required) {
switch (description) {
case 'namespace': {
return (
<NamespaceContext.Consumer>
{(namespace) => {
if (!value && namespace) {
setTimeout(() => {
// Fake type in the value
onChange(namespace);
}, 100);
}
return <Original {...props} />;
}}
</NamespaceContext.Consumer>
);
}
case 'name': {
return <K8sNameLookup onChange={onChange} value={value} Original={Original} props={props} />;
}
}
}
return <Original {...props} />;
},
// https://github.com/swagger-api/swagger-ui/blob/v5.17.14/src/core/plugins/oas3/components/request-body-editor.jsx
TextArea: (Original: React.ElementType) => (props: UntypedProps) => {
return (
<SchemaContext.Consumer>
{(schema) => {
if (schema) {
const val = props.value ?? props.defaultValue ?? '';
//console.log('JSON TextArea', props, info);
// Return a synthetic text area event
const cb = (txt: string) => {
props.onChange({
target: {
value: txt,
},
});
};
console.log('CodeEditor', schema);
return (
<CodeEditor
value={val}
height={300}
language="application/json"
showMiniMap={val.length > 500}
onBlur={cb}
onSave={cb}
onBeforeEditorMount={(monaco: Monaco) => {
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
schemas: [
{
uri: schema['$$ref'] ?? '#internal',
fileMatch: ['*'], // everything
schema: schema,
},
],
});
}}
/>
);
}
return <Original {...props} />;
}}
</SchemaContext.Consumer>
);
},
},
};
};

View File

@ -1,110 +1,44 @@
<!-- HTML for static distribution bundle build -->
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Swagger UI</title>
<link
rel="stylesheet"
type="text/css"
href="https://unpkg.com/swagger-ui-dist@5.17.14/swagger-ui.css"
integrity="sha384-wxLW6kwyHktdDGr6Pv1zgm/VGJh99lfUbzSn6HNHBENZlCN7W602k9VkGdxuFvPn"
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<link rel="icon" type="image/png" href="favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="favicon-16x16.png" sizes="16x16" />
<style>
html {
box-sizing: border-box;
overflow: -moz-scrollbars-vertical;
overflow-y: scroll;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
<head>
[[ if and .CSPEnabled .IsDevelopmentEnv ]]
<!-- Cypress overwrites CSP headers in HTTP requests, so this is required for e2e tests-->
<meta http-equiv="Content-Security-Policy" content="[[.CSPContent]]"/>
[[ end ]]
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="viewport" content="width=device-width" />
<meta name="theme-color" content="#000" />
body {
margin: 0;
background: #fafafa;
}
<title>Grafana API Reference</title>
.swagger-ui .topbar a {
content: url('public/img/grafana_icon.svg');
height: 50px;
flex: 0;
}
</style>
</head>
<link rel="stylesheet" href="[[.Assets.Light]]" />
<body>
<div id="swagger-ui"></div>
<link rel="icon" type="image/png" href="[[.FavIcon]]" />
<link rel="apple-touch-icon" sizes="180x180" href="[[.AppleTouchIcon]]" />
<link rel="mask-icon" href="[[.Assets.ContentDeliveryURL]]public/img/grafana_mask_icon.svg" color="#F05A28" />
</head>
<script
nonce="[[.Nonce]]"
src="https://unpkg.com/swagger-ui-dist@5.17.14/swagger-ui-bundle.js"
charset="UTF-8"
integrity="sha384-wmyclcVGX/WhUkdkATwhaK1X1JtiNrr2EoYJ+diV3vj4v6OC5yCeSu+yW13SYJep"
crossorigin="anonymous"
referrerpolicy="no-referrer"
></script>
<script
nonce="[[.Nonce]]"
src="https://unpkg.com/swagger-ui-dist@5.17.14/swagger-ui-standalone-preset.js"
charset="UTF-8"
integrity="sha384-2YH8WDRaj7V2OqU/trsmzSagmk/E2SutiCsGkdgoQwC9pNUJV1u/141DHB6jgs8t"
crossorigin="anonymous"
referrerpolicy="no-referrer"
></script>
<script nonce="[[.Nonce]]">
window.onload = async function () {
// the trailing slash breaks relative URL loading
if (window.location.pathname.endsWith('/')) {
const idx = window.location.href.lastIndexOf('/');
window.location.href = window.location.href.substring(0, idx);
return;
}
const urlParams = new URLSearchParams(window.location.search);
const v2 = { name: 'Grafana API (OpenAPI v2)', url: 'public/api-merged.json' };
const v3 = { name: 'Grafana API (OpenAPI v3)', url: 'public/openapi3.json' };
const urls = urlParams.get('show') == 'v3' ? [v3, v2] : [v2, v3];
try {
const rsp = await fetch('openapi/v3');
const apis = await rsp.json();
for (const [key, value] of Object.entries(apis.paths)) {
const parts = key.split('/');
if (parts.length == 3) {
urls.push({
name: `${parts[1]}/${parts[2]}`,
url: value.serverRelativeURL.substring(1), // remove initial slash
});
}
}
urls.push({ name: 'Grafana apps (OpenAPI v2)', url: 'openapi/v2' });
} catch (err) {
// console.warn('Error loading k8s apis', err);
}
// Begin Swagger UI call region
const ui = SwaggerUIBundle({
urls,
dom_id: '#swagger-ui',
deepLinking: true,
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
plugins: [SwaggerUIBundle.plugins.DownloadUrl],
layout: 'StandaloneLayout',
filter: true,
tagsSorter: 'alpha',
tryItOutEnabled: true,
queryConfigEnabled: true, // keeps the selected ?urls.primaryName=...
});
window.ui = ui;
};
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<script nonce="[[$.Nonce]]">
[[if .Assets.ContentDeliveryURL]]
window.public_cdn_path = '[[.Assets.ContentDeliveryURL]]public/build/';
[[end]]
</script>
</body>
<div id="root"></div>
[[range $asset := .Assets.Swagger]]
<script
nonce="[[$.Nonce]]"
src="[[$asset.FilePath]]"
type="text/javascript"
></script>
[[end]]
<script>
</script>
</body>
</html>

View File

@ -7,6 +7,7 @@ module.exports = {
target: 'web',
entry: {
app: './public/app/index.ts',
swagger: './public/swagger/index.tsx',
},
output: {
clean: true,

View File

@ -17,6 +17,7 @@
"extends": "@grafana/tsconfig/base.json",
"include": [
"public/app/**/*.ts*",
"public/swagger/**/*.ts*",
"public/e2e-test/**/*.ts",
"public/test/**/*.ts*",
"public/vendor/**/*.ts",

1301
yarn.lock

File diff suppressed because it is too large Load Diff