Compare commits

...

23 Commits

Author SHA1 Message Date
Tania B. 7dafd900bc Add support for other flag types than bool to static provider 2025-10-24 13:24:10 +02:00
Sven Grossmann f03125279a Loki: Fix merging responses would merge null notices (#112920)
* Loki: Fix merging responses would merge `null` notices

* fix tests
2025-10-24 10:28:39 +00:00
Josh Hunt bb6d7d02c7 FS: Call IndexDataHooks for custom version string (#112670)
* Add enterprise hooks

* wip...

* undo

* update wire gen

* remove old hook thing

* move build info into seperate func

* align fs context middleware with grafana, setting SignedInUser

* Call IndexDataHooks to get modified build info

* update tests

* go workspace

* idk, reset workspace files or whatever

* conditionally mount license

* support loading decoupled plugins from cdn

---------

Co-authored-by: Ashley Harrison <ashley.harrison@grafana.com>
2025-10-24 11:04:44 +01:00
Jo 71d10a3fa3 FolderPermissions: Return 404 error when folder does not exist instead of 500 (#112919)
* AccessControl: Improve folder permissions error handling

- Add proper error type handling for folder permission checks
- Convert dashboards.ErrFolderNotFound to folder.ErrFolderNotFound
- Preserve errutil.Error types when returned
- Wrap unhandled errors with new ErrFolderUnhandledError for better error tracking

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update pkg/services/accesscontrol/ossaccesscontrol/folder.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-24 09:50:38 +00:00
Matias Chomicki df4922ea78 mergeResponses: use map to find frames to combine (#112855)
* mergeResponses: use map to find frames to combine

* Remove console
2025-10-24 11:25:02 +02:00
Kevin Minehart e7a49fc472 CI: Windows builds with CGO cross-compiler toolchain (#112922)
* CI: Windows builds with CGO cross-compiler toolchain

* fix comments
2025-10-24 09:23:14 +00:00
Mihai Doarna 4bdee91501 IAM: Implement the delete method for team bindings (#112844)
* implement the delete method for team bindings

* add integration test

* remove team binding search from legacy store
2025-10-24 11:58:13 +03:00
Gilles De Mey 5f9ed73f82 Alerting: Mark triage as new in the navigation (#112887) 2025-10-24 10:54:25 +02:00
grafana-pr-automation[bot] 0fe06800d5 I18n: Download translations from Crowdin (#112911)
New Crowdin translations by GitHub Action

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-24 08:46:41 +00:00
renovate-sh-app[bot] 6f82e44283 chore(deps): update dependency @openfeature/core to v1.9.1 (#112896)
| datasource | package           | from  | to    |
| ---------- | ----------------- | ----- | ----- |
| npm        | @openfeature/core | 1.9.0 | 1.9.1 |

Signed-off-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com>
Co-authored-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com>
2025-10-24 09:41:55 +01:00
Gareth 20a11e0bc0 Tempo error source fix (#112916)
fix tempo error source
2025-10-24 16:58:58 +09:00
Marcus Andersson 52cd4d434f Bugfix: Adding support for resolving panel plugins that expose a Promise<PanelPlugin> (#112899)
Fixed issue where panel plugin module returns an async function that resolves a panel plugin.
2025-10-24 08:31:55 +02:00
Alex Khomenko f7748676b3 Provisioning: Display manager kind (#112831)
* Provisioning: Display manager kind

* Show repo id

* Show repository name
2025-10-24 08:02:08 +03:00
Adam Yeats df305c111e Elasticsearch: Update documentation to state that Elastic Cloud Serverless is not supported (#112898)
Elasticsearch: Update documentation to state Elastic Cloud Serverless is not supported
2025-10-23 22:04:36 +01:00
Larissa Wandzura 64f6bd5348 DOCS: Added a warning about using timezone with macros in MSSQL (#112900)
added warning about using timezone with macros in MSSQL
2025-10-23 16:03:58 -05:00
Juan Cabanas 89ca1dd0e4 DashboardLibrary: Fix border radius box (#112905) 2025-10-23 20:45:09 +00:00
renovate-sh-app[bot] 7ecb057414 chore(deps): update dependency @formatjs/intl-durationformat to v0.7.6 (#112884)
| datasource | package                       | from  | to    |
| ---------- | ----------------------------- | ----- | ----- |
| npm        | @formatjs/intl-durationformat | 0.7.4 | 0.7.6 |

Signed-off-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com>
Co-authored-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com>
2025-10-23 17:06:41 +01:00
Matias Chomicki 34cf970b54 Logs volume: set line width to 0 (#112881) 2025-10-23 17:36:51 +02:00
Yunwen Zheng ddc5ae6f4d Git Sync UI a11y finding fixes (#112751)
* ProgressBar: progressbar nodes must have accsible name fix

* BrowseActions: Bulk move and delete drawwer a11y fix

* FolderActionsButton: Move and delete drawer a11y fix

* ConfigForm: a11y fix missing id

* GettingStarted: Skip img alt since its decorative

* JobContent: heading a11y fix

* StatusBadge: add displayOnly prop to avoid cursor pointer display when its not necessary

* RepositoryTypeCards: Card missing discernible text

* i18n

* input id fix
2025-10-23 11:25:26 -04:00
Gilles De Mey 62c5df36d6 Alerting: Always initialize an empty slice of routes for the routingtree (#112880) 2025-10-23 17:21:13 +02:00
Ashley Harrison c74af4f3d4 Modal: Fix button focus being clipped (#112867)
add padding to modalbuttonrow
2025-10-23 16:18:30 +01:00
Gábor Farkas 87f40c65e4 datasources: forward the x-forwarded-for header (#112863) 2025-10-23 17:14:50 +02:00
Yuri Tseretyan 8b7f119cad Alerting: Provisioning to fix contact point type on save (#112246)
fix contact point type on create\update
2025-10-23 11:11:36 -04:00
79 changed files with 1090 additions and 257 deletions
+9 -2
View File
@@ -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
+13 -4
View File
@@ -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` |
-5
View File
@@ -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,
}),
};
+5 -3
View File
@@ -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)
}
+18 -2
View File
@@ -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 }}
+1
View File
@@ -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)
+14
View File
@@ -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)
@@ -0,0 +1,2 @@
DELETE FROM `grafana`.`team_member`
WHERE uid = 'team-member-1'
@@ -0,0 +1,2 @@
DELETE FROM "grafana"."team_member"
WHERE uid = 'team-member-1'
@@ -0,0 +1,2 @@
DELETE FROM "grafana"."team_member"
WHERE uid = 'team-member-1'
+28 -1
View File
@@ -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) {
+4
View File
@@ -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 {
+7 -2
View File
@@ -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)
+5 -1
View File
@@ -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,
+1 -1
View File
@@ -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
+5 -4
View File
@@ -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 {
+59 -25
View File
@@ -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)
}
+101 -13
View File
@@ -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)
})
+33 -15
View File
@@ -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
}
+3 -2
View File
@@ -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
}
+58 -1
View File
@@ -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")
})
}
+6 -1
View File
@@ -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"`
+60 -10
View File
@@ -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
}
+1 -1
View File
@@ -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,
})
}
}
+1 -1
View File
@@ -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 {
+76
View File
@@ -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) {
+1 -1
View File
@@ -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,
},
@@ -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">
+1 -1
View File
@@ -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
*/
+5
View File
@@ -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",
+5
View File
@@ -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",
+11
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -11760,6 +11760,11 @@
"next": "次へ",
"previous": "前へ"
},
"shared": {
"progress-bar": {
"aria-label": ""
}
},
"sidebar-item": {
"label-completed-step": "完了したステップ",
"label-current-step": "現在のステップ",
+5
View File
@@ -11760,6 +11760,11 @@
"next": "다음",
"previous": "이전"
},
"shared": {
"progress-bar": {
"aria-label": ""
}
},
"sidebar-item": {
"label-completed-step": "완료된 단계",
"label-current-step": "현재 단계",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -11910,6 +11910,11 @@
"next": "Далее",
"previous": "Назад"
},
"shared": {
"progress-bar": {
"aria-label": ""
}
},
"sidebar-item": {
"label-completed-step": "Выполненный шаг",
"label-current-step": "Текущий шаг",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -11760,6 +11760,11 @@
"next": "下一步",
"previous": "上一个"
},
"shared": {
"progress-bar": {
"aria-label": ""
}
},
"sidebar-item": {
"label-completed-step": "已完成的步骤",
"label-current-step": "当前步骤",
+5
View File
@@ -11760,6 +11760,11 @@
"next": "繼續",
"previous": "上一個"
},
"shared": {
"progress-bar": {
"aria-label": ""
}
},
"sidebar-item": {
"label-completed-step": "已完成步驟",
"label-current-step": "目前步驟",
+30 -53
View File
@@ -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: