mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge remote-tracking branch 'origin/main' into resource-store
This commit is contained in:
commit
4cde5bd59f
@ -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"]
|
||||
],
|
||||
|
@ -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 >}}
|
||||
|
@ -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
|
||||
|
||||
|
@ -194,4 +194,5 @@ export interface FeatureToggles {
|
||||
azureMonitorPrometheusExemplars?: boolean;
|
||||
pinNavItems?: boolean;
|
||||
authZGRPCServer?: boolean;
|
||||
openSearchBackendFlowEnabled?: boolean;
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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)}
|
||||
/>
|
||||
|
@ -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,
|
||||
})}
|
||||
|
@ -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',
|
||||
|
@ -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"
|
||||
```
|
||||
|
@ -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
|
||||
}
|
||||
|
43
pkg/services/authz/config.go
Normal file
43
pkg/services/authz/config.go
Normal 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
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -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
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
@ -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",
|
||||
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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.
|
||||
|
@ -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 {
|
||||
|
@ -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"
|
||||
|
@ -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"})},
|
||||
},
|
||||
}}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
@ -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": {
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
|
@ -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) => {
|
||||
|
@ -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',
|
||||
|
@ -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 },
|
||||
|
@ -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>
|
||||
|
@ -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} />;
|
||||
}
|
@ -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' });
|
@ -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(),
|
||||
});
|
||||
}
|
@ -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';
|
||||
|
@ -7,6 +7,7 @@ export interface Line {
|
||||
current: GrafanaAlertStateWithReason;
|
||||
values?: Record<string, number>;
|
||||
labels?: Record<string, string>;
|
||||
ruleUID?: string;
|
||||
}
|
||||
|
||||
export interface LogRecord {
|
||||
|
@ -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])));
|
||||
}
|
||||
|
||||
|
@ -381,11 +381,6 @@ export const browseDashboardsAPI = createApi({
|
||||
url: `/dashboards/uid/${dashboardUID}/trash`,
|
||||
method: 'PATCH',
|
||||
}),
|
||||
onQueryStarted: ({ dashboardUID }, { queryFulfilled, dispatch }) => {
|
||||
queryFulfilled.then(() => {
|
||||
dispatch(refreshParents([dashboardUID]));
|
||||
});
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
@ -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();
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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',
|
||||
|
@ -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) });
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -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 && (
|
||||
|
@ -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> {
|
||||
|
@ -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),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
@ -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', () => {
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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)}
|
||||
|
@ -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)}
|
||||
/>
|
||||
|
119
public/app/features/dashboard-scene/scene/Scopes/ScopesInput.tsx
Normal file
119
public/app/features/dashboard-scene/scene/Scopes/ScopesInput.tsx
Normal 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),
|
||||
}),
|
||||
};
|
||||
};
|
@ -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', () => {
|
||||
|
@ -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();
|
||||
|
@ -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),
|
||||
}),
|
||||
|
@ -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;
|
||||
}, {})
|
||||
);
|
||||
}
|
||||
|
@ -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'));
|
||||
|
@ -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[];
|
||||
}
|
||||
|
@ -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());
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
|
@ -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 {
|
||||
|
@ -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}>
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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`}
|
||||
|
@ -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 {
|
||||
|
@ -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": {
|
||||
|
@ -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": {
|
||||
|
@ -6194,10 +6194,14 @@
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"isDeleted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"isStarred": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"remainingTrashAtAge": {
|
||||
"permanentlyDeleteDate": {
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
"slug": {
|
||||
|
Loading…
Reference in New Issue
Block a user