Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7dafd900bc | |||
| f03125279a | |||
| bb6d7d02c7 | |||
| 71d10a3fa3 | |||
| df4922ea78 | |||
| e7a49fc472 | |||
| 4bdee91501 | |||
| 5f9ed73f82 | |||
| 0fe06800d5 | |||
| 6f82e44283 | |||
| 20a11e0bc0 | |||
| 52cd4d434f | |||
| f7748676b3 | |||
| df305c111e | |||
| 64f6bd5348 | |||
| 89ca1dd0e4 | |||
| 7ecb057414 | |||
| 34cf970b54 | |||
| ddc5ae6f4d | |||
| 62c5df36d6 | |||
| c74af4f3d4 | |||
| 87f40c65e4 | |||
| 8b7f119cad |
@@ -44,7 +44,14 @@ local_resource(
|
||||
)
|
||||
|
||||
# --- Docker Compose
|
||||
docker_compose("./docker-compose.yaml")
|
||||
# define service overrides needed for running with enterprise
|
||||
# this mounts the dev license into the grafana-api service
|
||||
base_config = read_yaml('./docker-compose.yaml')
|
||||
base_volumes = base_config['services']['grafana-api']['volumes']
|
||||
enterprise_overrides = {'services':{'grafana-api': {'volumes': base_volumes + ['../../data/license.jwt:/grafana/data/license.jwt'] }}}
|
||||
|
||||
# check if license exists and apply enterprise overrides if so
|
||||
docker_compose(["./docker-compose.yaml", encode_yaml(enterprise_overrides)]) if os.path.exists("../../data/license.jwt") else docker_compose("./docker-compose.yaml")
|
||||
dc_resource("proxy",
|
||||
resource_deps=["grafana-api", "frontend-service"],
|
||||
labels=["services"]
|
||||
@@ -66,7 +73,7 @@ dc_resource("postgres", labels=["misc"])
|
||||
dc_resource("tempo-init", labels=["misc"])
|
||||
|
||||
# paths in tilt files are confusing....
|
||||
# - if tilt is dealing the the path, it is relative to the Tiltfile
|
||||
# - if tilt is dealing with the path, it is relative to the Tiltfile
|
||||
# - if docker is dealing with the path, it is relative to the context
|
||||
docker_build('grafana-fs-dev',
|
||||
# Set the docker context to the root of the repo
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
cd ../../
|
||||
|
||||
echo "Go mod cache: $(go env GOMODCACHE), $(ls -1 $(go env GOMODCACHE) | wc -l) items"
|
||||
echo "Go build cache: $(go env GOCACHE), $(ls -1 $(go env GOCACHE) | wc -l) items"
|
||||
# Support running this file from tilt (where the cwd is devenv/frontend-service), or directly from the root
|
||||
if [[ -f build-grafana.sh ]]; then
|
||||
cd ../../
|
||||
fi
|
||||
|
||||
# The docker container, even on macOS, is linux, so we need to cross-compile
|
||||
# on macOS hosts to work on linux.
|
||||
@@ -17,7 +17,16 @@ fi
|
||||
# Need to build version into the binary so plugin compatibility works correctly
|
||||
VERSION=$(jq -r .version package.json)
|
||||
|
||||
# Build enterprise if it is linked in
|
||||
EXTRA_TAGS=""
|
||||
if [[ -f pkg/extensions/ext.go ]]; then
|
||||
EXTRA_TAGS="-tags enterprise"
|
||||
fi
|
||||
|
||||
# EXTRA_TAGS is intentionally unquoted to build the command
|
||||
# shellcheck disable=SC2086
|
||||
go build -v \
|
||||
-ldflags "-X main.version=${VERSION}" \
|
||||
-gcflags "all=-N -l" \
|
||||
${EXTRA_TAGS} \
|
||||
-o ./devenv/frontend-service/build/grafana ./pkg/cmd/grafana
|
||||
|
||||
@@ -8,6 +8,7 @@ services:
|
||||
dockerfile: proxy.dockerfile
|
||||
volumes:
|
||||
- ../../public/build:/cdn/public/build
|
||||
- ../../public/app/plugins:/cdn/public/app/plugins
|
||||
- ../../public/fonts:/cdn/public/fonts
|
||||
ports:
|
||||
- '3000:80' # Gateway
|
||||
|
||||
@@ -53,6 +53,10 @@ The following will help you get started working with Elasticsearch and Grafana:
|
||||
|
||||
## Supported Elasticsearch versions
|
||||
|
||||
{{< admonition type="warning" >}}
|
||||
The Elasticsearch data source plugin currently does not support Elastic Cloud Serverless, or any other serverless variant of Elasticsearch.
|
||||
{{< /admonition >}}
|
||||
|
||||
This data source supports these versions of Elasticsearch:
|
||||
|
||||
- ≥ v7.17
|
||||
|
||||
@@ -146,6 +146,10 @@ To simplify syntax and to allow for dynamic components, such as date range filte
|
||||
Use macros in the `SELECT` clause to simplify the creation of time series queries.
|
||||
From the **Data operations** drop-down, choose a macro such as `$\_\_timeGroup` or `$\_\_timeGroupAlias`. Then, select a time column from the **Column** drop-down and a time interval from the **Interval** drop-down. This generates a time-series query based on your selected time grouping.
|
||||
|
||||
{{< admonition type="warning" >}}
|
||||
Time macros (`$__time`, `$__timeFilter`, etc.) don't support time zone parameters in Microsoft SQL Server and always expand to UTC values. If your timestamps aren't stored in UTC (common with `datetime`/`datetime2` types), convert them to UTC in your SQL query using `AT TIME ZONE … AT TIME ZONE 'UTC'` rather than passing a time zone argument to a macro.
|
||||
{{< /admonition >}}
|
||||
|
||||
| **Macro** | **Description** |
|
||||
| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `$__time(dateColumn)` | Renames the specified column to `_time`. <br/>Example: `dateColumn AS time` |
|
||||
|
||||
@@ -3055,11 +3055,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/explore/TraceView/components/utils/DraggableManager/demo/index.tsx": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/explore/TraceView/components/utils/sort.ts": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
|
||||
@@ -93,6 +93,7 @@ export const getModalStyles = (theme: GrafanaTheme2) => {
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
paddingTop: theme.spacing(2),
|
||||
paddingBottom: theme.spacing(0.5),
|
||||
zIndex: 1,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -105,8 +105,8 @@ func GolangContainer(
|
||||
opts *BuildOpts,
|
||||
) (*dagger.Container, error) {
|
||||
os, _ := OSAndArch(distro)
|
||||
// Only use viceroy for all darwin and only windows/amd64
|
||||
if opts.CGOEnabled && (os == "darwin" || distro == DistWindowsAMD64) {
|
||||
// Only use viceroy for all darwin builds
|
||||
if opts.CGOEnabled && os == "darwin" {
|
||||
return ViceroyContainer(d, log, distro, goVersion, viceroyVersion, opts)
|
||||
}
|
||||
|
||||
@@ -125,7 +125,9 @@ func GolangContainer(
|
||||
WithExec([]string{"wget", "-q", "https://dl.grafana.com/ci/s390x-linux-musl-cross.tgz", "-P", "/toolchain"}).
|
||||
WithExec([]string{"tar", "-xf", "/toolchain/s390x-linux-musl-cross.tgz", "-C", "/toolchain"}).
|
||||
WithExec([]string{"wget", "-q", "https://dl.grafana.com/ci/riscv64-linux-musl-cross.tgz", "-P", "/toolchain"}).
|
||||
WithExec([]string{"tar", "-xf", "/toolchain/riscv64-linux-musl-cross.tgz", "-C", "/toolchain"})
|
||||
WithExec([]string{"tar", "-xf", "/toolchain/riscv64-linux-musl-cross.tgz", "-C", "/toolchain"}).
|
||||
WithExec([]string{"wget", "-q", "https://dl.grafana.com/ci/x86_64-w64-mingw32-cross.tgz", "-P", "/toolchain"}).
|
||||
WithExec([]string{"tar", "-xf", "/toolchain/x86_64-w64-mingw32-cross.tgz", "-C", "/toolchain"})
|
||||
}
|
||||
return WithGoEnv(log, container, distro, opts)
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ func BuildOptsStaticS390X(distro Distribution, experiments []string, tags []stri
|
||||
}
|
||||
}
|
||||
|
||||
// BuildOptsStaticS390X builds Grafana statically for the s390x arch
|
||||
// BuildOptsStaticRiscv64 builds Grafana statically for the riscv64 arch
|
||||
func BuildOptsStaticRiscv64(distro Distribution, experiments []string, tags []string) *GoBuildOpts {
|
||||
var (
|
||||
os, _ = OSAndArch(distro)
|
||||
@@ -280,6 +280,22 @@ func BuildOptsStaticRiscv64(distro Distribution, experiments []string, tags []st
|
||||
}
|
||||
}
|
||||
|
||||
// BuildOptsStaticWindows builds Grafana statically for Windows on amd64
|
||||
func BuildOptsStaticWindows(distro Distribution, experiments []string, tags []string) *GoBuildOpts {
|
||||
var (
|
||||
os, _ = OSAndArch(distro)
|
||||
)
|
||||
|
||||
return &GoBuildOpts{
|
||||
CC: "/toolchain/x86_64-w64-mingw32-cross/bin/x86_64-w64-mingw32-gcc",
|
||||
CXX: "/toolchain/x86_64-w64-mingw32-cross/bin/x86_64-w64-mingw32-cpp",
|
||||
ExperimentalFlags: experiments,
|
||||
OS: os,
|
||||
Arch: "amd64",
|
||||
CGOEnabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
func StdZigBuildOpts(distro Distribution, experiments []string, tags []string) *GoBuildOpts {
|
||||
var (
|
||||
os, arch = OSAndArch(distro)
|
||||
@@ -364,7 +380,7 @@ var DistributionGoOpts = map[Distribution]DistroBuildOptsFunc{
|
||||
|
||||
// Non-Linux distros can have whatever they want in CC and CXX; it'll get overridden
|
||||
// but it's probably not best to rely on that.
|
||||
DistWindowsAMD64: ViceroyBuildOpts,
|
||||
DistWindowsAMD64: BuildOptsStaticWindows,
|
||||
DistWindowsARM64: StdZigBuildOpts,
|
||||
DistDarwinAMD64: ViceroyBuildOpts,
|
||||
DistDarwinARM64: ViceroyBuildOpts,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DELETE FROM {{ .Ident .TeamMemberTable }}
|
||||
WHERE uid = {{ .Arg .Command.UID }}
|
||||
@@ -38,6 +38,7 @@ type LegacyIdentityStore interface {
|
||||
ListTeamBindings(ctx context.Context, ns claims.NamespaceInfo, query ListTeamBindingsQuery) (*ListTeamBindingsResult, error)
|
||||
ListTeamMembers(ctx context.Context, ns claims.NamespaceInfo, query ListTeamMembersQuery) (*ListTeamMembersResult, error)
|
||||
UpdateTeamMember(ctx context.Context, ns claims.NamespaceInfo, cmd UpdateTeamMemberCommand) (*UpdateTeamMemberResult, error)
|
||||
DeleteTeamMember(ctx context.Context, ns claims.NamespaceInfo, cmd DeleteTeamMemberCommand) error
|
||||
}
|
||||
|
||||
var _ LegacyIdentityStore = (*legacySQLStore)(nil)
|
||||
|
||||
@@ -97,6 +97,12 @@ func TestIdentityQueries(t *testing.T) {
|
||||
return &v
|
||||
}
|
||||
|
||||
deleteTeamMember := func(q *DeleteTeamMemberCommand) sqltemplate.SQLTemplate {
|
||||
v := newDeleteTeamMember(nodb, q)
|
||||
v.SQLTemplate = mocks.NewTestingSQLTemplate()
|
||||
return &v
|
||||
}
|
||||
|
||||
deleteTeam := func(q *DeleteTeamCommand) sqltemplate.SQLTemplate {
|
||||
v := newDeleteTeam(nodb, q)
|
||||
v.SQLTemplate = mocks.NewTestingSQLTemplate()
|
||||
@@ -294,6 +300,14 @@ func TestIdentityQueries(t *testing.T) {
|
||||
}),
|
||||
},
|
||||
},
|
||||
sqlDeleteTeamMemberQuery: {
|
||||
{
|
||||
Name: "delete_team_member_basic",
|
||||
Data: deleteTeamMember(&DeleteTeamMemberCommand{
|
||||
UID: "team-member-1",
|
||||
}),
|
||||
},
|
||||
},
|
||||
sqlQueryUserTeamsTemplate: {
|
||||
{
|
||||
Name: "team_1_members_page_1",
|
||||
|
||||
@@ -368,6 +368,61 @@ func (s *legacySQLStore) UpdateTeamMember(ctx context.Context, ns claims.Namespa
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
type DeleteTeamMemberCommand struct {
|
||||
UID string
|
||||
}
|
||||
|
||||
var sqlDeleteTeamMemberQuery = mustTemplate("delete_team_member_query.sql")
|
||||
|
||||
func newDeleteTeamMember(sql *legacysql.LegacyDatabaseHelper, cmd *DeleteTeamMemberCommand) deleteTeamMemberQuery {
|
||||
return deleteTeamMemberQuery{
|
||||
SQLTemplate: sqltemplate.New(sql.DialectForDriver()),
|
||||
TeamMemberTable: sql.Table("team_member"),
|
||||
Command: cmd,
|
||||
}
|
||||
}
|
||||
|
||||
type deleteTeamMemberQuery struct {
|
||||
sqltemplate.SQLTemplate
|
||||
TeamMemberTable string
|
||||
Command *DeleteTeamMemberCommand
|
||||
}
|
||||
|
||||
func (r deleteTeamMemberQuery) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *legacySQLStore) DeleteTeamMember(ctx context.Context, ns claims.NamespaceInfo, cmd DeleteTeamMemberCommand) error {
|
||||
sql, err := s.sql(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req := newDeleteTeamMember(sql, &cmd)
|
||||
if err := req.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = sql.DB.GetSqlxSession().WithTransaction(ctx, func(st *session.SessionTx) error {
|
||||
teamMemberQuery, err := sqltemplate.Execute(sqlDeleteTeamMemberQuery, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute team member template %q: %w", sqlDeleteTeamMemberQuery.Name(), err)
|
||||
}
|
||||
|
||||
_, err = st.Exec(ctx, teamMemberQuery, req.GetArgs()...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete team member: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanMember(rows *sql.Rows) (TeamMember, error) {
|
||||
m := TeamMember{}
|
||||
err := rows.Scan(&m.ID, &m.UID, &m.TeamUID, &m.TeamID, &m.UserUID, &m.UserID, &m.Name, &m.Email, &m.Username, &m.External, &m.Created, &m.Updated, &m.Permission)
|
||||
|
||||
Vendored
Executable
+2
@@ -0,0 +1,2 @@
|
||||
DELETE FROM `grafana`.`team_member`
|
||||
WHERE uid = 'team-member-1'
|
||||
Vendored
Executable
+2
@@ -0,0 +1,2 @@
|
||||
DELETE FROM "grafana"."team_member"
|
||||
WHERE uid = 'team-member-1'
|
||||
Vendored
Executable
+2
@@ -0,0 +1,2 @@
|
||||
DELETE FROM "grafana"."team_member"
|
||||
WHERE uid = 'team-member-1'
|
||||
@@ -125,7 +125,34 @@ func (l *LegacyBindingStore) Update(ctx context.Context, name string, objInfo re
|
||||
}
|
||||
|
||||
func (l *LegacyBindingStore) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
|
||||
return nil, false, apierrors.NewMethodNotSupported(bindingResource.GroupResource(), "delete")
|
||||
if !l.enableAuthnMutation {
|
||||
return nil, false, apierrors.NewMethodNotSupported(bindingResource.GroupResource(), "delete")
|
||||
}
|
||||
|
||||
ns, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// Check if the team binding exists
|
||||
_, err = l.Get(ctx, name, nil)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
err = l.store.DeleteTeamMember(ctx, ns, legacy.DeleteTeamMemberCommand{
|
||||
UID: name,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
return &iamv0alpha1.TeamBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: ns.Value,
|
||||
},
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func (l *LegacyBindingStore) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) {
|
||||
|
||||
@@ -19,6 +19,9 @@ import (
|
||||
//
|
||||
// The usage of strings.ToLower is because the server would convert `FromAlert` to `Fromalert`. So the make matching
|
||||
// easier, we just match all headers in lower case.
|
||||
//
|
||||
// the headers X-Real-IP and X-Forwarded-For are used by the HostedGrafanaACHeaderMiddleware at
|
||||
// https://github.com/grafana/grafana/blob/f191acf8114ab79609fc631e0f01fe2b47371188/pkg/services/pluginsintegration/clientmiddleware/grafana_request_id_header_middleware.go#L107
|
||||
var expectedHeaders = map[string]string{
|
||||
strings.ToLower(models.FromAlertHeaderName): models.FromAlertHeaderName,
|
||||
strings.ToLower(models.CacheSkipHeaderName): models.CacheSkipHeaderName,
|
||||
@@ -38,6 +41,7 @@ var expectedHeaders = map[string]string{
|
||||
strings.ToLower(queryService.HeaderDashboardTitle): queryService.HeaderDashboardTitle,
|
||||
strings.ToLower(queryService.HeaderPanelTitle): queryService.HeaderPanelTitle,
|
||||
strings.ToLower("X-Real-IP"): "X-Real-IP",
|
||||
strings.ToLower("X-Forwarded-For"): "X-Forwarded-For",
|
||||
}
|
||||
|
||||
func ExtractKnownHeaders(header http.Header) map[string]string {
|
||||
|
||||
@@ -28,6 +28,7 @@ func ConvertToK8sResource(orgID int64, r definitions.Route, version string, name
|
||||
RepeatInterval: optionalPrometheusDurationToString(r.RepeatInterval),
|
||||
Receiver: r.Receiver,
|
||||
},
|
||||
Routes: make([]model.RoutingTreeRoute, 0, len(r.Routes)),
|
||||
}
|
||||
for _, route := range r.Routes {
|
||||
if route == nil {
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/authz"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/frontend"
|
||||
"github.com/grafana/grafana/pkg/services/hooks"
|
||||
"github.com/grafana/grafana/pkg/services/licensing"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
@@ -46,8 +47,9 @@ func NewModule(opts Options,
|
||||
license licensing.Licensing,
|
||||
moduleRegisterer ModuleRegisterer,
|
||||
storageBackend resource.StorageBackend, // Ensures unified storage backend is initialized
|
||||
hooksService *hooks.HooksService,
|
||||
) (*ModuleServer, error) {
|
||||
s, err := newModuleServer(opts, apiOpts, features, cfg, storageMetrics, indexMetrics, reg, promGatherer, license, moduleRegisterer, storageBackend)
|
||||
s, err := newModuleServer(opts, apiOpts, features, cfg, storageMetrics, indexMetrics, reg, promGatherer, license, moduleRegisterer, storageBackend, hooksService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -70,6 +72,7 @@ func newModuleServer(opts Options,
|
||||
license licensing.Licensing,
|
||||
moduleRegisterer ModuleRegisterer,
|
||||
storageBackend resource.StorageBackend,
|
||||
hooksService *hooks.HooksService,
|
||||
) (*ModuleServer, error) {
|
||||
rootCtx, shutdownFn := context.WithCancel(context.Background())
|
||||
|
||||
@@ -93,6 +96,7 @@ func newModuleServer(opts Options,
|
||||
license: license,
|
||||
moduleRegisterer: moduleRegisterer,
|
||||
storageBackend: storageBackend,
|
||||
hooksService: hooksService,
|
||||
}
|
||||
|
||||
return s, nil
|
||||
@@ -134,6 +138,7 @@ type ModuleServer struct {
|
||||
|
||||
// moduleRegisterer allows registration of modules provided by other builds (e.g. enterprise).
|
||||
moduleRegisterer ModuleRegisterer
|
||||
hooksService *hooks.HooksService
|
||||
}
|
||||
|
||||
// init initializes the server and its services.
|
||||
@@ -205,7 +210,7 @@ func (s *ModuleServer) Run() error {
|
||||
})
|
||||
|
||||
m.RegisterModule(modules.FrontendServer, func() (services.Service, error) {
|
||||
return frontend.ProvideFrontendService(s.cfg, s.features, s.promGatherer, s.registerer, s.license)
|
||||
return frontend.ProvideFrontendService(s.cfg, s.features, s.promGatherer, s.registerer, s.license, s.hooksService)
|
||||
})
|
||||
|
||||
m.RegisterModule(modules.OperatorServer, s.initOperatorServer)
|
||||
|
||||
@@ -27,6 +27,8 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/modules"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/hooks"
|
||||
"github.com/grafana/grafana/pkg/services/licensing"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/sqlutil"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
@@ -330,8 +332,10 @@ func initModuleServerForTest(
|
||||
apiOpts api.ServerOptions,
|
||||
) testModuleServer {
|
||||
tracer := tracing.InitializeTracerForTest()
|
||||
hooksService := hooks.ProvideService()
|
||||
license := &licensing.OSSLicensingService{}
|
||||
|
||||
ms, err := NewModule(opts, apiOpts, featuremgmt.WithFeatures(featuremgmt.FlagUnifiedStorageSearch), cfg, nil, nil, prometheus.NewRegistry(), prometheus.DefaultGatherer, tracer, nil, ProvideNoopModuleRegisterer(), nil)
|
||||
ms, err := NewModule(opts, apiOpts, featuremgmt.WithFeatures(featuremgmt.FlagUnifiedStorageSearch), cfg, nil, nil, prometheus.NewRegistry(), prometheus.DefaultGatherer, tracer, license, ProvideNoopModuleRegisterer(), nil, hooksService)
|
||||
require.NoError(t, err)
|
||||
|
||||
conn, err := grpc.NewClient(cfg.GRPCServer.Address,
|
||||
|
||||
@@ -1644,7 +1644,7 @@ func InitializeModuleServer(cfg *setting.Cfg, opts Options, apiOpts api.ServerOp
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
moduleServer, err := NewModule(opts, apiOpts, featureToggles, cfg, storageMetrics, bleveIndexMetrics, registerer, gatherer, tracingService, ossLicensingService, moduleRegisterer, storageBackend)
|
||||
moduleServer, err := NewModule(opts, apiOpts, featureToggles, cfg, storageMetrics, bleveIndexMetrics, registerer, gatherer, tracingService, ossLicensingService, moduleRegisterer, storageBackend, hooksService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ package ossaccesscontrol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/routing"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/errutil"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
@@ -22,6 +24,8 @@ type FolderPermissionsService struct {
|
||||
*resourcepermissions.Service
|
||||
}
|
||||
|
||||
var ErrFolderUnhandledError = errutil.Internal("folder.unhandled-error", errutil.WithPublicMessage("Unhandled folder error"))
|
||||
|
||||
var FolderViewActions = []string{dashboards.ActionFoldersRead, accesscontrol.ActionAlertingRuleRead, libraryelements.ActionLibraryPanelsRead, accesscontrol.ActionAlertingSilencesRead}
|
||||
var FolderEditActions = append(FolderViewActions, []string{
|
||||
dashboards.ActionFoldersWrite,
|
||||
@@ -106,7 +110,16 @@ func ProvideFolderPermissions(
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
switch {
|
||||
case func() bool {
|
||||
var errUtilErr errutil.Error
|
||||
return errors.As(err, &errUtilErr)
|
||||
}():
|
||||
return err
|
||||
case errors.Is(err, dashboards.ErrFolderNotFound):
|
||||
return folder.ErrFolderNotFound.Errorf("folder not found")
|
||||
}
|
||||
return ErrFolderUnhandledError.Errorf("unhandled folder error: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -25,8 +25,8 @@ type OpenFeatureConfig struct {
|
||||
URL *url.URL
|
||||
// HTTPClient is a pre-configured HTTP client (optional, used for GOFF provider)
|
||||
HTTPClient *http.Client
|
||||
// StaticFlags are the feature flags to use with static provider
|
||||
StaticFlags map[string]bool
|
||||
// TypedFlags are the feature flags to use with static provider
|
||||
StaticFlags map[string]setting.TypedFeatureFlag
|
||||
// TargetingKey is used for evaluation context
|
||||
TargetingKey string
|
||||
// ContextAttrs are additional attributes for evaluation context
|
||||
@@ -60,7 +60,8 @@ func InitOpenFeature(config OpenFeatureConfig) error {
|
||||
|
||||
// InitOpenFeatureWithCfg initializes OpenFeature from setting.Cfg
|
||||
func InitOpenFeatureWithCfg(cfg *setting.Cfg) error {
|
||||
confFlags, err := setting.ReadFeatureTogglesFromInitFile(cfg.Raw.Section("feature_toggles"))
|
||||
// Read typed flags from config
|
||||
confFlags, err := setting.ReadTypedFeatureTogglesFromInitFile(cfg.Raw.Section("feature_toggles"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read feature flags from config: %w", err)
|
||||
}
|
||||
@@ -96,7 +97,7 @@ func InitOpenFeatureWithCfg(cfg *setting.Cfg) error {
|
||||
func createProvider(
|
||||
providerType string,
|
||||
u *url.URL,
|
||||
staticFlags map[string]bool,
|
||||
staticFlags map[string]setting.TypedFeatureFlag,
|
||||
httpClient *http.Client,
|
||||
) (openfeature.FeatureProvider, error) {
|
||||
if providerType != setting.GOFFProviderType {
|
||||
|
||||
@@ -24,12 +24,12 @@ func CreateStaticEvaluator(cfg *setting.Cfg) (StaticFlagEvaluator, error) {
|
||||
return nil, fmt.Errorf("provider is not a static provider, type %s", setting.StaticProviderType)
|
||||
}
|
||||
|
||||
staticFlags, err := setting.ReadFeatureTogglesFromInitFile(cfg.Raw.Section("feature_toggles"))
|
||||
typedFlags, err := setting.ReadTypedFeatureTogglesFromInitFile(cfg.Raw.Section("feature_toggles"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read feature flags from config: %w", err)
|
||||
}
|
||||
|
||||
staticProvider, err := newStaticProvider(staticFlags)
|
||||
staticProvider, err := newStaticProvider(typedFlags)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create static provider: %w", err)
|
||||
}
|
||||
@@ -56,19 +56,7 @@ type staticEvaluator struct {
|
||||
}
|
||||
|
||||
func (s *staticEvaluator) EvalFlag(ctx context.Context, flagKey string) (goffmodel.OFREPEvaluateSuccessResponse, error) {
|
||||
result, err := s.client.BooleanValueDetails(ctx, flagKey, false, openfeature.TransactionContext(ctx))
|
||||
if err != nil {
|
||||
return goffmodel.OFREPEvaluateSuccessResponse{}, fmt.Errorf("failed to evaluate flag %s: %w", flagKey, err)
|
||||
}
|
||||
resp := goffmodel.OFREPEvaluateSuccessResponse{
|
||||
Key: flagKey,
|
||||
Value: result.Value,
|
||||
Reason: "static provider evaluation result",
|
||||
Variant: result.Variant,
|
||||
Metadata: result.FlagMetadata,
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
return s.evaluateFlagWithTypeDetection(ctx, flagKey)
|
||||
}
|
||||
|
||||
func (s *staticEvaluator) EvalAllFlags(ctx context.Context) (goffmodel.OFREPBulkEvaluateSuccessResponse, error) {
|
||||
@@ -79,24 +67,70 @@ func (s *staticEvaluator) EvalAllFlags(ctx context.Context) (goffmodel.OFREPBulk
|
||||
|
||||
allFlags := make([]goffmodel.OFREPFlagBulkEvaluateSuccessResponse, 0, len(flags))
|
||||
for _, flagKey := range flags {
|
||||
result, err := s.client.BooleanValueDetails(ctx, flagKey, false, openfeature.TransactionContext(ctx))
|
||||
result, err := s.evaluateFlagWithTypeDetection(ctx, flagKey)
|
||||
if err != nil {
|
||||
s.log.Error("failed to evaluate flag during bulk evaluation", "flagKey", flagKey, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
allFlags = append(allFlags, goffmodel.OFREPFlagBulkEvaluateSuccessResponse{
|
||||
OFREPEvaluateSuccessResponse: goffmodel.OFREPEvaluateSuccessResponse{
|
||||
Key: flagKey,
|
||||
Value: result.Value,
|
||||
Reason: "static provider evaluation result",
|
||||
Variant: result.Variant,
|
||||
Metadata: result.FlagMetadata,
|
||||
},
|
||||
ErrorCode: string(result.ErrorCode),
|
||||
ErrorDetails: result.ErrorMessage,
|
||||
OFREPEvaluateSuccessResponse: result,
|
||||
})
|
||||
}
|
||||
|
||||
return goffmodel.OFREPBulkEvaluateSuccessResponse{Flags: allFlags}, nil
|
||||
}
|
||||
|
||||
// evaluateFlagWithTypeDetection tries different flag types and returns the first successful evaluation result
|
||||
func (s *staticEvaluator) evaluateFlagWithTypeDetection(ctx context.Context, flagKey string) (goffmodel.OFREPEvaluateSuccessResponse, error) {
|
||||
// Try boolean evaluation first for backward compatibility
|
||||
result, err := s.client.BooleanValueDetails(ctx, flagKey, false, openfeature.TransactionContext(ctx))
|
||||
if err == nil {
|
||||
return goffmodel.OFREPEvaluateSuccessResponse{
|
||||
Key: flagKey,
|
||||
Value: result.Value,
|
||||
Reason: "static provider evaluation result",
|
||||
Variant: result.Variant,
|
||||
Metadata: result.FlagMetadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// If boolean evaluation fails, try other types
|
||||
s.log.Debug("boolean evaluation failed, trying other types", "flagKey", flagKey, "error", err)
|
||||
|
||||
// Try string evaluation
|
||||
if stringResult, stringErr := s.client.StringValueDetails(ctx, flagKey, "", openfeature.TransactionContext(ctx)); stringErr == nil {
|
||||
return goffmodel.OFREPEvaluateSuccessResponse{
|
||||
Key: flagKey,
|
||||
Value: stringResult.Value,
|
||||
Reason: "static provider evaluation result",
|
||||
Variant: stringResult.Variant,
|
||||
Metadata: stringResult.FlagMetadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Try number evaluation
|
||||
if numberResult, numberErr := s.client.FloatValueDetails(ctx, flagKey, 0.0, openfeature.TransactionContext(ctx)); numberErr == nil {
|
||||
return goffmodel.OFREPEvaluateSuccessResponse{
|
||||
Key: flagKey,
|
||||
Value: numberResult.Value,
|
||||
Reason: "static provider evaluation result",
|
||||
Variant: numberResult.Variant,
|
||||
Metadata: numberResult.FlagMetadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Try object evaluation
|
||||
if objectResult, objectErr := s.client.ObjectValueDetails(ctx, flagKey, map[string]interface{}{}, openfeature.TransactionContext(ctx)); objectErr == nil {
|
||||
return goffmodel.OFREPEvaluateSuccessResponse{
|
||||
Key: flagKey,
|
||||
Value: objectResult.Value,
|
||||
Reason: "static provider evaluation result",
|
||||
Variant: objectResult.Variant,
|
||||
Metadata: objectResult.FlagMetadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// If all evaluations fail, return the original boolean error
|
||||
return goffmodel.OFREPEvaluateSuccessResponse{}, fmt.Errorf("failed to evaluate flag %s: %w", flagKey, err)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package featuremgmt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
|
||||
"github.com/open-feature/go-sdk/openfeature"
|
||||
"github.com/open-feature/go-sdk/openfeature/memprovider"
|
||||
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
// inMemoryBulkProvider is a wrapper around memprovider.InMemoryProvider that
|
||||
@@ -28,28 +33,59 @@ func (p *inMemoryBulkProvider) ListFlags() ([]string, error) {
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func newStaticProvider(confFlags map[string]bool) (openfeature.FeatureProvider, error) {
|
||||
flags := make(map[string]memprovider.InMemoryFlag, len(standardFeatureFlags))
|
||||
// newStaticProvider creates a provider with support for different flag types
|
||||
func newStaticProvider(typedFlags map[string]setting.TypedFeatureFlag) (openfeature.FeatureProvider, error) {
|
||||
flags := make(map[string]memprovider.InMemoryFlag, len(standardFeatureFlags)+len(typedFlags))
|
||||
|
||||
// Add flags from config.ini file
|
||||
for name, value := range confFlags {
|
||||
flags[name] = createInMemoryFlag(name, value)
|
||||
// Add standard flags first (these are always boolean)
|
||||
for _, flag := range standardFeatureFlags {
|
||||
enabled := flag.Expression == "true"
|
||||
flags[flag.Name] = createBooleanFlag(flag.Name, enabled)
|
||||
}
|
||||
|
||||
// Add standard flags
|
||||
for _, flag := range standardFeatureFlags {
|
||||
if _, exists := flags[flag.Name]; !exists {
|
||||
enabled := flag.Expression == "true"
|
||||
flags[flag.Name] = createInMemoryFlag(flag.Name, enabled)
|
||||
}
|
||||
// Add typed flags from config (these can override standard flags)
|
||||
for n, f := range typedFlags {
|
||||
flags[n] = createTypedFlag(n, f.Type, f.Value)
|
||||
}
|
||||
|
||||
return newInMemoryBulkProvider(flags), nil
|
||||
}
|
||||
|
||||
func createInMemoryFlag(name string, enabled bool) memprovider.InMemoryFlag {
|
||||
type FlagType string
|
||||
|
||||
const (
|
||||
FlagTypeBoolean FlagType = "boolean"
|
||||
FlagTypeString FlagType = "string"
|
||||
FlagTypeNumber FlagType = "number" // TODO: check in OFREP spec
|
||||
FlagTypeObject FlagType = "object"
|
||||
)
|
||||
|
||||
// TypedFlag represents a flag with its type and value
|
||||
type TypedFlag struct {
|
||||
Name string
|
||||
Type FlagType
|
||||
Value interface{}
|
||||
}
|
||||
|
||||
func createTypedFlag(name, flagType string, value interface{}) memprovider.InMemoryFlag {
|
||||
switch flagType {
|
||||
case "boolean":
|
||||
return createBooleanFlag(name, value.(bool))
|
||||
case "string":
|
||||
return createStringFlag(name, value.(string))
|
||||
case "number":
|
||||
return createNumberFlag(name, value.(float64))
|
||||
case "object":
|
||||
return createObjectFlag(name, value.(map[string]interface{}))
|
||||
default:
|
||||
// Default to boolean for backward compatibility
|
||||
return createBooleanFlag(name, false)
|
||||
}
|
||||
}
|
||||
|
||||
func createBooleanFlag(name string, value bool) memprovider.InMemoryFlag {
|
||||
variant := "disabled"
|
||||
if enabled {
|
||||
if value {
|
||||
variant = "enabled"
|
||||
}
|
||||
|
||||
@@ -62,3 +98,55 @@ func createInMemoryFlag(name string, enabled bool) memprovider.InMemoryFlag {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createStringFlag(name string, value string) memprovider.InMemoryFlag {
|
||||
return memprovider.InMemoryFlag{
|
||||
Key: name,
|
||||
DefaultVariant: "default",
|
||||
Variants: map[string]interface{}{
|
||||
"default": value,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createNumberFlag(name string, value float64) memprovider.InMemoryFlag {
|
||||
return memprovider.InMemoryFlag{
|
||||
Key: name,
|
||||
DefaultVariant: "default",
|
||||
Variants: map[string]interface{}{
|
||||
"default": value,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createObjectFlag(name string, value map[string]interface{}) memprovider.InMemoryFlag {
|
||||
return memprovider.InMemoryFlag{
|
||||
Key: name,
|
||||
DefaultVariant: "default",
|
||||
Variants: map[string]interface{}{
|
||||
"default": value,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// parseTypedFlagValue attempts to parse a string value into the appropriate type
|
||||
func parseTypedFlagValue(value string) (interface{}, FlagType, error) {
|
||||
// Try to parse as boolean
|
||||
if boolVal, err := strconv.ParseBool(value); err == nil {
|
||||
return boolVal, FlagTypeBoolean, nil
|
||||
}
|
||||
|
||||
// Try to parse as number
|
||||
if numVal, err := strconv.ParseFloat(value, 64); err == nil {
|
||||
return numVal, FlagTypeNumber, nil
|
||||
}
|
||||
|
||||
// Try to parse as JSON object
|
||||
var objVal map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(value), &objVal); err == nil {
|
||||
return objVal, FlagTypeObject, nil
|
||||
}
|
||||
|
||||
// Default to string
|
||||
return value, FlagTypeString, nil
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func Test_StaticProvider(t *testing.T) {
|
||||
t.Run("empty config loads standard flags", func(t *testing.T) {
|
||||
setup(t, []byte(``))
|
||||
// Check for one of the standard flags
|
||||
feat, err := openfeature.NewDefaultClient().BooleanValueDetails(ctx, stFeatName, !stFeatValue, evalCtx)
|
||||
feat, err := openfeature.NewClient("").BooleanValueDetails(ctx, stFeatName, !stFeatValue, evalCtx)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, stFeatValue == feat.Value)
|
||||
})
|
||||
|
||||
@@ -6,8 +6,12 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/contexthandler/ctxkey"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
)
|
||||
|
||||
@@ -18,24 +22,38 @@ func (s *frontendService) contextMiddleware() web.Middleware {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
reqContext := &contextmodel.ReqContext{
|
||||
Context: web.FromContext(ctx),
|
||||
Logger: log.New("context"),
|
||||
}
|
||||
span := trace.SpanFromContext(ctx)
|
||||
ctx = setRequestContext(ctx)
|
||||
|
||||
// inject ReqContext in the context
|
||||
ctx = context.WithValue(ctx, ctxkey.Key{}, reqContext)
|
||||
|
||||
// Set the context for the http.Request.Context
|
||||
// This modifies both r and reqContext.Req since they point to the same value
|
||||
*reqContext.Req = *reqContext.Req.WithContext(ctx)
|
||||
|
||||
traceID := tracing.TraceIDFromContext(ctx, false)
|
||||
if traceID != "" {
|
||||
reqContext.Logger = reqContext.Logger.New("traceID", traceID)
|
||||
}
|
||||
// Preserve the original span so the setRequestContext span doesn't get propagated as a parent of the rest of the request
|
||||
ctx = trace.ContextWithSpan(ctx, span)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func setRequestContext(ctx context.Context) context.Context {
|
||||
ctx, span := tracing.Start(ctx, "setRequestContext")
|
||||
defer span.End()
|
||||
|
||||
reqContext := &contextmodel.ReqContext{
|
||||
Context: web.FromContext(ctx),
|
||||
Logger: log.New("context"),
|
||||
SignedInUser: &user.SignedInUser{},
|
||||
}
|
||||
|
||||
// inject ReqContext in the context
|
||||
ctx = context.WithValue(ctx, ctxkey.Key{}, reqContext)
|
||||
|
||||
// Set the context for the http.Request.Context
|
||||
// This modifies both r and reqContext.Req since they point to the same value
|
||||
*reqContext.Req = *reqContext.Req.WithContext(ctx)
|
||||
|
||||
traceID := tracing.TraceIDFromContext(ctx, false)
|
||||
if traceID != "" {
|
||||
reqContext.Logger = reqContext.Logger.New("traceID", traceID)
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/middleware/requestmeta"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
fswebassets "github.com/grafana/grafana/pkg/services/frontend/webassets"
|
||||
"github.com/grafana/grafana/pkg/services/hooks"
|
||||
"github.com/grafana/grafana/pkg/services/licensing"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
@@ -50,13 +51,13 @@ type frontendService struct {
|
||||
index *IndexProvider
|
||||
}
|
||||
|
||||
func ProvideFrontendService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, promGatherer prometheus.Gatherer, promRegister prometheus.Registerer, license licensing.Licensing) (*frontendService, error) {
|
||||
func ProvideFrontendService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, promGatherer prometheus.Gatherer, promRegister prometheus.Registerer, license licensing.Licensing, hooksService *hooks.HooksService) (*frontendService, error) {
|
||||
assetsManifest, err := fswebassets.GetWebAssets(cfg, license)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
index, err := NewIndexProvider(cfg, assetsManifest)
|
||||
index, err := NewIndexProvider(cfg, assetsManifest, license, hooksService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -11,8 +11,11 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/dtos"
|
||||
"github.com/grafana/grafana/pkg/services/contexthandler"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/hooks"
|
||||
"github.com/grafana/grafana/pkg/services/licensing"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
@@ -24,6 +27,7 @@ func createTestService(t *testing.T, cfg *setting.Cfg) *frontendService {
|
||||
|
||||
features := featuremgmt.WithFeatures()
|
||||
license := &licensing.OSSLicensingService{}
|
||||
hooksService := hooks.ProvideService()
|
||||
|
||||
var promRegister prometheus.Registerer = prometheus.NewRegistry()
|
||||
promGatherer := promRegister.(*prometheus.Registry)
|
||||
@@ -32,7 +36,7 @@ func createTestService(t *testing.T, cfg *setting.Cfg) *frontendService {
|
||||
cfg.BuildVersion = "10.3.0"
|
||||
}
|
||||
|
||||
service, err := ProvideFrontendService(cfg, features, promGatherer, promRegister, license)
|
||||
service, err := ProvideFrontendService(cfg, features, promGatherer, promRegister, license, hooksService)
|
||||
require.NoError(t, err)
|
||||
|
||||
return service
|
||||
@@ -182,3 +186,56 @@ func TestFrontendService_Middleware(t *testing.T) {
|
||||
mux.ServeHTTP(recorder, req)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFrontendService_IndexHooks(t *testing.T) {
|
||||
publicDir := setupTestWebAssets(t)
|
||||
cfg := &setting.Cfg{
|
||||
HTTPPort: "3000",
|
||||
StaticRootPath: publicDir,
|
||||
BuildVersion: "10.3.0",
|
||||
}
|
||||
|
||||
t.Run("should handle hooks that modify buildInfo fields", func(t *testing.T) {
|
||||
service := createTestService(t, cfg)
|
||||
|
||||
// Add a hook that modifies various buildInfo fields
|
||||
service.index.hooksService.AddIndexDataHook(func(indexData *dtos.IndexViewData, req *contextmodel.ReqContext) {
|
||||
indexData.Settings.BuildInfo.Version = "99.99.99"
|
||||
indexData.Settings.BuildInfo.VersionString = "Custom Edition v99.99.99 (custom)"
|
||||
indexData.Settings.BuildInfo.Edition = "custom-edition"
|
||||
})
|
||||
|
||||
mux := web.New()
|
||||
service.addMiddlewares(mux)
|
||||
service.registerRoutes(mux)
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, 200, recorder.Code)
|
||||
body := recorder.Body.String()
|
||||
assert.Contains(t, body, "99.99.99", "Hook should have modified the version")
|
||||
assert.Contains(t, body, "Custom Edition v99.99.99 (custom)", "Hook should have modified the version string")
|
||||
assert.Contains(t, body, "custom-edition", "Hook should have modified the edition")
|
||||
})
|
||||
|
||||
t.Run("should work without any hooks registered", func(t *testing.T) {
|
||||
service := createTestService(t, cfg)
|
||||
|
||||
mux := web.New()
|
||||
service.addMiddlewares(mux)
|
||||
service.registerRoutes(mux)
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, 200, recorder.Code)
|
||||
body := recorder.Body.String()
|
||||
assert.Contains(t, body, "<div id=\"reactRoot\"></div>")
|
||||
// The build version comes from setting.BuildVersion (global), not cfg.BuildVersion
|
||||
// So we just check that the page renders successfully
|
||||
assert.Contains(t, body, "window.grafanaBootData")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package frontend
|
||||
|
||||
import "github.com/grafana/grafana/pkg/setting"
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/api/dtos"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
// This is a copy of dtos.FrontendSettingsDTO with only the fields that the frontend-service
|
||||
// sends, to prevent default values from overriding what comes from the /bootdata call.
|
||||
@@ -20,6 +23,8 @@ type FSFrontendSettings struct {
|
||||
PasswordHint string `json:"passwordHint,omitempty"`
|
||||
AnonymousEnabled bool `json:"anonymousEnabled,omitempty"`
|
||||
|
||||
BuildInfo dtos.FrontendSettingsBuildInfoDTO `json:"buildInfo"`
|
||||
|
||||
GoogleAnalyticsId string `json:"googleAnalyticsId,omitempty"`
|
||||
GoogleAnalytics4Id string `json:"googleAnalytics4Id,omitempty"`
|
||||
GoogleAnalytics4SendManualPageViews bool `json:"GoogleAnalytics4SendManualPageViews,omitempty"`
|
||||
|
||||
@@ -12,13 +12,18 @@ import (
|
||||
"github.com/grafana/grafana-app-sdk/logging"
|
||||
"github.com/grafana/grafana/pkg/api/dtos"
|
||||
"github.com/grafana/grafana/pkg/middleware"
|
||||
"github.com/grafana/grafana/pkg/services/contexthandler"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/hooks"
|
||||
"github.com/grafana/grafana/pkg/services/licensing"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
type IndexProvider struct {
|
||||
log logging.Logger
|
||||
index *template.Template
|
||||
data IndexViewData
|
||||
log logging.Logger
|
||||
index *template.Template
|
||||
data IndexViewData
|
||||
hooksService *hooks.HooksService
|
||||
}
|
||||
|
||||
type IndexViewData struct {
|
||||
@@ -51,12 +56,14 @@ var (
|
||||
htmlTemplates = template.Must(template.New("html").Delims("[[", "]]").ParseFS(templatesFS, `*.html`))
|
||||
)
|
||||
|
||||
func NewIndexProvider(cfg *setting.Cfg, assetsManifest dtos.EntryPointAssets) (*IndexProvider, error) {
|
||||
func NewIndexProvider(cfg *setting.Cfg, assetsManifest dtos.EntryPointAssets, license licensing.Licensing, hooksService *hooks.HooksService) (*IndexProvider, error) {
|
||||
t := htmlTemplates.Lookup("index.html")
|
||||
if t == nil {
|
||||
return nil, fmt.Errorf("missing index template")
|
||||
}
|
||||
|
||||
logger := logging.DefaultLogger.With("logger", "index-provider")
|
||||
|
||||
// subset of frontend settings needed for the login page
|
||||
// TODO what about enterprise settings here?
|
||||
frontendSettings := FSFrontendSettings{
|
||||
@@ -87,13 +94,13 @@ func NewIndexProvider(cfg *setting.Cfg, assetsManifest dtos.EntryPointAssets) (*
|
||||
RudderstackWriteKey: cfg.RudderstackWriteKey,
|
||||
TrustedTypesDefaultPolicyEnabled: (cfg.CSPEnabled && strings.Contains(cfg.CSPTemplate, "require-trusted-types-for")) || (cfg.CSPReportOnlyEnabled && strings.Contains(cfg.CSPReportOnlyTemplate, "require-trusted-types-for")),
|
||||
VerifyEmailEnabled: cfg.VerifyEmailEnabled,
|
||||
BuildInfo: getBuildInfo(license, cfg),
|
||||
}
|
||||
|
||||
defaultUser := dtos.CurrentUser{}
|
||||
|
||||
return &IndexProvider{
|
||||
log: logging.DefaultLogger.With("logger", "index-provider"),
|
||||
index: t,
|
||||
log: logger,
|
||||
index: t,
|
||||
hooksService: hooksService,
|
||||
data: IndexViewData{
|
||||
AppTitle: "Grafana",
|
||||
AppSubUrl: cfg.AppSubURL, // Based on the request?
|
||||
@@ -109,13 +116,13 @@ func NewIndexProvider(cfg *setting.Cfg, assetsManifest dtos.EntryPointAssets) (*
|
||||
|
||||
Assets: assetsManifest,
|
||||
Settings: frontendSettings,
|
||||
DefaultUser: defaultUser,
|
||||
DefaultUser: dtos.CurrentUser{},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *IndexProvider) HandleRequest(writer http.ResponseWriter, request *http.Request) {
|
||||
_, span := tracer.Start(request.Context(), "frontend.index.HandleRequest")
|
||||
ctx, span := tracer.Start(request.Context(), "frontend.index.HandleRequest")
|
||||
defer span.End()
|
||||
|
||||
if request.Method != "GET" {
|
||||
@@ -142,6 +149,9 @@ func (p *IndexProvider) HandleRequest(writer http.ResponseWriter, request *http.
|
||||
writer.Header().Set("Content-Security-Policy-Report-Only", policy)
|
||||
}
|
||||
|
||||
reqCtx := contexthandler.FromContext(ctx)
|
||||
p.runIndexDataHooks(reqCtx, &data)
|
||||
|
||||
writer.Header().Set("Content-Type", "text/html; charset=UTF-8")
|
||||
writer.WriteHeader(200)
|
||||
if err := p.index.Execute(writer, &data); err != nil {
|
||||
@@ -151,3 +161,43 @@ func (p *IndexProvider) HandleRequest(writer http.ResponseWriter, request *http.
|
||||
panic(fmt.Sprintf("Error rendering index\n %s", err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
func (p *IndexProvider) runIndexDataHooks(reqCtx *contextmodel.ReqContext, data *IndexViewData) {
|
||||
// Create a dummy struct to pass to the hooks, and then extract the data back out from it
|
||||
legacyIndexViewData := dtos.IndexViewData{
|
||||
Settings: &dtos.FrontendSettingsDTO{
|
||||
BuildInfo: data.Settings.BuildInfo,
|
||||
},
|
||||
}
|
||||
|
||||
p.hooksService.RunIndexDataHooks(&legacyIndexViewData, reqCtx)
|
||||
|
||||
data.Settings.BuildInfo = legacyIndexViewData.Settings.BuildInfo
|
||||
}
|
||||
|
||||
func getBuildInfo(license licensing.Licensing, cfg *setting.Cfg) dtos.FrontendSettingsBuildInfoDTO {
|
||||
version := setting.BuildVersion
|
||||
commit := setting.BuildCommit
|
||||
commitShort := getShortCommitHash(setting.BuildCommit, 10)
|
||||
buildstamp := setting.BuildStamp
|
||||
versionString := fmt.Sprintf(`%s v%s (%s)`, setting.ApplicationName, version, commitShort)
|
||||
|
||||
buildInfo := dtos.FrontendSettingsBuildInfoDTO{
|
||||
Version: version,
|
||||
VersionString: versionString,
|
||||
Commit: commit,
|
||||
CommitShort: commitShort,
|
||||
Buildstamp: buildstamp,
|
||||
Edition: license.Edition(),
|
||||
Env: cfg.Env,
|
||||
}
|
||||
|
||||
return buildInfo
|
||||
}
|
||||
|
||||
func getShortCommitHash(commitHash string, maxLength int) string {
|
||||
if len(commitHash) > maxLength {
|
||||
return commitHash[:maxLength]
|
||||
}
|
||||
return commitHash
|
||||
}
|
||||
|
||||
@@ -443,7 +443,7 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.Na
|
||||
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingTriage) {
|
||||
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) {
|
||||
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
|
||||
Text: "Triage", SubTitle: "Triage alerts", Id: "alert-triage", Url: s.cfg.AppSubURL + "/alerting/triage", Icon: "medkit",
|
||||
Text: "Triage", SubTitle: "Triage alerts", Id: "alert-triage", Url: s.cfg.AppSubURL + "/alerting/triage", Icon: "medkit", IsNew: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
func EmbeddedContactPointToGrafanaIntegrationConfig(e definitions.EmbeddedContactPoint) (alertingModels.IntegrationConfig, error) {
|
||||
func EmbeddedContactPointToGrafanaIntegrationConfig(e *definitions.EmbeddedContactPoint) (alertingModels.IntegrationConfig, error) {
|
||||
data, err := e.Settings.MarshalJSON()
|
||||
if err != nil {
|
||||
return alertingModels.IntegrationConfig{}, err
|
||||
|
||||
@@ -151,7 +151,7 @@ func (ecp *ContactPointService) CreateContactPoint(
|
||||
contactPoint apimodels.EmbeddedContactPoint,
|
||||
provenance models.Provenance,
|
||||
) (apimodels.EmbeddedContactPoint, error) {
|
||||
if err := ValidateContactPoint(ctx, contactPoint, ecp.encryptionService.GetDecryptedValue); err != nil {
|
||||
if err := ValidateContactPoint(ctx, &contactPoint, ecp.encryptionService.GetDecryptedValue); err != nil {
|
||||
return apimodels.EmbeddedContactPoint{}, fmt.Errorf("%w: %s", ErrValidation, err.Error())
|
||||
}
|
||||
|
||||
@@ -243,14 +243,20 @@ func (ecp *ContactPointService) UpdateContactPoint(ctx context.Context, orgID in
|
||||
if contactPoint.Settings == nil {
|
||||
return fmt.Errorf("%w: %s", ErrValidation, "settings should not be empty")
|
||||
}
|
||||
iType, err := alertingNotify.IntegrationTypeFromString(contactPoint.Type)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", ErrValidation, err.Error())
|
||||
}
|
||||
typeSchema, ok := alertingNotify.GetSchemaVersionForIntegration(iType, schema.V1)
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: failed to get secret keys for contact point type %s", ErrValidation, contactPoint.Type)
|
||||
}
|
||||
|
||||
// patch integration with the secrets from the existing version
|
||||
rawContactPoint, err := ecp.getContactPointDecrypted(ctx, orgID, contactPoint.UID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
typeSchema, ok := alertingNotify.GetSchemaVersionForIntegration(schema.IntegrationType(contactPoint.Type), schema.V1)
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: failed to get secret keys for contact point type %s", ErrValidation, contactPoint.Type)
|
||||
}
|
||||
for _, secretPath := range typeSchema.GetSecretFieldsPaths() {
|
||||
secretKey := secretPath.String()
|
||||
secretValue := contactPoint.Settings.Get(secretKey).MustString()
|
||||
@@ -260,7 +266,7 @@ func (ecp *ContactPointService) UpdateContactPoint(ctx context.Context, orgID in
|
||||
}
|
||||
|
||||
// validate merged values
|
||||
if err := ValidateContactPoint(ctx, contactPoint, ecp.encryptionService.GetDecryptedValue); err != nil {
|
||||
if err := ValidateContactPoint(ctx, &contactPoint, ecp.encryptionService.GetDecryptedValue); err != nil {
|
||||
return fmt.Errorf("%w: %s", ErrValidation, err.Error())
|
||||
}
|
||||
|
||||
@@ -512,7 +518,12 @@ groupLoop:
|
||||
return oldReceiverName, fullRemoval, newReceiverCreated
|
||||
}
|
||||
|
||||
func ValidateContactPoint(ctx context.Context, e apimodels.EmbeddedContactPoint, decryptFunc alertingNotify.GetDecryptedValueFn) error {
|
||||
func ValidateContactPoint(ctx context.Context, e *apimodels.EmbeddedContactPoint, decryptFunc alertingNotify.GetDecryptedValueFn) error {
|
||||
iType, err := alertingNotify.IntegrationTypeFromString(e.Type)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.Type = string(iType)
|
||||
integration, err := EmbeddedContactPointToGrafanaIntegrationConfig(e)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/grafana/alerting/notify"
|
||||
"github.com/grafana/alerting/notify/notifytest"
|
||||
"github.com/grafana/alerting/receivers/schema"
|
||||
"github.com/grafana/alerting/receivers/slack"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -146,6 +147,21 @@ func TestIntegrationContactPointService(t *testing.T) {
|
||||
require.ErrorIs(t, err, ErrValidation)
|
||||
})
|
||||
|
||||
t.Run("create accepts contact point with type in different cases", func(t *testing.T) {
|
||||
sut := createContactPointServiceSut(t, secretsService)
|
||||
newCp := createTestContactPoint()
|
||||
newCp.Type = "Slack"
|
||||
|
||||
created, err := sut.CreateContactPoint(context.Background(), 1, redactedUser, newCp, models.ProvenanceAPI)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, slack.Type, created.Type)
|
||||
|
||||
got, err := sut.GetContactPoints(context.Background(), cpsQueryWithName(1, newCp.Name), redactedUser)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1)
|
||||
assert.EqualValues(t, slack.Type, got[0].Type)
|
||||
})
|
||||
|
||||
t.Run("update rejects contact points with no settings", func(t *testing.T) {
|
||||
sut := createContactPointServiceSut(t, secretsService)
|
||||
newCp := createTestContactPoint()
|
||||
@@ -182,6 +198,22 @@ func TestIntegrationContactPointService(t *testing.T) {
|
||||
require.ErrorIs(t, err, ErrValidation)
|
||||
})
|
||||
|
||||
t.Run("update accepts contact points with type in another case", func(t *testing.T) {
|
||||
sut := createContactPointServiceSut(t, secretsService)
|
||||
newCp := createTestContactPoint()
|
||||
newCp, err := sut.CreateContactPoint(context.Background(), 1, redactedUser, newCp, models.ProvenanceAPI)
|
||||
require.NoError(t, err)
|
||||
newCp.Type = "Slack"
|
||||
|
||||
err = sut.UpdateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := sut.GetContactPoints(context.Background(), cpsQueryWithName(1, newCp.Name), redactedUser)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1)
|
||||
assert.EqualValues(t, slack.Type, got[0].Type)
|
||||
})
|
||||
|
||||
t.Run("update renames references when group is renamed", func(t *testing.T) {
|
||||
cfg := createEncryptedConfig(t, secretsService)
|
||||
store := fakes.NewFakeAlertmanagerConfigStore(cfg)
|
||||
|
||||
@@ -95,7 +95,7 @@ func (config *ReceiverV1) mapToModel(name string) (definitions.EmbeddedContactPo
|
||||
}
|
||||
// As the values are not encrypted when coming from disk files,
|
||||
// we can simply return the fallback for validation.
|
||||
err := provisioning.ValidateContactPoint(context.Background(), cp, func(_ context.Context, _ map[string][]byte, _, fallback string) string {
|
||||
err := provisioning.ValidateContactPoint(context.Background(), &cp, func(_ context.Context, _ map[string][]byte, _, fallback string) string {
|
||||
return fallback
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
@@ -45,3 +46,78 @@ func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[str
|
||||
}
|
||||
return featureToggles, nil
|
||||
}
|
||||
|
||||
// TypedFeatureFlag represents a flag with its type and value
|
||||
type TypedFeatureFlag struct {
|
||||
Type string `json:"type"`
|
||||
Value interface{} `json:"value"`
|
||||
}
|
||||
|
||||
// ReadTypedFeatureTogglesFromInitFile reads feature flags with support for different types
|
||||
func ReadTypedFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[string]TypedFeatureFlag, error) {
|
||||
typedFlags := make(map[string]TypedFeatureFlag, 10)
|
||||
|
||||
// parse the comma separated list of values in `enable` key
|
||||
featuresTogglesStr := valueAsString(featureTogglesSection, "enable", "")
|
||||
for _, feature := range util.SplitString(featuresTogglesStr) {
|
||||
typedFlags[feature] = TypedFeatureFlag{
|
||||
Type: "boolean",
|
||||
Value: true,
|
||||
}
|
||||
}
|
||||
|
||||
// read all the other keys under [feature_toggles] section
|
||||
for _, v := range featureTogglesSection.Keys() {
|
||||
if v.Name() == "enable" {
|
||||
continue
|
||||
}
|
||||
|
||||
value := v.Value()
|
||||
|
||||
// try to determine the type of flag value
|
||||
flagType, parsedValue, err := parseTypedFlagValue(value)
|
||||
if err != nil {
|
||||
// upon failure, default to boolean for backward compatibility
|
||||
if boolVal, boolErr := strconv.ParseBool(value); boolErr == nil {
|
||||
typedFlags[v.Name()] = TypedFeatureFlag{
|
||||
Type: "boolean",
|
||||
Value: boolVal,
|
||||
}
|
||||
} else {
|
||||
// treat as string if even parsing as boolean fails
|
||||
typedFlags[v.Name()] = TypedFeatureFlag{
|
||||
Type: "string",
|
||||
Value: value,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
typedFlags[v.Name()] = TypedFeatureFlag{
|
||||
Type: flagType,
|
||||
Value: parsedValue,
|
||||
}
|
||||
}
|
||||
}
|
||||
return typedFlags, nil
|
||||
}
|
||||
|
||||
// parseTypedFlagValue attempts to parse a string value
|
||||
// into the appropriate type - bool, float, object
|
||||
// defaults to string
|
||||
func parseTypedFlagValue(value string) (string, interface{}, error) {
|
||||
if boolVal, err := strconv.ParseBool(value); err == nil {
|
||||
return "boolean", boolVal, nil
|
||||
}
|
||||
|
||||
// TODO: probably int is needed as well
|
||||
|
||||
if numVal, err := strconv.ParseFloat(value, 64); err == nil {
|
||||
return "number", numVal, nil
|
||||
}
|
||||
|
||||
var objVal map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(value), &objVal); err == nil {
|
||||
return "object", objVal, nil
|
||||
}
|
||||
|
||||
return "string", value, nil
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ func TestIntegrationTeamBindings(t *testing.T) {
|
||||
}
|
||||
|
||||
func doTeamBindingCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHelper, team *unstructured.Unstructured, user *unstructured.Unstructured) {
|
||||
t.Run("should create/update/get team binding using the new APIs", func(t *testing.T) {
|
||||
t.Run("should create/update/get/delete team binding using the new APIs", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
teamBindingClient := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
@@ -137,6 +137,18 @@ func doTeamBindingCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHel
|
||||
require.Equal(t, "member", fetchedSpec["permission"])
|
||||
require.Equal(t, false, fetchedSpec["external"])
|
||||
require.Equal(t, createdUID, fetched.GetName())
|
||||
|
||||
// Delete the team binding
|
||||
err = teamBindingClient.Resource.Delete(ctx, createdUID, metav1.DeleteOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the team binding is deleted
|
||||
_, err = teamBindingClient.Resource.Get(ctx, createdUID, metav1.GetOptions{})
|
||||
require.Error(t, err)
|
||||
var statusErr *errors.StatusError
|
||||
require.ErrorAs(t, err, &statusErr)
|
||||
require.Equal(t, int32(404), statusErr.ErrStatus.Code)
|
||||
require.Contains(t, statusErr.ErrStatus.Message, "not found")
|
||||
})
|
||||
|
||||
t.Run("should not be able to create team binding when using a user with insufficient permissions", func(t *testing.T) {
|
||||
|
||||
@@ -127,7 +127,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported query type: '%s' for query with refID '%s'", q.QueryType, q.RefID)
|
||||
return nil, backend.DownstreamErrorf("unsupported query type: '%s' for query with refID '%s'", q.QueryType, q.RefID)
|
||||
}
|
||||
|
||||
if res != nil {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { Button, Drawer, Stack } from '@grafana/ui';
|
||||
import { Button, Drawer, Stack, Text } from '@grafana/ui';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { ManagerKind } from 'app/features/apiserver/types';
|
||||
import { BulkDeleteProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkDeleteProvisionedResource';
|
||||
@@ -152,7 +152,12 @@ export function BrowseActions({ folderDTO }: Props) {
|
||||
{/* bulk delete */}
|
||||
{showBulkDeleteProvisionedResource && (
|
||||
<Drawer
|
||||
title={t('browse-dashboards.action.bulk-delete-provisioned-resources', 'Bulk Delete Provisioned Resources')}
|
||||
title={
|
||||
// Heading levels should only increase by one (a11y)
|
||||
<Text variant="h3" element="h2">
|
||||
{t('browse-dashboards.action.bulk-delete-provisioned-resources', 'Bulk Delete Provisioned Resources')}
|
||||
</Text>
|
||||
}
|
||||
onClose={() => setShowBulkDeleteProvisionedResource(false)}
|
||||
size="md"
|
||||
>
|
||||
@@ -169,7 +174,12 @@ export function BrowseActions({ folderDTO }: Props) {
|
||||
{/* bulk move */}
|
||||
{showBulkMoveProvisionedResource && (
|
||||
<Drawer
|
||||
title={t('browse-dashboards.action.bulk-move-provisioned-resources', 'Bulk Move Provisioned Resources')}
|
||||
title={
|
||||
// Heading levels should only increase by one (a11y)
|
||||
<Text variant="h3" element="h2">
|
||||
{t('browse-dashboards.action.bulk-move-provisioned-resources', 'Bulk Move Provisioned Resources')}
|
||||
</Text>
|
||||
}
|
||||
onClose={() => setShowBulkMoveProvisionedResource(false)}
|
||||
size="md"
|
||||
>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState } from 'react';
|
||||
import { AppEvents } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { locationService, reportInteraction } from '@grafana/runtime';
|
||||
import { Button, Drawer, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui';
|
||||
import { Button, Drawer, Dropdown, Icon, Menu, MenuItem, Text } from '@grafana/ui';
|
||||
import { Permissions } from 'app/core/components/AccessControl/Permissions';
|
||||
import { appEvents } from 'app/core/core';
|
||||
import { RepoType } from 'app/features/provisioning/Wizard/types';
|
||||
@@ -180,7 +180,11 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props)
|
||||
)}
|
||||
{showDeleteProvisionedFolderDrawer && (
|
||||
<Drawer
|
||||
title={t('browse-dashboards.action.delete-provisioned-folder', 'Delete provisioned folder')}
|
||||
title={
|
||||
<Text variant="h3" element="h2">
|
||||
{t('browse-dashboards.action.delete-provisioned-folder', 'Delete provisioned folder')}
|
||||
</Text>
|
||||
}
|
||||
subtitle={folder.title}
|
||||
onClose={() => setShowDeleteProvisionedFolderDrawer(false)}
|
||||
>
|
||||
@@ -192,7 +196,11 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props)
|
||||
)}
|
||||
{showMoveProvisionedFolderDrawer && (
|
||||
<Drawer
|
||||
title={t('browse-dashboards.action.move-provisioned-folder', 'Move provisioned folder')}
|
||||
title={
|
||||
<Text variant="h3" element="h2">
|
||||
{t('browse-dashboards.action.move-provisioned-folder', 'Move provisioned folder')}
|
||||
</Text>
|
||||
}
|
||||
subtitle={folder.title}
|
||||
onClose={() => setShowMoveProvisionedFolderDrawer(false)}
|
||||
>
|
||||
|
||||
@@ -1,26 +1,46 @@
|
||||
import { Badge } from '@grafana/ui';
|
||||
import { AnnoKeyManagerIdentity, AnnoKeyManagerKind, ManagerKind } from 'app/features/apiserver/types';
|
||||
import { DashboardMeta } from 'app/types/dashboard';
|
||||
import { skipToken } from '@reduxjs/toolkit/query';
|
||||
|
||||
export default function ManagedDashboardNavBarBadge({ meta }: { meta: DashboardMeta }) {
|
||||
const obj = meta.k8s;
|
||||
if (!obj?.annotations) {
|
||||
return;
|
||||
import { t } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Badge } from '@grafana/ui';
|
||||
import { useGetRepositoryQuery } from 'app/api/clients/provisioning/v0alpha1';
|
||||
import { ManagerKind } from 'app/features/apiserver/types';
|
||||
|
||||
import { DashboardScene } from './DashboardScene';
|
||||
|
||||
export const ManagedDashboardNavBarBadge = ({ dashboard }: { dashboard: DashboardScene }) => {
|
||||
const kind = dashboard.getManagerKind();
|
||||
const id = dashboard.getManagerIdentity();
|
||||
|
||||
const shouldSkipQuery = !config.featureToggles.provisioning || kind !== ManagerKind.Repo || !id;
|
||||
const { data: repoData } = useGetRepositoryQuery(shouldSkipQuery ? skipToken : { name: id });
|
||||
|
||||
if (!kind) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let text = 'Provisioned';
|
||||
const kind = obj.annotations?.[AnnoKeyManagerKind];
|
||||
const id = obj.annotations?.[AnnoKeyManagerIdentity];
|
||||
let text;
|
||||
|
||||
switch (kind) {
|
||||
case ManagerKind.Terraform:
|
||||
text = 'Terraform';
|
||||
text = t('dashboard-scene.managed-badge.terraform', 'Managed by: Terraform');
|
||||
break;
|
||||
case ManagerKind.Kubectl:
|
||||
text = 'Kubectl';
|
||||
text = t('dashboard-scene.managed-badge.kubectl', 'Managed by: Kubectl');
|
||||
break;
|
||||
case ManagerKind.Plugin:
|
||||
text = `Plugin: ${id}`;
|
||||
text = t('dashboard-scene.managed-badge.plugin', 'Managed by: Plugin {{id}}', { id });
|
||||
break;
|
||||
case ManagerKind.Repo:
|
||||
text = t('dashboard-scene.managed-badge.repository', 'Managed by: Repository {{title}}', {
|
||||
title: repoData?.spec?.title || id,
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
break;
|
||||
default:
|
||||
console.error('Unknown kind ' + kind);
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Badge color="purple" icon="exchange-alt" tooltip={text} key="provisioned-dashboard-button-badge" />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ import { isLibraryPanel } from '../utils/utils';
|
||||
|
||||
import { DashboardScene } from './DashboardScene';
|
||||
import { GoToSnapshotOriginButton } from './GoToSnapshotOriginButton';
|
||||
import ManagedDashboardNavBarBadge from './ManagedDashboardNavBarBadge';
|
||||
import { ManagedDashboardNavBarBadge } from './ManagedDashboardNavBarBadge';
|
||||
import { LeftActions } from './new-toolbar/LeftActions';
|
||||
import { RightActions } from './new-toolbar/RightActions';
|
||||
import { PublicDashboardBadge } from './new-toolbar/actions/PublicDashboardBadge';
|
||||
@@ -145,7 +145,7 @@ export function ToolbarActions({ dashboard }: Props) {
|
||||
group: 'icon-actions',
|
||||
condition: true,
|
||||
render: () => {
|
||||
return <ManagedDashboardNavBarBadge meta={meta} key="managed-dashboard-badge" />;
|
||||
return <ManagedDashboardNavBarBadge dashboard={dashboard} key="managed-dashboard-badge" />;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { ToolbarButtonRow } from '@grafana/ui';
|
||||
|
||||
import { dynamicDashNavActions } from '../../utils/registerDynamicDashNavAction';
|
||||
import { DashboardScene } from '../DashboardScene';
|
||||
import { ManagedDashboardNavBarBadge } from '../ManagedDashboardNavBarBadge';
|
||||
|
||||
import { ManagedDashboardBadge } from './actions/ManagedDashboardBadge';
|
||||
import { OpenSnapshotOriginButton } from './actions/OpenSnapshotOriginButton';
|
||||
import { PublicDashboardBadge } from './actions/PublicDashboardBadge';
|
||||
import { StarButton } from './actions/StarButton';
|
||||
@@ -40,7 +40,7 @@ export const LeftActions = ({ dashboard }: { dashboard: DashboardScene }) => {
|
||||
},
|
||||
{
|
||||
key: 'managed-dashboard-badge',
|
||||
component: ManagedDashboardBadge,
|
||||
component: ManagedDashboardNavBarBadge,
|
||||
group: 'actions',
|
||||
condition: dashboard.isManaged() && canEdit,
|
||||
},
|
||||
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
import { Badge } from '@grafana/ui';
|
||||
import { AnnoKeyManagerIdentity, AnnoKeyManagerKind, ManagerKind } from 'app/features/apiserver/types';
|
||||
|
||||
import { ToolbarActionProps } from '../types';
|
||||
|
||||
export const ManagedDashboardBadge = ({ dashboard }: ToolbarActionProps) => {
|
||||
if (!dashboard.state.meta.k8s?.annotations) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let text = 'Provisioned';
|
||||
const kind = dashboard.state.meta.k8s.annotations[AnnoKeyManagerKind];
|
||||
const id = dashboard.state.meta.k8s.annotations[AnnoKeyManagerIdentity];
|
||||
|
||||
switch (kind) {
|
||||
case ManagerKind.Terraform:
|
||||
text = 'Terraform';
|
||||
break;
|
||||
case ManagerKind.Kubectl:
|
||||
text = 'Kubectl';
|
||||
break;
|
||||
case ManagerKind.Plugin:
|
||||
text = `Plugin: ${id}`;
|
||||
break;
|
||||
case ManagerKind.Repo:
|
||||
text = 'Repository';
|
||||
break;
|
||||
}
|
||||
|
||||
return <Badge color="purple" icon="exchange-alt" tooltip={text} key="provisioned-dashboard-button-badge" />;
|
||||
};
|
||||
@@ -89,7 +89,7 @@ export const DashboardLibrarySection = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Box borderColor="strong" borderStyle="dashed" padding={4} flex={1}>
|
||||
<Box borderRadius="lg" borderColor="strong" borderStyle="dashed" padding={4} flex={1}>
|
||||
<Stack direction="column" alignItems="center" gap={2}>
|
||||
<Text element="h3" textAlignment="center" weight="medium">
|
||||
<Trans i18nKey="dashboard.empty.start-with-suggested-dashboards">
|
||||
|
||||
@@ -608,7 +608,7 @@ function getLogVolumeFieldConfig(level: LogLevel, oneLevelDetected: boolean) {
|
||||
lineColor: color,
|
||||
pointColor: color,
|
||||
fillColor: color,
|
||||
lineWidth: 1,
|
||||
lineWidth: 0,
|
||||
fillOpacity: 100,
|
||||
stacking: {
|
||||
mode: StackingMode.Normal,
|
||||
|
||||
@@ -47,6 +47,25 @@ describe('pluginImporter', () => {
|
||||
expect(result).toEqual({ ...panelPlugin, meta: { ...panelPlugin } });
|
||||
});
|
||||
|
||||
it('should import a panel plugin returning a Promise<PanelPlugin> successfully', async () => {
|
||||
const spy = jest
|
||||
.spyOn(importPluginModule, 'importPluginModule')
|
||||
.mockResolvedValue({ plugin: Promise.resolve({ ...panelPlugin }) });
|
||||
|
||||
const result = await pluginImporter.importPanel({ ...panelPlugin });
|
||||
|
||||
expect(spy).toHaveBeenCalledWith({
|
||||
path: 'public/plugins/test-plugin/module.js',
|
||||
version: '1.0.0',
|
||||
loadingStrategy: 'fetch',
|
||||
pluginId: 'test-plugin',
|
||||
moduleHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b',
|
||||
translations: { 'en-US': 'public/plugins/test-plugin/locales/en-US/test-plugin.json' },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ...panelPlugin, meta: { ...panelPlugin } });
|
||||
});
|
||||
|
||||
it('should set correct loading strategy', async () => {
|
||||
const spy = jest
|
||||
.spyOn(importPluginModule, 'importPluginModule')
|
||||
|
||||
@@ -48,7 +48,8 @@ const panelPluginPostImport: PostImportStrategy<PanelPlugin, PanelPluginMeta> =
|
||||
const pluginExports = await module;
|
||||
|
||||
if (pluginExports.plugin) {
|
||||
const plugin: PanelPlugin = pluginExports.plugin;
|
||||
// pluginExports.plugin can either be a Promise<PanelPlugin> or a PanelPlugin
|
||||
const plugin: PanelPlugin = await pluginExports.plugin;
|
||||
plugin.meta = meta;
|
||||
pluginsCache.set(meta.id, plugin);
|
||||
return plugin;
|
||||
|
||||
@@ -162,7 +162,7 @@ export function ConfigForm({ data }: ConfigFormProps) {
|
||||
<FormPrompt onDiscard={reset} confirmRedirect={isDirty} />
|
||||
<Stack direction="column" gap={2}>
|
||||
<Field noMargin label={t('provisioning.config-form.label-repository-type', 'Repository type')}>
|
||||
<Input value={getRepositoryTypeConfig(type)?.label || type} disabled />
|
||||
<Input id="repository-type" value={getRepositoryTypeConfig(type)?.label || type} disabled />
|
||||
</Field>
|
||||
<Field
|
||||
noMargin
|
||||
@@ -274,7 +274,7 @@ export function ConfigForm({ data }: ConfigFormProps) {
|
||||
/>
|
||||
</Field>
|
||||
<Field noMargin label={gitFields.pathConfig.label} description={gitFields.pathConfig.description}>
|
||||
<Input {...register('path')} />
|
||||
<Input id="repository-path" {...register('path')} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -153,7 +153,8 @@ export default function GettingStarted({ items }: Props) {
|
||||
<Stack direction="column" gap={6} wrap="wrap">
|
||||
<Stack gap={10} alignItems="center">
|
||||
<div className={styles.imageContainer}>
|
||||
<img src={provisioningSvg} className={styles.image} alt={'Grafana provisioning'} />
|
||||
{/* decorative img, use empty str to skip alt*/}
|
||||
<img src={provisioningSvg} className={styles.image} alt="" />
|
||||
</div>
|
||||
<FeaturesList
|
||||
hasRequiredFeatures={hasRequiredFeatures}
|
||||
|
||||
@@ -79,7 +79,7 @@ export function JobContent({ jobType, job, isFinishedJob = false, onStatusChange
|
||||
<Stack direction="column" alignItems="center">
|
||||
<Stack direction="row" alignItems="center" justifyContent="center" gap={2}>
|
||||
<Spinner size={24} />
|
||||
<Text element="h5" color="secondary">
|
||||
<Text element="h3" variant="h5" color="secondary">
|
||||
{message ?? state ?? t('provisioning.job-status.starting', 'Starting...')}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
@@ -27,7 +27,7 @@ export function RepositoryActions({ repository }: RepositoryActionsProps) {
|
||||
return (
|
||||
<Stack wrap="wrap">
|
||||
{isReadOnlyRepo && <Badge color="darkgrey" text={t('folder-repo.read-only-badge', 'Read only')} />}
|
||||
<StatusBadge repo={repository} />
|
||||
<StatusBadge repo={repository} displayOnly />
|
||||
{repoHref && (
|
||||
<Button variant="secondary" icon={providerIcon} onClick={() => window.open(repoHref, '_blank')}>
|
||||
<Trans i18nKey="provisioning.repository-actions.source-code">Source code</Trans>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { css } from '@emotion/css';
|
||||
import { useRef, useEffect } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
interface ProgressBarProps {
|
||||
@@ -24,7 +25,14 @@ const ProgressBar = ({ progress, topBottomSpacing }: ProgressBarProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div
|
||||
className={styles.container}
|
||||
aria-label={t('provisioning.shared.progress-bar.aria-label', 'Progress Bar')}
|
||||
role="progressbar"
|
||||
aria-valuenow={progress}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<div className={shouldAnimate ? styles.fillerAnimated : styles.filler} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -27,15 +27,17 @@ export function RepositoryTypeCards() {
|
||||
<Stack direction="row" gap={1} wrap>
|
||||
{gitProviders.map((config) => (
|
||||
<Card key={config.type} href={`${CONNECT_URL}/${config.type}`} className={styles.card} noMargin>
|
||||
<Stack gap={2} alignItems="center">
|
||||
<RepoIcon type={config.type} />
|
||||
<Trans
|
||||
i18nKey="provisioning.repository-type-cards.configure-with-provider"
|
||||
values={{ provider: config.label }}
|
||||
>
|
||||
Configure with {'{{ provider }}'}
|
||||
</Trans>
|
||||
</Stack>
|
||||
<Card.Heading>
|
||||
<Stack gap={2} alignItems="center">
|
||||
<RepoIcon type={config.type} />
|
||||
<Trans
|
||||
i18nKey="provisioning.repository-type-cards.configure-with-provider"
|
||||
values={{ provider: config.label }}
|
||||
>
|
||||
Configure with {'{{ provider }}'}
|
||||
</Trans>
|
||||
</Stack>
|
||||
</Card.Heading>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -53,21 +55,23 @@ export function RepositoryTypeCards() {
|
||||
<Stack direction="row" gap={1} wrap>
|
||||
{otherProviders.map((config) => (
|
||||
<Card key={config.type} href={`${CONNECT_URL}/${config.type}`} className={styles.card} noMargin>
|
||||
<Stack gap={2} alignItems="center">
|
||||
<RepoIcon type={config.type} />
|
||||
{config.type === 'local' ? (
|
||||
<Trans i18nKey="provisioning.repository-type-cards.configure-file">
|
||||
Configure file provisioning
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="provisioning.repository-type-cards.configure-with-provider"
|
||||
values={{ provider: config.label }}
|
||||
>
|
||||
Configure with {'{{ provider }}'}
|
||||
</Trans>
|
||||
)}
|
||||
</Stack>
|
||||
<Card.Heading>
|
||||
<Stack gap={2} alignItems="center">
|
||||
<RepoIcon type={config.type} />
|
||||
{config.type === 'local' ? (
|
||||
<Trans i18nKey="provisioning.repository-type-cards.configure-file">
|
||||
Configure file provisioning
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="provisioning.repository-type-cards.configure-with-provider"
|
||||
values={{ provider: config.label }}
|
||||
>
|
||||
Configure with {'{{ provider }}'}
|
||||
</Trans>
|
||||
)}
|
||||
</Stack>
|
||||
</Card.Heading>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -7,9 +7,13 @@ import { PROVISIONING_URL } from '../constants';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
repo?: Repository;
|
||||
displayOnly?: boolean; // if true, disable click action and cursor will be default
|
||||
}
|
||||
|
||||
export function StatusBadge({ repo }: StatusBadgeProps) {
|
||||
/**
|
||||
* @description Displays a status badge for the given provisioned repository.
|
||||
*/
|
||||
export function StatusBadge({ repo, displayOnly = false }: StatusBadgeProps) {
|
||||
if (!repo) {
|
||||
return null;
|
||||
}
|
||||
@@ -86,10 +90,12 @@ export function StatusBadge({ repo }: StatusBadgeProps) {
|
||||
color={color}
|
||||
icon={icon}
|
||||
text={text}
|
||||
style={{ cursor: 'pointer' }}
|
||||
style={{ cursor: displayOnly ? 'default' : 'pointer' }}
|
||||
tooltip={tooltip}
|
||||
onClick={() => {
|
||||
locationService.push(`${PROVISIONING_URL}/${repo.metadata?.name}/?tab=overview`);
|
||||
if (!displayOnly) {
|
||||
locationService.push(`${PROVISIONING_URL}/${repo.metadata?.name}/?tab=overview`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
import { DataQueryResponse, Field, FieldType, QueryResultMetaStat } from '@grafana/data';
|
||||
import { DataQueryResponse, Field, FieldType, QueryResultMetaNotice, QueryResultMetaStat } from '@grafana/data';
|
||||
|
||||
import { cloneQueryResponse, combineResponses } from './mergeResponses';
|
||||
import { getMockFrames } from './mocks/frames';
|
||||
@@ -76,6 +76,7 @@ describe('combineResponses', () => {
|
||||
custom: {
|
||||
frameType: 'LabeledTimeValues',
|
||||
},
|
||||
notices: [],
|
||||
stats: [
|
||||
{
|
||||
displayName: 'Summary: total bytes processed',
|
||||
@@ -154,6 +155,7 @@ describe('combineResponses', () => {
|
||||
custom: {
|
||||
frameType: 'LabeledTimeValues',
|
||||
},
|
||||
notices: [],
|
||||
stats: [
|
||||
{
|
||||
displayName: 'Summary: total bytes processed',
|
||||
@@ -199,6 +201,7 @@ describe('combineResponses', () => {
|
||||
length: 4,
|
||||
meta: {
|
||||
type: 'timeseries-multi',
|
||||
notices: [],
|
||||
stats: [
|
||||
{
|
||||
displayName: 'Summary: total bytes processed',
|
||||
@@ -244,6 +247,7 @@ describe('combineResponses', () => {
|
||||
length: 4,
|
||||
meta: {
|
||||
type: 'timeseries-multi',
|
||||
notices: [],
|
||||
stats: [
|
||||
{
|
||||
displayName: 'Summary: total bytes processed',
|
||||
@@ -424,6 +428,7 @@ describe('combineResponses', () => {
|
||||
custom: {
|
||||
frameType: 'LabeledTimeValues',
|
||||
},
|
||||
notices: [],
|
||||
stats: [
|
||||
{
|
||||
displayName: 'Summary: total bytes processed',
|
||||
@@ -499,6 +504,7 @@ describe('combineResponses', () => {
|
||||
custom: {
|
||||
frameType: 'LabeledTimeValues',
|
||||
},
|
||||
notices: [],
|
||||
stats: [
|
||||
{
|
||||
displayName: 'Summary: total bytes processed',
|
||||
@@ -574,6 +580,7 @@ describe('combineResponses', () => {
|
||||
custom: {
|
||||
frameType: 'LabeledTimeValues',
|
||||
},
|
||||
notices: [],
|
||||
stats: [
|
||||
{
|
||||
displayName: 'Summary: total bytes processed',
|
||||
@@ -639,6 +646,84 @@ describe('combineResponses', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('combine notices', () => {
|
||||
const { metricFrameA } = getMockFrames();
|
||||
const makeResponse = (notices?: QueryResultMetaNotice[]): DataQueryResponse => ({
|
||||
data: [
|
||||
{
|
||||
...metricFrameA,
|
||||
meta: {
|
||||
...metricFrameA.meta,
|
||||
notices,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it('combines notices from both frames', () => {
|
||||
const responseA = makeResponse([{ severity: 'warning', text: 'Warning from frame A' }]);
|
||||
const responseB = makeResponse([{ severity: 'info', text: 'Info from frame B' }]);
|
||||
|
||||
expect(combineResponses(responseA, responseB).data[0].meta?.notices).toStrictEqual([
|
||||
{ severity: 'warning', text: 'Warning from frame A' },
|
||||
{ severity: 'info', text: 'Info from frame B' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates identical notices', () => {
|
||||
const responseA = makeResponse([{ severity: 'warning', text: 'Same warning' }]);
|
||||
const responseB = makeResponse([{ severity: 'warning', text: 'Same warning' }]);
|
||||
|
||||
expect(combineResponses(responseA, responseB).data[0].meta?.notices).toStrictEqual([
|
||||
{ severity: 'warning', text: 'Same warning' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps notices with same text but different severity', () => {
|
||||
const responseA = makeResponse([{ severity: 'warning', text: 'Message' }]);
|
||||
const responseB = makeResponse([{ severity: 'info', text: 'Message' }]);
|
||||
|
||||
expect(combineResponses(responseA, responseB).data[0].meta?.notices).toStrictEqual([
|
||||
{ severity: 'warning', text: 'Message' },
|
||||
{ severity: 'info', text: 'Message' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles one frame with notices and one without', () => {
|
||||
const responseA = makeResponse([{ severity: 'warning', text: 'Warning message' }]);
|
||||
const responseB = makeResponse();
|
||||
|
||||
expect(combineResponses(responseA, responseB).data[0].meta?.notices).toStrictEqual([
|
||||
{ severity: 'warning', text: 'Warning message' },
|
||||
]);
|
||||
|
||||
expect(combineResponses(responseB, responseA).data[0].meta?.notices).toStrictEqual([
|
||||
{ severity: 'warning', text: 'Warning message' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array when neither frame has notices', () => {
|
||||
const responseA = makeResponse();
|
||||
const responseB = makeResponse();
|
||||
expect(combineResponses(responseA, responseB).data[0].meta?.notices).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('filters out null values from notices arrays', () => {
|
||||
const responseA = makeResponse([
|
||||
{ severity: 'warning', text: 'Valid warning' },
|
||||
null as unknown as QueryResultMetaNotice, // Simulating the bug scenario
|
||||
]);
|
||||
const responseB = makeResponse([{ severity: 'info', text: 'Valid info' }]);
|
||||
|
||||
const result = combineResponses(responseA, responseB).data[0].meta?.notices;
|
||||
expect(result).toStrictEqual([
|
||||
{ severity: 'warning', text: 'Valid warning' },
|
||||
{ severity: 'info', text: 'Valid info' },
|
||||
]);
|
||||
expect(result).not.toContainEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not combine frames with different refId', () => {
|
||||
const { metricFrameA, metricFrameB } = getMockFrames();
|
||||
metricFrameA.refId = 'A';
|
||||
@@ -730,6 +815,7 @@ describe('combineResponses', () => {
|
||||
length: 4,
|
||||
meta: {
|
||||
type: 'timeseries-multi',
|
||||
notices: [],
|
||||
stats: [
|
||||
{
|
||||
displayName: 'Summary: total bytes processed',
|
||||
@@ -796,6 +882,7 @@ describe('combineResponses', () => {
|
||||
length: 4,
|
||||
meta: {
|
||||
type: 'timeseries-multi',
|
||||
notices: [],
|
||||
stats: [
|
||||
{
|
||||
displayName: 'Summary: total bytes processed',
|
||||
@@ -843,6 +930,7 @@ describe('mergeFrames', () => {
|
||||
length: 4,
|
||||
meta: {
|
||||
type: 'timeseries-multi',
|
||||
notices: [],
|
||||
stats: [
|
||||
{
|
||||
displayName: 'Summary: total bytes processed',
|
||||
@@ -892,6 +980,7 @@ describe('mergeFrames', () => {
|
||||
length: 3,
|
||||
meta: {
|
||||
type: 'timeseries-multi',
|
||||
notices: [],
|
||||
stats: [
|
||||
{
|
||||
displayName: 'Summary: total bytes processed',
|
||||
@@ -937,6 +1026,7 @@ describe('mergeFrames', () => {
|
||||
length: 4,
|
||||
meta: {
|
||||
type: 'timeseries-multi',
|
||||
notices: [],
|
||||
stats: [
|
||||
{
|
||||
displayName: 'Summary: total bytes processed',
|
||||
@@ -1016,6 +1106,7 @@ describe('mergeFrames', () => {
|
||||
custom: {
|
||||
frameType: 'LabeledTimeValues',
|
||||
},
|
||||
notices: [],
|
||||
stats: [
|
||||
{
|
||||
displayName: 'Summary: total bytes processed',
|
||||
@@ -1098,6 +1189,7 @@ describe('mergeFrames', () => {
|
||||
custom: {
|
||||
frameType: 'LabeledTimeValues',
|
||||
},
|
||||
notices: [],
|
||||
stats: [
|
||||
{
|
||||
displayName: 'Summary: total bytes processed',
|
||||
@@ -1129,6 +1221,7 @@ describe('mergeFrames', () => {
|
||||
custom: {
|
||||
frameType: 'LabeledTimeValues',
|
||||
},
|
||||
notices: [],
|
||||
stats: [
|
||||
{
|
||||
displayName: 'Summary: total bytes processed',
|
||||
@@ -1208,6 +1301,7 @@ describe('mergeFrames', () => {
|
||||
custom: {
|
||||
frameType: 'LabeledTimeValues',
|
||||
},
|
||||
notices: [],
|
||||
stats: [
|
||||
{
|
||||
displayName: 'Summary: total bytes processed',
|
||||
@@ -1242,6 +1336,7 @@ describe('mergeFrames', () => {
|
||||
custom: {
|
||||
frameType: 'LabeledTimeValues',
|
||||
},
|
||||
notices: [],
|
||||
stats: [{ displayName: 'Summary: total bytes processed', unit: 'decbytes', value: 22 }],
|
||||
},
|
||||
length: 2,
|
||||
@@ -1267,6 +1362,7 @@ describe('mergeFrames', () => {
|
||||
custom: {
|
||||
frameType: 'LabeledTimeValues',
|
||||
},
|
||||
notices: [],
|
||||
stats: [
|
||||
{
|
||||
displayName: 'Summary: total bytes processed',
|
||||
|
||||
@@ -7,24 +7,59 @@ import {
|
||||
Field,
|
||||
FieldType,
|
||||
LoadingState,
|
||||
QueryResultMetaNotice,
|
||||
QueryResultMetaStat,
|
||||
shallowCompare,
|
||||
} from '@grafana/data';
|
||||
|
||||
import { LOADING_FRAME_NAME } from './querySplitting';
|
||||
|
||||
function getFrameKey(frame: DataFrame): string | undefined {
|
||||
// Metric range query data
|
||||
if (frame.meta?.type === DataFrameType.TimeSeriesMulti) {
|
||||
const field = frame.fields.find((f) => f.type === FieldType.number);
|
||||
if (!field) {
|
||||
throw new Error(`Unable to find number field on sharded dataframe!`);
|
||||
}
|
||||
let key = '';
|
||||
if (frame.refId) {
|
||||
key += frame.refId;
|
||||
}
|
||||
if (frame.name) {
|
||||
key += frame.name;
|
||||
}
|
||||
if (field.labels) {
|
||||
key += JSON.stringify(field.labels);
|
||||
}
|
||||
return key !== '' ? key : undefined;
|
||||
}
|
||||
return frame.refId ?? frame.name;
|
||||
}
|
||||
|
||||
export function combineResponses(currentResponse: DataQueryResponse | null, newResponse: DataQueryResponse) {
|
||||
if (!currentResponse) {
|
||||
return cloneQueryResponse(newResponse);
|
||||
}
|
||||
|
||||
newResponse.data.forEach((newFrame) => {
|
||||
const currentFrame = currentResponse.data.find((frame) => shouldCombine(frame, newFrame));
|
||||
if (!currentFrame) {
|
||||
currentResponse.data.push(cloneDataFrame(newFrame));
|
||||
return;
|
||||
const currentResponseLabelsMap = new Map<string, DataFrame>();
|
||||
currentResponse.data.forEach((frame: DataFrame) => {
|
||||
const key = getFrameKey(frame);
|
||||
// It is expected that all frames contain a refId or a name, but since the type allows for it
|
||||
// we need to account for possibly undefined cases.
|
||||
if (key) {
|
||||
currentResponseLabelsMap.set(key, frame);
|
||||
}
|
||||
});
|
||||
|
||||
newResponse.data.forEach((newFrame: DataFrame) => {
|
||||
let currentFrame: DataFrame | undefined = undefined;
|
||||
const key = getFrameKey(newFrame);
|
||||
if (key !== undefined && currentResponseLabelsMap.has(key)) {
|
||||
currentFrame = currentResponseLabelsMap.get(key);
|
||||
mergeFrames(currentFrame!, newFrame);
|
||||
} else {
|
||||
currentResponse.data.push(cloneDataFrame(newFrame));
|
||||
}
|
||||
mergeFrames(currentFrame, newFrame);
|
||||
});
|
||||
|
||||
const mergedErrors = [...(currentResponse.errors ?? []), ...(newResponse.errors ?? [])];
|
||||
@@ -163,6 +198,7 @@ export function mergeFrames(dest: DataFrame, source: DataFrame) {
|
||||
dest.meta = {
|
||||
...dest.meta,
|
||||
stats: getCombinedMetadataStats(dest.meta?.stats ?? [], source.meta?.stats ?? []),
|
||||
notices: getCombinedNotices(dest.meta?.notices ?? [], source.meta?.notices ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -254,6 +290,27 @@ function getCombinedMetadataStats(
|
||||
return stats;
|
||||
}
|
||||
|
||||
function getCombinedNotices(
|
||||
destNotices: QueryResultMetaNotice[],
|
||||
sourceNotices: QueryResultMetaNotice[]
|
||||
): QueryResultMetaNotice[] {
|
||||
// Combine notices from both frames and filter out null/undefined values
|
||||
const allNotices = [...destNotices, ...sourceNotices].filter(
|
||||
(notice): notice is QueryResultMetaNotice => notice != null
|
||||
);
|
||||
|
||||
// Deduplicate notices based on text to avoid showing the same warning twice
|
||||
const uniqueNotices = allNotices.reduce((acc: QueryResultMetaNotice[], notice) => {
|
||||
const exists = acc.some((n) => n.severity === notice.severity && n.text === notice.text);
|
||||
if (!exists) {
|
||||
acc.push(notice);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return uniqueNotices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep clones a DataQueryResponse
|
||||
*/
|
||||
|
||||
@@ -11910,6 +11910,11 @@
|
||||
"next": "Další",
|
||||
"previous": "Předchozí"
|
||||
},
|
||||
"shared": {
|
||||
"progress-bar": {
|
||||
"aria-label": ""
|
||||
}
|
||||
},
|
||||
"sidebar-item": {
|
||||
"label-completed-step": "Dokončený krok",
|
||||
"label-current-step": "Aktuální krok",
|
||||
|
||||
@@ -11810,6 +11810,11 @@
|
||||
"next": "Weiter",
|
||||
"previous": "Zurück"
|
||||
},
|
||||
"shared": {
|
||||
"progress-bar": {
|
||||
"aria-label": ""
|
||||
}
|
||||
},
|
||||
"sidebar-item": {
|
||||
"label-completed-step": "Abgeschlossener Schritt",
|
||||
"label-current-step": "Aktueller Schritt",
|
||||
|
||||
@@ -5986,6 +5986,12 @@
|
||||
"usage-count_one": "Used on {{count}} dashboards",
|
||||
"usage-count_other": "Used on {{count}} dashboards"
|
||||
},
|
||||
"managed-badge": {
|
||||
"kubectl": "Managed by: Kubectl",
|
||||
"plugin": "Managed by: Plugin {{id}}",
|
||||
"repository": "Managed by: Repository {{title}}",
|
||||
"terraform": "Managed by: Terraform"
|
||||
},
|
||||
"move-provisioned-dashboard-form": {
|
||||
"api-error": "Failed to move dashboard",
|
||||
"cancel-action": "Cancel",
|
||||
@@ -11810,6 +11816,11 @@
|
||||
"next": "Next",
|
||||
"previous": "Previous"
|
||||
},
|
||||
"shared": {
|
||||
"progress-bar": {
|
||||
"aria-label": "Progress Bar"
|
||||
}
|
||||
},
|
||||
"sidebar-item": {
|
||||
"label-completed-step": "Completed step",
|
||||
"label-current-step": "Current step",
|
||||
|
||||
@@ -11810,6 +11810,11 @@
|
||||
"next": "Siguiente",
|
||||
"previous": "Anterior"
|
||||
},
|
||||
"shared": {
|
||||
"progress-bar": {
|
||||
"aria-label": ""
|
||||
}
|
||||
},
|
||||
"sidebar-item": {
|
||||
"label-completed-step": "Paso completado",
|
||||
"label-current-step": "Paso actual",
|
||||
|
||||
@@ -11810,6 +11810,11 @@
|
||||
"next": "Suivant",
|
||||
"previous": "Précédent"
|
||||
},
|
||||
"shared": {
|
||||
"progress-bar": {
|
||||
"aria-label": ""
|
||||
}
|
||||
},
|
||||
"sidebar-item": {
|
||||
"label-completed-step": "Étape terminée",
|
||||
"label-current-step": "Étape actuelle",
|
||||
|
||||
@@ -11810,6 +11810,11 @@
|
||||
"next": "Következő",
|
||||
"previous": "Előző"
|
||||
},
|
||||
"shared": {
|
||||
"progress-bar": {
|
||||
"aria-label": ""
|
||||
}
|
||||
},
|
||||
"sidebar-item": {
|
||||
"label-completed-step": "Befejezett lépés",
|
||||
"label-current-step": "Jelenlegi lépés",
|
||||
|
||||
@@ -11760,6 +11760,11 @@
|
||||
"next": "Berikutnya",
|
||||
"previous": "Sebelumnya"
|
||||
},
|
||||
"shared": {
|
||||
"progress-bar": {
|
||||
"aria-label": ""
|
||||
}
|
||||
},
|
||||
"sidebar-item": {
|
||||
"label-completed-step": "Langkah selesai",
|
||||
"label-current-step": "Langkah saat ini",
|
||||
|
||||
@@ -11810,6 +11810,11 @@
|
||||
"next": "Avanti",
|
||||
"previous": "Precedente"
|
||||
},
|
||||
"shared": {
|
||||
"progress-bar": {
|
||||
"aria-label": ""
|
||||
}
|
||||
},
|
||||
"sidebar-item": {
|
||||
"label-completed-step": "Passaggio completato",
|
||||
"label-current-step": "Passaggio corrente",
|
||||
|
||||
@@ -11760,6 +11760,11 @@
|
||||
"next": "次へ",
|
||||
"previous": "前へ"
|
||||
},
|
||||
"shared": {
|
||||
"progress-bar": {
|
||||
"aria-label": ""
|
||||
}
|
||||
},
|
||||
"sidebar-item": {
|
||||
"label-completed-step": "完了したステップ",
|
||||
"label-current-step": "現在のステップ",
|
||||
|
||||
@@ -11760,6 +11760,11 @@
|
||||
"next": "다음",
|
||||
"previous": "이전"
|
||||
},
|
||||
"shared": {
|
||||
"progress-bar": {
|
||||
"aria-label": ""
|
||||
}
|
||||
},
|
||||
"sidebar-item": {
|
||||
"label-completed-step": "완료된 단계",
|
||||
"label-current-step": "현재 단계",
|
||||
|
||||
@@ -11810,6 +11810,11 @@
|
||||
"next": "Volgende",
|
||||
"previous": "Vorige"
|
||||
},
|
||||
"shared": {
|
||||
"progress-bar": {
|
||||
"aria-label": ""
|
||||
}
|
||||
},
|
||||
"sidebar-item": {
|
||||
"label-completed-step": "Voltooide stap",
|
||||
"label-current-step": "Huidige stap",
|
||||
|
||||
@@ -11910,6 +11910,11 @@
|
||||
"next": "Dalej",
|
||||
"previous": "Wstecz"
|
||||
},
|
||||
"shared": {
|
||||
"progress-bar": {
|
||||
"aria-label": ""
|
||||
}
|
||||
},
|
||||
"sidebar-item": {
|
||||
"label-completed-step": "Krok ukończony",
|
||||
"label-current-step": "Aktualny krok",
|
||||
|
||||
@@ -11810,6 +11810,11 @@
|
||||
"next": "Avançar",
|
||||
"previous": "Anterior"
|
||||
},
|
||||
"shared": {
|
||||
"progress-bar": {
|
||||
"aria-label": ""
|
||||
}
|
||||
},
|
||||
"sidebar-item": {
|
||||
"label-completed-step": "Etapa concluída",
|
||||
"label-current-step": "Etapa atual",
|
||||
|
||||
@@ -11810,6 +11810,11 @@
|
||||
"next": "Seguinte",
|
||||
"previous": "Anterior"
|
||||
},
|
||||
"shared": {
|
||||
"progress-bar": {
|
||||
"aria-label": ""
|
||||
}
|
||||
},
|
||||
"sidebar-item": {
|
||||
"label-completed-step": "Passo concluído",
|
||||
"label-current-step": "Passo atual",
|
||||
|
||||
@@ -11910,6 +11910,11 @@
|
||||
"next": "Далее",
|
||||
"previous": "Назад"
|
||||
},
|
||||
"shared": {
|
||||
"progress-bar": {
|
||||
"aria-label": ""
|
||||
}
|
||||
},
|
||||
"sidebar-item": {
|
||||
"label-completed-step": "Выполненный шаг",
|
||||
"label-current-step": "Текущий шаг",
|
||||
|
||||
@@ -11810,6 +11810,11 @@
|
||||
"next": "Nästa",
|
||||
"previous": "Föregående"
|
||||
},
|
||||
"shared": {
|
||||
"progress-bar": {
|
||||
"aria-label": ""
|
||||
}
|
||||
},
|
||||
"sidebar-item": {
|
||||
"label-completed-step": "Slutfört steg",
|
||||
"label-current-step": "Nuvarande steg",
|
||||
|
||||
@@ -11810,6 +11810,11 @@
|
||||
"next": "İleri",
|
||||
"previous": "Önceki"
|
||||
},
|
||||
"shared": {
|
||||
"progress-bar": {
|
||||
"aria-label": ""
|
||||
}
|
||||
},
|
||||
"sidebar-item": {
|
||||
"label-completed-step": "Tamamlanan adım",
|
||||
"label-current-step": "Mevcut adım",
|
||||
|
||||
@@ -11760,6 +11760,11 @@
|
||||
"next": "下一步",
|
||||
"previous": "上一个"
|
||||
},
|
||||
"shared": {
|
||||
"progress-bar": {
|
||||
"aria-label": ""
|
||||
}
|
||||
},
|
||||
"sidebar-item": {
|
||||
"label-completed-step": "已完成的步骤",
|
||||
"label-current-step": "当前步骤",
|
||||
|
||||
@@ -11760,6 +11760,11 @@
|
||||
"next": "繼續",
|
||||
"previous": "上一個"
|
||||
},
|
||||
"shared": {
|
||||
"progress-bar": {
|
||||
"aria-label": ""
|
||||
}
|
||||
},
|
||||
"sidebar-item": {
|
||||
"label-completed-step": "已完成步驟",
|
||||
"label-current-step": "目前步驟",
|
||||
|
||||
@@ -2324,15 +2324,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/ecma402-abstract@npm:2.3.4":
|
||||
version: 2.3.4
|
||||
resolution: "@formatjs/ecma402-abstract@npm:2.3.4"
|
||||
"@formatjs/ecma402-abstract@npm:2.3.6":
|
||||
version: 2.3.6
|
||||
resolution: "@formatjs/ecma402-abstract@npm:2.3.6"
|
||||
dependencies:
|
||||
"@formatjs/fast-memoize": "npm:2.2.7"
|
||||
"@formatjs/intl-localematcher": "npm:0.6.1"
|
||||
"@formatjs/intl-localematcher": "npm:0.6.2"
|
||||
decimal.js: "npm:^10.4.3"
|
||||
tslib: "npm:^2.8.0"
|
||||
checksum: 10/573971ffc291096a4b9fcc80b4708124e89bf2e3ac50e0f78b41eb797e9aa1b842f4dc3665e4467a853c738386821769d9e40408a1d25bc73323a1f057a16cf2
|
||||
checksum: 10/30b1b5cd6b62ba46245f934429936592df5500bc1b089dc92dd49c826757b873dd92c305dcfe370701e4df6b057bf007782113abb9b65db550d73be4961718bc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -2376,13 +2376,13 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-durationformat@npm:^0.7.0":
|
||||
version: 0.7.4
|
||||
resolution: "@formatjs/intl-durationformat@npm:0.7.4"
|
||||
version: 0.7.6
|
||||
resolution: "@formatjs/intl-durationformat@npm:0.7.6"
|
||||
dependencies:
|
||||
"@formatjs/ecma402-abstract": "npm:2.3.4"
|
||||
"@formatjs/intl-localematcher": "npm:0.6.1"
|
||||
"@formatjs/ecma402-abstract": "npm:2.3.6"
|
||||
"@formatjs/intl-localematcher": "npm:0.6.2"
|
||||
tslib: "npm:^2.8.0"
|
||||
checksum: 10/d62273ecd635475ca91e9b501301f3f396403fa91b584c550734b19b2d194ba1316b27303fed985c1d42ae933d54eb220da6540edfdf376b0d9371ecfd0d4e15
|
||||
checksum: 10/442236ba85bcd9cb7296c43a708271fa09f110b1ca9d5899066d00812fc2965eaeaec6b5240be421b80daba62860352131088449ba0fcd2061f671cec6240f0b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -2395,12 +2395,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-localematcher@npm:0.6.1":
|
||||
version: 0.6.1
|
||||
resolution: "@formatjs/intl-localematcher@npm:0.6.1"
|
||||
"@formatjs/intl-localematcher@npm:0.6.2":
|
||||
version: 0.6.2
|
||||
resolution: "@formatjs/intl-localematcher@npm:0.6.2"
|
||||
dependencies:
|
||||
tslib: "npm:^2.8.0"
|
||||
checksum: 10/c7b3bc8395d18670677f207b2fd107561fff5d6394a9b4273c29e0bea920300ec3a2eefead600ebb7761c04a770cada28f78ac059f84d00520bfb57a9db36998
|
||||
checksum: 10/eb12a7f5367bbecdfafc20d7f005559ce840f420e970f425c5213d35e94e86dfe75bde03464971a26494bf8427d4961269db22ecad2834f2a19d888b5d9cc064
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -5820,9 +5820,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"@openfeature/core@npm:^1.9.0":
|
||||
version: 1.9.0
|
||||
resolution: "@openfeature/core@npm:1.9.0"
|
||||
checksum: 10/c6d20edc09053afd99752fe46d8328158680950bca4b86679f67f79249d7226eea127b31fffdc38e26ecb729f2bab5a4a5a7c1db708ae76b7fbbac68cd56f094
|
||||
version: 1.9.1
|
||||
resolution: "@openfeature/core@npm:1.9.1"
|
||||
checksum: 10/6099e16b1b4cd6e3c45c05ab4acd44c9cb4ab501b676ab6f3e77f0be1b56abc7506c5629187381333a02975d315d831e0905582435b356d9676255e72a465899
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -12830,14 +12830,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"chalk@npm:^5.2.0, chalk@npm:^5.3.0, chalk@npm:^5.4.1":
|
||||
version: 5.4.1
|
||||
resolution: "chalk@npm:5.4.1"
|
||||
checksum: 10/29df3ffcdf25656fed6e95962e2ef86d14dfe03cd50e7074b06bad9ffbbf6089adbb40f75c00744d843685c8d008adaf3aed31476780312553caf07fa86e5bc7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"chalk@npm:^5.6.2":
|
||||
"chalk@npm:^5.2.0, chalk@npm:^5.3.0, chalk@npm:^5.4.1, chalk@npm:^5.6.2":
|
||||
version: 5.6.2
|
||||
resolution: "chalk@npm:5.6.2"
|
||||
checksum: 10/1b2f48f6fba1370670d5610f9cd54c391d6ede28f4b7062dd38244ea5768777af72e5be6b74fb6c6d54cb84c4a2dff3f3afa9b7cb5948f7f022cfd3d087989e0
|
||||
@@ -15755,16 +15748,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"enquirer@npm:^2.3.6, enquirer@npm:~2.3.6":
|
||||
version: 2.3.6
|
||||
resolution: "enquirer@npm:2.3.6"
|
||||
dependencies:
|
||||
ansi-colors: "npm:^4.1.1"
|
||||
checksum: 10/751d14f037eb7683997e696fb8d5fe2675e0b0cde91182c128cf598acf3f5bd9005f35f7c2a9109e291140af496ebec237b6dac86067d59a9b44f3688107f426
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"enquirer@npm:^2.4.1":
|
||||
"enquirer@npm:^2.3.6, enquirer@npm:^2.4.1":
|
||||
version: 2.4.1
|
||||
resolution: "enquirer@npm:2.4.1"
|
||||
dependencies:
|
||||
@@ -15774,6 +15758,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"enquirer@npm:~2.3.6":
|
||||
version: 2.3.6
|
||||
resolution: "enquirer@npm:2.3.6"
|
||||
dependencies:
|
||||
ansi-colors: "npm:^4.1.1"
|
||||
checksum: 10/751d14f037eb7683997e696fb8d5fe2675e0b0cde91182c128cf598acf3f5bd9005f35f7c2a9109e291140af496ebec237b6dac86067d59a9b44f3688107f426
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ensure-posix-path@npm:^1.1.0":
|
||||
version: 1.1.1
|
||||
resolution: "ensure-posix-path@npm:1.1.1"
|
||||
@@ -17855,14 +17848,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"get-east-asian-width@npm:^1.0.0":
|
||||
version: 1.3.0
|
||||
resolution: "get-east-asian-width@npm:1.3.0"
|
||||
checksum: 10/8e8e779eb28701db7fdb1c8cab879e39e6ae23f52dadd89c8aed05869671cee611a65d4f8557b83e981428623247d8bc5d0c7a4ef3ea7a41d826e73600112ad8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"get-east-asian-width@npm:^1.3.0":
|
||||
"get-east-asian-width@npm:^1.0.0, get-east-asian-width@npm:^1.3.0":
|
||||
version: 1.4.0
|
||||
resolution: "get-east-asian-width@npm:1.4.0"
|
||||
checksum: 10/c9ae85bfc2feaf4cc71cdb236e60f1757ae82281964c206c6aa89a25f1987d326ddd8b0de9f9ccd56e37711b9fcd988f7f5137118b49b0b45e19df93c3be8f45
|
||||
@@ -30478,16 +30464,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"strip-ansi@npm:^7.0.1, strip-ansi@npm:^7.1.0":
|
||||
version: 7.1.0
|
||||
resolution: "strip-ansi@npm:7.1.0"
|
||||
dependencies:
|
||||
ansi-regex: "npm:^6.0.1"
|
||||
checksum: 10/475f53e9c44375d6e72807284024ac5d668ee1d06010740dec0b9744f2ddf47de8d7151f80e5f6190fc8f384e802fdf9504b76a7e9020c9faee7103623338be2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"strip-ansi@npm:^7.1.2":
|
||||
"strip-ansi@npm:^7.0.1, strip-ansi@npm:^7.1.0, strip-ansi@npm:^7.1.2":
|
||||
version: 7.1.2
|
||||
resolution: "strip-ansi@npm:7.1.2"
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user