Merge remote-tracking branch 'origin/main' into resource-store

This commit is contained in:
Ryan McKinley 2024-06-18 07:27:05 +03:00
commit 4cde5bd59f
91 changed files with 2134 additions and 452 deletions

View File

@ -1320,10 +1320,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"public/app/core/services/echo/backends/analytics/ApplicationInsightsBackend.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"public/app/core/services/echo/backends/analytics/RudderstackBackend.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
@ -1646,8 +1642,7 @@ exports[`better eslint`] = {
],
"public/app/features/alerting/unified/components/AlertLabels.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/alerting/unified/components/AnnotationDetailsField.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
@ -2876,6 +2871,9 @@ exports[`better eslint`] = {
"public/app/features/dashboard-scene/saving/shared.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/dashboard-scene/scene/DashboardControls.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],

View File

@ -50,6 +50,8 @@ To edit a feature toggle, follow these steps:
1. Navigate to the list of feature toggles and select your feature state overrides.
1. Click **Save changes** and wait for your Grafana instance to restart with the updated feature toggles.
{{% admonition type="note" %}}
{{< admonition type="note" >}}
If you don't have the feature toggle management page, enable the `featureToggleAdminPage` feature toggle.
Editing feature toggles with the feature toggle management page is available now in all tiers of [Grafana Cloud](/docs/grafana-cloud/).
{{% /admonition %}}
{{< /admonition >}}

View File

@ -101,6 +101,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
| `groupToNestedTableTransformation` | Enables the group to nested table transformation |
| `newPDFRendering` | New implementation for the dashboard-to-PDF rendering |
| `ssoSettingsSAML` | Use the new SSO Settings API to configure the SAML connector |
| `openSearchBackendFlowEnabled` | Enables the backend query flow for Open Search datasource plugin |
## Experimental feature toggles

View File

@ -194,4 +194,5 @@ export interface FeatureToggles {
azureMonitorPrometheusExemplars?: boolean;
pinNavItems?: boolean;
authZGRPCServer?: boolean;
openSearchBackendFlowEnabled?: boolean;
}

View File

@ -17,6 +17,7 @@ export const getSelectStyles = stylesFactory((theme: GrafanaTheme2) => {
option: css({
label: 'grafana-select-option',
padding: '8px',
position: 'relative',
display: 'flex',
alignItems: 'center',
flexDirection: 'row',
@ -64,6 +65,17 @@ export const getSelectStyles = stylesFactory((theme: GrafanaTheme2) => {
}),
optionSelected: css({
background: theme.colors.action.selected,
'&::before': {
backgroundImage: theme.colors.gradients.brandVertical,
borderRadius: theme.shape.radius.default,
content: '" "',
display: 'block',
height: '100%',
position: 'absolute',
transform: 'translateX(-50%)',
width: theme.spacing(0.5),
left: 0,
},
}),
optionDisabled: css({
label: 'grafana-select-option-disabled',

View File

@ -15,7 +15,7 @@ import { TableCellProps, CustomCellRendererProps, TableCellOptions } from './typ
import { getCellColors, getCellOptions } from './utils';
export const DefaultCell = (props: TableCellProps) => {
const { field, cell, tableStyles, row, cellProps, frame, rowStyled, textWrapped, height } = props;
const { field, cell, tableStyles, row, cellProps, frame, rowStyled, rowExpanded, textWrapped, height } = props;
const inspectEnabled = Boolean(field.config.custom?.inspect);
const displayValue = field.display!(cell.value);
@ -60,7 +60,8 @@ export const DefaultCell = (props: TableCellProps) => {
isStringValue,
textShouldWrap,
textWrapped,
rowStyled
rowStyled,
rowExpanded
);
if (isStringValue) {
@ -122,7 +123,8 @@ function getCellStyle(
isStringValue = false,
shouldWrapText = false,
textWrapped = false,
rowStyled = false
rowStyled = false,
rowExpanded = false
) {
// Setup color variables
let textColor: string | undefined = undefined;
@ -145,7 +147,8 @@ function getCellStyle(
isStringValue,
shouldWrapText,
textWrapped,
rowStyled
rowStyled,
rowExpanded
);
}

View File

@ -267,6 +267,7 @@ export const RowsList = (props: RowsListProps) => {
prepareRow(row);
const expandedRowStyle = tableState.expanded[row.id] ? css({ '&:hover': { background: 'inherit' } }) : {};
const rowExpanded = nestedDataField && tableState.expanded[row.id];
if (rowHighlightIndex !== undefined && row.index === rowHighlightIndex) {
style = { ...style, backgroundColor: theme.components.table.rowHoverBackground };
@ -304,7 +305,7 @@ export const RowsList = (props: RowsListProps) => {
onMouseLeave={onRowLeave}
>
{/*add the nested data to the DOM first to prevent a 1px border CSS issue on the last cell of the row*/}
{nestedDataField && tableState.expanded[row.id] && (
{rowExpanded && (
<ExpandedRow
nestedData={nestedDataField}
tableStyles={tableStyles}
@ -326,6 +327,7 @@ export const RowsList = (props: RowsListProps) => {
timeRange={timeRange}
frame={data}
rowStyled={rowBg !== undefined}
rowExpanded={rowExpanded}
textWrapped={textWrapField !== undefined}
height={Number(style.height)}
/>

View File

@ -16,6 +16,7 @@ export interface Props {
userProps?: object;
frame: DataFrame;
rowStyled?: boolean;
rowExpanded?: boolean;
textWrapped?: boolean;
height?: number;
}
@ -28,6 +29,7 @@ export const TableCell = ({
userProps,
frame,
rowStyled,
rowExpanded,
textWrapped,
height,
}: Props) => {
@ -57,6 +59,7 @@ export const TableCell = ({
userProps,
frame,
rowStyled,
rowExpanded,
textWrapped,
height,
})}

View File

@ -19,14 +19,15 @@ export function useTableStyles(theme: GrafanaTheme2, cellHeightOption: TableCell
asCellText?: boolean,
textShouldWrap?: boolean,
textWrapped?: boolean,
rowStyled?: boolean
rowStyled?: boolean,
rowExpanded?: boolean
) => {
return css({
label: overflowOnHover ? 'cellContainerOverflow' : 'cellContainerNoOverflow',
padding: `${cellPadding}px`,
width: '100%',
// Cell height need to account for row border
height: `${rowHeight - 1}px`,
height: rowExpanded ? 'auto !important' : `${rowHeight - 1}px`,
wordBreak: textWrapped ? 'break-all' : 'inherit',
display: 'flex',

View File

@ -1,3 +1,51 @@
# Authorization
This package contains the authorization server implementation.
## Feature toggles
The following feature toggles need to be activated:
```ini
[feature_toggles]
authZGRPCServer = true
grpcServer = true
```
## Configuration
To configure the authorization server and client, use the "authorization" section of the configuration ini file.
The `remote_address` setting, specifies the address where the authorization server is located (ex: `server.example.org:10000`).
The `mode` setting can be set to either `grpc` or `inproc`. When set to `grpc`, the client will connect to the specified address. When set to `inproc` the client will use inprocgrpc (relying on go channels) to wrap a local instantiation of the server.
The `listen` setting determines whether the authorization server should listen for incoming requests. When set to `true`, the authorization service will be registered to the Grafana GRPC server.
The default configuration does not register the authorization service on the Grafana GRPC server and binds the client to it `inproc`:
```ini
[authorization]
remote_address = ""
listen = false
mode = "inproc"
```
### Example
Here is an example to connect the authorization client to a remote grpc server.
```ini
[authorization]
remote_address = "server.example.org:10000"
mode = "grpc"
```
Here is an example to register the authorization service on the Grafana GRPC server and connect the client to it through grpc
```ini
[authorization]
remote_address = "localhost:10000"
listen = true
mode = "grpc"
```

View File

@ -1,10 +1,21 @@
package authz
import (
"context"
"github.com/fullstorydev/grpchan"
"github.com/fullstorydev/grpchan/inprocgrpc"
grpcAuth "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/auth"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
authzv1 "github.com/grafana/authlib/authz/proto/v1"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/grpcserver"
grpcUtils "github.com/grafana/grafana/pkg/services/store/entity/grpc"
"github.com/grafana/grafana/pkg/setting"
)
@ -13,8 +24,10 @@ type Client interface {
}
type LegacyClient struct {
clientV1 authzv1.AuthzServiceClient
}
// ProvideAuthZClient provides an AuthZ client and creates the AuthZ service.
func ProvideAuthZClient(
cfg *setting.Cfg, features featuremgmt.FeatureToggles, acSvc accesscontrol.Service,
grpcServer grpcserver.Provider, tracer tracing.Tracer,
@ -23,11 +36,90 @@ func ProvideAuthZClient(
return nil, nil
}
_, err := newLegacyServer(acSvc, features, grpcServer, tracer)
authCfg, err := ReadCfg(cfg)
if err != nil {
return nil, err
}
// TODO differentiate run local from run remote grpc
return &LegacyClient{}, nil
var client *LegacyClient
// Register the server
server, err := newLegacyServer(acSvc, features, grpcServer, tracer, authCfg)
if err != nil {
return nil, err
}
switch authCfg.mode {
case ModeInProc:
client = newInProcLegacyClient(server)
case ModeGRPC:
client, err = newGrpcLegacyClient(authCfg.remoteAddress)
if err != nil {
return nil, err
}
}
return client, err
}
// ProvideStandaloneAuthZClient provides a standalone AuthZ client, without registering the AuthZ service.
// You need to provide a remote address in the configuration
func ProvideStandaloneAuthZClient(
cfg *setting.Cfg, features featuremgmt.FeatureToggles, tracer tracing.Tracer,
) (Client, error) {
if !features.IsEnabledGlobally(featuremgmt.FlagAuthZGRPCServer) {
return nil, nil
}
authCfg, err := ReadCfg(cfg)
if err != nil {
return nil, err
}
return newGrpcLegacyClient(authCfg.remoteAddress)
}
func newInProcLegacyClient(server *legacyServer) *LegacyClient {
channel := &inprocgrpc.Channel{}
// TODO (gamab): change this once it's clear how to authenticate the client
// Choices are:
// - noAuth given it's in proc and we don't need the user
// - access_token verif only as it's consistent with when it's remote (we check the service is allowed to call the authz service)
// - access_token and id_token ? the id_token being only necessary when the user is trying to access the service straight away
// auth := grpcUtils.ProvideAuthenticator(cfg)
noAuth := func(ctx context.Context) (context.Context, error) {
return ctx, nil
}
channel.RegisterService(
grpchan.InterceptServer(
&authzv1.AuthzService_ServiceDesc,
grpcAuth.UnaryServerInterceptor(noAuth),
grpcAuth.StreamServerInterceptor(noAuth),
),
server,
)
conn := grpchan.InterceptClientConn(channel, grpcUtils.UnaryClientInterceptor, grpcUtils.StreamClientInterceptor)
client := authzv1.NewAuthzServiceClient(conn)
return &LegacyClient{
clientV1: client,
}
}
func newGrpcLegacyClient(address string) (*LegacyClient, error) {
// Create a connection to the gRPC server
conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, err
}
client := authzv1.NewAuthzServiceClient(conn)
return &LegacyClient{
clientV1: client,
}, nil
}

View File

@ -0,0 +1,43 @@
package authz
import (
"fmt"
"github.com/grafana/grafana/pkg/setting"
)
type Mode string
func (s Mode) IsValid() bool {
switch s {
case ModeGRPC, ModeInProc:
return true
}
return false
}
const (
ModeGRPC Mode = "grpc"
ModeInProc Mode = "inproc"
)
type Cfg struct {
remoteAddress string
listen bool
mode Mode
}
func ReadCfg(cfg *setting.Cfg) (*Cfg, error) {
section := cfg.SectionWithEnvOverrides("authorization")
mode := Mode(section.Key("mode").MustString(string(ModeInProc)))
if !mode.IsValid() {
return nil, fmt.Errorf("authorization: invalid mode %q", mode)
}
return &Cfg{
remoteAddress: section.Key("remote_address").MustString(""),
listen: section.Key("listen").MustBool(false),
mode: mode,
}, nil
}

View File

@ -24,7 +24,7 @@ type legacyServer struct {
func newLegacyServer(
acSvc accesscontrol.Service, features featuremgmt.FeatureToggles,
grpcServer grpcserver.Provider, tracer tracing.Tracer,
grpcServer grpcserver.Provider, tracer tracing.Tracer, cfg *Cfg,
) (*legacyServer, error) {
if !features.IsEnabledGlobally(featuremgmt.FlagAuthZGRPCServer) {
return nil, nil
@ -36,7 +36,9 @@ func newLegacyServer(
tracer: tracer,
}
grpcServer.GetServer().RegisterService(&authzv1.AuthzService_ServiceDesc, s)
if cfg.listen {
grpcServer.GetServer().RegisterService(&authzv1.AuthzService_ServiceDesc, s)
}
return s, nil
}

View File

@ -741,7 +741,9 @@ func makeQueryResult(query *dashboards.FindPersistedDashboardsQuery, res []dashb
hit.Tags = append(hit.Tags, item.Term)
}
if item.Deleted != nil {
hit.RemainingTrashAtAge = util.RemainingDaysUntil((*item.Deleted).Add(daysInTrash))
deletedDate := (*item.Deleted).Add(daysInTrash)
hit.IsDeleted = true
hit.PermanentlyDeleteDate = &deletedDate
}
}
return hitList

View File

@ -1317,6 +1317,12 @@ var (
HideFromAdminPage: true,
HideFromDocs: true,
},
{
Name: "openSearchBackendFlowEnabled",
Description: "Enables the backend query flow for Open Search datasource plugin",
Stage: FeatureStagePublicPreview,
Owner: awsDatasourcesSquad,
},
}
)

View File

@ -175,3 +175,4 @@ pluginProxyPreserveTrailingSlash,GA,@grafana/plugins-platform-backend,false,fals
azureMonitorPrometheusExemplars,experimental,@grafana/partner-datasources,false,false,false
pinNavItems,experimental,@grafana/grafana-frontend-platform,false,false,false
authZGRPCServer,experimental,@grafana/identity-access-team,false,false,false
openSearchBackendFlowEnabled,preview,@grafana/aws-datasources,false,false,false

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
175 azureMonitorPrometheusExemplars experimental @grafana/partner-datasources false false false
176 pinNavItems experimental @grafana/grafana-frontend-platform false false false
177 authZGRPCServer experimental @grafana/identity-access-team false false false
178 openSearchBackendFlowEnabled preview @grafana/aws-datasources false false false

View File

@ -710,4 +710,8 @@ const (
// FlagAuthZGRPCServer
// Enables the gRPC server for authorization
FlagAuthZGRPCServer = "authZGRPCServer"
// FlagOpenSearchBackendFlowEnabled
// Enables the backend query flow for Open Search datasource plugin
FlagOpenSearchBackendFlowEnabled = "openSearchBackendFlowEnabled"
)

View File

@ -1599,6 +1599,18 @@
"codeowner": "@grafana/grafana-operator-experience-squad"
}
},
{
"metadata": {
"name": "openSearchBackendFlowEnabled",
"resourceVersion": "1718357852240",
"creationTimestamp": "2024-06-14T09:37:32Z"
},
"spec": {
"description": "Enables the backend query flow for Open Search datasource plugin",
"stage": "preview",
"codeowner": "@grafana/aws-datasources"
}
},
{
"metadata": {
"name": "panelFilterVariable",

View File

@ -29,7 +29,8 @@ import (
)
type Service struct {
cfg *setting.Cfg
cfg *ldap.Config
adminUser string
userService user.Service
authInfoService login.AuthInfoService
ldapGroupsService ldap.Groups
@ -47,7 +48,8 @@ func ProvideService(
sessionService auth.UserTokenService, bundleRegistry supportbundles.Service,
) *Service {
s := &Service{
cfg: cfg,
cfg: ldap.GetLDAPConfig(cfg),
adminUser: cfg.AdminUser,
userService: userService,
authInfoService: authInfoService,
ldapGroupsService: ldapGroupsService,
@ -96,7 +98,7 @@ func ProvideService(
// 403: forbiddenError
// 500: internalServerError
func (s *Service) ReloadLDAPCfg(c *contextmodel.ReqContext) response.Response {
if !s.cfg.LDAPAuthEnabled {
if !s.cfg.Enabled {
return response.Error(http.StatusBadRequest, "LDAP is not enabled", nil)
}
@ -122,7 +124,7 @@ func (s *Service) ReloadLDAPCfg(c *contextmodel.ReqContext) response.Response {
// 403: forbiddenError
// 500: internalServerError
func (s *Service) GetLDAPStatus(c *contextmodel.ReqContext) response.Response {
if !s.cfg.LDAPAuthEnabled {
if !s.cfg.Enabled {
return response.Error(http.StatusBadRequest, "LDAP is not enabled", nil)
}
@ -169,7 +171,7 @@ func (s *Service) GetLDAPStatus(c *contextmodel.ReqContext) response.Response {
// 403: forbiddenError
// 500: internalServerError
func (s *Service) PostSyncUserWithLDAP(c *contextmodel.ReqContext) response.Response {
if !s.cfg.LDAPAuthEnabled {
if !s.cfg.Enabled {
return response.Error(http.StatusBadRequest, "LDAP is not enabled", nil)
}
@ -206,7 +208,7 @@ func (s *Service) PostSyncUserWithLDAP(c *contextmodel.ReqContext) response.Resp
userInfo, _, err := ldapClient.User(usr.Login)
if err != nil {
if errors.Is(err, multildap.ErrDidNotFindUser) { // User was not in the LDAP server - we need to take action:
if s.cfg.AdminUser == usr.Login { // User is *the* Grafana Admin. We cannot disable it.
if s.adminUser == usr.Login { // User is *the* Grafana Admin. We cannot disable it.
errMsg := fmt.Sprintf(`Refusing to sync grafana super admin "%s" - it would be disabled`, usr.Login)
s.log.Error(errMsg)
return response.Error(http.StatusBadRequest, errMsg, err)
@ -250,7 +252,7 @@ func (s *Service) PostSyncUserWithLDAP(c *contextmodel.ReqContext) response.Resp
// 403: forbiddenError
// 500: internalServerError
func (s *Service) GetUserFromLDAP(c *contextmodel.ReqContext) response.Response {
if !s.cfg.LDAPAuthEnabled {
if !s.cfg.Enabled {
return response.Error(http.StatusBadRequest, "LDAP is not enabled", nil)
}
@ -330,8 +332,8 @@ func (s *Service) identityFromLDAPUser(user *login.ExternalUserInfo) *authn.Iden
SyncUser: true,
SyncTeams: true,
EnableUser: true,
SyncOrgRoles: !s.cfg.LDAPSkipOrgRoleSync,
AllowSignUp: s.cfg.LDAPAllowSignup,
SyncOrgRoles: !s.cfg.SkipOrgRoleSync,
AllowSignUp: s.cfg.AllowSignUp,
},
}
}

View File

@ -95,7 +95,7 @@ func TestGetUserFromLDAPAPIEndpoint_UserNotFound(t *testing.T) {
ExpectedClient: &LDAPMock{
UserSearchResult: nil,
},
ExpectedConfig: &ldap.Config{},
ExpectedConfig: &ldap.ServersConfig{},
}
})
@ -160,7 +160,7 @@ func TestGetUserFromLDAPAPIEndpoint_OrgNotfound(t *testing.T) {
UserSearchResult: userSearchResult,
UserSearchConfig: userSearchConfig,
},
ExpectedConfig: &ldap.Config{},
ExpectedConfig: &ldap.ServersConfig{},
}
})
@ -229,7 +229,7 @@ func TestGetUserFromLDAPAPIEndpoint(t *testing.T) {
UserSearchResult: userSearchResult,
UserSearchConfig: userSearchConfig,
},
ExpectedConfig: &ldap.Config{},
ExpectedConfig: &ldap.ServersConfig{},
}
})
@ -314,7 +314,7 @@ func TestGetUserFromLDAPAPIEndpoint_WithTeamHandler(t *testing.T) {
UserSearchResult: userSearchResult,
UserSearchConfig: userSearchConfig,
},
ExpectedConfig: &ldap.Config{},
ExpectedConfig: &ldap.ServersConfig{},
}
})
@ -368,7 +368,7 @@ func TestGetLDAPStatusAPIEndpoint(t *testing.T) {
_, server := setupAPITest(t, func(a *Service) {
a.ldapService = &service.LDAPFakeService{
ExpectedClient: &LDAPMock{},
ExpectedConfig: &ldap.Config{},
ExpectedConfig: &ldap.ServersConfig{},
}
})
@ -407,7 +407,7 @@ func TestPostSyncUserWithLDAPAPIEndpoint_Success(t *testing.T) {
ExpectedClient: &LDAPMock{UserSearchResult: &login.ExternalUserInfo{
Login: "ldap-daniel",
}},
ExpectedConfig: &ldap.Config{},
ExpectedConfig: &ldap.ServersConfig{},
}
})
@ -442,7 +442,7 @@ func TestPostSyncUserWithLDAPAPIEndpoint_WhenUserNotFound(t *testing.T) {
a.userService = userServiceMock
a.ldapService = &service.LDAPFakeService{
ExpectedClient: &LDAPMock{},
ExpectedConfig: &ldap.Config{},
ExpectedConfig: &ldap.ServersConfig{},
}
})
@ -475,10 +475,10 @@ func TestPostSyncUserWithLDAPAPIEndpoint_WhenGrafanaAdmin(t *testing.T) {
_, server := setupAPITest(t, func(a *Service) {
a.userService = userServiceMock
a.cfg.AdminUser = "ldap-daniel"
a.adminUser = "ldap-daniel"
a.ldapService = &service.LDAPFakeService{
ExpectedClient: &LDAPMock{UserSearchError: multildap.ErrDidNotFindUser},
ExpectedConfig: &ldap.Config{},
ExpectedConfig: &ldap.ServersConfig{},
}
})
@ -511,7 +511,7 @@ func TestPostSyncUserWithLDAPAPIEndpoint_WhenUserNotInLDAP(t *testing.T) {
a.authInfoService = &authinfotest.FakeService{ExpectedExternalUser: &login.ExternalUserInfo{IsDisabled: true, UserId: 34}}
a.ldapService = &service.LDAPFakeService{
ExpectedClient: &LDAPMock{UserSearchError: multildap.ErrDidNotFindUser},
ExpectedConfig: &ldap.Config{},
ExpectedConfig: &ldap.ServersConfig{},
}
})
@ -641,12 +641,12 @@ search_base_dns = ["dc=grafana,dc=org"]`)
t.Run(tt.desc, func(t *testing.T) {
_, server := setupAPITest(t, func(a *Service) {
a.userService = &usertest.FakeUserService{ExpectedUser: &user.User{Login: "ldap-daniel", ID: 1}}
a.cfg.LDAPConfigFilePath = ldapConfigFile
a.cfg.ConfigFilePath = ldapConfigFile
a.ldapService = &service.LDAPFakeService{
ExpectedClient: &LDAPMock{UserSearchResult: &login.ExternalUserInfo{
Login: "ldap-daniel",
}},
ExpectedConfig: &ldap.Config{},
ExpectedConfig: &ldap.ServersConfig{},
}
})
// Add minimal setup to pass handler

View File

@ -73,12 +73,12 @@ func (s *Service) supportBundleCollector(context.Context) (*supportbundles.Suppo
bWriter.WriteString("```ini\n")
bWriter.WriteString(fmt.Sprintf("enabled = %v\n", s.cfg.LDAPAuthEnabled))
bWriter.WriteString(fmt.Sprintf("config_file = %s\n", s.cfg.LDAPConfigFilePath))
bWriter.WriteString(fmt.Sprintf("allow_sign_up = %v\n", s.cfg.LDAPAllowSignup))
bWriter.WriteString(fmt.Sprintf("sync_cron = %s\n", s.cfg.LDAPSyncCron))
bWriter.WriteString(fmt.Sprintf("active_sync_enabled = %v\n", s.cfg.LDAPActiveSyncEnabled))
bWriter.WriteString(fmt.Sprintf("skip_org_role_sync = %v\n", s.cfg.LDAPSkipOrgRoleSync))
bWriter.WriteString(fmt.Sprintf("enabled = %v\n", s.cfg.Enabled))
bWriter.WriteString(fmt.Sprintf("config_file = %s\n", s.cfg.ConfigFilePath))
bWriter.WriteString(fmt.Sprintf("allow_sign_up = %v\n", s.cfg.AllowSignUp))
bWriter.WriteString(fmt.Sprintf("sync_cron = %s\n", s.cfg.SyncCron))
bWriter.WriteString(fmt.Sprintf("active_sync_enabled = %v\n", s.cfg.ActiveSyncEnabled))
bWriter.WriteString(fmt.Sprintf("skip_org_role_sync = %v\n", s.cfg.SkipOrgRoleSync))
bWriter.WriteString("```\n\n")

View File

@ -18,7 +18,6 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
@ -45,7 +44,7 @@ type IServer interface {
// Server is basic struct of LDAP authorization
type Server struct {
cfg *setting.Cfg
cfg *Config
Config *ServerConfig
Connection IConnection
log log.Logger
@ -86,7 +85,7 @@ var (
)
// New creates the new LDAP connection
func New(config *ServerConfig, cfg *setting.Cfg) IServer {
func New(config *ServerConfig, cfg *Config) IServer {
return &Server{
Config: config,
cfg: cfg,
@ -414,7 +413,7 @@ func (server *Server) users(logins []string) (
// If there are no ldap group mappings access is true
// otherwise a single group must match
func (server *Server) validateGrafanaUser(user *login.ExternalUserInfo) error {
if !server.cfg.LDAPSkipOrgRoleSync && len(server.Config.Groups) > 0 &&
if !server.cfg.SkipOrgRoleSync && len(server.Config.Groups) > 0 &&
(len(user.OrgRoles) == 0 && (user.IsGrafanaAdmin == nil || !*user.IsGrafanaAdmin)) {
server.log.Warn(
"User does not belong in any of the specified LDAP groups",
@ -499,7 +498,7 @@ func (server *Server) buildGrafanaUser(user *ldap.Entry) (*login.ExternalUserInf
}
// Skipping org role sync
if server.cfg.LDAPSkipOrgRoleSync {
if server.cfg.SkipOrgRoleSync {
server.log.Debug("Skipping organization role mapping.")
return extUser, nil
}

View File

@ -10,7 +10,6 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/setting"
)
var defaultLogin = &login.LoginUserQuery{
@ -31,8 +30,9 @@ func TestServer_Login_UserBind_Fail(t *testing.T) {
}
}
cfg := setting.NewCfg()
cfg.LDAPAuthEnabled = true
cfg := &Config{
Enabled: true,
}
server := &Server{
cfg: cfg,
Config: &ServerConfig{
@ -105,8 +105,9 @@ func TestServer_Login_ValidCredentials(t *testing.T) {
return nil
}
cfg := setting.NewCfg()
cfg.LDAPAuthEnabled = true
cfg := &Config{
Enabled: true,
}
server := &Server{
cfg: cfg,
@ -142,8 +143,9 @@ func TestServer_Login_UnauthenticatedBind(t *testing.T) {
return nil
}
cfg := setting.NewCfg()
cfg.LDAPAuthEnabled = true
cfg := &Config{
Enabled: true,
}
server := &Server{
cfg: cfg,
@ -189,8 +191,9 @@ func TestServer_Login_AuthenticatedBind(t *testing.T) {
return nil
}
cfg := setting.NewCfg()
cfg.LDAPAuthEnabled = true
cfg := &Config{
Enabled: true,
}
server := &Server{
cfg: cfg,
@ -232,8 +235,9 @@ func TestServer_Login_UserWildcardBind(t *testing.T) {
return nil
}
cfg := setting.NewCfg()
cfg.LDAPAuthEnabled = true
cfg := &Config{
Enabled: true,
}
server := &Server{
cfg: cfg,

View File

@ -10,7 +10,6 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/setting"
)
func TestServer_getSearchRequest(t *testing.T) {
@ -53,8 +52,9 @@ func TestServer_getSearchRequest(t *testing.T) {
func TestSerializeUsers(t *testing.T) {
t.Run("simple case", func(t *testing.T) {
cfg := setting.NewCfg()
cfg.LDAPAuthEnabled = true
cfg := &Config{
Enabled: true,
}
server := &Server{
cfg: cfg,
@ -92,8 +92,9 @@ func TestSerializeUsers(t *testing.T) {
})
t.Run("without lastname", func(t *testing.T) {
cfg := setting.NewCfg()
cfg.LDAPAuthEnabled = true
cfg := &Config{
Enabled: true,
}
server := &Server{
cfg: cfg,
@ -129,8 +130,9 @@ func TestSerializeUsers(t *testing.T) {
})
t.Run("mark user without matching group as disabled", func(t *testing.T) {
cfg := setting.NewCfg()
cfg.LDAPAuthEnabled = true
cfg := &Config{
Enabled: true,
}
server := &Server{
cfg: cfg,
@ -163,8 +165,9 @@ func TestSerializeUsers(t *testing.T) {
func TestServer_validateGrafanaUser(t *testing.T) {
t.Run("no group config", func(t *testing.T) {
cfg := setting.NewCfg()
cfg.LDAPAuthEnabled = true
cfg := &Config{
Enabled: true,
}
server := &Server{
cfg: cfg,
@ -183,8 +186,9 @@ func TestServer_validateGrafanaUser(t *testing.T) {
})
t.Run("user in group", func(t *testing.T) {
cfg := setting.NewCfg()
cfg.LDAPAuthEnabled = true
cfg := &Config{
Enabled: true,
}
server := &Server{
cfg: cfg,
@ -210,8 +214,9 @@ func TestServer_validateGrafanaUser(t *testing.T) {
})
t.Run("user not in group", func(t *testing.T) {
cfg := setting.NewCfg()
cfg.LDAPAuthEnabled = true
cfg := &Config{
Enabled: true,
}
server := &Server{
cfg: cfg,

View File

@ -11,7 +11,6 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/setting"
)
const (
@ -55,7 +54,7 @@ func TestNew(t *testing.T) {
result := New(&ServerConfig{
Attr: AttributeMap{},
SearchBaseDNs: []string{"BaseDNHere"},
}, &setting.Cfg{})
}, &Config{})
assert.Implements(t, (*IServer)(nil), result)
}
@ -68,7 +67,7 @@ func TestServer_Dial(t *testing.T) {
ClientCert: "./testdata/parsable.cert",
ClientKey: "./testdata/parsable.pem",
}
server := New(serverConfig, &setting.Cfg{})
server := New(serverConfig, &Config{})
err := server.Dial()
require.Error(t, err)
@ -79,7 +78,7 @@ func TestServer_Dial(t *testing.T) {
serverConfig := &ServerConfig{
RootCACert: "./testdata/invalid.cert",
}
server := New(serverConfig, &setting.Cfg{})
server := New(serverConfig, &Config{})
err := server.Dial()
require.Error(t, err)
@ -90,7 +89,7 @@ func TestServer_Dial(t *testing.T) {
serverConfig := &ServerConfig{
RootCACert: "./testdata/nofile.cert",
}
server := New(serverConfig, &setting.Cfg{})
server := New(serverConfig, &Config{})
err := server.Dial()
require.Error(t, err)
@ -102,7 +101,7 @@ func TestServer_Dial(t *testing.T) {
ClientCert: "./testdata/invalid.cert",
ClientKey: "./testdata/invalid.pem",
}
server := New(serverConfig, &setting.Cfg{})
server := New(serverConfig, &Config{})
err := server.Dial()
require.Error(t, err)
@ -114,7 +113,7 @@ func TestServer_Dial(t *testing.T) {
ClientCert: "./testdata/nofile.cert",
ClientKey: "./testdata/parsable.pem",
}
server := New(serverConfig, &setting.Cfg{})
server := New(serverConfig, &Config{})
err := server.Dial()
require.Error(t, err)
@ -128,7 +127,7 @@ func TestServer_Dial(t *testing.T) {
ClientCertValue: validCert,
ClientKeyValue: validKey,
}
server := New(serverConfig, &setting.Cfg{})
server := New(serverConfig, &Config{})
err := server.Dial()
require.Error(t, err)
@ -139,7 +138,7 @@ func TestServer_Dial(t *testing.T) {
serverConfig := &ServerConfig{
RootCACertValue: []string{"invalid-certificate"},
}
server := New(serverConfig, &setting.Cfg{})
server := New(serverConfig, &Config{})
err := server.Dial()
require.Error(t, err)
@ -150,7 +149,7 @@ func TestServer_Dial(t *testing.T) {
serverConfig := &ServerConfig{
RootCACertValue: []string{"aW52YWxpZC1jZXJ0aWZpY2F0ZQ=="},
}
server := New(serverConfig, &setting.Cfg{})
server := New(serverConfig, &Config{})
err := server.Dial()
require.Error(t, err)
@ -162,7 +161,7 @@ func TestServer_Dial(t *testing.T) {
ClientCertValue: "invalid-certificate",
ClientKeyValue: validKey,
}
server := New(serverConfig, &setting.Cfg{})
server := New(serverConfig, &Config{})
err := server.Dial()
require.Error(t, err)
@ -174,7 +173,7 @@ func TestServer_Dial(t *testing.T) {
ClientCertValue: validCert,
ClientKeyValue: "aW52YWxpZC1rZXk=",
}
server := New(serverConfig, &setting.Cfg{})
server := New(serverConfig, &Config{})
err := server.Dial()
require.Error(t, err)
@ -226,8 +225,9 @@ func TestServer_Users(t *testing.T) {
conn.setSearchResult(&result)
// Set up attribute map without surname and email
cfg := setting.NewCfg()
cfg.LDAPAuthEnabled = true
cfg := &Config{
Enabled: true,
}
server := &Server{
cfg: cfg,
@ -323,7 +323,7 @@ func TestServer_Users(t *testing.T) {
})
server := &Server{
cfg: setting.NewCfg(),
cfg: &Config{},
Config: &ServerConfig{
Attr: AttributeMap{
Username: "username",
@ -370,8 +370,9 @@ func TestServer_Users(t *testing.T) {
}
})
cfg := setting.NewCfg()
cfg.LDAPAuthEnabled = true
cfg := &Config{
Enabled: true,
}
server := &Server{
cfg: cfg,
@ -464,8 +465,9 @@ func TestServer_Users(t *testing.T) {
})
isGrafanaAdmin := true
cfg := setting.NewCfg()
cfg.LDAPAuthEnabled = true
cfg := &Config{
Enabled: true,
}
server := &Server{
cfg: cfg,
@ -506,7 +508,7 @@ func TestServer_Users(t *testing.T) {
require.True(t, res[0].IsDisabled)
})
t.Run("skip org role sync", func(t *testing.T) {
server.cfg.LDAPSkipOrgRoleSync = true
server.cfg.SkipOrgRoleSync = true
res, err := server.Users([]string{"groot"})
require.NoError(t, err)
@ -517,7 +519,7 @@ func TestServer_Users(t *testing.T) {
require.False(t, res[0].IsDisabled)
})
t.Run("sync org role", func(t *testing.T) {
server.cfg.LDAPSkipOrgRoleSync = false
server.cfg.SkipOrgRoleSync = false
res, err := server.Users([]string{"groot"})
require.NoError(t, err)
require.Len(t, res, 1)

View File

@ -7,7 +7,6 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/ldap"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/setting"
)
// GetConfig gets LDAP config
@ -54,12 +53,12 @@ type IMultiLDAP interface {
// MultiLDAP is basic struct of LDAP authorization
type MultiLDAP struct {
configs []*ldap.ServerConfig
cfg *setting.Cfg
cfg *ldap.Config
log log.Logger
}
// New creates the new LDAP auth
func New(configs []*ldap.ServerConfig, cfg *setting.Cfg) IMultiLDAP {
func New(configs []*ldap.ServerConfig, cfg *ldap.Config) IMultiLDAP {
return &MultiLDAP{
configs: configs,
cfg: cfg,

View File

@ -8,7 +8,6 @@ import (
"github.com/grafana/grafana/pkg/services/ldap"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/setting"
//TODO(sh0rez): remove once import cycle resolved
_ "github.com/grafana/grafana/pkg/api/response"
@ -19,7 +18,7 @@ func TestMultiLDAP(t *testing.T) {
t.Run("Should return error for absent config list", func(t *testing.T) {
setup()
multi := New([]*ldap.ServerConfig{}, setting.NewCfg())
multi := New([]*ldap.ServerConfig{}, &ldap.Config{})
_, err := multi.Ping()
require.Error(t, err)
@ -35,7 +34,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{Host: "10.0.0.1", Port: 361},
}, setting.NewCfg())
}, &ldap.Config{})
statuses, err := multi.Ping()
@ -53,7 +52,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{Host: "10.0.0.1", Port: 361},
}, setting.NewCfg())
}, &ldap.Config{})
statuses, err := multi.Ping()
@ -71,7 +70,7 @@ func TestMultiLDAP(t *testing.T) {
t.Run("Should return error for absent config list", func(t *testing.T) {
setup()
multi := New([]*ldap.ServerConfig{}, setting.NewCfg())
multi := New([]*ldap.ServerConfig{}, &ldap.Config{})
_, err := multi.Login(&login.LoginUserQuery{})
require.Error(t, err)
@ -88,7 +87,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
_, err := multi.Login(&login.LoginUserQuery{})
@ -104,7 +103,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
_, err := multi.Login(&login.LoginUserQuery{})
require.Equal(t, 2, mock.dialCalledTimes)
@ -125,7 +124,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
result, err := multi.Login(&login.LoginUserQuery{})
require.Equal(t, 1, mock.dialCalledTimes)
@ -145,7 +144,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
_, err := multi.Login(&login.LoginUserQuery{})
require.Equal(t, 2, mock.dialCalledTimes)
@ -164,7 +163,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
_, err := multi.Login(&login.LoginUserQuery{})
require.Equal(t, 2, mock.dialCalledTimes)
@ -184,7 +183,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
_, err := multi.Login(&login.LoginUserQuery{})
require.Equal(t, 2, mock.dialCalledTimes)
@ -202,7 +201,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
_, err := multi.Login(&login.LoginUserQuery{})
require.Equal(t, 1, mock.dialCalledTimes)
@ -219,7 +218,7 @@ func TestMultiLDAP(t *testing.T) {
t.Run("Should return error for absent config list", func(t *testing.T) {
setup()
multi := New([]*ldap.ServerConfig{}, setting.NewCfg())
multi := New([]*ldap.ServerConfig{}, &ldap.Config{})
_, _, err := multi.User("test")
require.Error(t, err)
@ -236,7 +235,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
_, _, err := multi.User("test")
@ -251,7 +250,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
_, _, err := multi.User("test")
require.Equal(t, 2, mock.dialCalledTimes)
@ -271,7 +270,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
_, _, err := multi.User("test")
require.Equal(t, 1, mock.dialCalledTimes)
@ -298,7 +297,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
user, _, err := multi.User("test")
require.Equal(t, 1, mock.dialCalledTimes)
@ -319,7 +318,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
_, _, err := multi.User("test")
require.Equal(t, 2, mock.dialCalledTimes)
@ -338,7 +337,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
_, err := multi.Users([]string{"test"})
require.Equal(t, 2, mock.dialCalledTimes)
@ -349,7 +348,7 @@ func TestMultiLDAP(t *testing.T) {
t.Run("Should return error for absent config list", func(t *testing.T) {
setup()
multi := New([]*ldap.ServerConfig{}, setting.NewCfg())
multi := New([]*ldap.ServerConfig{}, &ldap.Config{})
_, err := multi.Users([]string{"test"})
require.Error(t, err)
@ -366,7 +365,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
_, err := multi.Users([]string{"test"})
@ -381,7 +380,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
_, err := multi.Users([]string{"test"})
require.Equal(t, 2, mock.dialCalledTimes)
@ -401,7 +400,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
_, err := multi.Users([]string{"test"})
require.Equal(t, 1, mock.dialCalledTimes)
@ -434,7 +433,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
}, setting.NewCfg())
}, &ldap.Config{})
users, err := multi.Users([]string{"test"})
require.Equal(t, 2, mock.dialCalledTimes)
@ -512,7 +511,7 @@ func (mock *mockLDAP) Bind() error {
func setup() *mockLDAP {
mock := &mockLDAP{}
newLDAP = func(config *ldap.ServerConfig, cfg *setting.Cfg) ldap.IServer {
newLDAP = func(config *ldap.ServerConfig, cfg *ldap.Config) ldap.IServer {
return mock
}

View File

@ -7,7 +7,7 @@ import (
)
type LDAPFakeService struct {
ExpectedConfig *ldap.Config
ExpectedConfig *ldap.ServersConfig
ExpectedClient multildap.IMultiLDAP
ExpectedError error
ExpectedUser *login.ExternalUserInfo
@ -22,7 +22,7 @@ func (s *LDAPFakeService) ReloadConfig() error {
return s.ExpectedError
}
func (s *LDAPFakeService) Config() *ldap.Config {
func (s *LDAPFakeService) Config() *ldap.ServersConfig {
return s.ExpectedConfig
}

View File

@ -13,8 +13,8 @@ import (
const defaultTimeout = 10
func readConfig(configFile string) (*ldap.Config, error) {
result := &ldap.Config{}
func readConfig(configFile string) (*ldap.ServersConfig, error) {
result := &ldap.ServersConfig{}
logger.Info("LDAP enabled, reading config file", "file", configFile)

View File

@ -19,7 +19,7 @@ var (
// LDAP is the interface for the LDAP service.
type LDAP interface {
ReloadConfig() error
Config() *ldap.Config
Config() *ldap.ServersConfig
Client() multildap.IMultiLDAP
// Login authenticates the user against the LDAP server.
@ -30,8 +30,8 @@ type LDAP interface {
type LDAPImpl struct {
client multildap.IMultiLDAP
cfg *setting.Cfg
ldapCfg *ldap.Config
cfg *ldap.Config
ldapCfg *ldap.ServersConfig
log log.Logger
// loadingMutex locks the reading of the config so multiple requests for reloading are sequential.
@ -42,7 +42,7 @@ func ProvideService(cfg *setting.Cfg) *LDAPImpl {
s := &LDAPImpl{
client: nil,
ldapCfg: nil,
cfg: cfg,
cfg: ldap.GetLDAPConfig(cfg),
log: log.New("ldap.service"),
loadingMutex: &sync.Mutex{},
}
@ -63,14 +63,14 @@ func ProvideService(cfg *setting.Cfg) *LDAPImpl {
}
func (s *LDAPImpl) ReloadConfig() error {
if !s.cfg.LDAPAuthEnabled {
if !s.cfg.Enabled {
return nil
}
s.loadingMutex.Lock()
defer s.loadingMutex.Unlock()
config, err := readConfig(s.cfg.LDAPConfigFilePath)
config, err := readConfig(s.cfg.ConfigFilePath)
if err != nil {
return err
}
@ -90,12 +90,12 @@ func (s *LDAPImpl) Client() multildap.IMultiLDAP {
return s.client
}
func (s *LDAPImpl) Config() *ldap.Config {
func (s *LDAPImpl) Config() *ldap.ServersConfig {
return s.ldapCfg
}
func (s *LDAPImpl) Login(query *login.LoginUserQuery) (*login.ExternalUserInfo, error) {
if !s.cfg.LDAPAuthEnabled {
if !s.cfg.Enabled {
return nil, ErrLDAPNotEnabled
}
@ -108,7 +108,7 @@ func (s *LDAPImpl) Login(query *login.LoginUserQuery) (*login.ExternalUserInfo,
}
func (s *LDAPImpl) User(username string) (*login.ExternalUserInfo, error) {
if !s.cfg.LDAPAuthEnabled {
if !s.cfg.Enabled {
return nil, ErrLDAPNotEnabled
}

View File

@ -15,8 +15,18 @@ import (
const defaultTimeout = 10
// Config holds list of connections to LDAP
// Config holds parameters from the .ini config file
type Config struct {
Enabled bool
ConfigFilePath string
AllowSignUp bool
SkipOrgRoleSync bool
SyncCron string
ActiveSyncEnabled bool
}
// ServersConfig holds list of connections to LDAP
type ServersConfig struct {
Servers []*ServerConfig `toml:"servers" json:"servers"`
}
@ -83,16 +93,27 @@ var loadingMutex = &sync.Mutex{}
// We need to define in this space so `GetConfig` fn
// could be defined as singleton
var config *Config
var config *ServersConfig
func GetLDAPConfig(cfg *setting.Cfg) *Config {
return &Config{
Enabled: cfg.LDAPAuthEnabled,
ConfigFilePath: cfg.LDAPConfigFilePath,
AllowSignUp: cfg.LDAPAllowSignup,
SkipOrgRoleSync: cfg.LDAPSkipOrgRoleSync,
SyncCron: cfg.LDAPSyncCron,
ActiveSyncEnabled: cfg.LDAPActiveSyncEnabled,
}
}
// GetConfig returns the LDAP config if LDAP is enabled otherwise it returns nil. It returns either cached value of
// the config or it reads it and caches it first.
func GetConfig(cfg *setting.Cfg) (*Config, error) {
func GetConfig(cfg *Config) (*ServersConfig, error) {
if cfg != nil {
if !cfg.LDAPAuthEnabled {
if !cfg.Enabled {
return nil, nil
}
} else if !cfg.LDAPAuthEnabled {
} else if !cfg.Enabled {
return nil, nil
}
@ -104,11 +125,11 @@ func GetConfig(cfg *setting.Cfg) (*Config, error) {
loadingMutex.Lock()
defer loadingMutex.Unlock()
return readConfig(cfg.LDAPConfigFilePath)
return readConfig(cfg.ConfigFilePath)
}
func readConfig(configFile string) (*Config, error) {
result := &Config{}
func readConfig(configFile string) (*ServersConfig, error) {
result := &ServersConfig{}
logger.Info("LDAP enabled, reading config file", "file", configFile)

View File

@ -43,7 +43,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink
}
if s.features.IsEnabled(ctx, featuremgmt.FlagFeatureToggleAdminPage) && hasAccess(ac.EvalPermission(ac.ActionFeatureManagementRead)) {
generalNodeLinks = append(generalNodeLinks, &navtree.NavLink{
Text: "Feature Toggles",
Text: "Feature toggles",
SubTitle: "View and edit feature toggles",
Id: "feature-toggles",
Url: s.cfg.AppSubURL + "/admin/featuretoggles",

View File

@ -408,6 +408,16 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.Na
alertChildNavs = append(alertChildNavs, &navtree.NavLink{Text: "Alert groups", SubTitle: "See grouped alerts from an Alertmanager instance", Id: "groups", Url: s.cfg.AppSubURL + "/alerting/groups", Icon: "layer-group"})
}
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingCentralAlertHistory) {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "History",
SubTitle: "History of events that were generated by your Grafana-managed alert rules. Silences and Mute timings are ignored.",
Id: "alerts-history",
Url: s.cfg.AppSubURL + "/alerting/history",
Icon: "history",
})
}
if c.SignedInUser.GetOrgRole() == org.RoleAdmin {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Settings", Id: "alerting-admin", Url: s.cfg.AppSubURL + "/alerting/admin",

View File

@ -97,8 +97,8 @@ func PrepareAlertStatuses(manager state.AlertInstanceManager, opts AlertStatuses
}
alertResponse.Data.Alerts = append(alertResponse.Data.Alerts, &apimodels.Alert{
Labels: alertState.GetLabels(labelOptions...),
Annotations: alertState.Annotations,
Labels: apimodels.LabelsFromMap(alertState.GetLabels(labelOptions...)),
Annotations: apimodels.LabelsFromMap(alertState.Annotations),
// TODO: or should we make this two fields? Using one field lets the
// frontend use the same logic for parsing text on annotations and this.
@ -444,12 +444,12 @@ func toRuleGroup(log log.Logger, manager state.AlertInstanceManager, groupKey ng
Name: rule.Title,
Query: ruleToQuery(log, rule),
Duration: rule.For.Seconds(),
Annotations: rule.Annotations,
Annotations: apimodels.LabelsFromMap(rule.Annotations),
}
newRule := apimodels.Rule{
Name: rule.Title,
Labels: rule.GetLabels(labelOptions...),
Labels: apimodels.LabelsFromMap(rule.GetLabels(labelOptions...)),
Health: "ok",
Type: rule.Type().String(),
LastEvaluation: time.Time{},
@ -471,8 +471,8 @@ func toRuleGroup(log log.Logger, manager state.AlertInstanceManager, groupKey ng
totals["error"] += 1
}
alert := apimodels.Alert{
Labels: alertState.GetLabels(labelOptions...),
Annotations: alertState.Annotations,
Labels: apimodels.LabelsFromMap(alertState.GetLabels(labelOptions...)),
Annotations: apimodels.LabelsFromMap(alertState.Annotations),
// TODO: or should we make this two fields? Using one field lets the
// frontend use the same logic for parsing text on annotations and this.

View File

@ -9,6 +9,7 @@ import (
"time"
v1 "github.com/prometheus/client_golang/api/prometheus/v1"
promlabels "github.com/prometheus/prometheus/model/labels"
)
// swagger:route GET /prometheus/grafana/api/v1/rules prometheus RouteGetGrafanaRuleStatuses
@ -151,7 +152,7 @@ type AlertingRule struct {
Query string `json:"query,omitempty"`
Duration float64 `json:"duration,omitempty"`
// required: true
Annotations overrideLabels `json:"annotations,omitempty"`
Annotations promlabels.Labels `json:"annotations,omitempty"`
// required: true
ActiveAt *time.Time `json:"activeAt,omitempty"`
Alerts []Alert `json:"alerts,omitempty"`
@ -166,8 +167,8 @@ type Rule struct {
// required: true
Name string `json:"name"`
// required: true
Query string `json:"query"`
Labels overrideLabels `json:"labels,omitempty"`
Query string `json:"query"`
Labels promlabels.Labels `json:"labels,omitempty"`
// required: true
Health string `json:"health"`
LastError string `json:"lastError,omitempty"`
@ -181,9 +182,9 @@ type Rule struct {
// swagger:model
type Alert struct {
// required: true
Labels overrideLabels `json:"labels"`
Labels promlabels.Labels `json:"labels"`
// required: true
Annotations overrideLabels `json:"annotations"`
Annotations promlabels.Labels `json:"annotations"`
// required: true
State string `json:"state"`
ActiveAt *time.Time `json:"activeAt"`
@ -300,31 +301,6 @@ func (by AlertsBy) TopK(alerts []Alert, k int) []Alert {
// is more important than "normal". If two alerts have the same importance
// then the ordering is based on their ActiveAt time and their labels.
func AlertsByImportance(a1, a2 *Alert) bool {
// labelsForComparison concatenates each key/value pair into a string and
// sorts them.
labelsForComparison := func(m map[string]string) []string {
s := make([]string, 0, len(m))
for k, v := range m {
s = append(s, k+v)
}
sort.Strings(s)
return s
}
// compareLabels returns true if labels1 are less than labels2. This happens
// when labels1 has fewer labels than labels2, or if the next label from
// labels1 is lexicographically less than the next label from labels2.
compareLabels := func(labels1, labels2 []string) bool {
if len(labels1) == len(labels2) {
for i := range labels1 {
if labels1[i] != labels2[i] {
return labels1[i] < labels2[i]
}
}
}
return len(labels1) < len(labels2)
}
// The importance of an alert is first based on the importance of their states.
// This ordering is intended to show the most important alerts first when
// using pagination.
@ -345,9 +321,7 @@ func AlertsByImportance(a1, a2 *Alert) bool {
return true
}
// Both alerts are active since the same time so compare their labels
labels1 := labelsForComparison(a1.Labels)
labels2 := labelsForComparison(a2.Labels)
return compareLabels(labels1, labels2)
return promlabels.Compare(a1.Labels, a2.Labels) < 0
}
return importance1 < importance2
@ -362,9 +336,16 @@ func (s AlertsSorter) Len() int { return len(s.alerts) }
func (s AlertsSorter) Swap(i, j int) { s.alerts[i], s.alerts[j] = s.alerts[j], s.alerts[i] }
func (s AlertsSorter) Less(i, j int) bool { return s.by(&s.alerts[i], &s.alerts[j]) }
// override the labels type with a map for generation.
// The custom marshaling for labels.Labels ends up doing this anyways.
type overrideLabels map[string]string
// LabelsFromMap creates Labels from a map. Note the Labels type requires the
// labels be sorted, so we make sure to do that.
func LabelsFromMap(m map[string]string) promlabels.Labels {
sb := promlabels.NewScratchBuilder(len(m))
for k, v := range m {
sb.Add(k, v)
}
sb.Sort()
return sb.Labels()
}
// swagger:parameters RouteGetGrafanaAlertStatuses
type GetGrafanaAlertStatusesParams struct {

View File

@ -21,10 +21,11 @@ func makeAlerts(amount int) []Alert {
alerts := make([]Alert, amount)
for i := 0; i < len(alerts); i++ {
alerts[i].Labels = make(map[string]string)
lbls := make(map[string]string)
for label := 0; label < numLabels; label++ {
alerts[i].Labels[fmt.Sprintf("label_%d", label)] = fmt.Sprintf("label_%d_value_%d", label, i%100)
lbls[fmt.Sprintf("label_%d", label)] = fmt.Sprintf("label_%d_value_%d", label, i%100)
}
alerts[i].Labels = LabelsFromMap(lbls)
if i%100 < percentAlerting {
alerts[i].State = "alerting"

View File

@ -69,32 +69,42 @@ func TestSortAlertsByImportance(t *testing.T) {
}, {
name: "inactive alerts with same importance are ordered by labels",
input: []Alert{
{State: "normal", Labels: map[string]string{"c": "d"}},
{State: "normal", Labels: map[string]string{"a": "b"}},
{State: "normal", Labels: LabelsFromMap(map[string]string{"c": "d"})},
{State: "normal", Labels: LabelsFromMap(map[string]string{"a": "b"})},
},
expected: []Alert{
{State: "normal", Labels: map[string]string{"a": "b"}},
{State: "normal", Labels: map[string]string{"c": "d"}},
{State: "normal", Labels: LabelsFromMap(map[string]string{"a": "b"})},
{State: "normal", Labels: LabelsFromMap(map[string]string{"c": "d"})},
},
}, {
name: "active alerts with same importance and active time are ordered fewest labels first",
name: "active alerts with same importance and active time are ordered by label names",
input: []Alert{
{State: "alerting", ActiveAt: &tm1, Labels: map[string]string{"a": "b", "c": "d"}},
{State: "alerting", ActiveAt: &tm1, Labels: map[string]string{"e": "f"}},
{State: "alerting", ActiveAt: &tm1, Labels: LabelsFromMap(map[string]string{"c": "d", "e": "f"})},
{State: "alerting", ActiveAt: &tm1, Labels: LabelsFromMap(map[string]string{"a": "b"})},
},
expected: []Alert{
{State: "alerting", ActiveAt: &tm1, Labels: map[string]string{"e": "f"}},
{State: "alerting", ActiveAt: &tm1, Labels: map[string]string{"a": "b", "c": "d"}},
{State: "alerting", ActiveAt: &tm1, Labels: LabelsFromMap(map[string]string{"a": "b"})},
{State: "alerting", ActiveAt: &tm1, Labels: LabelsFromMap(map[string]string{"c": "d", "e": "f"})},
},
}, {
name: "active alerts with same importance and active time are ordered by labels",
input: []Alert{
{State: "alerting", ActiveAt: &tm1, Labels: map[string]string{"c": "d"}},
{State: "alerting", ActiveAt: &tm1, Labels: map[string]string{"a": "b"}},
{State: "alerting", ActiveAt: &tm1, Labels: LabelsFromMap(map[string]string{"c": "d"})},
{State: "alerting", ActiveAt: &tm1, Labels: LabelsFromMap(map[string]string{"a": "b"})},
},
expected: []Alert{
{State: "alerting", ActiveAt: &tm1, Labels: map[string]string{"a": "b"}},
{State: "alerting", ActiveAt: &tm1, Labels: map[string]string{"c": "d"}},
{State: "alerting", ActiveAt: &tm1, Labels: LabelsFromMap(map[string]string{"a": "b"})},
{State: "alerting", ActiveAt: &tm1, Labels: LabelsFromMap(map[string]string{"c": "d"})},
},
}, {
name: "active alerts with same importance and active time are ordered by label values",
input: []Alert{
{State: "alerting", ActiveAt: &tm1, Labels: LabelsFromMap(map[string]string{"x": "b"})},
{State: "alerting", ActiveAt: &tm1, Labels: LabelsFromMap(map[string]string{"x": "a"})},
},
expected: []Alert{
{State: "alerting", ActiveAt: &tm1, Labels: LabelsFromMap(map[string]string{"x": "a"})},
{State: "alerting", ActiveAt: &tm1, Labels: LabelsFromMap(map[string]string{"x": "b"})},
},
}}

View File

@ -2,6 +2,7 @@ package model
import (
"strings"
"time"
)
// FilterWhere limits the set of dashboard IDs to the dashboards for
@ -62,22 +63,23 @@ const (
)
type Hit struct {
ID int64 `json:"id"`
UID string `json:"uid"`
Title string `json:"title"`
URI string `json:"uri"`
URL string `json:"url"`
Slug string `json:"slug"`
Type HitType `json:"type"`
Tags []string `json:"tags"`
IsStarred bool `json:"isStarred"`
FolderID int64 `json:"folderId,omitempty"` // Deprecated: use FolderUID instead
FolderUID string `json:"folderUid,omitempty"`
FolderTitle string `json:"folderTitle,omitempty"`
FolderURL string `json:"folderUrl,omitempty"`
SortMeta int64 `json:"sortMeta"`
SortMetaName string `json:"sortMetaName,omitempty"`
RemainingTrashAtAge string `json:"remainingTrashAtAge,omitempty"`
ID int64 `json:"id"`
UID string `json:"uid"`
Title string `json:"title"`
URI string `json:"uri"`
URL string `json:"url"`
Slug string `json:"slug"`
Type HitType `json:"type"`
Tags []string `json:"tags"`
IsStarred bool `json:"isStarred"`
FolderID int64 `json:"folderId,omitempty"` // Deprecated: use FolderUID instead
FolderUID string `json:"folderUid,omitempty"`
FolderTitle string `json:"folderTitle,omitempty"`
FolderURL string `json:"folderUrl,omitempty"`
SortMeta int64 `json:"sortMeta"`
SortMetaName string `json:"sortMetaName,omitempty"`
IsDeleted bool `json:"isDeleted"`
PermanentlyDeleteDate *time.Time `json:"permanentlyDeleteDate,omitempty"`
}
type HitList []*Hit

View File

@ -3,18 +3,97 @@ package sqlstash
import (
"context"
"fmt"
"io"
)
type ConnectFunc[T any] func(chan T) error
// Please, when reviewing or working on this file have the following cheat-sheet
// in mind:
// 1. A channel type in Go has one of three directions: send-only (chan<- T),
// receive-only (<-chan T) or bidirctional (chan T). Each of them are a
// different type. A bidirectional type can be converted to any of the other
// two types and is automatic, any other conversion attempt results in a
// panic.
// 2. There are three operations you can do on a channel: send, receive and
// close. Availability of operation for each channel direction:
// | Channel direction
// Operation | Receive-only | Send-only | Bidirectional
// ----------+--------------+------------+--------------
// Receive | Yes | No (panic) | Yes
// Send | No (panic) | Yes | Yes
// Close | No (panic) | Yes | Yes
// 3. A channel of any type also has one of three states: nil (zero value),
// closed, or open (technically called "non-nil, not-closed channel",
// created with the `make` builtin). Nil and closed channels are also
// useful, but you have to know and care for how you use them. Outcome of
// each operation on a channel depending on its state, assuming the
// operation is available to the channel given its direction:
// | Channel state
// Operation | Nil | Closed | Open
// ----------+---------------+---------------+------------------
// Receive | Block forever | Block forever | Receive/Block until receive
// Send | Block forever | Panic | Send/Block until send
// Close | Panic | Panic | Close the channel
// 4. A `select` statement has zero or more `case` branches, each one of them
// containing either a send or a receive channel operation. A `select` with
// no branches blocks forever. At most one branch will be executed, which
// means it behaves similar to a `switch`. If more than one branch can be
// executed then one of them is picked AT RANDOM (i.e. not the one first in
// the list). A `select` statement can also have a (single and optional)
// `default` branch that is executed if all the other branches are
// operations that are blocked at the time the `select` statement is
// reached. This means that having a `default` branch causes the `select`
// statement to never block.
// 5. A receive operation on a closed channel never blocks (as said before),
// but it will always yield a zero value. As it is also valid to send a zero
// value to the channel, you can receive from channels in two forms:
// v := <-c // get a zero value if closed
// v2, ok := <-c // `ok` is set to false iif the channel is closed
// 6. The `make` builtin is used to create open channels (and is the only way
// to get them). It has an optional second parameter to specify the amount
// of items that can buffered. After that, a send operation will block
// waiting for another goroutine to receive from it (which would make room
// for the new item). When the second argument is not passed to `make`, then
// all operations are fully synchronized, meaning that a send will block
// until a receive in another goroutine is performed, and vice versa. Less
// interestingly, `make` can also create send-only or receive-only channel.
//
// The sources are the Go Specs, Effective Go and Go 101, which are already
// linked in the contributing guide for the backend or elsewhere in Grafana, but
// this file exploits so many of these subtleties that it's worth keeping a
// refresher about them at all times. The above is unlikely to change in the
// foreseeable future, so it's zero maintenance as well. We exclude patterns for
// using channels and other concurrency patterns since that's a way longer
// topic for a refresher.
// ConnectFunc is used to initialize the watch implementation. It should do very
// basic work and checks and it has the chance to return an error. After that,
// it should fork to a different goroutine with the provided channel and send to
// it all the new events from the backing database. It is also responsible for
// closing the provided channel under all circumstances, included returning an
// error. The caller of this function will only receive from this channel (i.e.
// it is guaranteed to never send to it or close it), hence providing a safe
// separation of concerns and preventing panics.
//
// FIXME: this signature suffers from inversion of control. It would also be
// much simpler if NewBroadcaster receives a context.Context and a <-chan T
// instead. That would also reduce the scope of the broadcaster to only
// broadcast to subscribers what it receives on the provided <-chan T. The
// context.Context is still needed to provide additional values in case we want
// to add observability into the broadcaster, which we want. The broadcaster
// should still terminate on either the context being done or the provided
// channel being closed.
type ConnectFunc[T any] func(chan<- T) error
type Broadcaster[T any] interface {
Subscribe(context.Context) (<-chan T, error)
Unsubscribe(chan T)
Unsubscribe(<-chan T)
}
func NewBroadcaster[T any](ctx context.Context, connect ConnectFunc[T]) (Broadcaster[T], error) {
b := &broadcaster[T]{}
err := b.start(ctx, connect)
b := &broadcaster[T]{
started: make(chan struct{}),
}
err := b.init(ctx, connect)
if err != nil {
return nil, err
}
@ -23,101 +102,132 @@ func NewBroadcaster[T any](ctx context.Context, connect ConnectFunc[T]) (Broadca
}
type broadcaster[T any] struct {
running bool // FIXME: race condition between `Subscribe`/`Unsubscribe` and `start`
ctx context.Context
subs map[chan T]struct{}
// lifecycle management
started, terminated chan struct{}
shouldTerminate <-chan struct{}
// subscription management
cache Cache[T]
subscribe chan chan T
unsubscribe chan chan T
unsubscribe chan (<-chan T)
subs map[<-chan T]chan T
}
func (b *broadcaster[T]) Subscribe(ctx context.Context) (<-chan T, error) {
if !b.running {
return nil, fmt.Errorf("broadcaster not running")
select {
case <-ctx.Done(): // client canceled
return nil, ctx.Err()
case <-b.started: // wait for broadcaster to start
}
// create the subscription
sub := make(chan T, 100)
b.subscribe <- sub
go func() {
<-ctx.Done()
b.unsubscribe <- sub
}()
return sub, nil
}
func (b *broadcaster[T]) Unsubscribe(sub chan T) {
b.unsubscribe <- sub
}
func (b *broadcaster[T]) start(ctx context.Context, connect ConnectFunc[T]) error {
if b.running {
return fmt.Errorf("broadcaster already running")
select {
case <-ctx.Done(): // client canceled
return nil, ctx.Err()
case <-b.terminated: // no more data
return nil, io.EOF
case b.subscribe <- sub: // success submitting subscription
return sub, nil
}
}
func (b *broadcaster[T]) Unsubscribe(sub <-chan T) {
// wait for broadcaster to start. In practice, the only way to reach
// Unsubscribe is by first having called Subscribe, which means we have
// already started. But a malfunctioning caller may call Unsubscribe freely,
// which would cause us to block forever the goroutine of the caller when
// trying to send to a nil `b.unsubscribe` or receive from a nil
// `b.terminated` if we haven't yet initialized those values. This would
// mean leaking that malfunctioninig caller's goroutine, so we rather make
// Unsubscribe safe in any possible case
if sub == nil {
return
}
<-b.started // wait for broadcaster to start
select {
case b.unsubscribe <- sub: // success submitting unsubscription
case <-b.terminated: // broadcaster terminated, nothing to do
}
}
// init initializes the broadcaster. It should not be run more than once.
func (b *broadcaster[T]) init(ctx context.Context, connect ConnectFunc[T]) error {
// create the stream that will connect us with the watch implementation and
// send it to them so they initialize and start sending data
stream := make(chan T, 100)
err := connect(stream)
if err != nil {
if err := connect(stream); err != nil {
return err
}
b.ctx = ctx
// initialize our internal state
b.shouldTerminate = ctx.Done()
b.cache = NewCache[T](ctx, 100)
b.subscribe = make(chan chan T, 100)
b.unsubscribe = make(chan chan T, 100)
b.subs = make(map[chan T]struct{})
b.unsubscribe = make(chan (<-chan T), 100)
b.subs = make(map[<-chan T]chan T)
b.terminated = make(chan struct{})
// start handling incoming data from the watch implementation. If data came
// in until now, it will be buffered in `stream`
go b.stream(stream)
b.running = true
// unblock any Subscribe/Unsubscribe calls since we are ready to handle them
close(b.started)
return nil
}
func (b *broadcaster[T]) stream(input chan T) {
// stream acts a message broker between the watch implementation that receives a
// raw stream of events and the individual clients watching for those events.
// Thus, we hold the receive side of the watch implementation, and we are
// limited here to receive from it, whereas we are responsible for sending to
// watchers and closing their channels. The responsibility of closing `input`
// (as with any other channel) will always be of the sending side. Hence, the
// watch implementation should do it.
func (b *broadcaster[T]) stream(input <-chan T) {
// make sure we unconditionally cleanup upon return
defer func() {
// prevent new subscriptions and make sure to discard unsubscriptions
close(b.terminated)
// terminate all subscirptions and clean the map
for _, sub := range b.subs {
close(sub)
delete(b.subs, sub)
}
}()
for {
select {
// context cancelled
case <-b.ctx.Done():
close(input)
for sub := range b.subs {
close(sub)
delete(b.subs, sub)
}
b.running = false
case <-b.shouldTerminate: // service context cancelled
return
// new subscriber
case sub := <-b.subscribe:
case sub := <-b.subscribe: // subscribe
// send initial batch of cached items
err := b.cache.ReadInto(sub)
if err != nil {
close(sub)
continue
}
b.subs[sub] = sub
b.subs[sub] = struct{}{}
// unsubscribe
case sub := <-b.unsubscribe:
if _, ok := b.subs[sub]; ok {
case recv := <-b.unsubscribe: // unsubscribe
if sub, ok := b.subs[recv]; ok {
close(sub)
delete(b.subs, sub)
}
// read item from input
case item, ok := <-input:
case item, ok := <-input: // data arrived, send to subscribers
// input closed, drain subscribers and exit
if !ok {
for sub := range b.subs {
close(sub)
delete(b.subs, sub)
}
b.running = false
return
}
b.cache.Add(item)
for sub := range b.subs {
for _, sub := range b.subs {
select {
case sub <- item:
default:

View File

@ -63,6 +63,9 @@ func ProvideSQLEntityServer(db db.EntityDBInterface, tracer tracing.Tracer /*, c
type SqlEntityServer interface {
entity.EntityStoreServer
// FIXME: accpet a context.Context in the lifecycle methods, and Stop should
// also return an error.
Init() error
Stop()
}
@ -75,7 +78,6 @@ type sqlEntityServer struct {
broadcaster Broadcaster[*entity.EntityWatchResponse]
ctx context.Context // TODO: remove
cancel context.CancelFunc
stream chan *entity.EntityWatchResponse
tracer trace.Tracer
once sync.Once
@ -139,9 +141,7 @@ func (s *sqlEntityServer) init() error {
s.dialect = migrator.NewDialect(engine.DriverName())
// set up the broadcaster
s.broadcaster, err = NewBroadcaster(s.ctx, func(stream chan *entity.EntityWatchResponse) error {
s.stream = stream
s.broadcaster, err = NewBroadcaster(s.ctx, func(stream chan<- *entity.EntityWatchResponse) error {
// start the poller
go s.poller(stream)
@ -994,13 +994,18 @@ func (s *sqlEntityServer) watchInit(ctx context.Context, r *entity.EntityWatchRe
return lastRv, nil
}
func (s *sqlEntityServer) poller(stream chan *entity.EntityWatchResponse) {
func (s *sqlEntityServer) poller(stream chan<- *entity.EntityWatchResponse) {
var err error
// FIXME: we need a way to state startup of server from a (Group, Resource)
// standpoint, and consider that new (Group, Resource) may be added to
// `kind_version`, so we should probably also poll for changes in there
since := int64(0)
interval := 1 * time.Second
t := time.NewTicker(interval)
defer close(stream)
defer t.Stop()
for {
@ -1017,7 +1022,7 @@ func (s *sqlEntityServer) poller(stream chan *entity.EntityWatchResponse) {
}
}
func (s *sqlEntityServer) poll(since int64, out chan *entity.EntityWatchResponse) (int64, error) {
func (s *sqlEntityServer) poll(since int64, out chan<- *entity.EntityWatchResponse) (int64, error) {
ctx, span := s.tracer.Start(s.ctx, "storage_server.poll")
defer span.End()
ctxLogger := s.log.FromContext(log.WithContextualAttributes(ctx, []any{"method", "poll"}))
@ -1182,26 +1187,25 @@ func (s *sqlEntityServer) watch(r *entity.EntityWatchRequest, w entity.EntitySto
if err != nil {
return err
}
defer s.broadcaster.Unsubscribe(evts)
stop := make(chan struct{})
since := r.Since
go func() {
defer close(stop)
for {
r, err := w.Recv()
if errors.Is(err, io.EOF) {
s.log.Debug("watch client closed stream")
stop <- struct{}{}
return
}
if err != nil {
s.log.Error("error receiving message", "err", err)
stop <- struct{}{}
return
}
if r.Action == entity.EntityWatchRequest_STOP {
s.log.Debug("watch stop requested")
stop <- struct{}{}
return
}
// handle any other message types
@ -1211,7 +1215,6 @@ func (s *sqlEntityServer) watch(r *entity.EntityWatchRequest, w entity.EntitySto
for {
select {
// stop signal
case <-stop:
s.log.Debug("watch stopped")
return nil

View File

@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"net"
"testing"
"github.com/apache/arrow/go/v15/arrow/flight"
@ -21,9 +22,14 @@ type FSQLTestSuite struct {
suite.Suite
db *sql.DB
server flight.Server
addr string
}
func (suite *FSQLTestSuite) SetupTest() {
addr, _ := freeport(suite.T())
suite.addr = addr
db, err := example.CreateDB()
require.NoError(suite.T(), err)
@ -32,7 +38,7 @@ func (suite *FSQLTestSuite) SetupTest() {
sqliteServer.Alloc = memory.NewCheckedAllocator(memory.DefaultAllocator)
server := flight.NewServerWithMiddleware(nil)
server.RegisterFlightService(flightsql.NewFlightServer(sqliteServer))
err = server.Init("localhost:12345")
err = server.Init(suite.addr)
require.NoError(suite.T(), err)
go func() {
err := server.Serve()
@ -59,7 +65,7 @@ func (suite *FSQLTestSuite) TestIntegration_QueryData() {
&models.DatasourceInfo{
HTTPClient: nil,
Token: "secret",
URL: "http://localhost:12345",
URL: "http://" + suite.addr,
DbName: "influxdb",
Version: "test",
HTTPMode: "proxy",
@ -109,3 +115,15 @@ func mustQueryJSON(t *testing.T, refID, sql string) []byte {
}
return b
}
func freeport(t *testing.T) (addr string, err error) {
l, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.ParseIP("127.0.0.1")})
if err != nil {
t.Fatal(err)
}
defer func() {
err = l.Close()
}()
a := l.Addr().(*net.TCPAddr)
return a.String(), nil
}

View File

@ -23,7 +23,10 @@ import (
"github.com/grafana/grafana/pkg/tsdb/influxdb/models"
)
const defaultRetentionPolicy = "default"
const (
defaultRetentionPolicy = "default"
metadataPrefix = "x-grafana-meta-add-"
)
var (
ErrInvalidHttpMode = errors.New("'httpMode' should be either 'GET' or 'POST'")
@ -195,9 +198,27 @@ func execute(ctx context.Context, tracer trace.Tracer, dsInfo *models.Datasource
} else {
resp = buffered.ResponseParse(res.Body, res.StatusCode, query)
}
if resp.Frames != nil && len(resp.Frames) > 0 {
resp.Frames[0].Meta.Custom = readCustomMetadata(res)
}
return *resp, nil
}
func readCustomMetadata(res *http.Response) map[string]any {
var result map[string]any
for k := range res.Header {
if key, found := strings.CutPrefix(strings.ToLower(k), metadataPrefix); found {
if result == nil {
result = make(map[string]any)
}
result[key] = res.Header.Get(k)
}
}
return result
}
// startTrace setups a trace but does not panic if tracer is nil which helps with testing
func startTrace(ctx context.Context, tracer trace.Tracer, name string, attributes ...attribute.KeyValue) (context.Context, func()) {
if tracer == nil {

View File

@ -3,6 +3,7 @@ package influxql
import (
"context"
"io"
"net/http"
"net/url"
"testing"
@ -60,3 +61,52 @@ func TestExecutor_createRequest(t *testing.T) {
require.EqualError(t, err, ErrInvalidHttpMode.Error())
})
}
func TestReadCustomMetadata(t *testing.T) {
t.Run("should read nothing if no X-Grafana-Meta-Add-<Thing> header exists", func(t *testing.T) {
header := http.Header{}
header.Add("content-type", "text/html")
header.Add("content-encoding", "gzip")
res := &http.Response{
Header: header,
}
result := readCustomMetadata(res)
require.Nil(t, result)
})
t.Run("should read X-Grafana-Meta-Add-<Thing> header", func(t *testing.T) {
header := http.Header{}
header.Add("content-type", "text/html")
header.Add("content-encoding", "gzip")
header.Add("X-Grafana-Meta-Add-TestThing", "test1234")
res := &http.Response{
Header: header,
}
result := readCustomMetadata(res)
expected := map[string]any{
"testthing": "test1234",
}
require.NotNil(t, result)
require.Equal(t, expected, result)
})
t.Run("should read multiple X-Grafana-Meta-Add-<Thing> header", func(t *testing.T) {
header := http.Header{}
header.Add("content-type", "text/html")
header.Add("content-encoding", "gzip")
header.Add("X-Grafana-Meta-Add-TestThing", "test111")
header.Add("X-Grafana-Meta-Add-TestThing2", "test222")
header.Add("X-Grafana-Meta-Add-Test-Other", "other")
res := &http.Response{
Header: header,
}
result := readCustomMetadata(res)
expected := map[string]any{
"testthing": "test111",
"testthing2": "test222",
"test-other": "other",
}
require.NotNil(t, result)
require.Equal(t, expected, result)
})
}

View File

@ -4703,11 +4703,15 @@
"type": "integer",
"format": "int64"
},
"isDeleted": {
"type": "boolean"
},
"isStarred": {
"type": "boolean"
},
"remainingTrashAtAge": {
"type": "string"
"permanentlyDeleteDate": {
"type": "string",
"format": "date-time"
},
"slug": {
"type": "string"
@ -7049,6 +7053,7 @@
}
},
"State": {
"description": "+enum",
"type": "string"
},
"Status": {
@ -7492,6 +7497,7 @@
}
},
"Type": {
"description": "+enum",
"type": "string"
},
"TypeMeta": {

View File

@ -15815,11 +15815,15 @@
"type": "integer",
"format": "int64"
},
"isDeleted": {
"type": "boolean"
},
"isStarred": {
"type": "boolean"
},
"remainingTrashAtAge": {
"type": "string"
"permanentlyDeleteDate": {
"type": "string",
"format": "date-time"
},
"slug": {
"type": "string"

View File

@ -60,7 +60,7 @@ export const PermissionListItem = ({ item, permissionLevels, canSet, onRemove, o
/>
) : (
<Tooltip content={item.isInherited ? 'Inherited Permission' : 'Provisioned Permission'}>
<Button size="sm" icon="lock" />
<Button size="sm" icon="lock" aria-label="Locked permission indicator" />
</Tooltip>
)}
</td>

View File

@ -7,8 +7,6 @@ import {
PageviewEchoEvent,
} from '@grafana/runtime';
import { loadScript } from '../../utils';
interface ApplicationInsights {
trackPageView: () => void;
trackEvent: (event: { name: string; properties?: Record<string, unknown> }) => void;
@ -39,10 +37,12 @@ export class ApplicationInsightsBackend implements EchoBackend<PageviewEchoEvent
};
const url = 'https://js.monitor.azure.com/scripts/b/ai.2.min.js';
loadScript(url).then(() => {
const init = new (window as any).Microsoft.ApplicationInsights.ApplicationInsights(applicationInsightsOpts);
window.applicationInsights = init.loadAppInsights();
});
System.import(url)
.then((m) => (m.default ? m.default : m))
.then(({ ApplicationInsights }) => {
const init = new ApplicationInsights(applicationInsightsOpts);
window.applicationInsights = init.loadAppInsights();
});
}
addEvent = (e: PageviewEchoEvent | InteractionEchoEvent) => {

View File

@ -166,6 +166,19 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
() => import(/* webpackChunkName: "AlertGroups" */ 'app/features/alerting/unified/AlertGroups')
),
},
{
path: '/alerting/history/',
roles: evaluateAccess([
AccessControlAction.AlertingInstanceRead,
AccessControlAction.AlertingInstancesExternalRead,
]),
component: importAlertingComponent(
() =>
import(
/* webpackChunkName: "HistoryPage" */ 'app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryPage'
)
),
},
{
path: '/alerting/new/:type?',
pageClass: 'page-alerting',

View File

@ -4,7 +4,7 @@ import { alertingApi } from './alertingApi';
export const stateHistoryApi = alertingApi.injectEndpoints({
endpoints: (build) => ({
getRuleHistory: build.query<DataFrameJSON, { ruleUid: string; from?: number; to?: number; limit?: number }>({
getRuleHistory: build.query<DataFrameJSON, { ruleUid?: string; from?: number; to?: number; limit?: number }>({
query: ({ ruleUid, from, to, limit = 100 }) => ({
url: '/api/v1/rules/history',
params: { ruleUID: ruleUid, from, to, limit },

View File

@ -5,6 +5,7 @@ import React, { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, getTagColorsFromName, useStyles2 } from '@grafana/ui';
import { Trans, t } from 'app/core/internationalization';
import { isPrivateLabel } from '../utils/labels';
@ -28,6 +29,7 @@ export const AlertLabels = ({ labels, commonLabels = {}, size }: Props) => {
const commonLabelsCount = Object.keys(commonLabels).length;
const hasCommonLabels = commonLabelsCount > 0;
const tooltip = t('alert-labels.button.show.tooltip', 'Show common labels');
return (
<div className={styles.wrapper} role="list" aria-label="Labels">
@ -39,7 +41,7 @@ export const AlertLabels = ({ labels, commonLabels = {}, size }: Props) => {
variant="secondary"
fill="text"
onClick={() => setShowCommonLabels(true)}
tooltip="Show common labels"
tooltip={tooltip}
tooltipPlacement="top"
size="sm"
>
@ -54,7 +56,7 @@ export const AlertLabels = ({ labels, commonLabels = {}, size }: Props) => {
tooltipPlacement="top"
size="sm"
>
Hide common labels
<Trans i18nKey="alert-labels.button.hide">Hide common labels</Trans>
</Button>
)}
</div>

View File

@ -0,0 +1,383 @@
import { css } from '@emotion/css';
import React, { useCallback, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useMeasure } from 'react-use';
import { GrafanaTheme2, TimeRange } from '@grafana/data';
import { isFetchError } from '@grafana/runtime';
import { SceneComponentProps, SceneObjectBase, sceneGraph } from '@grafana/scenes';
import {
Alert,
Button,
Field,
Icon,
Input,
Label,
LoadingBar,
Stack,
Text,
Tooltip,
useStyles2,
withErrorBoundary,
} from '@grafana/ui';
import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound';
import { Trans, t } from 'app/core/internationalization';
import {
GrafanaAlertStateWithReason,
isAlertStateWithReason,
isGrafanaAlertState,
mapStateWithReasonToBaseState,
mapStateWithReasonToReason,
} from 'app/types/unified-alerting-dto';
import { stateHistoryApi } from '../../../api/stateHistoryApi';
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
import { stringifyErrorLike } from '../../../utils/misc';
import { hashLabelsOrAnnotations } from '../../../utils/rule-id';
import { AlertLabels } from '../../AlertLabels';
import { CollapseToggle } from '../../CollapseToggle';
import { LogRecord } from '../state-history/common';
import { useRuleHistoryRecords } from '../state-history/useRuleHistoryRecords';
const LIMIT_EVENTS = 250;
const HistoryEventsList = ({ timeRange }: { timeRange?: TimeRange }) => {
const styles = useStyles2(getStyles);
// Filter state
const [eventsFilter, setEventsFilter] = useState('');
// form for filter fields
const { register, handleSubmit, reset } = useForm({ defaultValues: { query: '' } }); // form for search field
const from = timeRange?.from.unix();
const to = timeRange?.to.unix();
const onFilterCleared = useCallback(() => {
setEventsFilter('');
reset();
}, [setEventsFilter, reset]);
const {
data: stateHistory,
isLoading,
isError,
error,
} = stateHistoryApi.endpoints.getRuleHistory.useQuery(
{
from: from,
to: to,
limit: LIMIT_EVENTS,
},
{
refetchOnFocus: true,
refetchOnReconnect: true,
}
);
const { historyRecords } = useRuleHistoryRecords(stateHistory, eventsFilter);
if (isError) {
return <HistoryErrorMessage error={error} />;
}
return (
<Stack direction="column" gap={1}>
<div className={styles.labelsFilter}>
<form onSubmit={handleSubmit((data) => setEventsFilter(data.query))}>
<SearchFieldInput
{...register('query')}
showClearFilterSuffix={!!eventsFilter}
onClearFilterClick={onFilterCleared}
/>
<input type="submit" hidden />
</form>
</div>
<LoadingIndicator visible={isLoading} />
<HistoryLogEvents logRecords={historyRecords} />
</Stack>
);
};
// todo: this function has been copied from RuleList.v2.tsx, should be moved to a shared location
const LoadingIndicator = ({ visible = false }) => {
const [measureRef, { width }] = useMeasure<HTMLDivElement>();
return <div ref={measureRef}>{visible && <LoadingBar width={width} />}</div>;
};
interface HistoryLogEventsProps {
logRecords: LogRecord[];
}
function HistoryLogEvents({ logRecords }: HistoryLogEventsProps) {
// display log records
return (
<ul>
{logRecords.map((record) => {
return <EventRow key={record.timestamp + hashLabelsOrAnnotations(record.line.labels ?? {})} record={record} />;
})}
</ul>
);
}
interface HistoryErrorMessageProps {
error: unknown;
}
function HistoryErrorMessage({ error }: HistoryErrorMessageProps) {
if (isFetchError(error) && error.status === 404) {
return <EntityNotFound entity="History" />;
}
const title = t('central-alert-history.error', 'Something went wrong loading the alert state history');
return <Alert title={title}>{stringifyErrorLike(error)}</Alert>;
}
interface SearchFieldInputProps {
showClearFilterSuffix: boolean;
onClearFilterClick: () => void;
}
const SearchFieldInput = React.forwardRef<HTMLInputElement, SearchFieldInputProps>(
({ showClearFilterSuffix, onClearFilterClick, ...rest }: SearchFieldInputProps, ref) => {
const placeholder = t('central-alert-history.filter.placeholder', 'Filter events in the list with labels');
return (
<Field
label={
<Label htmlFor="eventsSearchInput">
<Stack gap={0.5}>
<span>
<Trans i18nKey="central-alert-history.filter.label">Filter events</Trans>
</span>
</Stack>
</Label>
}
>
<Input
id="eventsSearchInput"
prefix={<Icon name="search" />}
suffix={
showClearFilterSuffix && (
<Button fill="text" icon="times" size="sm" onClick={onClearFilterClick}>
<Trans i18nKey="central-alert-history.filter.button.clear">Clear</Trans>
</Button>
)
}
placeholder={placeholder}
ref={ref}
{...rest}
/>
</Field>
);
}
);
SearchFieldInput.displayName = 'SearchFieldInput';
function EventRow({ record }: { record: LogRecord }) {
const styles = useStyles2(getStyles);
const [isCollapsed, setIsCollapsed] = useState(true);
return (
<div>
<div className={styles.header} data-testid="rule-group-header">
<CollapseToggle
size="sm"
className={styles.collapseToggle}
isCollapsed={isCollapsed}
onToggle={setIsCollapsed}
/>
<Stack gap={0.5} direction={'row'} alignItems={'center'}>
<div className={styles.timeCol}>
<Timestamp time={record.timestamp} />
</div>
<div className={styles.transitionCol}>
<EventTransition previous={record.line.previous} current={record.line.current} />
</div>
<div className={styles.alertNameCol}>
{record.line.labels ? <AlertRuleName labels={record.line.labels} ruleUID={record.line.ruleUID} /> : null}
</div>
<div className={styles.labelsCol}>
<AlertLabels labels={record.line.labels ?? {}} size="xs" />
</div>
</Stack>
</div>
</div>
);
}
function AlertRuleName({ labels, ruleUID }: { labels: Record<string, string>; ruleUID?: string }) {
const styles = useStyles2(getStyles);
const alertRuleName = labels['alertname'];
if (!ruleUID) {
return <Text>{alertRuleName}</Text>;
}
return (
<Tooltip content={alertRuleName ?? ''}>
<a
href={`/alerting/${GRAFANA_RULES_SOURCE_NAME}/${ruleUID}/view?returnTo=${encodeURIComponent('/alerting/history')}`}
className={styles.alertName}
>
{alertRuleName}
</a>
</Tooltip>
);
}
interface EventTransitionProps {
previous: GrafanaAlertStateWithReason;
current: GrafanaAlertStateWithReason;
}
function EventTransition({ previous, current }: EventTransitionProps) {
return (
<Stack gap={0.5} direction={'row'}>
<EventState state={previous} />
<Icon name="arrow-right" size="lg" />
<EventState state={current} />
</Stack>
);
}
function EventState({ state }: { state: GrafanaAlertStateWithReason }) {
const styles = useStyles2(getStyles);
if (!isGrafanaAlertState(state) && !isAlertStateWithReason(state)) {
return (
<Tooltip content={'No recognized state'}>
<Icon name="exclamation-triangle" size="md" />
</Tooltip>
);
}
const baseState = mapStateWithReasonToBaseState(state);
const reason = mapStateWithReasonToReason(state);
switch (baseState) {
case 'Normal':
return (
<Tooltip content={Boolean(reason) ? `Normal (${reason})` : 'Normal'}>
<Icon name="check-circle" size="md" className={Boolean(reason) ? styles.warningColor : styles.normalColor} />
</Tooltip>
);
case 'Alerting':
return (
<Tooltip content={'Alerting'}>
<Icon name="exclamation-circle" size="md" className={styles.alertingColor} />
</Tooltip>
);
case 'NoData': //todo:change icon
return (
<Tooltip content={'Insufficient data'}>
<Icon name="exclamation-triangle" size="md" className={styles.warningColor} />
{/* no idea which icon to use */}
</Tooltip>
);
case 'Error':
return (
<Tooltip content={'Error'}>
<Icon name="exclamation-circle" size="md" />
</Tooltip>
);
case 'Pending':
return (
<Tooltip content={Boolean(reason) ? `Pending (${reason})` : 'Pending'}>
<Icon name="circle" size="md" className={styles.warningColor} />
</Tooltip>
);
default:
return <Icon name="exclamation-triangle" size="md" />;
}
}
interface TimestampProps {
time: number; // epoch timestamp
}
const Timestamp = ({ time }: TimestampProps) => {
const dateTime = new Date(time);
const formattedDate = dateTime.toLocaleString('en-US', {
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
return (
<Text variant="body" weight="light">
{formattedDate}
</Text>
);
};
export default withErrorBoundary(HistoryEventsList, { style: 'page' });
export const getStyles = (theme: GrafanaTheme2) => {
return {
header: css({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
padding: `${theme.spacing(1)} ${theme.spacing(1)} ${theme.spacing(1)} 0`,
flexWrap: 'nowrap',
borderBottom: `1px solid ${theme.colors.border.weak}`,
'&:hover': {
backgroundColor: theme.components.table.rowHoverBackground,
},
}),
collapseToggle: css({
background: 'none',
border: 'none',
marginTop: `-${theme.spacing(1)}`,
marginBottom: `-${theme.spacing(1)}`,
svg: {
marginBottom: 0,
},
}),
normalColor: css({
fill: theme.colors.success.text,
}),
warningColor: css({
fill: theme.colors.warning.text,
}),
alertingColor: css({
fill: theme.colors.error.text,
}),
timeCol: css({
width: '150px',
}),
transitionCol: css({
width: '80px',
}),
alertNameCol: css({
width: '300px',
}),
labelsCol: css({
display: 'flex',
overflow: 'hidden',
alignItems: 'center',
paddingRight: theme.spacing(2),
flex: 1,
}),
alertName: css({
whiteSpace: 'nowrap',
cursor: 'pointer',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: 'block',
color: theme.colors.text.link,
}),
labelsFilter: css({
width: '100%',
paddingTop: theme.spacing(4),
}),
};
};
export class HistoryEventsListObject extends SceneObjectBase {
public static Component = HistoryEventsListObjectRenderer;
}
export function HistoryEventsListObjectRenderer({ model }: SceneComponentProps<HistoryEventsListObject>) {
const { value: timeRange } = sceneGraph.getTimeRange(model).useState(); // get time range from scene graph
return <HistoryEventsList timeRange={timeRange} />;
}

View File

@ -0,0 +1,16 @@
import React from 'react';
import { withErrorBoundary } from '@grafana/ui';
import { AlertingPageWrapper } from '../../AlertingPageWrapper';
import { CentralAlertHistoryScene } from './CentralAlertHistoryScene';
const HistoryPage = () => {
return (
<AlertingPageWrapper navId="alerts-history" isLoading={false}>
<CentralAlertHistoryScene />
</AlertingPageWrapper>
);
};
export default withErrorBoundary(HistoryPage, { style: 'page' });

View File

@ -0,0 +1,124 @@
import React from 'react';
import { getDataSourceSrv } from '@grafana/runtime';
import {
EmbeddedScene,
PanelBuilders,
SceneControlsSpacer,
SceneFlexItem,
SceneFlexLayout,
SceneQueryRunner,
SceneReactObject,
SceneRefreshPicker,
SceneTimePicker,
} from '@grafana/scenes';
import {
GraphDrawStyle,
GraphGradientMode,
LegendDisplayMode,
LineInterpolation,
ScaleDistribution,
StackingMode,
TooltipDisplayMode,
VisibilityMode,
} from '@grafana/schema/dist/esm/index';
import { DataSourceInformation, PANEL_STYLES } from '../../../home/Insights';
import { SectionSubheader } from '../../../insights/SectionSubheader';
import { HistoryEventsListObjectRenderer } from './CentralAlertHistory';
export const CentralAlertHistoryScene = () => {
const dataSourceSrv = getDataSourceSrv();
const alertStateHistoryDatasource: DataSourceInformation = {
type: 'loki',
uid: 'grafanacloud-alert-state-history',
settings: undefined,
};
alertStateHistoryDatasource.settings = dataSourceSrv.getInstanceSettings(alertStateHistoryDatasource.uid);
const scene = new EmbeddedScene({
controls: [new SceneControlsSpacer(), new SceneTimePicker({}), new SceneRefreshPicker({})],
body: new SceneFlexLayout({
direction: 'column',
children: [
new SceneFlexItem({
ySizing: 'content',
body: getEventsSceneObject(alertStateHistoryDatasource),
}),
new SceneFlexItem({
body: new SceneReactObject({
component: HistoryEventsListObjectRenderer,
}),
}),
],
}),
});
return <scene.Component model={scene} />;
};
function getEventsSceneObject(ashDs: DataSourceInformation) {
return new EmbeddedScene({
controls: [
new SceneReactObject({
component: SectionSubheader,
}),
],
body: new SceneFlexLayout({
direction: 'column',
children: [
new SceneFlexItem({
ySizing: 'content',
body: new SceneFlexLayout({
children: [getEventsScenesFlexItem(ashDs)],
}),
}),
],
}),
});
}
function getSceneQuery(datasource: DataSourceInformation) {
const query = new SceneQueryRunner({
datasource,
queries: [
{
refId: 'A',
expr: 'count_over_time({from="state-history"} |= `` [$__auto])',
queryType: 'range',
step: '10s',
},
],
});
return query;
}
export function getEventsScenesFlexItem(datasource: DataSourceInformation) {
return new SceneFlexItem({
...PANEL_STYLES,
body: PanelBuilders.timeseries()
.setTitle('Events')
.setDescription('Alert events during the period of time.')
.setData(getSceneQuery(datasource))
.setColor({ mode: 'continuous-BlPu' })
.setCustomFieldConfig('fillOpacity', 100)
.setCustomFieldConfig('drawStyle', GraphDrawStyle.Bars)
.setCustomFieldConfig('lineInterpolation', LineInterpolation.Linear)
.setCustomFieldConfig('lineWidth', 1)
.setCustomFieldConfig('barAlignment', 0)
.setCustomFieldConfig('spanNulls', false)
.setCustomFieldConfig('insertNulls', false)
.setCustomFieldConfig('showPoints', VisibilityMode.Auto)
.setCustomFieldConfig('pointSize', 5)
.setCustomFieldConfig('stacking', { mode: StackingMode.None, group: 'A' })
.setCustomFieldConfig('gradientMode', GraphGradientMode.Hue)
.setCustomFieldConfig('scaleDistribution', { type: ScaleDistribution.Linear })
.setOption('legend', { showLegend: false, displayMode: LegendDisplayMode.Hidden })
.setOption('tooltip', { mode: TooltipDisplayMode.Single })
.setNoValue('No events found')
.build(),
});
}

View File

@ -4,7 +4,7 @@ import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { DataFrame, dateTime, GrafanaTheme2, TimeRange } from '@grafana/data';
import { Alert, Button, Field, Icon, Input, Label, Tooltip, useStyles2, Stack } from '@grafana/ui';
import { Alert, Button, Field, Icon, Input, Label, Stack, Tooltip, useStyles2 } from '@grafana/ui';
import { stateHistoryApi } from '../../../api/stateHistoryApi';
import { combineMatcherStrings } from '../../../utils/alertmanager';

View File

@ -7,6 +7,7 @@ export interface Line {
current: GrafanaAlertStateWithReason;
values?: Record<string, number>;
labels?: Record<string, string>;
ruleUID?: string;
}
export interface LogRecord {

View File

@ -240,7 +240,7 @@ export function hashRule(rule: Rule): string {
throw new Error('only recording and alerting rules can be hashed');
}
function hashLabelsOrAnnotations(item: Labels | Annotations | undefined): string {
export function hashLabelsOrAnnotations(item: Labels | Annotations | undefined): string {
return JSON.stringify(Object.entries(item || {}).sort((a, b) => a[0].localeCompare(b[0])));
}

View File

@ -381,11 +381,6 @@ export const browseDashboardsAPI = createApi({
url: `/dashboards/uid/${dashboardUID}/trash`,
method: 'PATCH',
}),
onQueryStarted: ({ dashboardUID }, { queryFulfilled, dispatch }) => {
queryFulfilled.then(() => {
dispatch(refreshParents([dashboardUID]));
});
},
}),
}),
});

View File

@ -3,16 +3,16 @@ import React, { useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data/';
import { Button, useStyles2 } from '@grafana/ui';
import { GENERAL_FOLDER_UID } from 'app/features/search/constants';
import appEvents from '../../../core/app_events';
import { Trans } from '../../../core/internationalization';
import { useDispatch } from '../../../types';
import { ShowModalReactEvent } from '../../../types/events';
import { useRestoreDashboardMutation } from '../api/browseDashboardsAPI';
import { useRestoreDashboardMutation } from '../../browse-dashboards/api/browseDashboardsAPI';
import { clearFolders, setAllSelection, useActionSelectionState } from '../../browse-dashboards/state';
import { useRecentlyDeletedStateManager } from '../api/useRecentlyDeletedStateManager';
import { setAllSelection, useActionSelectionState } from '../state';
import { RestoreModal } from './RestoreModal';
import { RestoreModal } from '../components/RestoreModal';
export function RecentlyDeletedActions() {
const styles = useStyles2(getStyles);
@ -36,9 +36,31 @@ export function RecentlyDeletedActions() {
};
const onRestore = async () => {
const promises = selectedDashboards.map((uid) => restoreDashboard({ dashboardUID: uid }));
const resultsView = stateManager.state.result?.view.toArray();
if (!resultsView) {
return;
}
const promises = selectedDashboards.map((uid) => {
return restoreDashboard({ dashboardUID: uid });
});
await Promise.all(promises);
const parentUIDs = new Set<string | undefined>();
for (const uid of selectedDashboards) {
const foundItem = resultsView.find((v) => v.uid === uid);
if (!foundItem) {
continue;
}
// Search API returns items with no parent with a location of 'general', so we
// need to convert that back to undefined
const folderUID = foundItem.location === GENERAL_FOLDER_UID ? undefined : foundItem.location;
parentUIDs.add(folderUID);
}
dispatch(clearFolders(Array.from(parentUIDs)));
onActionComplete();
};

View File

@ -200,3 +200,18 @@ export function setAllSelection(
}
}
}
export function clearFolders(state: BrowseDashboardsState, action: PayloadAction<Array<string | undefined>>) {
const folderUIDs = Array.isArray(action.payload) ? action.payload : [action.payload];
for (const folderUID of folderUIDs) {
if (!folderUID) {
state.rootItems = undefined;
} else {
state.childrenByParentUID[folderUID] = undefined;
// close the folder to require it to be refetched next time its opened
state.openFolders[folderUID] = false;
}
}
}

View File

@ -32,7 +32,8 @@ const browseDashboardsSlice = createSlice({
export const browseDashboardsReducer = browseDashboardsSlice.reducer;
export const { setFolderOpenState, setItemSelectionState, setAllSelection } = browseDashboardsSlice.actions;
export const { setFolderOpenState, setItemSelectionState, setAllSelection, clearFolders } =
browseDashboardsSlice.actions;
export default {
browseDashboards: browseDashboardsReducer,

View File

@ -149,6 +149,13 @@ export const navIndex: NavIndex = {
icon: 'layer-group',
url: '/alerting/groups',
},
{
id: 'history',
text: 'History',
subTitle: 'Alert state history',
icon: 'history',
url: '/alerting/history',
},
{
id: 'alerting-admin',
text: 'Settings',

View File

@ -5,6 +5,7 @@ import { default as localStorageStore } from 'app/core/store';
import { startMeasure, stopMeasure } from 'app/core/utils/metrics';
import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { emitDashboardViewEvent } from 'app/features/dashboard/state/analyticsProcessor';
import {
DASHBOARD_FROM_LS_KEY,
removeDashboardToFetchFromLocalStorage,
@ -185,6 +186,15 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
this.setState({ dashboard: dashboard, isLoading: false });
const measure = stopMeasure(LOAD_SCENE_MEASUREMENT);
trackDashboardSceneLoaded(dashboard, measure?.duration);
if (options.route !== DashboardRoutes.New) {
emitDashboardViewEvent({
meta: dashboard.state.meta,
uid: dashboard.state.uid,
title: dashboard.state.title,
id: dashboard.state.id,
});
}
} catch (err) {
this.setState({ isLoading: false, loadError: String(err) });
}

View File

@ -0,0 +1,159 @@
import { render } from '@testing-library/react';
import React from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { SceneDataLayerControls, SceneVariableSet, TextBoxVariable, VariableValueSelectors } from '@grafana/scenes';
import { DashboardControls, DashboardControlsState } from './DashboardControls';
import { DashboardScene } from './DashboardScene';
describe('DashboardControls', () => {
describe('Given a standard scene', () => {
it('should initialize with default values', () => {
const scene = buildTestScene();
expect(scene.state.variableControls).toEqual([]);
expect(scene.state.timePicker).toBeDefined();
expect(scene.state.refreshPicker).toBeDefined();
});
it('should return if time controls are hidden', () => {
const scene = buildTestScene({ hideTimeControls: false, hideVariableControls: false, hideLinksControls: false });
expect(scene.hasControls()).toBeTruthy();
scene.setState({ hideTimeControls: true });
expect(scene.hasControls()).toBeTruthy();
scene.setState({ hideVariableControls: true, hideLinksControls: true });
expect(scene.hasControls()).toBeFalsy();
});
});
describe('Component', () => {
it('should render', () => {
const scene = buildTestScene();
expect(() => {
render(<scene.Component model={scene} />);
}).not.toThrow();
});
it('should render visible controls', async () => {
const scene = buildTestScene({
variableControls: [new VariableValueSelectors({}), new SceneDataLayerControls()],
});
const renderer = render(<scene.Component model={scene} />);
expect(await renderer.findByTestId(selectors.pages.Dashboard.Controls)).toBeInTheDocument();
expect(await renderer.findByTestId(selectors.components.DashboardLinks.container)).toBeInTheDocument();
expect(await renderer.findByTestId(selectors.components.TimePicker.openButton)).toBeInTheDocument();
expect(await renderer.findByTestId(selectors.components.RefreshPicker.runButtonV2)).toBeInTheDocument();
expect(await renderer.findByTestId(selectors.pages.Dashboard.SubMenu.submenuItem)).toBeInTheDocument();
});
it('should render with hidden controls', async () => {
const scene = buildTestScene({
hideTimeControls: true,
hideVariableControls: true,
hideLinksControls: true,
variableControls: [new VariableValueSelectors({}), new SceneDataLayerControls()],
});
const renderer = render(<scene.Component model={scene} />);
expect(await renderer.queryByTestId(selectors.pages.Dashboard.Controls)).not.toBeInTheDocument();
});
});
describe('UrlSync', () => {
it('should return keys', () => {
const scene = buildTestScene();
// @ts-expect-error
expect(scene._urlSync.getKeys()).toEqual(['_dash.hideTimePicker', '_dash.hideVariables', '_dash.hideLinks']);
});
it('should return url state', () => {
const scene = buildTestScene();
expect(scene.getUrlState()).toEqual({
'_dash.hideTimePicker': undefined,
'_dash.hideVariables': undefined,
'_dash.hideLinks': undefined,
});
scene.setState({
hideTimeControls: true,
hideVariableControls: true,
hideLinksControls: true,
});
expect(scene.getUrlState()).toEqual({
'_dash.hideTimePicker': 'true',
'_dash.hideVariables': 'true',
'_dash.hideLinks': 'true',
});
});
it('should update from url', () => {
const scene = buildTestScene();
scene.updateFromUrl({
'_dash.hideTimePicker': 'true',
'_dash.hideVariables': 'true',
'_dash.hideLinks': 'true',
});
expect(scene.state.hideTimeControls).toBeTruthy();
expect(scene.state.hideVariableControls).toBeTruthy();
expect(scene.state.hideLinksControls).toBeTruthy();
scene.updateFromUrl({
'_dash.hideTimePicker': '',
'_dash.hideVariables': '',
'_dash.hideLinks': '',
});
expect(scene.state.hideTimeControls).toBeTruthy();
expect(scene.state.hideVariableControls).toBeTruthy();
expect(scene.state.hideLinksControls).toBeTruthy();
scene.updateFromUrl({});
expect(scene.state.hideTimeControls).toBeFalsy();
expect(scene.state.hideVariableControls).toBeFalsy();
expect(scene.state.hideLinksControls).toBeFalsy();
});
it('should not call setState if no changes', () => {
const scene = buildTestScene();
const setState = jest.spyOn(scene, 'setState');
scene.updateFromUrl({});
scene.updateFromUrl({});
expect(setState).toHaveBeenCalledTimes(1);
});
});
});
function buildTestScene(state?: Partial<DashboardControlsState>): DashboardControls {
const variable = new TextBoxVariable({
name: 'A',
label: 'A',
description: 'A',
type: 'textbox',
value: 'Text',
});
const dashboard = new DashboardScene({
uid: 'A',
links: [
{
title: 'Link',
url: 'http://localhost:3000/$A',
type: 'link',
asDropdown: false,
icon: '',
includeVars: true,
keepTime: true,
tags: [],
targetBlank: false,
tooltip: 'Link',
},
],
$variables: new SceneVariableSet({
variables: [variable],
}),
controls: new DashboardControls({
...state,
}),
});
dashboard.activate();
variable.activate();
return dashboard.state.controls as DashboardControls;
}

View File

@ -1,7 +1,7 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { GrafanaTheme2, VariableHide } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import {
SceneObjectState,
@ -12,6 +12,9 @@ import {
SceneRefreshPicker,
SceneDebugger,
VariableDependencyConfig,
sceneGraph,
SceneObjectUrlSyncConfig,
SceneObjectUrlValues,
} from '@grafana/scenes';
import { Box, Stack, useStyles2 } from '@grafana/ui';
@ -20,12 +23,15 @@ import { getDashboardSceneFor } from '../utils/utils';
import { DashboardLinksControls } from './DashboardLinksControls';
interface DashboardControlsState extends SceneObjectState {
export interface DashboardControlsState extends SceneObjectState {
variableControls: SceneObject[];
timePicker: SceneTimePicker;
refreshPicker: SceneRefreshPicker;
hideTimeControls?: boolean;
hideVariableControls?: boolean;
hideLinksControls?: boolean;
}
export class DashboardControls extends SceneObjectBase<DashboardControlsState> {
static Component = DashboardControlsRenderer;
@ -33,6 +39,30 @@ export class DashboardControls extends SceneObjectBase<DashboardControlsState> {
onAnyVariableChanged: this._onAnyVariableChanged.bind(this),
});
protected _urlSync = new SceneObjectUrlSyncConfig(this, {
keys: ['_dash.hideTimePicker', '_dash.hideVariables', '_dash.hideLinks'],
});
getUrlState() {
return {
'_dash.hideTimePicker': this.state.hideTimeControls ? 'true' : undefined,
'_dash.hideVariables': this.state.hideVariableControls ? 'true' : undefined,
'_dash.hideLinks': this.state.hideLinksControls ? 'true' : undefined,
};
}
updateFromUrl(values: SceneObjectUrlValues) {
const update: Partial<DashboardControlsState> = {};
update.hideTimeControls = values['_dash.hideTimePicker'] === 'true' || values['_dash.hideTimePicker'] === '';
update.hideVariableControls = values['_dash.hideVariables'] === 'true' || values['_dash.hideVariables'] === '';
update.hideLinksControls = values['_dash.hideLinks'] === 'true' || values['_dash.hideLinks'] === '';
if (Object.entries(update).some(([k, v]) => v !== this.state[k as keyof DashboardControlsState])) {
this.setState(update);
}
}
public constructor(state: Partial<DashboardControlsState>) {
super({
variableControls: [],
@ -51,26 +81,42 @@ export class DashboardControls extends SceneObjectBase<DashboardControlsState> {
this.forceRender();
}
}
public hasControls(): boolean {
const hasVariables = sceneGraph
.getVariables(this)
?.state.variables.some((v) => v.state.hide !== VariableHide.hideVariable);
const hasAnnotations = sceneGraph.getDataLayers(this).some((d) => d.state.isEnabled && !d.state.isHidden);
const hasLinks = getDashboardSceneFor(this).state.links?.length > 0;
const hideLinks = this.state.hideLinksControls || !hasLinks;
const hideVariables = this.state.hideVariableControls || (!hasAnnotations && !hasVariables);
const hideTimePicker = this.state.hideTimeControls;
return !(hideVariables && hideLinks && hideTimePicker);
}
}
function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardControls>) {
const { variableControls, refreshPicker, timePicker, hideTimeControls } = model.useState();
const { variableControls, refreshPicker, timePicker, hideTimeControls, hideVariableControls, hideLinksControls } =
model.useState();
const dashboard = getDashboardSceneFor(model);
const { links, meta, editPanel } = dashboard.useState();
const styles = useStyles2(getStyles);
const showDebugger = location.search.includes('scene-debugger');
if (!model.hasControls()) {
return null;
}
return (
<div
data-testid={selectors.pages.Dashboard.Controls}
className={cx(styles.controls, meta.isEmbedded && styles.embedded)}
>
<Stack grow={1} wrap={'wrap'}>
{variableControls.map((c) => (
<c.Component model={c} key={c.state.key} />
))}
{!hideVariableControls && variableControls.map((c) => <c.Component model={c} key={c.state.key} />)}
<Box grow={1} />
{!editPanel && <DashboardLinksControls links={links} uid={dashboard.state.uid} />}
{!hideLinksControls && !editPanel && <DashboardLinksControls links={links} uid={dashboard.state.uid} />}
{editPanel && <PanelEditControls panelEditor={editPanel} />}
</Stack>
{!hideTimeControls && (

View File

@ -35,7 +35,7 @@ import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { deleteDashboard } from 'app/features/manage-dashboards/state/actions';
import { VariablesChanged } from 'app/features/variables/types';
import { DashboardDTO, DashboardMeta, SaveDashboardResponseDTO } from 'app/types';
import { DashboardDTO, DashboardMeta, KioskMode, SaveDashboardResponseDTO } from 'app/types';
import { ShowConfirmModalEvent } from 'app/types/events';
import { PanelEditor } from '../panel-edit/PanelEditor';
@ -125,6 +125,8 @@ export interface DashboardSceneState extends SceneObjectState {
isEmpty?: boolean;
/** Scene object that handles the scopes selector */
scopes?: ScopesScene;
/** Kiosk mode */
kioskMode?: KioskMode;
}
export class DashboardScene extends SceneObjectBase<DashboardSceneState> {

View File

@ -24,6 +24,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
const bodyToRender = model.getBodyToRender();
const navModel = getNavModel(navIndex, 'dashboards/browse');
const isHomePage = !meta.url && !meta.slug && !meta.isNew && !meta.isSnapshot;
const hasControls = controls?.hasControls();
if (editview) {
return (
@ -37,7 +38,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
const emptyState = <DashboardEmpty dashboard={model} canCreate={!!model.state.meta.canEdit} />;
const withPanels = (
<div className={cx(styles.body)}>
<div className={cx(styles.body, !hasControls && styles.bodyWithoutControls)}>
<bodyToRender.Component model={bodyToRender} />
</div>
);
@ -49,14 +50,14 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
<div
className={cx(
styles.pageContainer,
controls && !scopes && styles.pageContainerWithControls,
hasControls && !scopes && styles.pageContainerWithControls,
scopes && styles.pageContainerWithScopes,
scopes && isScopesExpanded && styles.pageContainerWithScopesExpanded
)}
>
{scopes && <scopes.Component model={scopes} />}
<NavToolbarActions dashboard={model} />
{!isHomePage && controls && (
{!isHomePage && controls && hasControls && (
<div
className={cx(styles.controlsWrapper, scopes && !isScopesExpanded && styles.controlsWrapperWithScopes)}
>
@ -119,6 +120,9 @@ function getStyles(theme: GrafanaTheme2) {
flexGrow: 0,
gridArea: 'controls',
padding: theme.spacing(2),
':empty': {
display: 'none',
},
}),
controlsWrapperWithScopes: css({
padding: theme.spacing(2, 2, 2, 0),
@ -139,7 +143,11 @@ function getStyles(theme: GrafanaTheme2) {
flexGrow: 1,
display: 'flex',
gap: '8px',
marginBottom: theme.spacing(2),
paddingBottom: theme.spacing(2),
boxSizing: 'border-box',
}),
bodyWithoutControls: css({
paddingTop: theme.spacing(2),
}),
};
}

View File

@ -1,6 +1,7 @@
import { AppEvents } from '@grafana/data';
import { SceneGridLayout, SceneQueryRunner, VizPanel } from '@grafana/scenes';
import appEvents from 'app/core/app_events';
import { KioskMode } from 'app/types';
import { DashboardGridItem } from './DashboardGridItem';
import { DashboardScene } from './DashboardScene';
@ -39,6 +40,29 @@ describe('DashboardSceneUrlSync', () => {
(scene.state.body as SceneGridLayout).setState({ UNSAFE_fitPanels: true });
expect(scene.urlSync?.getUrlState().autofitpanels).toBe('true');
});
it('Should set kiosk mode when url has kiosk', () => {
const scene = buildTestScene();
scene.urlSync?.updateFromUrl({ kiosk: 'invalid' });
expect(scene.state.kioskMode).toBe(undefined);
scene.urlSync?.updateFromUrl({ kiosk: '' });
expect(scene.state.kioskMode).toBe(KioskMode.Full);
scene.urlSync?.updateFromUrl({ kiosk: 'tv' });
expect(scene.state.kioskMode).toBe(KioskMode.TV);
scene.urlSync?.updateFromUrl({ kiosk: 'true' });
expect(scene.state.kioskMode).toBe(KioskMode.Full);
});
it('Should get the kiosk mode from the scene state', () => {
const scene = buildTestScene();
expect(scene.urlSync?.getUrlState().kiosk).toBe(undefined);
scene.setState({ kioskMode: KioskMode.TV });
expect(scene.urlSync?.getUrlState().kiosk).toBe(KioskMode.TV);
scene.setState({ kioskMode: KioskMode.Full });
expect(scene.urlSync?.getUrlState().kiosk).toBe('');
});
});
describe('entering edit mode', () => {

View File

@ -11,6 +11,7 @@ import {
VizPanel,
} from '@grafana/scenes';
import appEvents from 'app/core/app_events';
import { KioskMode } from 'app/types';
import { PanelInspectDrawer } from '../inspect/PanelInspectDrawer';
import { buildPanelEditScene } from '../panel-edit/PanelEditor';
@ -28,7 +29,7 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
constructor(private _scene: DashboardScene) {}
getKeys(): string[] {
return ['inspect', 'viewPanel', 'editPanel', 'editview', 'autofitpanels'];
return ['inspect', 'viewPanel', 'editPanel', 'editview', 'autofitpanels', 'kiosk'];
}
getUrlState(): SceneObjectUrlValues {
@ -39,6 +40,7 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
viewPanel: state.viewPanelScene?.getUrlKey(),
editview: state.editview?.getUrlKey(),
editPanel: state.editPanel?.getUrlKey() || undefined,
kiosk: state.kioskMode === KioskMode.Full ? '' : state.kioskMode === KioskMode.TV ? 'tv' : undefined,
};
}
@ -159,6 +161,14 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
}
}
if (typeof values.kiosk === 'string') {
if (values.kiosk === 'true' || values.kiosk === '') {
update.kioskMode = KioskMode.Full;
} else if (values.kiosk === 'tv') {
update.kioskMode = KioskMode.TV;
}
}
if (Object.keys(update).length > 0) {
this._scene.setState(update);
}

View File

@ -566,7 +566,12 @@ export function ToolbarActions({ dashboard }: Props) {
Save dashboard
</Button>
<Dropdown overlay={menu}>
<Button icon="angle-down" variant={isDirty ? 'primary' : 'secondary'} size="sm" />
<Button
aria-label="More save options"
icon="angle-down"
variant={isDirty ? 'primary' : 'secondary'}
size="sm"
/>
</Dropdown>
</ButtonGroup>
);

View File

@ -2,17 +2,18 @@ import { css } from '@emotion/css';
import React from 'react';
import { Link } from 'react-router-dom';
import { GrafanaTheme2, Scope, ScopeDashboardBinding, urlUtil } from '@grafana/data';
import { GrafanaTheme2, Scope, urlUtil } from '@grafana/data';
import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { CustomScrollbar, Icon, Input, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { t } from 'app/core/internationalization';
import { fetchDashboards } from './api';
import { fetchSuggestedDashboards } from './api';
import { SuggestedDashboard } from './types';
export interface ScopesDashboardsSceneState extends SceneObjectState {
dashboards: ScopeDashboardBinding[];
filteredDashboards: ScopeDashboardBinding[];
dashboards: SuggestedDashboard[];
filteredDashboards: SuggestedDashboard[];
isLoading: boolean;
searchQuery: string;
}
@ -36,7 +37,7 @@ export class ScopesDashboardsScene extends SceneObjectBase<ScopesDashboardsScene
this.setState({ isLoading: true });
const dashboards = await fetchDashboards(scopes);
const dashboards = await fetchSuggestedDashboards(scopes);
this.setState({
dashboards,
@ -54,12 +55,10 @@ export class ScopesDashboardsScene extends SceneObjectBase<ScopesDashboardsScene
});
}
private filterDashboards(dashboards: ScopeDashboardBinding[], searchQuery: string) {
private filterDashboards(dashboards: SuggestedDashboard[], searchQuery: string): SuggestedDashboard[] {
const lowerCasedSearchQuery = searchQuery.toLowerCase();
return dashboards.filter(({ spec: { dashboardTitle } }) =>
dashboardTitle.toLowerCase().includes(lowerCasedSearchQuery)
);
return dashboards.filter(({ dashboardTitle }) => dashboardTitle.toLowerCase().includes(lowerCasedSearchQuery));
}
}
@ -74,7 +73,7 @@ export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps<Sco
<div className={styles.searchInputContainer}>
<Input
prefix={<Icon name="search" />}
placeholder={t('scopes.suggestedDashboards.search', 'Filter')}
placeholder={t('scopes.suggestedDashboards.search', 'Search')}
disabled={isLoading}
data-testid="scopes-dashboards-search"
onChange={(evt) => model.changeSearchQuery(evt.currentTarget.value)}
@ -89,7 +88,7 @@ export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps<Sco
/>
) : (
<CustomScrollbar>
{filteredDashboards.map(({ spec: { dashboard, dashboardTitle } }) => (
{filteredDashboards.map(({ dashboard, dashboardTitle }) => (
<Link
key={dashboard}
to={urlUtil.renderUrl(`/d/${dashboard}/`, queryParams)}

View File

@ -13,19 +13,20 @@ import {
SceneObjectUrlValues,
SceneObjectWithUrlSync,
} from '@grafana/scenes';
import { Button, Drawer, IconButton, Input, Spinner, useStyles2 } from '@grafana/ui';
import { Button, Drawer, Spinner, useStyles2 } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { ScopesInput } from './ScopesInput';
import { ScopesScene } from './ScopesScene';
import { ScopesTreeLevel } from './ScopesTreeLevel';
import { fetchNodes, fetchScope, fetchScopes } from './api';
import { NodesMap } from './types';
import { fetchNodes, fetchScope, fetchSelectedScopes } from './api';
import { NodesMap, SelectedScope, TreeScope } from './types';
export interface ScopesFiltersSceneState extends SceneObjectState {
nodes: NodesMap;
loadingNodeName: string | undefined;
scopes: Scope[];
dirtyScopeNames: string[];
scopes: SelectedScope[];
treeScopes: TreeScope[];
isLoadingScopes: boolean;
isOpened: boolean;
}
@ -57,7 +58,7 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
},
loadingNodeName: undefined,
scopes: [],
dirtyScopeNames: [],
treeScopes: [],
isLoadingScopes: false,
isOpened: false,
});
@ -72,14 +73,16 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
}
public getUrlState() {
return { scopes: this.getScopeNames() };
return {
scopes: this.state.scopes.map(({ scope }) => scope.metadata.name),
};
}
public updateFromUrl(values: SceneObjectUrlValues) {
let dirtyScopeNames = values.scopes ?? [];
dirtyScopeNames = Array.isArray(dirtyScopeNames) ? dirtyScopeNames : [dirtyScopeNames];
let scopeNames = values.scopes ?? [];
scopeNames = Array.isArray(scopeNames) ? scopeNames : [scopeNames];
this.updateScopes(dirtyScopeNames);
this.updateScopes(scopeNames.map((scopeName) => ({ scopeName, path: [] })));
}
public fetchBaseNodes() {
@ -126,7 +129,7 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
}
public toggleNodeSelect(path: string[]) {
let dirtyScopeNames = [...this.state.dirtyScopeNames];
let treeScopes = [...this.state.treeScopes];
let siblings = this.state.nodes;
@ -134,22 +137,27 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
siblings = siblings[path[idx]].nodes;
}
const name = path[path.length - 1];
const { linkId } = siblings[name];
const nodeName = path[path.length - 1];
const { linkId } = siblings[nodeName];
const selectedIdx = dirtyScopeNames.findIndex((scopeName) => scopeName === linkId);
const selectedIdx = treeScopes.findIndex(({ scopeName }) => scopeName === linkId);
if (selectedIdx === -1) {
fetchScope(linkId!);
const selectedFromSameNode =
dirtyScopeNames.length === 0 || Object.values(siblings).some(({ linkId }) => linkId === dirtyScopeNames[0]);
treeScopes.length === 0 || Object.values(siblings).some(({ linkId }) => linkId === treeScopes[0].scopeName);
this.setState({ dirtyScopeNames: !selectedFromSameNode ? [linkId!] : [...dirtyScopeNames, linkId!] });
const treeScope = {
scopeName: linkId!,
path,
};
this.setState({ treeScopes: !selectedFromSameNode ? [treeScope] : [...treeScopes, treeScope] });
} else {
dirtyScopeNames.splice(selectedIdx, 1);
treeScopes.splice(selectedIdx, 1);
this.setState({ dirtyScopeNames });
this.setState({ treeScopes });
}
}
@ -164,62 +172,53 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
}
public getSelectedScopes(): Scope[] {
return this.state.scopes;
return this.state.scopes.map(({ scope }) => scope);
}
public async updateScopes(dirtyScopeNames = this.state.dirtyScopeNames) {
if (isEqual(dirtyScopeNames, this.getScopeNames())) {
public async updateScopes(treeScopes = this.state.treeScopes) {
if (isEqual(treeScopes, this.getTreeScopes())) {
return;
}
this.setState({ dirtyScopeNames, isLoadingScopes: true });
this.setState({ treeScopes, isLoadingScopes: true });
this.setState({ scopes: await fetchScopes(dirtyScopeNames), isLoadingScopes: false });
this.setState({ scopes: await fetchSelectedScopes(treeScopes), isLoadingScopes: false });
}
public resetDirtyScopeNames() {
this.setState({ dirtyScopeNames: this.getScopeNames() });
this.setState({ treeScopes: this.getTreeScopes() });
}
public removeAllScopes() {
this.setState({ scopes: [], dirtyScopeNames: [], isLoadingScopes: false });
this.setState({ scopes: [], treeScopes: [], isLoadingScopes: false });
}
public enterViewMode() {
this.setState({ isOpened: false });
}
private getScopeNames(): string[] {
return this.state.scopes.map(({ metadata: { name } }) => name);
private getTreeScopes(): TreeScope[] {
return this.state.scopes.map(({ scope, path }) => ({
scopeName: scope.metadata.name,
path,
}));
}
}
export function ScopesFiltersSceneRenderer({ model }: SceneComponentProps<ScopesFiltersScene>) {
const styles = useStyles2(getStyles);
const { nodes, loadingNodeName, dirtyScopeNames, isLoadingScopes, isOpened, scopes } = model.useState();
const { nodes, loadingNodeName, treeScopes, isLoadingScopes, isOpened, scopes } = model.useState();
const { isViewing } = model.scopesParent.useState();
const scopesTitles = scopes.map(({ spec: { title } }) => title).join(', ');
return (
<>
<Input
readOnly
placeholder={t('scopes.filters.input.placeholder', 'Select scopes...')}
loading={isLoadingScopes}
value={scopesTitles}
aria-label={t('scopes.filters.input.placeholder', 'Select scopes...')}
data-testid="scopes-filters-input"
suffix={
scopes.length > 0 && !isViewing ? (
<IconButton
aria-label={t('scopes.filters.input.removeAll', 'Remove all scopes')}
name="times"
onClick={() => model.removeAllScopes()}
/>
) : undefined
}
onClick={() => model.open()}
<ScopesInput
nodes={nodes}
scopes={scopes}
isDisabled={isViewing}
isLoading={isLoadingScopes}
onInputClick={() => model.open()}
onRemoveAllClick={() => model.removeAllScopes()}
/>
{isOpened && (
@ -238,7 +237,7 @@ export function ScopesFiltersSceneRenderer({ model }: SceneComponentProps<Scopes
nodes={nodes}
nodePath={['']}
loadingNodeName={loadingNodeName}
scopeNames={dirtyScopeNames}
scopes={treeScopes}
onNodeUpdate={(path, isExpanded, query) => model.updateNode(path, isExpanded, query)}
onNodeSelectToggle={(path) => model.toggleNodeSelect(path)}
/>

View File

@ -0,0 +1,119 @@
import { css } from '@emotion/css';
import { groupBy } from 'lodash';
import React, { useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { IconButton, Input, Tooltip } from '@grafana/ui';
import { useStyles2 } from '@grafana/ui/';
import { t } from 'app/core/internationalization';
import { NodesMap, SelectedScope } from './types';
export interface ScopesInputProps {
nodes: NodesMap;
scopes: SelectedScope[];
isDisabled: boolean;
isLoading: boolean;
onInputClick: () => void;
onRemoveAllClick: () => void;
}
export function ScopesInput({
nodes,
scopes,
isDisabled,
isLoading,
onInputClick,
onRemoveAllClick,
}: ScopesInputProps) {
const styles = useStyles2(getStyles);
const scopesPaths = useMemo(() => {
const pathsTitles = scopes.map(({ scope, path }) => {
let currentLevel = nodes;
let titles: string[];
if (path.length > 0) {
titles = path.map((nodeName) => {
const { title, nodes } = currentLevel[nodeName];
currentLevel = nodes;
return title;
});
if (titles[0] === '') {
titles.splice(0, 1);
}
} else {
titles = [scope.spec.title];
}
const scopeName = titles.pop();
return [titles.join(' > '), scopeName];
});
const groupedByPath = groupBy(pathsTitles, ([path]) => path);
return Object.entries(groupedByPath)
.map(([path, pathScopes]) => {
const scopesTitles = pathScopes.map(([, scopeTitle]) => scopeTitle).join(', ');
return (path ? [path, scopesTitles] : [scopesTitles]).join(' > ');
})
.map((path) => (
<p key={path} className={styles.scopePath}>
{path}
</p>
));
}, [nodes, scopes, styles]);
const scopesTitles = useMemo(() => scopes.map(({ scope }) => scope.spec.title).join(', '), [scopes]);
const input = (
<Input
readOnly
placeholder={t('scopes.filters.input.placeholder', 'Select scopes...')}
loading={isLoading}
value={scopesTitles}
aria-label={t('scopes.filters.input.placeholder', 'Select scopes...')}
data-testid="scopes-filters-input"
suffix={
scopes.length > 0 && !isDisabled ? (
<IconButton
aria-label={t('scopes.filters.input.removeAll', 'Remove all scopes')}
name="times"
onClick={() => onRemoveAllClick()}
/>
) : undefined
}
onClick={() => {
if (!isDisabled) {
onInputClick();
}
}}
/>
);
if (scopes.length === 0) {
return input;
}
return (
<Tooltip content={<>{scopesPaths}</>} interactive={true}>
{input}
</Tooltip>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
scopePath: css({
color: theme.colors.text.primary,
fontSize: theme.typography.pxToRem(14),
margin: theme.spacing(1, 0),
}),
};
};

View File

@ -9,12 +9,14 @@ import { ScopesFiltersScene } from './ScopesFiltersScene';
import { ScopesScene } from './ScopesScene';
import {
buildTestScene,
fetchDashboardsSpy,
fetchSuggestedDashboardsSpy,
fetchNodesSpy,
fetchScopeSpy,
fetchScopesSpy,
fetchSelectedScopesSpy,
getApplicationsClustersExpand,
getApplicationsClustersSelect,
getApplicationsClustersSlothClusterNorthSelect,
getApplicationsClustersSlothClusterSouthSelect,
getApplicationsExpand,
getApplicationsSearch,
getApplicationsSlothPictureFactorySelect,
@ -34,6 +36,7 @@ import {
mocksNodes,
mocksScopeDashboardBindings,
mocksScopes,
queryAllDashboard,
queryFiltersApply,
queryApplicationsClustersSlothClusterNorthTitle,
queryApplicationsClustersTitle,
@ -104,8 +107,8 @@ describe('ScopesScene', () => {
fetchNodesSpy.mockClear();
fetchScopeSpy.mockClear();
fetchScopesSpy.mockClear();
fetchDashboardsSpy.mockClear();
fetchSelectedScopesSpy.mockClear();
fetchSuggestedDashboardsSpy.mockClear();
dashboardScene = buildTestScene();
scopesScene = dashboardScene.state.scopes!;
@ -134,7 +137,12 @@ describe('ScopesScene', () => {
});
it('Selects the proper scopes', async () => {
await act(async () => filtersScene.updateScopes(['slothPictureFactory', 'slothVoteTracker']));
await act(async () =>
filtersScene.updateScopes([
{ scopeName: 'slothPictureFactory', path: [] },
{ scopeName: 'slothVoteTracker', path: [] },
])
);
await userEvents.click(getFiltersInput());
await userEvents.click(getApplicationsExpand());
expect(getApplicationsSlothVoteTrackerSelect()).toBeChecked();
@ -203,7 +211,7 @@ describe('ScopesScene', () => {
await userEvents.click(getFiltersInput());
await userEvents.click(getClustersSelect());
await userEvents.click(getFiltersApply());
await waitFor(() => expect(fetchScopesSpy).toHaveBeenCalled());
await waitFor(() => expect(fetchSelectedScopesSpy).toHaveBeenCalled());
expect(filtersScene.getSelectedScopes()).toEqual(
mocksScopes.filter(({ metadata: { name } }) => name === 'indexHelperCluster')
);
@ -213,7 +221,7 @@ describe('ScopesScene', () => {
await userEvents.click(getFiltersInput());
await userEvents.click(getClustersSelect());
await userEvents.click(getFiltersCancel());
await waitFor(() => expect(fetchScopesSpy).not.toHaveBeenCalled());
await waitFor(() => expect(fetchSelectedScopesSpy).not.toHaveBeenCalled());
expect(filtersScene.getSelectedScopes()).toEqual([]);
});
@ -236,7 +244,7 @@ describe('ScopesScene', () => {
await userEvents.click(getApplicationsExpand());
await userEvents.click(getApplicationsSlothPictureFactorySelect());
await userEvents.click(getFiltersApply());
await waitFor(() => expect(fetchDashboardsSpy).not.toHaveBeenCalled());
await waitFor(() => expect(fetchSuggestedDashboardsSpy).not.toHaveBeenCalled());
});
it('Fetches dashboards list when the list is expanded', async () => {
@ -245,7 +253,7 @@ describe('ScopesScene', () => {
await userEvents.click(getApplicationsExpand());
await userEvents.click(getApplicationsSlothPictureFactorySelect());
await userEvents.click(getFiltersApply());
await waitFor(() => expect(fetchDashboardsSpy).toHaveBeenCalled());
await waitFor(() => expect(fetchSuggestedDashboardsSpy).toHaveBeenCalled());
});
it('Fetches dashboards list when the list is expanded after scope selection', async () => {
@ -254,7 +262,7 @@ describe('ScopesScene', () => {
await userEvents.click(getApplicationsSlothPictureFactorySelect());
await userEvents.click(getFiltersApply());
await userEvents.click(getDashboardsExpand());
await waitFor(() => expect(fetchDashboardsSpy).toHaveBeenCalled());
await waitFor(() => expect(fetchSuggestedDashboardsSpy).toHaveBeenCalled());
});
it('Shows dashboards for multiple scopes', async () => {
@ -294,6 +302,20 @@ describe('ScopesScene', () => {
await userEvents.type(getDashboardsSearch(), '1');
expect(queryDashboard('2')).not.toBeInTheDocument();
});
it('Deduplicates the dashboards list', async () => {
await userEvents.click(getDashboardsExpand());
await userEvents.click(getFiltersInput());
await userEvents.click(getApplicationsExpand());
await userEvents.click(getApplicationsClustersExpand());
await userEvents.click(getApplicationsClustersSlothClusterNorthSelect());
await userEvents.click(getApplicationsClustersSlothClusterSouthSelect());
await userEvents.click(getFiltersApply());
expect(queryAllDashboard('5')).toHaveLength(1);
expect(queryAllDashboard('6')).toHaveLength(1);
expect(queryAllDashboard('7')).toHaveLength(1);
expect(queryAllDashboard('8')).toHaveLength(1);
});
});
describe('View mode', () => {

View File

@ -32,7 +32,7 @@ export class ScopesScene extends SceneObjectBase<ScopesSceneState> {
this.state.filters.subscribeToState((newState, prevState) => {
if (newState.scopes !== prevState.scopes) {
if (this.state.isExpanded) {
this.state.dashboards.fetchDashboards(newState.scopes);
this.state.dashboards.fetchDashboards(this.state.filters.getSelectedScopes());
}
sceneGraph.getTimeRange(this.parent!).onRefresh();

View File

@ -5,15 +5,15 @@ import Skeleton from 'react-loading-skeleton';
import { GrafanaTheme2 } from '@grafana/data';
import { Checkbox, Icon, IconButton, Input, useStyles2 } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { t, Trans } from 'app/core/internationalization';
import { NodesMap } from './types';
import { NodesMap, TreeScope } from './types';
export interface ScopesTreeLevelProps {
nodes: NodesMap;
nodePath: string[];
loadingNodeName: string | undefined;
scopeNames: string[];
scopes: TreeScope[];
onNodeUpdate: (path: string[], isExpanded: boolean, query: string) => void;
onNodeSelectToggle: (path: string[]) => void;
}
@ -22,7 +22,7 @@ export function ScopesTreeLevel({
nodes,
nodePath,
loadingNodeName,
scopeNames,
scopes,
onNodeUpdate,
onNodeSelectToggle,
}: ScopesTreeLevelProps) {
@ -34,6 +34,7 @@ export function ScopesTreeLevel({
const childNodesArr = Object.values(childNodes);
const isNodeLoading = loadingNodeName === nodeId;
const scopeNames = scopes.map(({ scopeName }) => scopeName);
const anyChildExpanded = childNodesArr.some(({ isExpanded }) => isExpanded);
const anyChildSelected = childNodesArr.some(({ linkId }) => linkId && scopeNames.includes(linkId!));
@ -45,13 +46,19 @@ export function ScopesTreeLevel({
<Input
prefix={<Icon name="filter" />}
className={styles.searchInput}
placeholder={t('scopes.tree.search', 'Filter')}
placeholder={t('scopes.tree.search', 'Search')}
defaultValue={node.query}
data-testid={`scopes-tree-${nodeId}-search`}
onInput={(evt) => onQueryUpdate(nodePath, true, evt.currentTarget.value)}
/>
)}
{!anyChildExpanded && !node.query && (
<h6 className={styles.headline}>
<Trans i18nKey="scopes.tree.headline">Recommended</Trans>
</h6>
)}
<div role="tree">
{isNodeLoading && <Skeleton count={5} className={styles.loader} />}
@ -102,7 +109,7 @@ export function ScopesTreeLevel({
nodes={node.nodes}
nodePath={childNodePath}
loadingNodeName={loadingNodeName}
scopeNames={scopeNames}
scopes={scopes}
onNodeUpdate={onNodeUpdate}
onNodeSelectToggle={onNodeSelectToggle}
/>
@ -121,6 +128,10 @@ const getStyles = (theme: GrafanaTheme2) => {
searchInput: css({
margin: theme.spacing(1, 0),
}),
headline: css({
color: theme.colors.text.secondary,
margin: theme.spacing(1, 0),
}),
loader: css({
margin: theme.spacing(0.5, 0),
}),

View File

@ -1,7 +1,8 @@
import { Scope, ScopeSpec, ScopeNode, ScopeDashboardBinding } from '@grafana/data';
import { config, getBackendSrv } from '@grafana/runtime';
import { ScopedResourceClient } from 'app/features/apiserver/client';
import { NodesMap } from 'app/features/dashboard-scene/scene/Scopes/types';
import { NodesMap, SelectedScope, SuggestedDashboard, TreeScope } from './types';
const group = 'scope.grafana.app';
const version = 'v0alpha1';
@ -90,6 +91,19 @@ export async function fetchScopes(names: string[]): Promise<Scope[]> {
return await Promise.all(names.map(fetchScope));
}
export async function fetchSelectedScopes(treeScopes: TreeScope[]): Promise<SelectedScope[]> {
const scopes = await fetchScopes(treeScopes.map(({ scopeName }) => scopeName));
return scopes.reduce<SelectedScope[]>((acc, scope, idx) => {
acc.push({
scope,
path: treeScopes[idx].path,
});
return acc;
}, []);
}
export async function fetchDashboards(scopes: Scope[]): Promise<ScopeDashboardBinding[]> {
try {
const response = await getBackendSrv().get<{ items: ScopeDashboardBinding[] }>(dashboardsEndpoint, {
@ -101,3 +115,23 @@ export async function fetchDashboards(scopes: Scope[]): Promise<ScopeDashboardBi
return [];
}
}
export async function fetchSuggestedDashboards(scopes: Scope[]): Promise<SuggestedDashboard[]> {
const items = await fetchDashboards(scopes);
return Object.values(
items.reduce<Record<string, SuggestedDashboard>>((acc, item) => {
if (!acc[item.spec.dashboard]) {
acc[item.spec.dashboard] = {
dashboard: item.spec.dashboard,
dashboardTitle: item.spec.dashboardTitle,
items: [],
};
}
acc[item.spec.dashboard].items.push(item);
return acc;
}, {})
);
}

View File

@ -89,6 +89,30 @@ export const mocksScopeDashboardBindings: ScopeDashboardBinding[] = [
metadata: { name: 'binding4' },
spec: { dashboard: '4', dashboardTitle: 'My Dashboard 4', scope: 'slothVoteTracker' },
},
{
metadata: { name: 'binding5' },
spec: { dashboard: '5', dashboardTitle: 'My Dashboard 5', scope: 'slothClusterNorth' },
},
{
metadata: { name: 'binding6' },
spec: { dashboard: '6', dashboardTitle: 'My Dashboard 6', scope: 'slothClusterNorth' },
},
{
metadata: { name: 'binding7' },
spec: { dashboard: '7', dashboardTitle: 'My Dashboard 7', scope: 'slothClusterNorth' },
},
{
metadata: { name: 'binding8' },
spec: { dashboard: '5', dashboardTitle: 'My Dashboard 5', scope: 'slothClusterSouth' },
},
{
metadata: { name: 'binding9' },
spec: { dashboard: '6', dashboardTitle: 'My Dashboard 6', scope: 'slothClusterSouth' },
},
{
metadata: { name: 'binding10' },
spec: { dashboard: '8', dashboardTitle: 'My Dashboard 8', scope: 'slothClusterSouth' },
},
] as const;
export const mocksNodes: Array<ScopeNode & { parent: string }> = [
@ -225,8 +249,8 @@ export const mocksNodes: Array<ScopeNode & { parent: string }> = [
export const fetchNodesSpy = jest.spyOn(api, 'fetchNodes');
export const fetchScopeSpy = jest.spyOn(api, 'fetchScope');
export const fetchScopesSpy = jest.spyOn(api, 'fetchScopes');
export const fetchDashboardsSpy = jest.spyOn(api, 'fetchDashboards');
export const fetchSelectedScopesSpy = jest.spyOn(api, 'fetchSelectedScopes');
export const fetchSuggestedDashboardsSpy = jest.spyOn(api, 'fetchSuggestedDashboards');
const selectors = {
tree: {
@ -261,6 +285,7 @@ export const getDashboardsExpand = () => screen.getByTestId(selectors.dashboards
export const queryDashboardsContainer = () => screen.queryByTestId(selectors.dashboards.container);
export const getDashboardsContainer = () => screen.getByTestId(selectors.dashboards.container);
export const getDashboardsSearch = () => screen.getByTestId(selectors.dashboards.search);
export const queryAllDashboard = (uid: string) => screen.queryAllByTestId(selectors.dashboards.dashboard(uid));
export const queryDashboard = (uid: string) => screen.queryByTestId(selectors.dashboards.dashboard(uid));
export const getDashboard = (uid: string) => screen.getByTestId(selectors.dashboards.dashboard(uid));
@ -281,6 +306,10 @@ export const getApplicationsClustersSelect = () => screen.getByTestId(selectors.
export const getApplicationsClustersExpand = () => screen.getByTestId(selectors.tree.expand('applications.clusters'));
export const queryApplicationsClustersSlothClusterNorthTitle = () =>
screen.queryByTestId(selectors.tree.title('applications.clusters-slothClusterNorth'));
export const getApplicationsClustersSlothClusterNorthSelect = () =>
screen.getByTestId(selectors.tree.select('applications.clusters-slothClusterNorth'));
export const getApplicationsClustersSlothClusterSouthSelect = () =>
screen.getByTestId(selectors.tree.select('applications.clusters-slothClusterSouth'));
export const getClustersSelect = () => screen.getByTestId(selectors.tree.select('clusters'));
export const getClustersExpand = () => screen.getByTestId(selectors.tree.expand('clusters'));

View File

@ -1,4 +1,4 @@
import { ScopeNodeSpec } from '@grafana/data';
import { Scope, ScopeDashboardBinding, ScopeNodeSpec } from '@grafana/data';
export interface Node extends ScopeNodeSpec {
name: string;
@ -10,3 +10,19 @@ export interface Node extends ScopeNodeSpec {
}
export type NodesMap = Record<string, Node>;
export interface SelectedScope {
scope: Scope;
path: string[];
}
export interface TreeScope {
scopeName: string;
path: string[];
}
export interface SuggestedDashboard {
dashboard: string;
dashboardTitle: string;
items: ScopeDashboardBinding[];
}

View File

@ -346,7 +346,6 @@ export function panelRepeaterToPanels(
return [libraryVizPanelToPanel(repeater.state.body, { x, y, w, h })];
}
// console.log('repeater.state', repeater.state);
if (repeater.state.repeatedPanels) {
const itemHeight = repeater.state.itemHeight ?? 10;
const rowCount = Math.ceil(repeater.state.repeatedPanels!.length / repeater.getMaxPerRow());

View File

@ -2,7 +2,7 @@ import { reportMetaAnalytics, MetaAnalyticsEventName, DashboardViewEventPayload
import { DashboardModel } from './DashboardModel';
export function emitDashboardViewEvent(dashboard: DashboardModel) {
export function emitDashboardViewEvent(dashboard: Pick<DashboardModel, 'id' | 'title' | 'uid' | 'meta'>) {
const eventData: DashboardViewEventPayload = {
/** @deprecated */
dashboardId: dashboard.id,

View File

@ -4,7 +4,7 @@ import { useAsync, useDebounce } from 'react-use';
import { FetchError, isFetchError } from '@grafana/runtime';
import { LibraryPanel } from '@grafana/schema/dist/esm/index.gen';
import { Button, Field, Input, Modal } from '@grafana/ui';
import { OldFolderPicker } from 'app/core/components/Select/OldFolderPicker';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { t, Trans } from 'app/core/internationalization';
import { PanelModel } from '../../../dashboard/state';
@ -36,6 +36,7 @@ export const AddLibraryPanelContents = ({
const onCreate = useCallback(() => {
panel.libraryPanel = { uid: '', name: panelName };
saveLibraryPanel(panel, folderUid!).then((res: LibraryPanel | FetchError) => {
if (!isFetchError(res)) {
onDismiss?.();
@ -84,9 +85,9 @@ export const AddLibraryPanelContents = ({
'Library panel permissions are derived from the folder permissions'
)}
>
<OldFolderPicker
onChange={({ uid }) => setFolderUid(uid)}
initialFolderUid={initialFolderUid}
<FolderPicker
onChange={(uid) => setFolderUid(uid)}
value={folderUid}
inputId="share-panel-library-panel-folder-picker"
/>
</Field>

View File

@ -3,9 +3,9 @@ import userEvent from '@testing-library/user-event';
import { range } from 'lodash';
import React from 'react';
import { CoreApp, LogRowModel, LogsDedupStrategy, LogsSortOrder } from '@grafana/data';
import { LogRowModel, LogsDedupStrategy, LogsSortOrder } from '@grafana/data';
import { LogRows, PREVIEW_LIMIT } from './LogRows';
import { LogRows, PREVIEW_LIMIT, Props } from './LogRows';
import { createLogRow } from './__mocks__/logRow';
jest.mock('@grafana/runtime', () => ({
@ -207,7 +207,7 @@ describe('LogRows', () => {
});
describe('Popover menu', () => {
function setup(app = CoreApp.Explore) {
function setup(overrides: Partial<Props> = {}) {
const rows: LogRowModel[] = [createLogRow({ uid: '1' })];
return render(
<LogRows
@ -223,7 +223,7 @@ describe('Popover menu', () => {
displayedFields={[]}
onClickFilterOutString={() => {}}
onClickFilterString={() => {}}
app={app}
{...overrides}
/>
);
}
@ -232,6 +232,8 @@ describe('Popover menu', () => {
orgGetSelection = document.getSelection;
jest.spyOn(document, 'getSelection').mockReturnValue({
toString: () => 'selected log line',
removeAllRanges: () => {},
addRange: (range: Range) => {},
} as Selection);
});
afterAll(() => {
@ -248,9 +250,30 @@ describe('Popover menu', () => {
expect(screen.getByText('Add as line contains filter')).toBeInTheDocument();
expect(screen.getByText('Add as line does not contain filter')).toBeInTheDocument();
});
it('Does not appear outside Explore', async () => {
setup(CoreApp.Unknown);
it('Does not appear when the props are not defined', async () => {
setup({
onClickFilterOutString: undefined,
onClickFilterString: undefined,
});
await userEvent.click(screen.getByText('log message 1'));
expect(screen.queryByText('Copy selection')).not.toBeInTheDocument();
});
it('Appears after selecting test', async () => {
const onClickFilterOutString = jest.fn();
const onClickFilterString = jest.fn();
setup({
onClickFilterOutString,
onClickFilterString,
});
await userEvent.click(screen.getByText('log message 1'));
expect(screen.getByText('Copy selection')).toBeInTheDocument();
await userEvent.click(screen.getByText('Add as line contains filter'));
await userEvent.click(screen.getByText('log message 1'));
expect(screen.getByText('Copy selection')).toBeInTheDocument();
await userEvent.click(screen.getByText('Add as line does not contain filter'));
expect(onClickFilterOutString).toHaveBeenCalledTimes(1);
expect(onClickFilterString).toHaveBeenCalledTimes(1);
});
});

View File

@ -105,7 +105,7 @@ class UnThemedLogRows extends PureComponent<Props, State> {
};
popoverMenuSupported() {
if (!config.featureToggles.logRowsPopoverMenu || this.props.app !== CoreApp.Explore) {
if (!config.featureToggles.logRowsPopoverMenu) {
return false;
}
return Boolean(this.props.onClickFilterOutString || this.props.onClickFilterString);

View File

@ -12,6 +12,9 @@ export const queryParamsToPreserve: { [key: string]: boolean } = {
kiosk: true,
autofitpanels: true,
orgId: true,
'_dash.hideTimePicker': true,
'_dash.hideVariables': true,
'_dash.hideLinks': true,
};
export interface PlaylistSrvState {

View File

@ -1,8 +1,8 @@
import React, { useState } from 'react';
import { SelectableValue, UrlQueryMap, urlUtil } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { Button, Checkbox, Field, FieldSet, Modal, RadioButtonGroup } from '@grafana/ui';
import { config, locationService } from '@grafana/runtime';
import { Box, Button, Checkbox, Field, FieldSet, Modal, RadioButtonGroup, Stack } from '@grafana/ui';
import { Playlist, PlaylistMode } from './types';
@ -14,6 +14,9 @@ export interface Props {
export const StartModal = ({ playlist, onDismiss }: Props) => {
const [mode, setMode] = useState<PlaylistMode>(false);
const [autoFit, setAutofit] = useState(false);
const [displayTimePicker, setDisplayTimePicker] = useState(true);
const [displayVariables, setDisplayVariables] = useState(true);
const [displayLinks, setDisplayLinks] = useState(true);
const modes: Array<SelectableValue<PlaylistMode>> = [
{ label: 'Normal', value: false },
@ -29,6 +32,17 @@ export const StartModal = ({ playlist, onDismiss }: Props) => {
if (autoFit) {
params.autofitpanels = true;
}
if (!displayTimePicker) {
params['_dash.hideTimePicker'] = true;
}
if (!displayVariables) {
params['_dash.hideVariables'] = true;
}
if (!displayLinks) {
params['_dash.hideLinks'] = true;
}
locationService.push(urlUtil.renderUrl(`/playlists/play/${playlist.uid}`, params));
};
@ -38,13 +52,41 @@ export const StartModal = ({ playlist, onDismiss }: Props) => {
<Field label="Mode">
<RadioButtonGroup value={mode} options={modes} onChange={setMode} />
</Field>
<Checkbox
label="Autofit"
description="Panel heights will be adjusted to fit screen size"
name="autofix"
value={autoFit}
onChange={(e) => setAutofit(e.currentTarget.checked)}
/>
<Field>
<Checkbox
label="Autofit"
description="Panel heights will be adjusted to fit screen size"
name="autofix"
value={autoFit}
onChange={(e) => setAutofit(e.currentTarget.checked)}
/>
</Field>
{config.featureToggles.dashboardScene && (
<Field label="Display dashboard controls" description="Customize dashboard elements visibility">
<Box marginTop={2} marginBottom={2}>
<Stack direction="column" alignItems="start" justifyContent="left" gap={2}>
<Checkbox
label="Time and refresh"
name="displayTimePicker"
value={displayTimePicker}
onChange={(e) => setDisplayTimePicker(e.currentTarget.checked)}
/>
<Checkbox
label="Variables"
name="displayVariableControls"
value={displayVariables}
onChange={(e) => setDisplayVariables(e.currentTarget.checked)}
/>
<Checkbox
label="Dashboard links"
name="displayLinks"
value={displayLinks}
onChange={(e) => setDisplayLinks(e.currentTarget.checked)}
/>
</Stack>
</Box>
</Field>
)}
</FieldSet>
<Modal.ButtonRow>
<Button variant="primary" onClick={onStart}>

View File

@ -101,4 +101,17 @@ describe('AdvancedResourcePicker', () => {
expect(screen.getByDisplayValue('rg2')).toBeInTheDocument();
expect(screen.getByDisplayValue('res2')).toBeInTheDocument();
});
it('should display an error when the namespace field ends in a /', async () => {
const onChange = jest.fn();
render(
<AdvancedResourcePicker
onChange={onChange}
resources={[{ metricNamespace: 'Microsoft.OperationalInsights/workspaces/' }]}
/>
);
expect(screen.getByText('Namespace cannot end with a "/"')).toBeInTheDocument();
});
});

View File

@ -81,6 +81,8 @@ const AdvancedResourcePicker = ({ resources, onChange }: ResourcePickerProps<Azu
htmlFor={`input-advanced-resource-picker-metricNamespace`}
labelWidth={15}
data-testid={selectors.components.queryEditor.resourcePicker.advanced.namespace.input}
invalid={resources[0]?.metricNamespace?.endsWith('/')}
error={'Namespace cannot end with a "/"'}
>
<Input
id={`input-advanced-resource-picker-metricNamespace`}

View File

@ -42,6 +42,11 @@ export function isAlertStateWithReason(
return state !== null && state !== undefined && !propAlertingRuleStateValues.includes(state);
}
export function mapStateWithReasonToReason(state: GrafanaAlertStateWithReason): string {
const match = state.match(/\((.*?)\)/);
return match ? match[1] : '';
}
export function mapStateWithReasonToBaseState(
state: GrafanaAlertStateWithReason | PromAlertingRuleState
): GrafanaAlertState | PromAlertingRuleState {

View File

@ -25,6 +25,14 @@
"user": "User"
}
},
"alert-labels": {
"button": {
"hide": "Hide common labels",
"show": {
"tooltip": "Show common labels"
}
}
},
"alert-rule-form": {
"evaluation-behaviour": {
"description": {
@ -84,15 +92,15 @@
},
"counts": {
"alertRule_one": "{{count}} alert rule",
"alertRule_other": "{{count}} alert rules",
"alertRule_other": "{{count}} alert rule",
"dashboard_one": "{{count}} dashboard",
"dashboard_other": "{{count}} dashboards",
"dashboard_other": "{{count}} dashboard",
"folder_one": "{{count}} folder",
"folder_other": "{{count}} folders",
"folder_other": "{{count}} folder",
"libraryPanel_one": "{{count}} library panel",
"libraryPanel_other": "{{count}} library panels",
"libraryPanel_other": "{{count}} library panel",
"total_one": "{{count}} item",
"total_other": "{{count}} items"
"total_other": "{{count}} item"
},
"dashboards-tree": {
"collapse-folder-button": "Collapse folder {{title}}",
@ -138,6 +146,16 @@
"text": "No results found for your query"
}
},
"central-alert-history": {
"error": "Something went wrong loading the alert state history",
"filter": {
"button": {
"clear": "Clear"
},
"label": "Filter events",
"placeholder": "Filter events in the list with labels"
}
},
"clipboard-button": {
"inline-toast": {
"success": "Copied"
@ -758,7 +776,7 @@
},
"modal": {
"body_one": "This panel is being used in {{count}} dashboard. Please choose which dashboard to view the panel in:",
"body_other": "This panel is being used in {{count}} dashboards. Please choose which dashboard to view the panel in:",
"body_other": "This panel is being used in {{count}} dashboard. Please choose which dashboard to view the panel in:",
"button-cancel": "Cancel",
"button-view-panel1": "View panel in {{label}}...",
"button-view-panel2": "View panel in dashboard...",
@ -1606,7 +1624,7 @@
},
"suggestedDashboards": {
"loading": "Loading dashboards",
"search": "Filter",
"search": "Search",
"toggle": {
"collapse": "Collapse scope filters",
"expand": "Expand scope filters"
@ -1615,7 +1633,8 @@
"tree": {
"collapse": "Collapse",
"expand": "Expand",
"search": "Filter"
"headline": "Recommended",
"search": "Search"
}
},
"search": {

View File

@ -25,6 +25,14 @@
"user": "Ůşęř"
}
},
"alert-labels": {
"button": {
"hide": "Ħįđę čőmmőʼn ľäþęľş",
"show": {
"tooltip": "Ŝĥőŵ čőmmőʼn ľäþęľş"
}
}
},
"alert-rule-form": {
"evaluation-behaviour": {
"description": {
@ -84,15 +92,15 @@
},
"counts": {
"alertRule_one": "{{count}} äľęřŧ řūľę",
"alertRule_other": "{{count}} äľęřŧ řūľęş",
"alertRule_other": "{{count}} äľęřŧ řūľę",
"dashboard_one": "{{count}} đäşĥþőäřđ",
"dashboard_other": "{{count}} đäşĥþőäřđş",
"dashboard_other": "{{count}} đäşĥþőäřđ",
"folder_one": "{{count}} ƒőľđęř",
"folder_other": "{{count}} ƒőľđęřş",
"folder_other": "{{count}} ƒőľđęř",
"libraryPanel_one": "{{count}} ľįþřäřy päʼnęľ",
"libraryPanel_other": "{{count}} ľįþřäřy päʼnęľş",
"libraryPanel_other": "{{count}} ľįþřäřy päʼnęľ",
"total_one": "{{count}} įŧęm",
"total_other": "{{count}} įŧęmş"
"total_other": "{{count}} įŧęm"
},
"dashboards-tree": {
"collapse-folder-button": "Cőľľäpşę ƒőľđęř {{title}}",
@ -138,6 +146,16 @@
"text": "Ńő řęşūľŧş ƒőūʼnđ ƒőř yőūř qūęřy"
}
},
"central-alert-history": {
"error": "Ŝőmęŧĥįʼnģ ŵęʼnŧ ŵřőʼnģ ľőäđįʼnģ ŧĥę äľęřŧ şŧäŧę ĥįşŧőřy",
"filter": {
"button": {
"clear": "Cľęäř"
},
"label": "Fįľŧęř ęvęʼnŧş",
"placeholder": "Fįľŧęř ęvęʼnŧş įʼn ŧĥę ľįşŧ ŵįŧĥ ľäþęľş"
}
},
"clipboard-button": {
"inline-toast": {
"success": "Cőpįęđ"
@ -758,7 +776,7 @@
},
"modal": {
"body_one": "Ŧĥįş päʼnęľ įş þęįʼnģ ūşęđ įʼn {{count}} đäşĥþőäřđ. Pľęäşę čĥőőşę ŵĥįčĥ đäşĥþőäřđ ŧő vįęŵ ŧĥę päʼnęľ įʼn:",
"body_other": "Ŧĥįş päʼnęľ įş þęįʼnģ ūşęđ įʼn {{count}} đäşĥþőäřđş. Pľęäşę čĥőőşę ŵĥįčĥ đäşĥþőäřđ ŧő vįęŵ ŧĥę päʼnęľ įʼn:",
"body_other": "Ŧĥįş päʼnęľ įş þęįʼnģ ūşęđ įʼn {{count}} đäşĥþőäřđ. Pľęäşę čĥőőşę ŵĥįčĥ đäşĥþőäřđ ŧő vįęŵ ŧĥę päʼnęľ įʼn:",
"button-cancel": "Cäʼnčęľ",
"button-view-panel1": "Vįęŵ päʼnęľ įʼn {{label}}...",
"button-view-panel2": "Vįęŵ päʼnęľ įʼn đäşĥþőäřđ...",
@ -1606,7 +1624,7 @@
},
"suggestedDashboards": {
"loading": "Ŀőäđįʼnģ đäşĥþőäřđş",
"search": "Fįľŧęř",
"search": "Ŝęäřčĥ",
"toggle": {
"collapse": "Cőľľäpşę şčőpę ƒįľŧęřş",
"expand": "Ēχpäʼnđ şčőpę ƒįľŧęřş"
@ -1615,7 +1633,8 @@
"tree": {
"collapse": "Cőľľäpşę",
"expand": "Ēχpäʼnđ",
"search": "Fįľŧęř"
"headline": "Ŗęčőmmęʼnđęđ",
"search": "Ŝęäřčĥ"
}
},
"search": {

View File

@ -6194,10 +6194,14 @@
"format": "int64",
"type": "integer"
},
"isDeleted": {
"type": "boolean"
},
"isStarred": {
"type": "boolean"
},
"remainingTrashAtAge": {
"permanentlyDeleteDate": {
"format": "date-time",
"type": "string"
},
"slug": {