Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6710c563c7 | |||
| c3bbd588e0 | |||
| ba12ac68cc | |||
| f625902e4b | |||
| 71a65e1f80 | |||
| ec12176220 | |||
| 0cf4f7c4de | |||
| b0785e506f | |||
| 5f8668b3aa |
+2
-2
@@ -1,8 +1,8 @@
|
||||
diff --git a/dist/builder-manager/index.js b/dist/builder-manager/index.js
|
||||
index 3d7f9b213dae1801bda62b31db31b9113e382ccd..212501c63d20146c29db63fb0f6300c6779eecb5 100644
|
||||
index ac8ac6a5f6a3b7852c4064e93dc9acd3201289e6..34a0a5a5c38dd7fe525c9ebd382a10a451d4d4f3 100644
|
||||
--- a/dist/builder-manager/index.js
|
||||
+++ b/dist/builder-manager/index.js
|
||||
@@ -1970,7 +1970,7 @@ var pa = /^\/($|\?)/, G, C, xt = /* @__PURE__ */ o(async (e) => {
|
||||
@@ -1974,7 +1974,7 @@ var pa = /^\/($|\?)/, G, C, xt = /* @__PURE__ */ o(async (e) => {
|
||||
bundle: !0,
|
||||
minify: !0,
|
||||
sourcemap: !1,
|
||||
@@ -0,0 +1,4 @@
|
||||
package connection
|
||||
|
||||
// BlockDeletionFinalizer prevents deletion of connections while repositories reference them
|
||||
const BlockDeletionFinalizer = "block-deletion-while-repositories-exist"
|
||||
@@ -186,7 +186,7 @@ For the JSON and field usage notes, refer to the [links schema documentation](ht
|
||||
|
||||
### `tags`
|
||||
|
||||
The tags associated with the dashboard:
|
||||
Tags associated with the dashboard. Each tag can be up to 50 characters long.
|
||||
|
||||
` [...string]`
|
||||
|
||||
|
||||
@@ -41,7 +41,8 @@ Query parameters:
|
||||
- `sortDirection`: Sort order of elements. Use `alpha-asc` for ascending and `alpha-desc` for descending sort order.
|
||||
- `typeFilter`: A comma separated list of types to filter the elements by.
|
||||
- `excludeUid`: Element UID to exclude from search results.
|
||||
- `folderFilter`: A comma separated list of folder IDs to filter the elements by.
|
||||
- `folderFilter`: **Deprecated.** A comma separated list of folder IDs to filter the elements by. Use `folderFilterUIDs` instead.
|
||||
- `folderFilterUIDs`: A comma separated list of folder UIDs to filter the elements by.
|
||||
- `perPage`: The number of results per page; default is 100.
|
||||
- `page`: The page for a set of records, given that only `perPage` records are returned at a time. Numbering starts at `1`.
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ Keys:
|
||||
- **theme** - One of: `light`, `dark`, or an empty string for the default theme
|
||||
- **homeDashboardId** - Deprecated. Use `homeDashboardUID` instead.
|
||||
- **homeDashboardUID**: The `:uid` of a dashboard
|
||||
- **timezone** - One of: `utc`, `browser`, or an empty string for the default
|
||||
- **timezone** - Any valid IANA timezone string (e.g., `America/New_York`, `Europe/London`), `utc`, `browser`, or an empty string for the default.
|
||||
|
||||
Omitting a key will cause the current value to be replaced with the
|
||||
system default value.
|
||||
|
||||
+1
-1
@@ -462,7 +462,7 @@
|
||||
"js-yaml@npm:4.1.0": "^4.1.0",
|
||||
"js-yaml@npm:=4.1.0": "^4.1.0",
|
||||
"nodemailer": "7.0.11",
|
||||
"@storybook/core@npm:8.6.2": "patch:@storybook/core@npm%3A8.6.2#~/.yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch"
|
||||
"@storybook/core@npm:8.6.15": "patch:@storybook/core@npm%3A8.6.15#~/.yarn/patches/@storybook-core-npm-8.6.15-a468a35170.patch"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
|
||||
@@ -1021,6 +1021,7 @@ const injectedRtkApi = api
|
||||
typeFilter: queryArg.typeFilter,
|
||||
excludeUid: queryArg.excludeUid,
|
||||
folderFilter: queryArg.folderFilter,
|
||||
folderFilterUIDs: queryArg.folderFilterUiDs,
|
||||
perPage: queryArg.perPage,
|
||||
page: queryArg.page,
|
||||
},
|
||||
@@ -2915,8 +2916,11 @@ export type GetLibraryElementsApiArg = {
|
||||
typeFilter?: string;
|
||||
/** Element UID to exclude from search results. */
|
||||
excludeUid?: string;
|
||||
/** A comma separated list of folder ID(s) to filter the elements by. */
|
||||
/** A comma separated list of folder ID(s) to filter the elements by.
|
||||
Deprecated: Use FolderFilterUIDs instead. */
|
||||
folderFilter?: string;
|
||||
/** A comma separated list of folder UID(s) to filter the elements by. */
|
||||
folderFilterUiDs?: string;
|
||||
/** The number of results per page. */
|
||||
perPage?: number;
|
||||
/** The page for a set of records, given that only perPage records are returned at a time. Numbering starts at 1. */
|
||||
@@ -5312,7 +5316,8 @@ export type PatchPrefsCmd = {
|
||||
queryHistory?: QueryHistoryPreference;
|
||||
regionalFormat?: string;
|
||||
theme?: 'light' | 'dark';
|
||||
timezone?: 'utc' | 'browser';
|
||||
/** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */
|
||||
timezone?: string;
|
||||
weekStart?: string;
|
||||
};
|
||||
export type UpdatePrefsCmd = {
|
||||
@@ -5325,7 +5330,8 @@ export type UpdatePrefsCmd = {
|
||||
queryHistory?: QueryHistoryPreference;
|
||||
regionalFormat?: string;
|
||||
theme?: 'light' | 'dark' | 'system';
|
||||
timezone?: 'utc' | 'browser';
|
||||
/** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */
|
||||
timezone?: string;
|
||||
weekStart?: string;
|
||||
};
|
||||
export type OrgUserDto = {
|
||||
|
||||
+4
-2
@@ -86,7 +86,8 @@ export type PatchPrefsCmd = {
|
||||
queryHistory?: QueryHistoryPreference;
|
||||
regionalFormat?: string;
|
||||
theme?: 'light' | 'dark';
|
||||
timezone?: 'utc' | 'browser';
|
||||
/** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */
|
||||
timezone?: string;
|
||||
weekStart?: string;
|
||||
};
|
||||
export type UpdatePrefsCmd = {
|
||||
@@ -99,7 +100,8 @@ export type UpdatePrefsCmd = {
|
||||
queryHistory?: QueryHistoryPreference;
|
||||
regionalFormat?: string;
|
||||
theme?: 'light' | 'dark' | 'system';
|
||||
timezone?: 'utc' | 'browser';
|
||||
/** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */
|
||||
timezone?: string;
|
||||
weekStart?: string;
|
||||
};
|
||||
export const {
|
||||
|
||||
@@ -137,23 +137,23 @@
|
||||
"@babel/core": "7.28.0",
|
||||
"@faker-js/faker": "^9.0.0",
|
||||
"@rollup/plugin-node-resolve": "16.0.1",
|
||||
"@storybook/addon-a11y": "^8.6.2",
|
||||
"@storybook/addon-actions": "^8.6.2",
|
||||
"@storybook/addon-docs": "^8.6.2",
|
||||
"@storybook/addon-essentials": "^8.6.2",
|
||||
"@storybook/addon-storysource": "^8.6.2",
|
||||
"@storybook/addon-a11y": "^8.6.15",
|
||||
"@storybook/addon-actions": "^8.6.15",
|
||||
"@storybook/addon-docs": "^8.6.15",
|
||||
"@storybook/addon-essentials": "^8.6.15",
|
||||
"@storybook/addon-storysource": "^8.6.15",
|
||||
"@storybook/addon-webpack5-compiler-swc": "^2.1.0",
|
||||
"@storybook/blocks": "^8.6.2",
|
||||
"@storybook/components": "^8.6.2",
|
||||
"@storybook/core-events": "^8.6.2",
|
||||
"@storybook/manager-api": "^8.6.2",
|
||||
"@storybook/blocks": "^8.6.15",
|
||||
"@storybook/components": "^8.6.15",
|
||||
"@storybook/core-events": "^8.6.15",
|
||||
"@storybook/manager-api": "^8.6.15",
|
||||
"@storybook/mdx2-csf": "1.1.0",
|
||||
"@storybook/preset-scss": "1.0.3",
|
||||
"@storybook/preview-api": "^8.6.2",
|
||||
"@storybook/react": "^8.6.2",
|
||||
"@storybook/react-webpack5": "^8.6.2",
|
||||
"@storybook/preview-api": "^8.6.15",
|
||||
"@storybook/react": "^8.6.15",
|
||||
"@storybook/react-webpack5": "^8.6.15",
|
||||
"@storybook/test-runner": "^0.23.0",
|
||||
"@storybook/theming": "^8.6.2",
|
||||
"@storybook/theming": "^8.6.15",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/jest-dom": "6.6.4",
|
||||
"@testing-library/react": "16.3.0",
|
||||
@@ -200,7 +200,7 @@
|
||||
"rollup-plugin-node-externals": "^8.0.0",
|
||||
"rollup-plugin-svg-import": "3.0.0",
|
||||
"sass-loader": "16.0.5",
|
||||
"storybook": "^8.6.2",
|
||||
"storybook": "^8.6.15",
|
||||
"style-loader": "4.0.0",
|
||||
"typescript": "5.9.2",
|
||||
"webpack": "5.101.0"
|
||||
|
||||
@@ -54,6 +54,7 @@ export const TagsInput = forwardRef<HTMLInputElement, Props>(
|
||||
const [newTagName, setNewTagName] = useState('');
|
||||
const styles = useStyles2(getStyles);
|
||||
const theme = useTheme2();
|
||||
const isTagTooLong = newTagName.length > 50;
|
||||
|
||||
const onNameChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNewTagName(event.target.value);
|
||||
@@ -65,6 +66,9 @@ export const TagsInput = forwardRef<HTMLInputElement, Props>(
|
||||
|
||||
const onAdd = (event?: React.MouseEvent | React.KeyboardEvent) => {
|
||||
event?.preventDefault();
|
||||
if (newTagName.length > 50) {
|
||||
return;
|
||||
}
|
||||
if (!tags.includes(newTagName)) {
|
||||
onChange(tags.concat(newTagName));
|
||||
}
|
||||
@@ -94,14 +98,17 @@ export const TagsInput = forwardRef<HTMLInputElement, Props>(
|
||||
value={newTagName}
|
||||
onKeyDown={onKeyboardAdd}
|
||||
onBlur={onBlur}
|
||||
invalid={invalid}
|
||||
invalid={invalid || isTagTooLong}
|
||||
suffix={
|
||||
<Button
|
||||
fill="text"
|
||||
className={styles.addButtonStyle}
|
||||
onClick={onAdd}
|
||||
size="md"
|
||||
disabled={newTagName.length <= 0}
|
||||
disabled={newTagName.length <= 0 || isTagTooLong}
|
||||
title={
|
||||
isTagTooLong ? t('grafana-ui.tags-input.tag-too-long', 'Tag too long, max 50 characters') : undefined
|
||||
}
|
||||
>
|
||||
<Trans i18nKey="grafana-ui.tags-input.add">Add</Trans>
|
||||
</Button>
|
||||
|
||||
@@ -13,7 +13,7 @@ type UpdatePrefsCmd struct {
|
||||
// Deprecated: Use HomeDashboardUID instead
|
||||
HomeDashboardID int64 `json:"homeDashboardId"`
|
||||
HomeDashboardUID *string `json:"homeDashboardUID,omitempty"`
|
||||
// Enum: utc,browser
|
||||
// Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string
|
||||
Timezone string `json:"timezone"`
|
||||
WeekStart string `json:"weekStart"`
|
||||
QueryHistory *pref.QueryHistoryPreference `json:"queryHistory,omitempty"`
|
||||
@@ -31,7 +31,7 @@ type PatchPrefsCmd struct {
|
||||
// Default:0
|
||||
// Deprecated: Use HomeDashboardUID instead
|
||||
HomeDashboardID *int64 `json:"homeDashboardId,omitempty"`
|
||||
// Enum: utc,browser
|
||||
// Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string
|
||||
Timezone *string `json:"timezone,omitempty"`
|
||||
WeekStart *string `json:"weekStart,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
|
||||
@@ -134,6 +134,10 @@ func (hs *HTTPServer) patchPreferencesFor(ctx context.Context, orgID, userID, te
|
||||
return response.Error(http.StatusBadRequest, "Invalid theme", nil)
|
||||
}
|
||||
|
||||
if dtoCmd.Timezone != nil && !pref.IsValidTimezone(*dtoCmd.Timezone) {
|
||||
return response.Error(http.StatusBadRequest, "Invalid timezone. Must be a valid IANA timezone (e.g., America/New_York), 'utc', 'browser', or empty string", nil)
|
||||
}
|
||||
|
||||
// convert dashboard UID to ID in order to store internally if it exists in the query, otherwise take the id from query
|
||||
// nolint:staticcheck
|
||||
dashboardID := dtoCmd.HomeDashboardID
|
||||
|
||||
@@ -1,26 +1,58 @@
|
||||
package generic
|
||||
|
||||
import (
|
||||
"k8s.io/apimachinery/pkg/fields"
|
||||
"k8s.io/apimachinery/pkg/labels"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/generic"
|
||||
"k8s.io/apiserver/pkg/registry/generic/registry"
|
||||
"k8s.io/apiserver/pkg/storage"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
)
|
||||
|
||||
// SelectableFieldsOptions allows customizing field selector behavior for a resource.
|
||||
type SelectableFieldsOptions struct {
|
||||
// GetAttrs returns labels and fields for the object.
|
||||
// If nil, the default GetAttrs is used which only exposes metadata.name.
|
||||
GetAttrs func(obj runtime.Object) (labels.Set, fields.Set, error)
|
||||
}
|
||||
|
||||
func NewRegistryStore(scheme *runtime.Scheme, resourceInfo utils.ResourceInfo, optsGetter generic.RESTOptionsGetter) (*registry.Store, error) {
|
||||
return NewRegistryStoreWithSelectableFields(scheme, resourceInfo, optsGetter, SelectableFieldsOptions{})
|
||||
}
|
||||
|
||||
// NewRegistryStoreWithSelectableFields creates a registry store with custom selectable fields support.
|
||||
// Use this when you need to filter resources by custom fields like spec.connection.name.
|
||||
func NewRegistryStoreWithSelectableFields(scheme *runtime.Scheme, resourceInfo utils.ResourceInfo, optsGetter generic.RESTOptionsGetter, fieldOpts SelectableFieldsOptions) (*registry.Store, error) {
|
||||
gv := resourceInfo.GroupVersion()
|
||||
gv.Version = runtime.APIVersionInternal
|
||||
strategy := NewStrategy(scheme, gv)
|
||||
if resourceInfo.IsClusterScoped() {
|
||||
strategy = strategy.WithClusterScope()
|
||||
}
|
||||
|
||||
// Use custom GetAttrs if provided, otherwise use default
|
||||
attrFunc := GetAttrs
|
||||
predicateFunc := Matcher
|
||||
if fieldOpts.GetAttrs != nil {
|
||||
attrFunc = fieldOpts.GetAttrs
|
||||
// Create a matcher that uses the custom GetAttrs
|
||||
predicateFunc = func(label labels.Selector, field fields.Selector) storage.SelectionPredicate {
|
||||
return storage.SelectionPredicate{
|
||||
Label: label,
|
||||
Field: field,
|
||||
GetAttrs: attrFunc,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
store := ®istry.Store{
|
||||
NewFunc: resourceInfo.NewFunc,
|
||||
NewListFunc: resourceInfo.NewListFunc,
|
||||
KeyRootFunc: KeyRootFunc(resourceInfo.GroupResource()),
|
||||
KeyFunc: NamespaceKeyFunc(resourceInfo.GroupResource()),
|
||||
PredicateFunc: Matcher,
|
||||
PredicateFunc: predicateFunc,
|
||||
DefaultQualifiedResource: resourceInfo.GroupResource(),
|
||||
SingularQualifiedResource: resourceInfo.SingularGroupResource(),
|
||||
TableConvertor: resourceInfo.TableConverter(),
|
||||
@@ -28,7 +60,7 @@ func NewRegistryStore(scheme *runtime.Scheme, resourceInfo utils.ResourceInfo, o
|
||||
UpdateStrategy: strategy,
|
||||
DeleteStrategy: strategy,
|
||||
}
|
||||
options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: GetAttrs}
|
||||
options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: attrFunc}
|
||||
if err := store.CompleteWithOptions(options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -389,6 +389,11 @@ func (b *DashboardsAPIBuilder) validateCreate(ctx context.Context, a admission.A
|
||||
return apierrors.NewBadRequest(err.Error())
|
||||
}
|
||||
|
||||
// Validate tags
|
||||
if err := validateDashboardTags(dashObj); err != nil {
|
||||
return apierrors.NewBadRequest(err.Error())
|
||||
}
|
||||
|
||||
id, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting requester: %w", err)
|
||||
@@ -459,6 +464,11 @@ func (b *DashboardsAPIBuilder) validateUpdate(ctx context.Context, a admission.A
|
||||
return apierrors.NewBadRequest(err.Error())
|
||||
}
|
||||
|
||||
// Validate tags
|
||||
if err := validateDashboardTags(newDashObj); err != nil {
|
||||
return apierrors.NewBadRequest(err.Error())
|
||||
}
|
||||
|
||||
// Validate folder existence if specified and changed
|
||||
if !a.IsDryRun() && newAccessor.GetFolder() != oldAccessor.GetFolder() && newAccessor.GetFolder() != "" {
|
||||
id, err := identity.GetRequester(ctx)
|
||||
@@ -556,6 +566,32 @@ func getDashboardProperties(obj runtime.Object) (string, string, error) {
|
||||
return title, refresh, nil
|
||||
}
|
||||
|
||||
// validateDashboardTags validates that all dashboard tags are within the maximum length
|
||||
func validateDashboardTags(obj runtime.Object) error {
|
||||
var tags []string
|
||||
|
||||
switch d := obj.(type) {
|
||||
case *dashv0.Dashboard:
|
||||
tags = d.Spec.GetNestedStringSlice("tags")
|
||||
case *dashv1.Dashboard:
|
||||
tags = d.Spec.GetNestedStringSlice("tags")
|
||||
case *dashv2alpha1.Dashboard:
|
||||
tags = d.Spec.Tags
|
||||
case *dashv2beta1.Dashboard:
|
||||
tags = d.Spec.Tags
|
||||
default:
|
||||
return fmt.Errorf("unsupported dashboard version: %T", obj)
|
||||
}
|
||||
|
||||
for _, tag := range tags {
|
||||
if len(tag) > 50 {
|
||||
return dashboards.ErrDashboardTagTooLong
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
|
||||
storageOpts := apistore.StorageOptions{
|
||||
EnableFolderSupport: true,
|
||||
|
||||
@@ -208,6 +208,11 @@ func (s *preferenceStorage) save(ctx context.Context, obj runtime.Object) (runti
|
||||
|
||||
// Create implements rest.Creater.
|
||||
func (s *preferenceStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
|
||||
if createValidation != nil {
|
||||
if err := createValidation(ctx, obj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s.save(ctx, obj)
|
||||
}
|
||||
|
||||
@@ -223,6 +228,12 @@ func (s *preferenceStorage) Update(ctx context.Context, name string, objInfo res
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if updateValidation != nil {
|
||||
if err := updateValidation(ctx, obj, old); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
|
||||
obj, err = s.save(ctx, obj)
|
||||
return obj, false, err
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package preferences
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apiserver/pkg/admission"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
genericapiserver "k8s.io/apiserver/pkg/server"
|
||||
@@ -24,7 +29,8 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
_ builder.APIGroupBuilder = (*APIBuilder)(nil)
|
||||
_ builder.APIGroupBuilder = (*APIBuilder)(nil)
|
||||
_ builder.APIGroupValidation = (*APIBuilder)(nil)
|
||||
)
|
||||
|
||||
type APIBuilder struct {
|
||||
@@ -108,3 +114,31 @@ func (b *APIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes {
|
||||
defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} })
|
||||
return b.merger.GetAPIRoutes(defs)
|
||||
}
|
||||
|
||||
// Validate validates that the preference object has valid theme and timezone (if specified)
|
||||
func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) error {
|
||||
if a.GetResource().Resource != "preferences" {
|
||||
return nil
|
||||
}
|
||||
|
||||
op := a.GetOperation()
|
||||
if op != admission.Create && op != admission.Update {
|
||||
return nil
|
||||
}
|
||||
|
||||
obj := a.GetObject()
|
||||
p, ok := obj.(*preferences.Preferences)
|
||||
if !ok {
|
||||
return apierrors.NewBadRequest(fmt.Sprintf("expected Preferences object, got %T", obj))
|
||||
}
|
||||
|
||||
if p.Spec.Timezone != nil && !pref.IsValidTimezone(*p.Spec.Timezone) {
|
||||
return apierrors.NewBadRequest("invalid timezone: must be a valid IANA timezone (e.g., America/New_York), 'utc', 'browser', or empty string")
|
||||
}
|
||||
|
||||
if p.Spec.Theme != nil && *p.Spec.Theme != "" && !pref.IsValidThemeID(*p.Spec.Theme) {
|
||||
return apierrors.NewBadRequest("invalid theme")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,9 +4,15 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
||||
"k8s.io/apimachinery/pkg/util/wait"
|
||||
"k8s.io/client-go/tools/cache"
|
||||
@@ -14,9 +20,11 @@ import (
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/logging"
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
connectionvalidation "github.com/grafana/grafana/apps/provisioning/pkg/connection"
|
||||
client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1"
|
||||
informer "github.com/grafana/grafana/apps/provisioning/pkg/generated/informers/externalversions/provisioning/v0alpha1"
|
||||
listers "github.com/grafana/grafana/apps/provisioning/pkg/generated/listers/provisioning/v0alpha1"
|
||||
"k8s.io/apimachinery/pkg/fields"
|
||||
)
|
||||
|
||||
const connectionLoggerName = "provisioning-connection-controller"
|
||||
@@ -41,6 +49,11 @@ type ConnectionStatusPatcher interface {
|
||||
Patch(ctx context.Context, conn *provisioning.Connection, patchOperations ...map[string]interface{}) error
|
||||
}
|
||||
|
||||
// RepositoryLister interface for listing repositories
|
||||
type RepositoryLister interface {
|
||||
List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error)
|
||||
}
|
||||
|
||||
// ConnectionController controls Connection resources.
|
||||
type ConnectionController struct {
|
||||
client client.ProvisioningV0alpha1Interface
|
||||
@@ -49,6 +62,7 @@ type ConnectionController struct {
|
||||
logger logging.Logger
|
||||
|
||||
statusPatcher ConnectionStatusPatcher
|
||||
repoLister RepositoryLister
|
||||
|
||||
queue workqueue.TypedRateLimitingInterface[*connectionQueueItem]
|
||||
}
|
||||
@@ -58,6 +72,7 @@ func NewConnectionController(
|
||||
provisioningClient client.ProvisioningV0alpha1Interface,
|
||||
connInformer informer.ConnectionInformer,
|
||||
statusPatcher ConnectionStatusPatcher,
|
||||
repoLister RepositoryLister,
|
||||
) (*ConnectionController, error) {
|
||||
cc := &ConnectionController{
|
||||
client: provisioningClient,
|
||||
@@ -70,6 +85,7 @@ func NewConnectionController(
|
||||
},
|
||||
),
|
||||
statusPatcher: statusPatcher,
|
||||
repoLister: repoLister,
|
||||
logger: logging.DefaultLogger.With("logger", connectionLoggerName),
|
||||
}
|
||||
|
||||
@@ -141,13 +157,14 @@ func (cc *ConnectionController) processNextWorkItem(ctx context.Context) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
if !apierrors.IsServiceUnavailable(err) {
|
||||
logger.Info("ConnectionController will not retry")
|
||||
// Check if error is transient and should be retried
|
||||
if !isTransientError(err) {
|
||||
logger.Info("ConnectionController will not retry (non-transient error)")
|
||||
cc.queue.Forget(item)
|
||||
return true
|
||||
}
|
||||
|
||||
logger.Info("ConnectionController will retry as service is unavailable")
|
||||
logger.Info("ConnectionController will retry (transient error)")
|
||||
utilruntime.HandleError(fmt.Errorf("%v failed with: %v", item, err))
|
||||
cc.queue.AddRateLimited(item)
|
||||
|
||||
@@ -171,10 +188,9 @@ func (cc *ConnectionController) process(ctx context.Context, item *connectionQue
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip if being deleted
|
||||
// Handle deletion if being deleted
|
||||
if conn.DeletionTimestamp != nil {
|
||||
logger.Info("connection is being deleted, skipping")
|
||||
return nil
|
||||
return cc.handleDelete(ctx, conn)
|
||||
}
|
||||
|
||||
hasSpecChanged := conn.Generation != conn.Status.ObservedGeneration
|
||||
@@ -229,6 +245,147 @@ func (cc *ConnectionController) process(ctx context.Context, item *connectionQue
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cc *ConnectionController) handleDelete(ctx context.Context, conn *provisioning.Connection) error {
|
||||
logger := logging.FromContext(ctx)
|
||||
logger.Info("handle connection delete")
|
||||
|
||||
// Check if finalizer is present
|
||||
hasFinalizer := false
|
||||
for _, f := range conn.Finalizers {
|
||||
if f == connectionvalidation.BlockDeletionFinalizer {
|
||||
hasFinalizer = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasFinalizer {
|
||||
logger.Info("no finalizer to process")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if any repositories reference this connection using field selector
|
||||
fieldSelector := fields.OneTermEqualSelector("spec.connection.name", conn.Name)
|
||||
var allRepos []provisioning.Repository
|
||||
continueToken := ""
|
||||
var err error
|
||||
|
||||
for {
|
||||
var obj runtime.Object
|
||||
obj, err = cc.repoLister.List(ctx, &internalversion.ListOptions{
|
||||
Limit: 100,
|
||||
Continue: continueToken,
|
||||
FieldSelector: fieldSelector,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("failed to check for connected repositories", "error", err)
|
||||
return fmt.Errorf("check for connected repositories: %w", err)
|
||||
}
|
||||
|
||||
repositoryList, ok := obj.(*provisioning.RepositoryList)
|
||||
if !ok {
|
||||
logger.Error("expected repository list", "type", fmt.Sprintf("%T", obj))
|
||||
return fmt.Errorf("expected repository list, got %T", obj)
|
||||
}
|
||||
|
||||
allRepos = append(allRepos, repositoryList.Items...)
|
||||
|
||||
continueToken = repositoryList.GetContinue()
|
||||
if continueToken == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(allRepos) > 0 {
|
||||
repoNames := make([]string, 0, len(allRepos))
|
||||
for _, repo := range allRepos {
|
||||
repoNames = append(repoNames, repo.Name)
|
||||
}
|
||||
logger.Info("cannot delete connection while repositories reference it", "repositories", repoNames)
|
||||
// Don't remove finalizer - this will prevent deletion
|
||||
// The connection will remain in deletion state until repositories are removed
|
||||
return fmt.Errorf("cannot delete connection while repositories are using it: %s", strings.Join(repoNames, ", "))
|
||||
}
|
||||
|
||||
// No repositories reference this connection, remove finalizer to allow deletion
|
||||
logger.Info("no repositories reference connection, removing finalizer")
|
||||
_, err = cc.client.Connections(conn.GetNamespace()).
|
||||
Patch(ctx, conn.Name, types.JSONPatchType, []byte(`[
|
||||
{ "op": "remove", "path": "/metadata/finalizers" }
|
||||
]`), metav1.PatchOptions{
|
||||
FieldManager: "provisioning-connection-controller",
|
||||
})
|
||||
if err != nil {
|
||||
// If we can't remove the finalizer, undelete the connection so it can be retried later
|
||||
// This prevents the connection from being stuck in deletion state
|
||||
logger.Error("failed to remove finalizer, undeleting connection", "error", err)
|
||||
undeleteErr := cc.undeleteConnection(ctx, conn, err)
|
||||
if undeleteErr != nil {
|
||||
return fmt.Errorf("remove finalizer: %w; failed to undelete: %w", err, undeleteErr)
|
||||
}
|
||||
return fmt.Errorf("remove finalizer: %w (connection has been undeleted, deletion can be retried)", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// undeleteConnection removes the DeletionTimestamp to "undelete" the connection
|
||||
// This is used when finalizer removal fails, allowing the deletion to be retried later
|
||||
func (cc *ConnectionController) undeleteConnection(ctx context.Context, conn *provisioning.Connection, originalErr error) error {
|
||||
logger := logging.FromContext(ctx)
|
||||
logger.Info("undeleting connection due to finalizer removal failure", "error", originalErr.Error())
|
||||
|
||||
// Remove DeletionTimestamp by patching it to null
|
||||
_, err := cc.client.Connections(conn.GetNamespace()).
|
||||
Patch(ctx, conn.Name, types.JSONPatchType, []byte(`[
|
||||
{ "op": "remove", "path": "/metadata/deletionTimestamp" }
|
||||
]`), metav1.PatchOptions{
|
||||
FieldManager: "provisioning-connection-controller",
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("failed to undelete connection", "error", err)
|
||||
return fmt.Errorf("undelete connection: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("connection undeleted successfully, deletion can be retried")
|
||||
return nil
|
||||
}
|
||||
|
||||
// isTransientError determines if an error is transient and should be retried
|
||||
func isTransientError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for Kubernetes API transient errors
|
||||
if apierrors.IsServiceUnavailable(err) {
|
||||
return true
|
||||
}
|
||||
if apierrors.IsServerTimeout(err) {
|
||||
return true
|
||||
}
|
||||
if apierrors.IsTooManyRequests(err) {
|
||||
return true
|
||||
}
|
||||
if apierrors.IsInternalError(err) {
|
||||
return true
|
||||
}
|
||||
if apierrors.IsTimeout(err) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for network errors
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) {
|
||||
if netErr.Timeout() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for connection errors
|
||||
var opErr *net.OpError
|
||||
return errors.As(err, &opErr)
|
||||
}
|
||||
|
||||
// shouldCheckHealth determines if a connection health check should be performed.
|
||||
func (cc *ConnectionController) shouldCheckHealth(conn *provisioning.Connection) bool {
|
||||
// If the connection has been updated, always check health
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
"k8s.io/client-go/rest"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
connectionvalidation "github.com/grafana/grafana/apps/provisioning/pkg/connection"
|
||||
applyconfiguration "github.com/grafana/grafana/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1"
|
||||
client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1"
|
||||
)
|
||||
|
||||
func TestConnectionController_shouldCheckHealth(t *testing.T) {
|
||||
@@ -285,3 +300,398 @@ func TestConnectionController_processNextWorkItem(t *testing.T) {
|
||||
assert.NotNil(t, cc)
|
||||
})
|
||||
}
|
||||
|
||||
// mockRepositoryLister is a mock implementation of RepositoryLister for testing
|
||||
type mockRepositoryLister struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *mockRepositoryLister) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
|
||||
args := m.Called(ctx, options)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(runtime.Object), args.Error(1)
|
||||
}
|
||||
|
||||
// mockConnectionInterface is a mock implementation of client.ConnectionInterface for testing
|
||||
type mockConnectionInterface struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *mockConnectionInterface) Create(ctx context.Context, connection *provisioning.Connection, opts metav1.CreateOptions) (*provisioning.Connection, error) {
|
||||
args := m.Called(ctx, connection, opts)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*provisioning.Connection), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockConnectionInterface) Update(ctx context.Context, connection *provisioning.Connection, opts metav1.UpdateOptions) (*provisioning.Connection, error) {
|
||||
args := m.Called(ctx, connection, opts)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*provisioning.Connection), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockConnectionInterface) UpdateStatus(ctx context.Context, connection *provisioning.Connection, opts metav1.UpdateOptions) (*provisioning.Connection, error) {
|
||||
args := m.Called(ctx, connection, opts)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*provisioning.Connection), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockConnectionInterface) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error {
|
||||
args := m.Called(ctx, name, opts)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *mockConnectionInterface) DeleteCollection(ctx context.Context, opts metav1.DeleteOptions, listOpts metav1.ListOptions) error {
|
||||
args := m.Called(ctx, opts, listOpts)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *mockConnectionInterface) Get(ctx context.Context, name string, opts metav1.GetOptions) (*provisioning.Connection, error) {
|
||||
args := m.Called(ctx, name, opts)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*provisioning.Connection), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockConnectionInterface) List(ctx context.Context, opts metav1.ListOptions) (*provisioning.ConnectionList, error) {
|
||||
args := m.Called(ctx, opts)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*provisioning.ConnectionList), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockConnectionInterface) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) {
|
||||
args := m.Called(ctx, opts)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(watch.Interface), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockConnectionInterface) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (*provisioning.Connection, error) {
|
||||
args := m.Called(ctx, name, pt, data, opts, subresources)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*provisioning.Connection), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockConnectionInterface) Apply(ctx context.Context, connection *applyconfiguration.ConnectionApplyConfiguration, opts metav1.ApplyOptions) (*provisioning.Connection, error) {
|
||||
args := m.Called(ctx, connection, opts)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*provisioning.Connection), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockConnectionInterface) ApplyStatus(ctx context.Context, connection *applyconfiguration.ConnectionApplyConfiguration, opts metav1.ApplyOptions) (*provisioning.Connection, error) {
|
||||
args := m.Called(ctx, connection, opts)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*provisioning.Connection), args.Error(1)
|
||||
}
|
||||
|
||||
// mockProvisioningV0alpha1InterfaceForConnections is a mock implementation of client.ProvisioningV0alpha1Interface for connection tests
|
||||
type mockProvisioningV0alpha1InterfaceForConnections struct {
|
||||
mock.Mock
|
||||
connections *mockConnectionInterface
|
||||
}
|
||||
|
||||
func (m *mockProvisioningV0alpha1InterfaceForConnections) RESTClient() rest.Interface {
|
||||
panic("not needed for testing")
|
||||
}
|
||||
|
||||
func (m *mockProvisioningV0alpha1InterfaceForConnections) HistoricJobs(namespace string) client.HistoricJobInterface {
|
||||
panic("not needed for testing")
|
||||
}
|
||||
|
||||
func (m *mockProvisioningV0alpha1InterfaceForConnections) Jobs(namespace string) client.JobInterface {
|
||||
panic("not needed for testing")
|
||||
}
|
||||
|
||||
func (m *mockProvisioningV0alpha1InterfaceForConnections) Connections(namespace string) client.ConnectionInterface {
|
||||
return m.connections
|
||||
}
|
||||
|
||||
func (m *mockProvisioningV0alpha1InterfaceForConnections) Repositories(namespace string) client.RepositoryInterface {
|
||||
panic("not needed for testing")
|
||||
}
|
||||
|
||||
func TestConnectionController_handleDelete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
connection *provisioning.Connection
|
||||
repoListerSetup func(*mockRepositoryLister)
|
||||
connectionSetup func(*mockConnectionInterface)
|
||||
expectedError string
|
||||
expectFinalizerRemoved bool
|
||||
}{
|
||||
{
|
||||
name: "no finalizer present, should return nil",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-conn",
|
||||
Namespace: "default",
|
||||
DeletionTimestamp: &metav1.Time{Time: time.Now()},
|
||||
Finalizers: []string{},
|
||||
},
|
||||
},
|
||||
repoListerSetup: func(m *mockRepositoryLister) {},
|
||||
connectionSetup: func(m *mockConnectionInterface) {},
|
||||
expectedError: "",
|
||||
expectFinalizerRemoved: false,
|
||||
},
|
||||
{
|
||||
name: "finalizer present but repositories exist, should block deletion",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-conn",
|
||||
Namespace: "default",
|
||||
DeletionTimestamp: &metav1.Time{Time: time.Now()},
|
||||
Finalizers: []string{connectionvalidation.BlockDeletionFinalizer},
|
||||
},
|
||||
},
|
||||
repoListerSetup: func(m *mockRepositoryLister) {
|
||||
m.On("List", ctx, mock.MatchedBy(func(opts *internalversion.ListOptions) bool {
|
||||
return opts.FieldSelector != nil && opts.FieldSelector.String() == "spec.connection.name=test-conn"
|
||||
})).Return(&provisioning.RepositoryList{
|
||||
Items: []provisioning.Repository{
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "repo-1"},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Connection: &provisioning.ConnectionInfo{Name: "test-conn"},
|
||||
},
|
||||
},
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "repo-2"},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Connection: &provisioning.ConnectionInfo{Name: "test-conn"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
},
|
||||
connectionSetup: func(m *mockConnectionInterface) {},
|
||||
expectedError: "cannot delete connection while repositories are using it: repo-1, repo-2",
|
||||
expectFinalizerRemoved: false,
|
||||
},
|
||||
{
|
||||
name: "finalizer present and no repositories, should remove finalizer",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-conn",
|
||||
Namespace: "default",
|
||||
DeletionTimestamp: &metav1.Time{Time: time.Now()},
|
||||
Finalizers: []string{connectionvalidation.BlockDeletionFinalizer},
|
||||
},
|
||||
},
|
||||
repoListerSetup: func(m *mockRepositoryLister) {
|
||||
m.On("List", ctx, mock.MatchedBy(func(opts *internalversion.ListOptions) bool {
|
||||
return opts.FieldSelector != nil && opts.FieldSelector.String() == "spec.connection.name=test-conn"
|
||||
})).Return(&provisioning.RepositoryList{
|
||||
Items: []provisioning.Repository{},
|
||||
}, nil)
|
||||
},
|
||||
connectionSetup: func(m *mockConnectionInterface) {
|
||||
m.On("Patch", ctx, "test-conn", types.JSONPatchType, mock.Anything, metav1.PatchOptions{
|
||||
FieldManager: "provisioning-connection-controller",
|
||||
}, mock.Anything).Return(&provisioning.Connection{}, nil)
|
||||
},
|
||||
expectedError: "",
|
||||
expectFinalizerRemoved: true,
|
||||
},
|
||||
{
|
||||
name: "error checking repositories, should return error",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-conn",
|
||||
Namespace: "default",
|
||||
DeletionTimestamp: &metav1.Time{Time: time.Now()},
|
||||
Finalizers: []string{connectionvalidation.BlockDeletionFinalizer},
|
||||
},
|
||||
},
|
||||
repoListerSetup: func(m *mockRepositoryLister) {
|
||||
m.On("List", ctx, mock.Anything).Return(nil, errors.New("list error"))
|
||||
},
|
||||
connectionSetup: func(m *mockConnectionInterface) {},
|
||||
expectedError: "check for connected repositories: list error",
|
||||
expectFinalizerRemoved: false,
|
||||
},
|
||||
{
|
||||
name: "error removing finalizer, should undelete connection",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-conn",
|
||||
Namespace: "default",
|
||||
DeletionTimestamp: &metav1.Time{Time: time.Now()},
|
||||
Finalizers: []string{connectionvalidation.BlockDeletionFinalizer},
|
||||
},
|
||||
},
|
||||
repoListerSetup: func(m *mockRepositoryLister) {
|
||||
m.On("List", ctx, mock.Anything).Return(&provisioning.RepositoryList{
|
||||
Items: []provisioning.Repository{},
|
||||
}, nil)
|
||||
},
|
||||
connectionSetup: func(m *mockConnectionInterface) {
|
||||
// First patch fails (remove finalizer)
|
||||
m.On("Patch", ctx, "test-conn", types.JSONPatchType, mock.MatchedBy(func(data []byte) bool {
|
||||
return string(data) == `[
|
||||
{ "op": "remove", "path": "/metadata/finalizers" }
|
||||
]`
|
||||
}), metav1.PatchOptions{
|
||||
FieldManager: "provisioning-connection-controller",
|
||||
}, mock.Anything).Return(nil, errors.New("patch error")).Once()
|
||||
// Second patch succeeds (undelete - remove DeletionTimestamp)
|
||||
m.On("Patch", ctx, "test-conn", types.JSONPatchType, mock.MatchedBy(func(data []byte) bool {
|
||||
return string(data) == `[
|
||||
{ "op": "remove", "path": "/metadata/deletionTimestamp" }
|
||||
]`
|
||||
}), metav1.PatchOptions{
|
||||
FieldManager: "provisioning-connection-controller",
|
||||
}, mock.Anything).Return(&provisioning.Connection{}, nil).Once()
|
||||
},
|
||||
expectedError: "remove finalizer: patch error (connection has been undeleted, deletion can be retried)",
|
||||
expectFinalizerRemoved: false,
|
||||
},
|
||||
{
|
||||
name: "pagination handled correctly",
|
||||
connection: &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-conn",
|
||||
Namespace: "default",
|
||||
DeletionTimestamp: &metav1.Time{Time: time.Now()},
|
||||
Finalizers: []string{connectionvalidation.BlockDeletionFinalizer},
|
||||
},
|
||||
},
|
||||
repoListerSetup: func(m *mockRepositoryLister) {
|
||||
// First call returns empty with continue token (testing pagination even when empty)
|
||||
m.On("List", ctx, mock.MatchedBy(func(opts *internalversion.ListOptions) bool {
|
||||
return opts.Continue == ""
|
||||
})).Return(&provisioning.RepositoryList{
|
||||
Items: []provisioning.Repository{},
|
||||
ListMeta: metav1.ListMeta{Continue: "continue-token"},
|
||||
}, nil)
|
||||
// Second call returns empty with no continue token
|
||||
m.On("List", ctx, mock.MatchedBy(func(opts *internalversion.ListOptions) bool {
|
||||
return opts.Continue == "continue-token"
|
||||
})).Return(&provisioning.RepositoryList{
|
||||
Items: []provisioning.Repository{},
|
||||
}, nil)
|
||||
},
|
||||
connectionSetup: func(m *mockConnectionInterface) {
|
||||
m.On("Patch", ctx, "test-conn", types.JSONPatchType, mock.Anything, metav1.PatchOptions{
|
||||
FieldManager: "provisioning-connection-controller",
|
||||
}, mock.Anything).Return(&provisioning.Connection{}, nil)
|
||||
},
|
||||
expectedError: "",
|
||||
expectFinalizerRemoved: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
repoLister := new(mockRepositoryLister)
|
||||
connInterface := new(mockConnectionInterface)
|
||||
client := &mockProvisioningV0alpha1InterfaceForConnections{connections: connInterface}
|
||||
|
||||
tt.repoListerSetup(repoLister)
|
||||
tt.connectionSetup(connInterface)
|
||||
|
||||
cc := &ConnectionController{
|
||||
client: client,
|
||||
repoLister: repoLister,
|
||||
logger: nil, // logger is optional for testing
|
||||
}
|
||||
|
||||
err := cc.handleDelete(ctx, tt.connection)
|
||||
|
||||
if tt.expectedError != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.expectedError)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
if tt.expectFinalizerRemoved {
|
||||
connInterface.AssertCalled(t, "Patch", ctx, "test-conn", types.JSONPatchType, mock.Anything, metav1.PatchOptions{
|
||||
FieldManager: "provisioning-connection-controller",
|
||||
}, mock.Anything)
|
||||
} else if tt.expectedError != "" && strings.Contains(tt.expectedError, "undeleted") {
|
||||
// For undelete case, we expect both patches to be called (remove finalizer fails, then undelete succeeds)
|
||||
connInterface.AssertNumberOfCalls(t, "Patch", 2)
|
||||
}
|
||||
// For other error cases (repositories exist), no successful patch should occur
|
||||
|
||||
repoLister.AssertExpectations(t)
|
||||
connInterface.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTransientError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "nil error",
|
||||
err: nil,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "service unavailable",
|
||||
err: apierrors.NewServiceUnavailable("service unavailable"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "server timeout",
|
||||
err: apierrors.NewServerTimeout(schema.GroupResource{}, "operation", 0),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "too many requests",
|
||||
err: apierrors.NewTooManyRequests("too many requests", 0),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "internal error",
|
||||
err: apierrors.NewInternalError(errors.New("internal error")),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "not found error",
|
||||
err: apierrors.NewNotFound(schema.GroupResource{}, "resource"),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "forbidden error",
|
||||
err: apierrors.NewForbidden(schema.GroupResource{}, "resource", errors.New("forbidden")),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "generic error",
|
||||
err: errors.New("generic error"),
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isTransientError(tt.err)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,6 +559,22 @@ func (b *APIBuilder) InstallSchema(scheme *runtime.Scheme) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Register custom field label conversion for Repository to enable field selectors like spec.connection.name
|
||||
err = scheme.AddFieldLabelConversionFunc(
|
||||
provisioning.SchemeGroupVersion.WithKind("Repository"),
|
||||
func(label, value string) (string, string, error) {
|
||||
switch label {
|
||||
case "metadata.name", "metadata.namespace", "spec.connection.name":
|
||||
return label, value, nil
|
||||
default:
|
||||
return "", "", fmt.Errorf("field label not supported for Repository: %s", label)
|
||||
}
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
metav1.AddToGroupVersion(scheme, provisioning.SchemeGroupVersion)
|
||||
// Only 1 version (for now?)
|
||||
return scheme.SetVersionPriority(provisioning.SchemeGroupVersion)
|
||||
@@ -569,10 +585,19 @@ func (b *APIBuilder) AllowedV0Alpha1Resources() []string {
|
||||
}
|
||||
|
||||
func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
|
||||
repositoryStorage, err := grafanaregistry.NewRegistryStore(opts.Scheme, provisioning.RepositoryResourceInfo, opts.OptsGetter)
|
||||
// Create repository storage with custom field selectors (e.g., spec.connection.name)
|
||||
repositoryStorage, err := grafanaregistry.NewRegistryStoreWithSelectableFields(
|
||||
opts.Scheme,
|
||||
provisioning.RepositoryResourceInfo,
|
||||
opts.OptsGetter,
|
||||
grafanaregistry.SelectableFieldsOptions{
|
||||
GetAttrs: RepositoryGetAttrs,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create repository storage: %w", err)
|
||||
}
|
||||
|
||||
repositoryStatusStorage := grafanaregistry.NewRegistryStatusStore(opts.Scheme, repositoryStorage)
|
||||
b.store = repositoryStorage
|
||||
|
||||
@@ -660,6 +685,12 @@ func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admis
|
||||
// TODO: complete this as part of https://github.com/grafana/git-ui-sync-project/issues/700
|
||||
c, ok := obj.(*provisioning.Connection)
|
||||
if ok {
|
||||
// Add finalizer on create to prevent deletion while repositories reference it
|
||||
if len(c.Finalizers) == 0 && a.GetOperation() == admission.Create {
|
||||
c.Finalizers = []string{
|
||||
connectionvalidation.BlockDeletionFinalizer,
|
||||
}
|
||||
}
|
||||
return connectionvalidation.MutateConnection(c)
|
||||
}
|
||||
|
||||
@@ -695,7 +726,13 @@ func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admis
|
||||
// TODO: move logic to a more appropriate place. Probably controller/validation.go
|
||||
func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
|
||||
obj := a.GetObject()
|
||||
if obj == nil || a.GetOperation() == admission.Connect || a.GetOperation() == admission.Delete {
|
||||
|
||||
// Handle Connection deletion - check for connected repositories
|
||||
if a.GetOperation() == admission.Delete {
|
||||
return b.validateDelete(ctx, a)
|
||||
}
|
||||
|
||||
if obj == nil || a.GetOperation() == admission.Connect {
|
||||
return nil // This is normal for sub-resource
|
||||
}
|
||||
|
||||
@@ -775,6 +812,42 @@ func invalidRepositoryError(name string, list field.ErrorList) error {
|
||||
name, list)
|
||||
}
|
||||
|
||||
// validateDelete handles validation for delete operations
|
||||
func (b *APIBuilder) validateDelete(ctx context.Context, a admission.Attributes) error {
|
||||
// Only validate Connection deletions
|
||||
if a.GetResource().Resource != "connections" {
|
||||
return nil
|
||||
}
|
||||
|
||||
connectionName := a.GetName()
|
||||
namespace := a.GetNamespace()
|
||||
|
||||
// Set namespace in context for the repository store query
|
||||
ctx, _, err := identity.WithProvisioningIdentity(ctx, namespace)
|
||||
if err != nil {
|
||||
return apierrors.NewInternalError(fmt.Errorf("failed to set provisioning identity: %w", err))
|
||||
}
|
||||
|
||||
repos, err := GetRepositoriesByConnection(ctx, b.store, connectionName)
|
||||
if err != nil {
|
||||
return apierrors.NewInternalError(fmt.Errorf("failed to check for connected repositories: %w", err))
|
||||
}
|
||||
|
||||
if len(repos) > 0 {
|
||||
repoNames := make([]string, 0, len(repos))
|
||||
for _, repo := range repos {
|
||||
repoNames = append(repoNames, repo.Name)
|
||||
}
|
||||
return apierrors.NewForbidden(
|
||||
provisioning.ConnectionResourceInfo.GroupResource(),
|
||||
connectionName,
|
||||
fmt.Errorf("cannot delete connection while repositories are using it: %s", strings.Join(repoNames, ", ")),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *APIBuilder) VerifyAgainstExistingRepositories(ctx context.Context, cfg *provisioning.Repository) *field.Error {
|
||||
return VerifyAgainstExistingRepositories(ctx, b.store, cfg)
|
||||
}
|
||||
@@ -947,6 +1020,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
|
||||
b.GetClient(),
|
||||
connInformer,
|
||||
connStatusPatcher,
|
||||
b.store,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package provisioning
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"k8s.io/apimachinery/pkg/fields"
|
||||
"k8s.io/apimachinery/pkg/labels"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/generic"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
)
|
||||
|
||||
// RepositoryToSelectableFields returns a field set that can be used for field selectors.
|
||||
// This includes standard metadata fields plus custom fields like spec.connection.name.
|
||||
func RepositoryToSelectableFields(obj *provisioning.Repository) fields.Set {
|
||||
objectMetaFields := generic.ObjectMetaFieldsSet(&obj.ObjectMeta, true)
|
||||
|
||||
// Add custom selectable fields
|
||||
specificFields := fields.Set{
|
||||
"spec.connection.name": getConnectionName(obj),
|
||||
}
|
||||
|
||||
return generic.MergeFieldsSets(objectMetaFields, specificFields)
|
||||
}
|
||||
|
||||
// getConnectionName safely extracts the connection name from a Repository.
|
||||
// Returns empty string if no connection is configured.
|
||||
func getConnectionName(obj *provisioning.Repository) string {
|
||||
if obj == nil || obj.Spec.Connection == nil {
|
||||
return ""
|
||||
}
|
||||
return obj.Spec.Connection.Name
|
||||
}
|
||||
|
||||
// RepositoryGetAttrs returns labels and fields of a Repository object.
|
||||
// This is used by the storage layer for filtering.
|
||||
func RepositoryGetAttrs(obj runtime.Object) (labels.Set, fields.Set, error) {
|
||||
repo, ok := obj.(*provisioning.Repository)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("given object is not a Repository")
|
||||
}
|
||||
return labels.Set(repo.Labels), RepositoryToSelectableFields(repo), nil
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package provisioning
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
)
|
||||
|
||||
func TestGetConnectionName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
repo *provisioning.Repository
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "nil repository returns empty string",
|
||||
repo: nil,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "repository without connection returns empty string",
|
||||
repo: &provisioning.Repository{
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Title: "test-repo",
|
||||
},
|
||||
},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "repository with connection returns connection name",
|
||||
repo: &provisioning.Repository{
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Title: "test-repo",
|
||||
Connection: &provisioning.ConnectionInfo{
|
||||
Name: "my-connection",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: "my-connection",
|
||||
},
|
||||
{
|
||||
name: "repository with empty connection name returns empty string",
|
||||
repo: &provisioning.Repository{
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Title: "test-repo",
|
||||
Connection: &provisioning.ConnectionInfo{
|
||||
Name: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := getConnectionName(tt.repo)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepositoryToSelectableFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
repo *provisioning.Repository
|
||||
expectedFields map[string]string
|
||||
}{
|
||||
{
|
||||
name: "includes metadata.name and metadata.namespace",
|
||||
repo: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Title: "Test Repository",
|
||||
},
|
||||
},
|
||||
expectedFields: map[string]string{
|
||||
"metadata.name": "test-repo",
|
||||
"metadata.namespace": "default",
|
||||
"spec.connection.name": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "includes spec.connection.name when set",
|
||||
repo: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "repo-with-connection",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Title: "Repo With Connection",
|
||||
Connection: &provisioning.ConnectionInfo{
|
||||
Name: "github-connection",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedFields: map[string]string{
|
||||
"metadata.name": "repo-with-connection",
|
||||
"metadata.namespace": "org-1",
|
||||
"spec.connection.name": "github-connection",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fields := RepositoryToSelectableFields(tt.repo)
|
||||
|
||||
for key, expectedValue := range tt.expectedFields {
|
||||
actualValue, exists := fields[key]
|
||||
assert.True(t, exists, "field %s should exist", key)
|
||||
assert.Equal(t, expectedValue, actualValue, "field %s should have correct value", key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepositoryGetAttrs(t *testing.T) {
|
||||
t.Run("returns error for non-Repository object", func(t *testing.T) {
|
||||
// Pass a different runtime.Object type instead of a Repository
|
||||
connection := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "not-a-repository",
|
||||
},
|
||||
}
|
||||
_, _, err := RepositoryGetAttrs(connection)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not a Repository")
|
||||
})
|
||||
|
||||
t.Run("returns labels and fields for valid Repository", func(t *testing.T) {
|
||||
repo := &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{
|
||||
"app": "grafana",
|
||||
"env": "test",
|
||||
},
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Title: "Test Repository",
|
||||
Connection: &provisioning.ConnectionInfo{
|
||||
Name: "my-connection",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
labels, fields, err := RepositoryGetAttrs(repo)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check labels
|
||||
assert.Equal(t, "grafana", labels["app"])
|
||||
assert.Equal(t, "test", labels["env"])
|
||||
|
||||
// Check fields
|
||||
assert.Equal(t, "test-repo", fields["metadata.name"])
|
||||
assert.Equal(t, "default", fields["metadata.namespace"])
|
||||
assert.Equal(t, "my-connection", fields["spec.connection.name"])
|
||||
})
|
||||
|
||||
t.Run("returns empty connection name when not set", func(t *testing.T) {
|
||||
repo := &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Title: "Test Repository",
|
||||
},
|
||||
}
|
||||
|
||||
_, fields, err := RepositoryGetAttrs(repo)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", fields["spec.connection.name"])
|
||||
})
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||
"k8s.io/apimachinery/pkg/fields"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||
"k8s.io/apiserver/pkg/endpoints/request"
|
||||
@@ -50,6 +51,39 @@ func GetRepositoriesInNamespace(ctx context.Context, store RepositoryLister) ([]
|
||||
return allRepositories, nil
|
||||
}
|
||||
|
||||
// GetRepositoriesByConnection retrieves all repositories that reference a specific connection
|
||||
func GetRepositoriesByConnection(ctx context.Context, store RepositoryLister, connectionName string) ([]provisioning.Repository, error) {
|
||||
var allRepositories []provisioning.Repository
|
||||
continueToken := ""
|
||||
|
||||
fieldSelector := fields.OneTermEqualSelector("spec.connection.name", connectionName)
|
||||
|
||||
for {
|
||||
obj, err := store.List(ctx, &internalversion.ListOptions{
|
||||
Limit: 100,
|
||||
Continue: continueToken,
|
||||
FieldSelector: fieldSelector,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
repositoryList, ok := obj.(*provisioning.RepositoryList)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected repository list")
|
||||
}
|
||||
|
||||
allRepositories = append(allRepositories, repositoryList.Items...)
|
||||
|
||||
continueToken = repositoryList.GetContinue()
|
||||
if continueToken == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return allRepositories, nil
|
||||
}
|
||||
|
||||
// VerifyAgainstExistingRepositories validates a repository configuration against existing repositories
|
||||
func VerifyAgainstExistingRepositories(ctx context.Context, store RepositoryLister, cfg *provisioning.Repository) *field.Error {
|
||||
ctx, _, err := identity.WithProvisioningIdentity(ctx, cfg.Namespace)
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package provisioning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/fields"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
)
|
||||
|
||||
// mockRepositoryLister is a mock implementation of RepositoryLister for testing
|
||||
type mockRepositoryLister struct {
|
||||
repositories []provisioning.Repository
|
||||
listErr error
|
||||
// Track the field selector used in List calls
|
||||
lastFieldSelector fields.Selector
|
||||
}
|
||||
|
||||
func (m *mockRepositoryLister) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
|
||||
if m.listErr != nil {
|
||||
return nil, m.listErr
|
||||
}
|
||||
|
||||
// Store the field selector for verification
|
||||
m.lastFieldSelector = options.FieldSelector
|
||||
|
||||
// Filter repositories based on field selector if present
|
||||
filteredRepos := m.repositories
|
||||
if options.FieldSelector != nil && !options.FieldSelector.Empty() {
|
||||
filteredRepos = make([]provisioning.Repository, 0)
|
||||
for _, repo := range m.repositories {
|
||||
// Simulate field selector matching for spec.connection.name
|
||||
repoFields := fields.Set{
|
||||
"spec.connection.name": getRepoConnectionName(&repo),
|
||||
}
|
||||
if options.FieldSelector.Matches(repoFields) {
|
||||
filteredRepos = append(filteredRepos, repo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &provisioning.RepositoryList{
|
||||
Items: filteredRepos,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getRepoConnectionName(repo *provisioning.Repository) string {
|
||||
if repo.Spec.Connection == nil {
|
||||
return ""
|
||||
}
|
||||
return repo.Spec.Connection.Name
|
||||
}
|
||||
|
||||
func TestGetRepositoriesByConnection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
repositories []provisioning.Repository
|
||||
connectionName string
|
||||
expectedCount int
|
||||
expectedNames []string
|
||||
expectedErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty repository list returns empty",
|
||||
repositories: []provisioning.Repository{},
|
||||
connectionName: "test-conn",
|
||||
expectedCount: 0,
|
||||
expectedNames: []string{},
|
||||
},
|
||||
{
|
||||
name: "finds single matching repository",
|
||||
repositories: []provisioning.Repository{
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "repo-1"},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Connection: &provisioning.ConnectionInfo{Name: "conn-a"},
|
||||
},
|
||||
},
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "repo-2"},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Connection: &provisioning.ConnectionInfo{Name: "conn-b"},
|
||||
},
|
||||
},
|
||||
},
|
||||
connectionName: "conn-a",
|
||||
expectedCount: 1,
|
||||
expectedNames: []string{"repo-1"},
|
||||
},
|
||||
{
|
||||
name: "finds multiple matching repositories",
|
||||
repositories: []provisioning.Repository{
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "repo-1"},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Connection: &provisioning.ConnectionInfo{Name: "shared-conn"},
|
||||
},
|
||||
},
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "repo-2"},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Connection: &provisioning.ConnectionInfo{Name: "shared-conn"},
|
||||
},
|
||||
},
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "repo-3"},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Connection: &provisioning.ConnectionInfo{Name: "different-conn"},
|
||||
},
|
||||
},
|
||||
},
|
||||
connectionName: "shared-conn",
|
||||
expectedCount: 2,
|
||||
expectedNames: []string{"repo-1", "repo-2"},
|
||||
},
|
||||
{
|
||||
name: "no matches returns empty list",
|
||||
repositories: []provisioning.Repository{
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "repo-1"},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Connection: &provisioning.ConnectionInfo{Name: "conn-a"},
|
||||
},
|
||||
},
|
||||
},
|
||||
connectionName: "non-existent",
|
||||
expectedCount: 0,
|
||||
expectedNames: []string{},
|
||||
},
|
||||
{
|
||||
name: "empty connection name matches repos without connection",
|
||||
repositories: []provisioning.Repository{
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "repo-with-conn"},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Connection: &provisioning.ConnectionInfo{Name: "some-conn"},
|
||||
},
|
||||
},
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "repo-without-conn"},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Connection: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
connectionName: "",
|
||||
expectedCount: 1,
|
||||
expectedNames: []string{"repo-without-conn"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mock := &mockRepositoryLister{repositories: tt.repositories}
|
||||
ctx := context.Background()
|
||||
|
||||
repos, err := GetRepositoriesByConnection(ctx, mock, tt.connectionName)
|
||||
|
||||
if tt.expectedErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, repos, tt.expectedCount)
|
||||
|
||||
// Verify the field selector was used
|
||||
require.NotNil(t, mock.lastFieldSelector, "field selector should have been set")
|
||||
expectedSelector := fields.OneTermEqualSelector("spec.connection.name", tt.connectionName)
|
||||
assert.Equal(t, expectedSelector.String(), mock.lastFieldSelector.String())
|
||||
|
||||
// Verify the correct repositories were returned
|
||||
actualNames := make([]string, len(repos))
|
||||
for i, repo := range repos {
|
||||
actualNames[i] = repo.Name
|
||||
}
|
||||
for _, expectedName := range tt.expectedNames {
|
||||
assert.Contains(t, actualNames, expectedName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRepositoriesByConnection_ListError(t *testing.T) {
|
||||
mock := &mockRepositoryLister{
|
||||
listErr: assert.AnError,
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
repos, err := GetRepositoriesByConnection(ctx, mock, "any-conn")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, repos)
|
||||
}
|
||||
@@ -542,6 +542,9 @@ func (d *dashboardStore) saveDashboard(ctx context.Context, sess *db.Session, cm
|
||||
tags := dash.GetTags()
|
||||
if len(tags) > 0 {
|
||||
for _, tag := range tags {
|
||||
if len(tag) > 50 {
|
||||
return nil, dashboards.ErrDashboardTagTooLong
|
||||
}
|
||||
if _, err := sess.Insert(dashboardTag{DashboardId: dash.ID, Term: tag, OrgID: dash.OrgID, DashboardUID: dash.UID}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -79,6 +79,11 @@ var (
|
||||
Reason: "message too long, max 500 characters",
|
||||
StatusCode: 400,
|
||||
}
|
||||
ErrDashboardTagTooLong = dashboardaccess.DashboardErr{
|
||||
Reason: "dashboard tag too long, max 50 characters",
|
||||
StatusCode: 400,
|
||||
Status: "tag-too-long",
|
||||
}
|
||||
ErrDashboardCannotSaveProvisionedDashboard = dashboardaccess.DashboardErr{
|
||||
Reason: "Cannot save provisioned dashboard",
|
||||
StatusCode: 400,
|
||||
|
||||
@@ -501,9 +501,15 @@ type GetLibraryElementsParams struct {
|
||||
// required:false
|
||||
ExcludeUID string `json:"excludeUid"`
|
||||
// A comma separated list of folder ID(s) to filter the elements by.
|
||||
// Deprecated: Use FolderFilterUIDs instead.
|
||||
// in:query
|
||||
// required:false
|
||||
// deprecated:true
|
||||
FolderFilter string `json:"folderFilter"`
|
||||
// A comma separated list of folder UID(s) to filter the elements by.
|
||||
// in:query
|
||||
// required:false
|
||||
FolderFilterUIDs string `json:"folderFilterUIDs"`
|
||||
// The number of results per page.
|
||||
// in:query
|
||||
// required:false
|
||||
|
||||
@@ -20,6 +20,10 @@ func UpdatePreferencesFor(ctx context.Context,
|
||||
return response.Error(http.StatusBadRequest, "Invalid theme", nil)
|
||||
}
|
||||
|
||||
if !pref.IsValidTimezone(dtoCmd.Timezone) {
|
||||
return response.Error(http.StatusBadRequest, "Invalid timezone. Must be a valid IANA timezone (e.g., America/New_York), 'utc', 'browser', or empty string", nil)
|
||||
}
|
||||
|
||||
// convert dashboard UID to ID in order to store internally if it exists in the query, otherwise take the id from query
|
||||
// nolint:staticcheck
|
||||
dashboardID := dtoCmd.HomeDashboardID
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package pref
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// IsValidTimezone checks if the timezone string is valid.
|
||||
// It accepts:
|
||||
// - "" - uses default
|
||||
// - "utc"
|
||||
// - "browser"
|
||||
// - Any valid IANA timezone (e.g., "America/New_York", "Europe/London")
|
||||
func IsValidTimezone(timezone string) bool {
|
||||
if timezone == "" || timezone == "utc" || timezone == "browser" {
|
||||
return true
|
||||
}
|
||||
|
||||
// try to load as IANA timezone
|
||||
_, err := time.LoadLocation(timezone)
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package pref
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIsValidTimezone(t *testing.T) {
|
||||
tests := []struct {
|
||||
timezone string
|
||||
valid bool
|
||||
}{
|
||||
{
|
||||
timezone: "utc",
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
timezone: "browser",
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
timezone: "Europe/London",
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
timezone: "invalid",
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
timezone: "",
|
||||
valid: true,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
assert.Equal(t, test.valid, IsValidTimezone(test.timezone))
|
||||
}
|
||||
}
|
||||
@@ -393,6 +393,66 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) {
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Dashboard tag validations", func(t *testing.T) {
|
||||
t.Run("reject dashboard with tag over 50 characters on creation", func(t *testing.T) {
|
||||
dashObj := createDashboardObject(t, "Dashboard with Long Tag", "", 0)
|
||||
meta, _ := utils.MetaAccessor(dashObj)
|
||||
spec, _ := meta.GetSpec()
|
||||
specMap := spec.(map[string]interface{})
|
||||
specMap["tags"] = []string{"this-is-a-very-long-tag-that-exceeds-fifty-characters-limit"}
|
||||
_ = meta.SetSpec(specMap)
|
||||
_, err := adminClient.Resource.Create(context.Background(), dashObj, v1.CreateOptions{})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "tag too long")
|
||||
})
|
||||
|
||||
t.Run("reject dashboard update with tag over 50 characters", func(t *testing.T) {
|
||||
dash, err := createDashboard(t, adminClient, "Valid Dashboard", nil, nil, ctx.Helper)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, dash)
|
||||
meta, _ := utils.MetaAccessor(dash)
|
||||
spec, _ := meta.GetSpec()
|
||||
specMap := spec.(map[string]interface{})
|
||||
specMap["tags"] = []string{"this-is-a-very-long-tag-that-exceeds-fifty-characters-limit"}
|
||||
_ = meta.SetSpec(specMap)
|
||||
_, err = adminClient.Resource.Update(context.Background(), dash, v1.UpdateOptions{})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "tag too long")
|
||||
err = adminClient.Resource.Delete(context.Background(), dash.GetName(), v1.DeleteOptions{})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("accept dashboard with tag at 50 characters", func(t *testing.T) {
|
||||
dashObj := createDashboardObject(t, "Dashboard with Valid Tag", "", 0)
|
||||
meta, _ := utils.MetaAccessor(dashObj)
|
||||
spec, _ := meta.GetSpec()
|
||||
specMap := spec.(map[string]interface{})
|
||||
specMap["tags"] = []string{"this-tag-is-exactly-fifty-characters-long-12345"}
|
||||
_ = meta.SetSpec(specMap)
|
||||
createdDash, err := adminClient.Resource.Create(context.Background(), dashObj, v1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, createdDash)
|
||||
err = adminClient.Resource.Delete(context.Background(), createdDash.GetName(), v1.DeleteOptions{})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("reject dashboard with multiple tags where one exceeds limit", func(t *testing.T) {
|
||||
dashObj := createDashboardObject(t, "Dashboard with Mixed Tags", "", 0)
|
||||
meta, _ := utils.MetaAccessor(dashObj)
|
||||
spec, _ := meta.GetSpec()
|
||||
specMap := spec.(map[string]interface{})
|
||||
specMap["tags"] = []string{
|
||||
"valid-tag",
|
||||
"another-valid-tag",
|
||||
"this-is-a-very-long-tag-that-exceeds-fifty-characters-limit",
|
||||
}
|
||||
_ = meta.SetSpec(specMap)
|
||||
_, err := adminClient.Resource.Create(context.Background(), dashObj, v1.CreateOptions{})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "tag too long")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Dashboard folder validations", func(t *testing.T) {
|
||||
// Test non-existent folder UID
|
||||
t.Run("reject dashboard with non-existent folder UID", func(t *testing.T) {
|
||||
|
||||
@@ -67,7 +67,7 @@ func TestIntegrationPreferences(t *testing.T) {
|
||||
Path: fmt.Sprintf("/api/teams/%d/preferences", helper.Org1.Staff.ID),
|
||||
Body: []byte(`{
|
||||
"weekStart": "sunday",
|
||||
"timezone": "africa"
|
||||
"timezone": "Africa/Johannesburg"
|
||||
}`),
|
||||
}, &raw)
|
||||
require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "create preference for user")
|
||||
@@ -79,7 +79,7 @@ func TestIntegrationPreferences(t *testing.T) {
|
||||
Path: "/api/org/preferences",
|
||||
Body: []byte(`{
|
||||
"weekStart": "sunday",
|
||||
"timezone": "africa",
|
||||
"timezone": "Africa/Accra",
|
||||
"theme": "dark"
|
||||
}`),
|
||||
}, &raw)
|
||||
@@ -144,7 +144,7 @@ func TestIntegrationPreferences(t *testing.T) {
|
||||
|
||||
jj, _ = json.Marshal(bootdata.Result.User)
|
||||
require.JSONEq(t, `{
|
||||
"timezone":"africa",
|
||||
"timezone":"Africa/Johannesburg",
|
||||
"weekStart":"saturday",
|
||||
"theme":"dark",
|
||||
"language":"en-US", `+ // FROM global default!
|
||||
@@ -157,10 +157,10 @@ func TestIntegrationPreferences(t *testing.T) {
|
||||
Path: "/apis/preferences.grafana.app/v1alpha1/namespaces/default/preferences/merged",
|
||||
}, &preferences.Preferences{})
|
||||
require.Equal(t, http.StatusOK, merged.Response.StatusCode, "get merged preferences")
|
||||
require.Equal(t, "saturday", *merged.Result.Spec.WeekStart) // from user
|
||||
require.Equal(t, "africa", *merged.Result.Spec.Timezone) // from team
|
||||
require.Equal(t, "dark", *merged.Result.Spec.Theme) // from org
|
||||
require.Equal(t, "en-US", *merged.Result.Spec.Language) // settings.ini
|
||||
require.Equal(t, "dd/mm/yyyy", *merged.Result.Spec.RegionalFormat) // from user update
|
||||
require.Equal(t, "saturday", *merged.Result.Spec.WeekStart) // from user
|
||||
require.Equal(t, "Africa/Johannesburg", *merged.Result.Spec.Timezone) // from team
|
||||
require.Equal(t, "dark", *merged.Result.Spec.Theme) // from org
|
||||
require.Equal(t, "en-US", *merged.Result.Spec.Language) // settings.ini
|
||||
require.Equal(t, "dd/mm/yyyy", *merged.Result.Spec.RegionalFormat) // from user update
|
||||
})
|
||||
}
|
||||
|
||||
@@ -559,3 +559,376 @@ func TestIntegrationConnectionController_HealthCheckUpdates(t *testing.T) {
|
||||
assert.True(t, final.Status.Health.Healthy, "connection should remain healthy")
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationProvisioning_RepositoryFieldSelectorByConnection(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
helper := runGrafana(t)
|
||||
ctx := context.Background()
|
||||
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
|
||||
|
||||
// Create a connection first
|
||||
connection := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
"kind": "Connection",
|
||||
"metadata": map[string]any{
|
||||
"name": "test-conn-for-field-selector",
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"type": "github",
|
||||
"github": map[string]any{
|
||||
"appID": "123456",
|
||||
"installationID": "789012",
|
||||
},
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": "test-private-key",
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.NoError(t, err, "failed to create connection")
|
||||
|
||||
t.Cleanup(func() {
|
||||
// Clean up repositories first
|
||||
_ = helper.Repositories.Resource.Delete(ctx, "repo-with-connection", metav1.DeleteOptions{})
|
||||
_ = helper.Repositories.Resource.Delete(ctx, "repo-without-connection", metav1.DeleteOptions{})
|
||||
_ = helper.Repositories.Resource.Delete(ctx, "repo-with-different-connection", metav1.DeleteOptions{})
|
||||
// Then clean up the connection
|
||||
_ = helper.Connections.Resource.Delete(ctx, "test-conn-for-field-selector", metav1.DeleteOptions{})
|
||||
})
|
||||
|
||||
// Create a repository WITH the connection
|
||||
repoWithConnection := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
"kind": "Repository",
|
||||
"metadata": map[string]any{
|
||||
"name": "repo-with-connection",
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"title": "Repo With Connection",
|
||||
"type": "local",
|
||||
"sync": map[string]any{
|
||||
"enabled": false,
|
||||
"target": "folder",
|
||||
},
|
||||
"local": map[string]any{
|
||||
"path": helper.ProvisioningPath,
|
||||
},
|
||||
"connection": map[string]any{
|
||||
"name": "test-conn-for-field-selector",
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
_, err = helper.Repositories.Resource.Create(ctx, repoWithConnection, createOptions)
|
||||
require.NoError(t, err, "failed to create repository with connection")
|
||||
|
||||
// Create a repository WITHOUT the connection
|
||||
repoWithoutConnection := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
"kind": "Repository",
|
||||
"metadata": map[string]any{
|
||||
"name": "repo-without-connection",
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"title": "Repo Without Connection",
|
||||
"type": "local",
|
||||
"sync": map[string]any{
|
||||
"enabled": false,
|
||||
"target": "folder",
|
||||
},
|
||||
"local": map[string]any{
|
||||
"path": helper.ProvisioningPath,
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
_, err = helper.Repositories.Resource.Create(ctx, repoWithoutConnection, createOptions)
|
||||
require.NoError(t, err, "failed to create repository without connection")
|
||||
|
||||
// Create a repository with a DIFFERENT connection name (non-existent)
|
||||
repoWithDifferentConnection := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
"kind": "Repository",
|
||||
"metadata": map[string]any{
|
||||
"name": "repo-with-different-connection",
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"title": "Repo With Different Connection",
|
||||
"type": "local",
|
||||
"sync": map[string]any{
|
||||
"enabled": false,
|
||||
"target": "folder",
|
||||
},
|
||||
"local": map[string]any{
|
||||
"path": helper.ProvisioningPath,
|
||||
},
|
||||
"connection": map[string]any{
|
||||
"name": "some-other-connection",
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
_, err = helper.Repositories.Resource.Create(ctx, repoWithDifferentConnection, createOptions)
|
||||
require.NoError(t, err, "failed to create repository with different connection")
|
||||
|
||||
t.Run("filter repositories by spec.connection.name", func(t *testing.T) {
|
||||
// List repositories with field selector for the specific connection
|
||||
list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{
|
||||
FieldSelector: "spec.connection.name=test-conn-for-field-selector",
|
||||
})
|
||||
require.NoError(t, err, "failed to list repositories with field selector")
|
||||
|
||||
// Should only return the repository with the matching connection
|
||||
assert.Len(t, list.Items, 1, "should return exactly one repository")
|
||||
assert.Equal(t, "repo-with-connection", list.Items[0].GetName(), "should return the correct repository")
|
||||
})
|
||||
|
||||
t.Run("filter repositories by non-existent connection returns empty", func(t *testing.T) {
|
||||
// List repositories with field selector for a non-existent connection
|
||||
list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{
|
||||
FieldSelector: "spec.connection.name=non-existent-connection",
|
||||
})
|
||||
require.NoError(t, err, "failed to list repositories with field selector")
|
||||
|
||||
// Should return empty list
|
||||
assert.Len(t, list.Items, 0, "should return no repositories for non-existent connection")
|
||||
})
|
||||
|
||||
t.Run("filter repositories by empty connection name", func(t *testing.T) {
|
||||
// List repositories with field selector for empty connection (repos without connection)
|
||||
list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{
|
||||
FieldSelector: "spec.connection.name=",
|
||||
})
|
||||
require.NoError(t, err, "failed to list repositories with empty connection field selector")
|
||||
|
||||
// Should return the repository without a connection
|
||||
assert.Len(t, list.Items, 1, "should return exactly one repository without connection")
|
||||
assert.Equal(t, "repo-without-connection", list.Items[0].GetName(), "should return the repository without connection")
|
||||
})
|
||||
|
||||
t.Run("list all repositories without field selector", func(t *testing.T) {
|
||||
// List all repositories without field selector
|
||||
list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{})
|
||||
require.NoError(t, err, "failed to list all repositories")
|
||||
|
||||
// Should return all three repositories
|
||||
assert.Len(t, list.Items, 3, "should return all three repositories")
|
||||
|
||||
names := make([]string, len(list.Items))
|
||||
for i, item := range list.Items {
|
||||
names[i] = item.GetName()
|
||||
}
|
||||
assert.Contains(t, names, "repo-with-connection")
|
||||
assert.Contains(t, names, "repo-without-connection")
|
||||
assert.Contains(t, names, "repo-with-different-connection")
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationProvisioning_ConnectionDeletionBlocking(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
helper := runGrafana(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
createOptions := metav1.CreateOptions{}
|
||||
|
||||
// Create a connection for testing deletion blocking
|
||||
connName := "test-conn-delete-blocking"
|
||||
_, err := helper.Connections.Resource.Create(ctx, &unstructured.Unstructured{
|
||||
Object: map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
"kind": "Connection",
|
||||
"metadata": map[string]any{
|
||||
"name": connName,
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"type": "github",
|
||||
"github": map[string]any{
|
||||
"appID": "123456",
|
||||
"installationID": "454545",
|
||||
},
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": "someSecret",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, createOptions)
|
||||
require.NoError(t, err, "failed to create test connection")
|
||||
|
||||
t.Run("delete connection without connected repositories succeeds", func(t *testing.T) {
|
||||
// Create a connection that has no repositories
|
||||
emptyConnName := "test-conn-no-repos"
|
||||
_, err := helper.Connections.Resource.Create(ctx, &unstructured.Unstructured{
|
||||
Object: map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
"kind": "Connection",
|
||||
"metadata": map[string]any{
|
||||
"name": emptyConnName,
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"type": "github",
|
||||
"github": map[string]any{
|
||||
"appID": "123457",
|
||||
"installationID": "454546",
|
||||
},
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": "someSecret",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, createOptions)
|
||||
require.NoError(t, err, "failed to create test connection without repos")
|
||||
|
||||
// Delete should succeed since no repositories reference it
|
||||
err = helper.Connections.Resource.Delete(ctx, emptyConnName, metav1.DeleteOptions{})
|
||||
require.NoError(t, err, "deleting connection without connected repositories should succeed")
|
||||
|
||||
// Verify the connection is deleted
|
||||
_, err = helper.Connections.Resource.Get(ctx, emptyConnName, metav1.GetOptions{})
|
||||
require.True(t, k8serrors.IsNotFound(err), "connection should be deleted")
|
||||
})
|
||||
|
||||
t.Run("delete connection with connected repository fails", func(t *testing.T) {
|
||||
// Create a repository that uses the connection
|
||||
repoName := "repo-using-connection"
|
||||
_, err := helper.Repositories.Resource.Create(ctx, &unstructured.Unstructured{
|
||||
Object: map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
"kind": "Repository",
|
||||
"metadata": map[string]any{
|
||||
"name": repoName,
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"title": "Test Repository",
|
||||
"type": "local",
|
||||
"sync": map[string]any{
|
||||
"enabled": false,
|
||||
"target": "folder",
|
||||
},
|
||||
"local": map[string]any{
|
||||
"path": helper.ProvisioningPath,
|
||||
},
|
||||
"connection": map[string]any{
|
||||
"name": connName,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, createOptions)
|
||||
require.NoError(t, err, "failed to create repository using connection")
|
||||
|
||||
// Attempt to delete the connection - should fail
|
||||
err = helper.Connections.Resource.Delete(ctx, connName, metav1.DeleteOptions{})
|
||||
require.Error(t, err, "deleting connection with connected repository should fail")
|
||||
require.True(t, k8serrors.IsForbidden(err), "error should be Forbidden, got: %v", err)
|
||||
assert.Contains(t, err.Error(), repoName, "error should mention the connected repository name")
|
||||
assert.Contains(t, err.Error(), "cannot delete connection while repositories are using it", "error should explain why deletion is blocked")
|
||||
|
||||
// Clean up: delete the repository first
|
||||
err = helper.Repositories.Resource.Delete(ctx, repoName, metav1.DeleteOptions{})
|
||||
require.NoError(t, err, "failed to delete test repository")
|
||||
|
||||
// Wait for the repository to be deleted
|
||||
require.Eventually(t, func() bool {
|
||||
_, err := helper.Repositories.Resource.Get(ctx, repoName, metav1.GetOptions{})
|
||||
return k8serrors.IsNotFound(err)
|
||||
}, 10*time.Second, 100*time.Millisecond, "repository should be deleted")
|
||||
})
|
||||
|
||||
t.Run("delete connection after disconnecting repository succeeds", func(t *testing.T) {
|
||||
// Now that the repository is deleted, the connection should be deletable
|
||||
err = helper.Connections.Resource.Delete(ctx, connName, metav1.DeleteOptions{})
|
||||
require.NoError(t, err, "deleting connection after removing connected repositories should succeed")
|
||||
|
||||
// Verify the connection is deleted
|
||||
_, err = helper.Connections.Resource.Get(ctx, connName, metav1.GetOptions{})
|
||||
require.True(t, k8serrors.IsNotFound(err), "connection should be deleted")
|
||||
})
|
||||
|
||||
t.Run("delete connection with multiple connected repositories lists all", func(t *testing.T) {
|
||||
// Create a new connection
|
||||
multiConnName := "test-conn-multi-repos"
|
||||
_, err := helper.Connections.Resource.Create(ctx, &unstructured.Unstructured{
|
||||
Object: map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
"kind": "Connection",
|
||||
"metadata": map[string]any{
|
||||
"name": multiConnName,
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"type": "github",
|
||||
"github": map[string]any{
|
||||
"appID": "123458",
|
||||
"installationID": "454547",
|
||||
},
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": "someSecret",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, createOptions)
|
||||
require.NoError(t, err, "failed to create multi-repo test connection")
|
||||
|
||||
// Create multiple repositories using this connection
|
||||
repoNames := []string{"multi-repo-1", "multi-repo-2"}
|
||||
for _, repoName := range repoNames {
|
||||
_, err := helper.Repositories.Resource.Create(ctx, &unstructured.Unstructured{
|
||||
Object: map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
"kind": "Repository",
|
||||
"metadata": map[string]any{
|
||||
"name": repoName,
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"title": "Test Repository " + repoName,
|
||||
"type": "local",
|
||||
"sync": map[string]any{
|
||||
"enabled": false,
|
||||
"target": "folder",
|
||||
},
|
||||
"local": map[string]any{
|
||||
"path": helper.ProvisioningPath,
|
||||
},
|
||||
"connection": map[string]any{
|
||||
"name": multiConnName,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, createOptions)
|
||||
require.NoError(t, err, "failed to create repository %s", repoName)
|
||||
}
|
||||
|
||||
// Attempt to delete - should fail and list all repos
|
||||
err = helper.Connections.Resource.Delete(ctx, multiConnName, metav1.DeleteOptions{})
|
||||
require.Error(t, err, "deleting connection with multiple repos should fail")
|
||||
require.True(t, k8serrors.IsForbidden(err), "error should be Forbidden")
|
||||
for _, repoName := range repoNames {
|
||||
assert.Contains(t, err.Error(), repoName, "error should mention repository %s", repoName)
|
||||
}
|
||||
|
||||
// Clean up
|
||||
for _, repoName := range repoNames {
|
||||
_ = helper.Repositories.Resource.Delete(ctx, repoName, metav1.DeleteOptions{})
|
||||
}
|
||||
_ = helper.Connections.Resource.Delete(ctx, multiConnName, metav1.DeleteOptions{})
|
||||
})
|
||||
}
|
||||
|
||||
Generated
+4
-10
@@ -6152,11 +6152,8 @@
|
||||
]
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"utc",
|
||||
"browser"
|
||||
]
|
||||
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
|
||||
"type": "string"
|
||||
},
|
||||
"weekStart": {
|
||||
"type": "string"
|
||||
@@ -8657,11 +8654,8 @@
|
||||
]
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"utc",
|
||||
"browser"
|
||||
]
|
||||
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
|
||||
"type": "string"
|
||||
},
|
||||
"weekStart": {
|
||||
"type": "string"
|
||||
|
||||
Generated
+11
-11
@@ -6167,10 +6167,16 @@
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "A comma separated list of folder ID(s) to filter the elements by.",
|
||||
"description": "A comma separated list of folder ID(s) to filter the elements by.\nDeprecated: Use FolderFilterUIDs instead.",
|
||||
"name": "folderFilter",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "A comma separated list of folder UID(s) to filter the elements by.",
|
||||
"name": "folderFilterUIDs",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
@@ -18729,11 +18735,8 @@
|
||||
]
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"utc",
|
||||
"browser"
|
||||
]
|
||||
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
|
||||
"type": "string"
|
||||
},
|
||||
"weekStart": {
|
||||
"type": "string"
|
||||
@@ -23120,11 +23123,8 @@
|
||||
]
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"utc",
|
||||
"browser"
|
||||
]
|
||||
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
|
||||
"type": "string"
|
||||
},
|
||||
"weekStart": {
|
||||
"type": "string"
|
||||
|
||||
@@ -3,7 +3,8 @@ import { css, keyframes } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import grafanaIconSvg from 'img/grafana_icon.svg';
|
||||
|
||||
import { Branding } from '../Branding/Branding';
|
||||
|
||||
export function BouncingLoader() {
|
||||
const styles = useStyles2(getStyles);
|
||||
@@ -16,7 +17,7 @@ export function BouncingLoader() {
|
||||
aria-label={t('bouncing-loader.label', 'Loading')}
|
||||
>
|
||||
<div className={styles.bounce}>
|
||||
<img alt="" src={grafanaIconSvg} className={styles.logo} />
|
||||
<Branding.LoginLogo className={styles.logo} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,10 @@ export const validateDashboardJson = (json: string) => {
|
||||
if (hasInvalidTag) {
|
||||
return t('dashboard.validation.tags-expected-strings', 'tags expected array of strings');
|
||||
}
|
||||
const hasTooLongTag = dashboard.tags.some((tag: string) => tag.length > 50);
|
||||
if (hasTooLongTag) {
|
||||
return t('dashboard.validation.tag-too-long', 'Dashboard tag too long, max 50 characters');
|
||||
}
|
||||
} else {
|
||||
return t('dashboard.validation.tags-expected-array', 'tags expected array');
|
||||
}
|
||||
|
||||
@@ -5696,6 +5696,7 @@
|
||||
"validation": {
|
||||
"invalid-dashboard-id": "Could not find a valid Grafana.com ID",
|
||||
"invalid-json": "Not valid JSON",
|
||||
"tag-too-long": "Dashboard tag too long, max 50 characters",
|
||||
"tags-expected-array": "tags expected array",
|
||||
"tags-expected-strings": "tags expected array of strings"
|
||||
},
|
||||
@@ -9254,7 +9255,8 @@
|
||||
"tags-input": {
|
||||
"add": "Add",
|
||||
"placeholder-new-tag": "New tag (enter key to add)",
|
||||
"remove": "Remove tag: {{name}}"
|
||||
"remove": "Remove tag: {{name}}",
|
||||
"tag-too-long": "Tag too long, max 50 characters"
|
||||
},
|
||||
"time-sync-button": {
|
||||
"aria-label-sync": "Sync times",
|
||||
|
||||
Generated
+11
-9
@@ -8264,10 +8264,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
"enum": [
|
||||
"utc",
|
||||
"browser"
|
||||
],
|
||||
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
|
||||
"type": "string"
|
||||
},
|
||||
"weekStart": {
|
||||
@@ -12654,10 +12651,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
"enum": [
|
||||
"utc",
|
||||
"browser"
|
||||
],
|
||||
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
|
||||
"type": "string"
|
||||
},
|
||||
"weekStart": {
|
||||
@@ -20727,13 +20721,21 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "A comma separated list of folder ID(s) to filter the elements by.",
|
||||
"description": "A comma separated list of folder ID(s) to filter the elements by.\nDeprecated: Use FolderFilterUIDs instead.",
|
||||
"in": "query",
|
||||
"name": "folderFilter",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "A comma separated list of folder UID(s) to filter the elements by.",
|
||||
"in": "query",
|
||||
"name": "folderFilterUIDs",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "The number of results per page.",
|
||||
"in": "query",
|
||||
|
||||
@@ -3792,23 +3792,23 @@ __metadata:
|
||||
"@react-aria/overlays": "npm:3.30.0"
|
||||
"@react-aria/utils": "npm:3.31.0"
|
||||
"@rollup/plugin-node-resolve": "npm:16.0.1"
|
||||
"@storybook/addon-a11y": "npm:^8.6.2"
|
||||
"@storybook/addon-actions": "npm:^8.6.2"
|
||||
"@storybook/addon-docs": "npm:^8.6.2"
|
||||
"@storybook/addon-essentials": "npm:^8.6.2"
|
||||
"@storybook/addon-storysource": "npm:^8.6.2"
|
||||
"@storybook/addon-a11y": "npm:^8.6.15"
|
||||
"@storybook/addon-actions": "npm:^8.6.15"
|
||||
"@storybook/addon-docs": "npm:^8.6.15"
|
||||
"@storybook/addon-essentials": "npm:^8.6.15"
|
||||
"@storybook/addon-storysource": "npm:^8.6.15"
|
||||
"@storybook/addon-webpack5-compiler-swc": "npm:^2.1.0"
|
||||
"@storybook/blocks": "npm:^8.6.2"
|
||||
"@storybook/components": "npm:^8.6.2"
|
||||
"@storybook/core-events": "npm:^8.6.2"
|
||||
"@storybook/manager-api": "npm:^8.6.2"
|
||||
"@storybook/blocks": "npm:^8.6.15"
|
||||
"@storybook/components": "npm:^8.6.15"
|
||||
"@storybook/core-events": "npm:^8.6.15"
|
||||
"@storybook/manager-api": "npm:^8.6.15"
|
||||
"@storybook/mdx2-csf": "npm:1.1.0"
|
||||
"@storybook/preset-scss": "npm:1.0.3"
|
||||
"@storybook/preview-api": "npm:^8.6.2"
|
||||
"@storybook/react": "npm:^8.6.2"
|
||||
"@storybook/react-webpack5": "npm:^8.6.2"
|
||||
"@storybook/preview-api": "npm:^8.6.15"
|
||||
"@storybook/react": "npm:^8.6.15"
|
||||
"@storybook/react-webpack5": "npm:^8.6.15"
|
||||
"@storybook/test-runner": "npm:^0.23.0"
|
||||
"@storybook/theming": "npm:^8.6.2"
|
||||
"@storybook/theming": "npm:^8.6.15"
|
||||
"@tanstack/react-virtual": "npm:^3.5.1"
|
||||
"@testing-library/dom": "npm:10.4.1"
|
||||
"@testing-library/jest-dom": "npm:6.6.4"
|
||||
@@ -3899,7 +3899,7 @@ __metadata:
|
||||
slate: "npm:0.47.9"
|
||||
slate-plain-serializer: "npm:0.7.13"
|
||||
slate-react: "npm:0.22.10"
|
||||
storybook: "npm:^8.6.2"
|
||||
storybook: "npm:^8.6.15"
|
||||
style-loader: "npm:4.0.0"
|
||||
tinycolor2: "npm:1.6.0"
|
||||
tslib: "npm:2.8.1"
|
||||
@@ -7961,22 +7961,23 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/addon-a11y@npm:^8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/addon-a11y@npm:8.6.2"
|
||||
"@storybook/addon-a11y@npm:^8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/addon-a11y@npm:8.6.15"
|
||||
dependencies:
|
||||
"@storybook/addon-highlight": "npm:8.6.2"
|
||||
"@storybook/test": "npm:8.6.2"
|
||||
"@storybook/addon-highlight": "npm:8.6.15"
|
||||
"@storybook/global": "npm:^5.0.0"
|
||||
"@storybook/test": "npm:8.6.15"
|
||||
axe-core: "npm:^4.2.0"
|
||||
peerDependencies:
|
||||
storybook: ^8.6.2
|
||||
checksum: 10/c7a161734c4d587bbc2b926fcf01103203b4f50112f5ea19bdbd4dad4c1c62bb358613e3c6e608335f064ef7b7627f5a1ffeb1f524ec008bb19e86e50b1dfb27
|
||||
storybook: ^8.6.15
|
||||
checksum: 10/558fcb105486112118bfc5f0068efcb5d4b66b508820f8e2a6d4041e7b06029b64d2d3a8b51a7a6049c836b900ead67c33fa9f89e0a1b550f0e5eedbab18e33c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/addon-actions@npm:8.6.2, @storybook/addon-actions@npm:^8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/addon-actions@npm:8.6.2"
|
||||
"@storybook/addon-actions@npm:8.6.15, @storybook/addon-actions@npm:^8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/addon-actions@npm:8.6.15"
|
||||
dependencies:
|
||||
"@storybook/global": "npm:^5.0.0"
|
||||
"@types/uuid": "npm:^9.0.1"
|
||||
@@ -7984,139 +7985,139 @@ __metadata:
|
||||
polished: "npm:^4.2.2"
|
||||
uuid: "npm:^9.0.0"
|
||||
peerDependencies:
|
||||
storybook: ^8.6.2
|
||||
checksum: 10/16127ee35f08fe98df98a688a8724c803274cbe1a81d3ae46727ad6b34bdae913dd905a3a124954805e4f2650e56e18f80de2f4cf7cb0fc0480c185cf342a8e5
|
||||
storybook: ^8.6.15
|
||||
checksum: 10/4d47e3ce9319d282e5abb44e7694748792c29f37c883afbcfc8939353be47bcc004ab41ce1f421c3d5bafbaa0366935f0b90272e5e6542a2229db55f63bef82c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/addon-backgrounds@npm:8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/addon-backgrounds@npm:8.6.2"
|
||||
"@storybook/addon-backgrounds@npm:8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/addon-backgrounds@npm:8.6.15"
|
||||
dependencies:
|
||||
"@storybook/global": "npm:^5.0.0"
|
||||
memoizerific: "npm:^1.11.3"
|
||||
ts-dedent: "npm:^2.0.0"
|
||||
peerDependencies:
|
||||
storybook: ^8.6.2
|
||||
checksum: 10/b303afb745fb34cb77565f595fdd5f1749927f3be6bc58702f3ed6b8931d89abc099b46940436a1f0509b4a6580f7c58100bf091ba6e5fe7f040704ac723118f
|
||||
storybook: ^8.6.15
|
||||
checksum: 10/c96107e892d39d5841e7f64f53a619527268636c61184770ca07684432fb3cee30c224a35b716ff30ba9bcdb4326649cd4e7873359d65a7fa8889b0fe7a15ad4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/addon-controls@npm:8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/addon-controls@npm:8.6.2"
|
||||
"@storybook/addon-controls@npm:8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/addon-controls@npm:8.6.15"
|
||||
dependencies:
|
||||
"@storybook/global": "npm:^5.0.0"
|
||||
dequal: "npm:^2.0.2"
|
||||
ts-dedent: "npm:^2.0.0"
|
||||
peerDependencies:
|
||||
storybook: ^8.6.2
|
||||
checksum: 10/4d54617c514b6e88d5708e2d3b067a01f3863e83dcf327bdec0b55df36bf8e6269ab1d9e5c5cb0edb27e9554c2d212ce087356b9d68779744854207febd26808
|
||||
storybook: ^8.6.15
|
||||
checksum: 10/ee7ea1e4d6cdb47c233ff3dd48196e649bea62bb88816261f65eb0ecd4f83e1593f09e657fc91ed8595b6279a04a1267c01d82020ef749d90c28d4f71b879224
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/addon-docs@npm:8.6.2, @storybook/addon-docs@npm:^8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/addon-docs@npm:8.6.2"
|
||||
"@storybook/addon-docs@npm:8.6.15, @storybook/addon-docs@npm:^8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/addon-docs@npm:8.6.15"
|
||||
dependencies:
|
||||
"@mdx-js/react": "npm:^3.0.0"
|
||||
"@storybook/blocks": "npm:8.6.2"
|
||||
"@storybook/csf-plugin": "npm:8.6.2"
|
||||
"@storybook/react-dom-shim": "npm:8.6.2"
|
||||
"@storybook/blocks": "npm:8.6.15"
|
||||
"@storybook/csf-plugin": "npm:8.6.15"
|
||||
"@storybook/react-dom-shim": "npm:8.6.15"
|
||||
react: "npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
react-dom: "npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
ts-dedent: "npm:^2.0.0"
|
||||
peerDependencies:
|
||||
storybook: ^8.6.2
|
||||
checksum: 10/d13752c4f31f01426724dbfdc70313948475dbbf6c43ed1cceca8d37d86424f79369c1b06c5e4daebe581e8702b02c9522ea77aff1864a7228662f4c8517fedf
|
||||
storybook: ^8.6.15
|
||||
checksum: 10/ec18d166ebab276258098ef86e9ad9bcaa163b357766bcef957a8f8c2260d4c128834497304cd0cde8fed1b0dfa02b9e79d82b88d61e21eab35437b2d17fa163
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/addon-essentials@npm:^8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/addon-essentials@npm:8.6.2"
|
||||
"@storybook/addon-essentials@npm:^8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/addon-essentials@npm:8.6.15"
|
||||
dependencies:
|
||||
"@storybook/addon-actions": "npm:8.6.2"
|
||||
"@storybook/addon-backgrounds": "npm:8.6.2"
|
||||
"@storybook/addon-controls": "npm:8.6.2"
|
||||
"@storybook/addon-docs": "npm:8.6.2"
|
||||
"@storybook/addon-highlight": "npm:8.6.2"
|
||||
"@storybook/addon-measure": "npm:8.6.2"
|
||||
"@storybook/addon-outline": "npm:8.6.2"
|
||||
"@storybook/addon-toolbars": "npm:8.6.2"
|
||||
"@storybook/addon-viewport": "npm:8.6.2"
|
||||
"@storybook/addon-actions": "npm:8.6.15"
|
||||
"@storybook/addon-backgrounds": "npm:8.6.15"
|
||||
"@storybook/addon-controls": "npm:8.6.15"
|
||||
"@storybook/addon-docs": "npm:8.6.15"
|
||||
"@storybook/addon-highlight": "npm:8.6.15"
|
||||
"@storybook/addon-measure": "npm:8.6.15"
|
||||
"@storybook/addon-outline": "npm:8.6.15"
|
||||
"@storybook/addon-toolbars": "npm:8.6.15"
|
||||
"@storybook/addon-viewport": "npm:8.6.15"
|
||||
ts-dedent: "npm:^2.0.0"
|
||||
peerDependencies:
|
||||
storybook: ^8.6.2
|
||||
checksum: 10/2e8a9cb6fed038122230929f72dfc94116c7884d838f633b81f1b485bf169129cf8e91bc31cc9949cbfc39d31ada1d5ae4a8bc61c533b47c9b63ea7d5045c468
|
||||
storybook: ^8.6.15
|
||||
checksum: 10/0416693b5f0b7f727deaa2c575e1ad826b93852f41fbd0d905c7bad54c31cf312278575cacc28498db81b35b027373e98c5980b307876297e075e74f9267e7f8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/addon-highlight@npm:8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/addon-highlight@npm:8.6.2"
|
||||
"@storybook/addon-highlight@npm:8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/addon-highlight@npm:8.6.15"
|
||||
dependencies:
|
||||
"@storybook/global": "npm:^5.0.0"
|
||||
peerDependencies:
|
||||
storybook: ^8.6.2
|
||||
checksum: 10/0bd8298612390daa6d455876c3485e8e60751f51d6a2e67c79d2bb37b27a5059b1fab3ed4117371f6b375a6294a5e038abe6bd65db9259723578e4c55b0abcd2
|
||||
storybook: ^8.6.15
|
||||
checksum: 10/51f0a7fbf6c81e78e74f71943f8cf2b5f7e9ec08712e9f186381ecaa15f011c4c7df9d8d99e1e3b44e37bd646ff4d162cf91024d6b6a8b881fe988ccab47f786
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/addon-measure@npm:8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/addon-measure@npm:8.6.2"
|
||||
"@storybook/addon-measure@npm:8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/addon-measure@npm:8.6.15"
|
||||
dependencies:
|
||||
"@storybook/global": "npm:^5.0.0"
|
||||
tiny-invariant: "npm:^1.3.1"
|
||||
peerDependencies:
|
||||
storybook: ^8.6.2
|
||||
checksum: 10/c81946d8459aa953f633503872f30ae13336cfb9c7e539c49dccbeae6f33e57e774ff35a197d3aea46d28a33c0a45a497a14eacd403817fa87572d377d5ad4d9
|
||||
storybook: ^8.6.15
|
||||
checksum: 10/62b899f873e0024ed21e081759d731ff978fe917e8739dab0de3ebc857bb43641c9c9074f4a562864b353550fafd17950ab8dbd17b20a97371501ebe5f188a05
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/addon-outline@npm:8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/addon-outline@npm:8.6.2"
|
||||
"@storybook/addon-outline@npm:8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/addon-outline@npm:8.6.15"
|
||||
dependencies:
|
||||
"@storybook/global": "npm:^5.0.0"
|
||||
ts-dedent: "npm:^2.0.0"
|
||||
peerDependencies:
|
||||
storybook: ^8.6.2
|
||||
checksum: 10/357a72cb76cd8d1d2e7ff5ab1fc152f17b1f8c4ab087d8b94e1f157554a641aa47d96ede8087d5045eda8b99dfc2d94c29c2bdc1dce8f8d7d0c0a0b660aae966
|
||||
storybook: ^8.6.15
|
||||
checksum: 10/9439e6ab319475df7fed652ea4265916699df45d4ba4a04ec6b576a604f4d0917b2b5c8f1a48cabfd174f7baf3b943d3789ba80efb9e299ecefb388f8bf22f79
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/addon-storysource@npm:^8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/addon-storysource@npm:8.6.2"
|
||||
"@storybook/addon-storysource@npm:^8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/addon-storysource@npm:8.6.15"
|
||||
dependencies:
|
||||
"@storybook/source-loader": "npm:8.6.2"
|
||||
"@storybook/source-loader": "npm:8.6.15"
|
||||
estraverse: "npm:^5.2.0"
|
||||
tiny-invariant: "npm:^1.3.1"
|
||||
peerDependencies:
|
||||
storybook: ^8.6.2
|
||||
checksum: 10/4c06dab42fd2ff88df632960bfc70144e7ab423bec373be7f1b318519a383f33515a296440e77c085345e220af5de77dd9c500a49fcc66e23db9612164d94d9f
|
||||
storybook: ^8.6.15
|
||||
checksum: 10/499555d1178795c8c046a0269a980006e586d3445a734e65d9789ab84b2b62d4c4193b3d0e241bfc3954aec9977b9777b5ec357d22a6929c9325b3f4427d399c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/addon-toolbars@npm:8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/addon-toolbars@npm:8.6.2"
|
||||
"@storybook/addon-toolbars@npm:8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/addon-toolbars@npm:8.6.15"
|
||||
peerDependencies:
|
||||
storybook: ^8.6.2
|
||||
checksum: 10/7c7863a1e9698128557cf38bdce81aab127958c4892aeaa2f9035042d0fd36edcabb296575cf02a69d8f19abe4b1b114223a6312f25c84dff827314f004303ee
|
||||
storybook: ^8.6.15
|
||||
checksum: 10/27cfba470fd0f85d8bd236a7929b77cc50c89948926a4463f3473bcd04f823cebbf08f8e240a4055d77aac9636d32cfaaee3ca912ff32ee5806e3250df53dff7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/addon-viewport@npm:8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/addon-viewport@npm:8.6.2"
|
||||
"@storybook/addon-viewport@npm:8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/addon-viewport@npm:8.6.15"
|
||||
dependencies:
|
||||
memoizerific: "npm:^1.11.3"
|
||||
peerDependencies:
|
||||
storybook: ^8.6.2
|
||||
checksum: 10/60e67fb0b2f21c889f416bbb7d1729bf3310f56efa23d016d9984f6a9fce39cccccc02422b6ece736a558fef0053c5ac39d46df1735aa2e560e580a3fae3337f
|
||||
storybook: ^8.6.15
|
||||
checksum: 10/2b3359ac92e9a131c7dc7c4b6bc9d131786c86bead9490d464e4b275278e309a579cd67756d4d9ea5764aae502e3e65104e98486933f7f57b583ab8a3757db8b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -8142,22 +8143,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/blocks@npm:8.6.2, @storybook/blocks@npm:^8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/blocks@npm:8.6.2"
|
||||
"@storybook/blocks@npm:8.6.15, @storybook/blocks@npm:^8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/blocks@npm:8.6.15"
|
||||
dependencies:
|
||||
"@storybook/icons": "npm:^1.2.12"
|
||||
ts-dedent: "npm:^2.0.0"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
storybook: ^8.6.2
|
||||
storybook: ^8.6.15
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
checksum: 10/8137b042e99572b7bdd6df3484c75d3b1cf78b15bb7d3a7ad09738e94ec21481d295acfe2b59fa547be9ee5a0b075bb485f88a1972948e4703aef6b174b60ead
|
||||
checksum: 10/7598b9fe3c5dcabc02b22eee3780055ae7ca259bc51520c5bf12c34395d11dbac4b0b3f817c46492b407a16f67ff5ba36879fd30c91299ddb053e9f274664e21
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -8189,11 +8190,11 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/builder-webpack5@npm:8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/builder-webpack5@npm:8.6.2"
|
||||
"@storybook/builder-webpack5@npm:8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/builder-webpack5@npm:8.6.15"
|
||||
dependencies:
|
||||
"@storybook/core-webpack": "npm:8.6.2"
|
||||
"@storybook/core-webpack": "npm:8.6.15"
|
||||
"@types/semver": "npm:^7.3.4"
|
||||
browser-assert: "npm:^1.2.1"
|
||||
case-sensitive-paths-webpack-plugin: "npm:^2.4.0"
|
||||
@@ -8218,29 +8219,29 @@ __metadata:
|
||||
webpack-hot-middleware: "npm:^2.25.1"
|
||||
webpack-virtual-modules: "npm:^0.6.0"
|
||||
peerDependencies:
|
||||
storybook: ^8.6.2
|
||||
storybook: ^8.6.15
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
checksum: 10/909d74c281a41a43d17ff9f313231283ac21c9c97ef47d8f8f96753f0b639dbdaeb2d12d46a7cd4fd8001bffcc2d707e83d47ab62fe3757ab30093159a1eded1
|
||||
checksum: 10/8e8e816dbfa83e2fd56401a65a1551ecbaa658db54b2270e90560710c024878a09df48e1a4e114eacc75ff5c924cb7a277f4e3501ae4744c6a2742ab55135afe
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/components@npm:8.6.2, @storybook/components@npm:^8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/components@npm:8.6.2"
|
||||
"@storybook/components@npm:8.6.15, @storybook/components@npm:^8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/components@npm:8.6.15"
|
||||
peerDependencies:
|
||||
storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0
|
||||
checksum: 10/d85bb39aedd03a05043194debf3d35965dbe84d386029f63f0aa20ac943f4cc635558f6b08cc8ec013a2cc7f2407ef3c8735a76d4805e41569aaabe8efe59fd4
|
||||
checksum: 10/350075ffe67cfc307c0f8f9b6568c5f25e97a2ffb55d723b88c16a3f3ad6d687c312580c4b4d3cbf8ef245a30723797d89be20a44f6c500f79e7b173b0cb79d0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/core-events@npm:^8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/core-events@npm:8.6.2"
|
||||
"@storybook/core-events@npm:^8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/core-events@npm:8.6.15"
|
||||
peerDependencies:
|
||||
storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0
|
||||
checksum: 10/d2f574be4bc4fd5be82be376954b795dca291dd834b62ef4545eb912bf0c879bd6b4e544613cc399bbe5161c05788d32ad9119811d05921f690417fd3f1448f4
|
||||
checksum: 10/95dd9f683d502c8b83600d455c960afe9b18278177324bfbb3904a9ef7a6022939017924659088118bb7b272b6ad7e4912b6e6a222e3274a480ab61183b89616
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -8255,22 +8256,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/core-webpack@npm:8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/core-webpack@npm:8.6.2"
|
||||
"@storybook/core-webpack@npm:8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/core-webpack@npm:8.6.15"
|
||||
dependencies:
|
||||
ts-dedent: "npm:^2.0.0"
|
||||
peerDependencies:
|
||||
storybook: ^8.6.2
|
||||
checksum: 10/666edcb895b034b74fa86bb6d6dc16d26078ffc3524d20917ca27c40b4f722706622f83c4f97a323f8fc441d655a5ab124c9fbea12a2fa2e25000cf1231a5e78
|
||||
storybook: ^8.6.15
|
||||
checksum: 10/494110266d622be00a61cd160a4e799ed4c4c447ab04699d497de7c3d95ee800f212680c31722b8938819577853e095af545f7853e7ce4cf751d2a9e9b4c7f12
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/core@npm:8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/core@npm:8.6.2"
|
||||
"@storybook/core@npm:8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/core@npm:8.6.15"
|
||||
dependencies:
|
||||
"@storybook/theming": "npm:8.6.2"
|
||||
"@storybook/theming": "npm:8.6.15"
|
||||
better-opn: "npm:^3.0.2"
|
||||
browser-assert: "npm:^1.2.1"
|
||||
esbuild: "npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0"
|
||||
@@ -8286,15 +8287,15 @@ __metadata:
|
||||
peerDependenciesMeta:
|
||||
prettier:
|
||||
optional: true
|
||||
checksum: 10/57d8af6d822c4cbeab201aec813830d632165c2411bf8708481ad0daa2fa942ce6a912e0ef764e6acb9bd2053baf0614b7726e5827b071f3f845d459ac1f3d3a
|
||||
checksum: 10/d268d6fa00c38b35e5c363ee33779c2e087ab8e4681e0e205baa2fdb2780ea9feda3c9f6db35d60092778878d2782b2093c744bdf1af173c5688c3e1e0e960ac
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/core@patch:@storybook/core@npm%3A8.6.2#~/.yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/core@patch:@storybook/core@npm%3A8.6.2#~/.yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch::version=8.6.2&hash=f4cc1f"
|
||||
"@storybook/core@patch:@storybook/core@npm%3A8.6.15#~/.yarn/patches/@storybook-core-npm-8.6.15-a468a35170.patch":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/core@patch:@storybook/core@npm%3A8.6.15#~/.yarn/patches/@storybook-core-npm-8.6.15-a468a35170.patch::version=8.6.15&hash=c479fb"
|
||||
dependencies:
|
||||
"@storybook/theming": "npm:8.6.2"
|
||||
"@storybook/theming": "npm:8.6.15"
|
||||
better-opn: "npm:^3.0.2"
|
||||
browser-assert: "npm:^1.2.1"
|
||||
esbuild: "npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0"
|
||||
@@ -8310,18 +8311,18 @@ __metadata:
|
||||
peerDependenciesMeta:
|
||||
prettier:
|
||||
optional: true
|
||||
checksum: 10/cd95a51437135dd3c4333b14acefd528d8064b2cea7789f859ba80783c115c92ed4be51d4a7bd6236888fdd5f46f488a379e0c71bc1a712ffe6dc1353fb4e648
|
||||
checksum: 10/fd635098effe4ae87122ac706394dc89a26b3cae74d7d126897852a9da1960d617c76dc1be6d6d8a4c2d02c19e694c7222a3cfe7a6beb7daa605b30ffbf41644
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/csf-plugin@npm:8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/csf-plugin@npm:8.6.2"
|
||||
"@storybook/csf-plugin@npm:8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/csf-plugin@npm:8.6.15"
|
||||
dependencies:
|
||||
unplugin: "npm:^1.3.1"
|
||||
peerDependencies:
|
||||
storybook: ^8.6.2
|
||||
checksum: 10/6d71101640975cbe08d5dc9bae30938337b0e999f5724c4802713cf3d8c34671b646158ffde659e9cbcd005153844223cd4f9a4af2aa0d6f5a15b3db8d31f85d
|
||||
storybook: ^8.6.15
|
||||
checksum: 10/c544089d7a675d19e226e331a791db6c2e2cede893a2955578959086e7c35e846319a9080d91968ec81a2115e2c396fe3d76d2f50d74baca098dd5b03ad939b0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -8342,24 +8343,24 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/instrumenter@npm:8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/instrumenter@npm:8.6.2"
|
||||
"@storybook/instrumenter@npm:8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/instrumenter@npm:8.6.15"
|
||||
dependencies:
|
||||
"@storybook/global": "npm:^5.0.0"
|
||||
"@vitest/utils": "npm:^2.1.1"
|
||||
peerDependencies:
|
||||
storybook: ^8.6.2
|
||||
checksum: 10/40d028d6f8b5ab51eb112bb5b903f64438eaf818c501a78ef77a5e946676e13f15ad8f5188a85e40bf91f987c6ab15c6a33b4e8bef8b8f8e05011013dcda092a
|
||||
storybook: ^8.6.15
|
||||
checksum: 10/5f56da838ccd47b9a262e5aa54a5574985295994fb1b6fb165d4c3a66d96367588fa93c60fe55892ffa450ea3e41e0abcc313a12a873d5b4be7625a1c3b0d883
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/manager-api@npm:8.6.2, @storybook/manager-api@npm:^8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/manager-api@npm:8.6.2"
|
||||
"@storybook/manager-api@npm:8.6.15, @storybook/manager-api@npm:^8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/manager-api@npm:8.6.15"
|
||||
peerDependencies:
|
||||
storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0
|
||||
checksum: 10/d344c88c6cad0bcc54767a8ef1d269a9df868c76ae22927c31936e46aaeb47075783d84f8815b4d0c7812ed50b2d4616417429479e3a9ea36043c557eb141590
|
||||
checksum: 10/0b378fc657830c48b7c304ea915883161e8271d76b8f90b90e86e4260f6e513ae9f0f87eee6cad057a6c38e8a9faf7bac0ba169e586aabce2b4e48d36a0d27c3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -8394,12 +8395,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/preset-react-webpack@npm:8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/preset-react-webpack@npm:8.6.2"
|
||||
"@storybook/preset-react-webpack@npm:8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/preset-react-webpack@npm:8.6.15"
|
||||
dependencies:
|
||||
"@storybook/core-webpack": "npm:8.6.2"
|
||||
"@storybook/react": "npm:8.6.2"
|
||||
"@storybook/core-webpack": "npm:8.6.15"
|
||||
"@storybook/react": "npm:8.6.15"
|
||||
"@storybook/react-docgen-typescript-plugin": "npm:1.0.6--canary.9.0c3f3b7.0"
|
||||
"@types/semver": "npm:^7.3.4"
|
||||
find-up: "npm:^5.0.0"
|
||||
@@ -8412,11 +8413,11 @@ __metadata:
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta
|
||||
storybook: ^8.6.2
|
||||
storybook: ^8.6.15
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
checksum: 10/d903a14e6e65bdfb56568962f456a103ec3f64d339d4ed3d091befe66dddcff2e31d7a2acee1bbf52cf9a33d09564af2dc36483961f73c812eacb34d71f7a951
|
||||
checksum: 10/e3c2bf792a3dc051f27f8723b1b2c9671ecda57f6f20950fe30d65c17ff3751c382ffcf606a2efd85d6b5266356e0b62b57e23560b44cc497d78335dd5ae6462
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -8431,12 +8432,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/preview-api@npm:8.6.2, @storybook/preview-api@npm:^8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/preview-api@npm:8.6.2"
|
||||
"@storybook/preview-api@npm:8.6.15, @storybook/preview-api@npm:^8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/preview-api@npm:8.6.15"
|
||||
peerDependencies:
|
||||
storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0
|
||||
checksum: 10/5d286ed8c266a8aa63361bbb0245163e6fe3fd85a5e60555a79a579535faf36c30a9ad07bff30b0d9d536d35ec8f5ee78484629b174ecc965cbe0726e749d6de
|
||||
checksum: 10/70df6006ce7340371e207f7f077d52c8684743a64f172d54f3e99fb6d2e190f46a4321c40be7ea813b9bd407530f971372156b3dd6ba1f61681f7b3acc498010
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -8469,14 +8470,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/react-dom-shim@npm:8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/react-dom-shim@npm:8.6.2"
|
||||
"@storybook/react-dom-shim@npm:8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/react-dom-shim@npm:8.6.15"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta
|
||||
storybook: ^8.6.2
|
||||
checksum: 10/f32718a49ccbd7c01233c83d738479eb60c41a3f8855066b85593fb7a07129b1b18a0c93fe64b01dce9c9105293b40c1e131410169d5aaaa2f8b7a7c00f836ee
|
||||
storybook: ^8.6.15
|
||||
checksum: 10/7625cfa2a385315851cd0aeb36f517e75679262c2942a5a53dd2909483b62602b9e5157ba51a8e464eb70112a4916278fba63b05178beef3d2ff493a464a2c64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -8499,22 +8500,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/react-webpack5@npm:^8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/react-webpack5@npm:8.6.2"
|
||||
"@storybook/react-webpack5@npm:^8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/react-webpack5@npm:8.6.15"
|
||||
dependencies:
|
||||
"@storybook/builder-webpack5": "npm:8.6.2"
|
||||
"@storybook/preset-react-webpack": "npm:8.6.2"
|
||||
"@storybook/react": "npm:8.6.2"
|
||||
"@storybook/builder-webpack5": "npm:8.6.15"
|
||||
"@storybook/preset-react-webpack": "npm:8.6.15"
|
||||
"@storybook/react": "npm:8.6.15"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta
|
||||
storybook: ^8.6.2
|
||||
storybook: ^8.6.15
|
||||
typescript: ">= 4.2.x"
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
checksum: 10/1d8c745d21da7853328a1870797808982864be68783c2a56248ab642a81eaad667f5522cc9f0eba74a9b96658d6fcd9b6a5b9296e235ff8e1566edbc48e5d146
|
||||
checksum: 10/0468aa5ca0ed76170cbdef6b2a211625e64b74a8012aabeca03d38c8fa8f34013b2068f68dd173bb5db32aa9ccaa87c923344a506c561ad98d0afb0f88856b79
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -8536,41 +8537,41 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/react@npm:8.6.2, @storybook/react@npm:^8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/react@npm:8.6.2"
|
||||
"@storybook/react@npm:8.6.15, @storybook/react@npm:^8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/react@npm:8.6.15"
|
||||
dependencies:
|
||||
"@storybook/components": "npm:8.6.2"
|
||||
"@storybook/components": "npm:8.6.15"
|
||||
"@storybook/global": "npm:^5.0.0"
|
||||
"@storybook/manager-api": "npm:8.6.2"
|
||||
"@storybook/preview-api": "npm:8.6.2"
|
||||
"@storybook/react-dom-shim": "npm:8.6.2"
|
||||
"@storybook/theming": "npm:8.6.2"
|
||||
"@storybook/manager-api": "npm:8.6.15"
|
||||
"@storybook/preview-api": "npm:8.6.15"
|
||||
"@storybook/react-dom-shim": "npm:8.6.15"
|
||||
"@storybook/theming": "npm:8.6.15"
|
||||
peerDependencies:
|
||||
"@storybook/test": 8.6.2
|
||||
"@storybook/test": 8.6.15
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta
|
||||
storybook: ^8.6.2
|
||||
storybook: ^8.6.15
|
||||
typescript: ">= 4.2.x"
|
||||
peerDependenciesMeta:
|
||||
"@storybook/test":
|
||||
optional: true
|
||||
typescript:
|
||||
optional: true
|
||||
checksum: 10/b8a91e6a8aeb9e32e05e12db4df1dafd59b54d82954e481ea9224316bd1e432093259b474f773911e90aeebe441cbb17e8dc0a6940b79b5899eff1f2b4aae334
|
||||
checksum: 10/a7e33bd68e25bf04fac77c9573c51d76d556c51a4329e84dae2a4e2afd39950e4d3ab9a49787ac465e9e81522b40ea12dc86cd0b60a2aab1ee2f88a753bc9b43
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/source-loader@npm:8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/source-loader@npm:8.6.2"
|
||||
"@storybook/source-loader@npm:8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/source-loader@npm:8.6.15"
|
||||
dependencies:
|
||||
es-toolkit: "npm:^1.22.0"
|
||||
estraverse: "npm:^5.2.0"
|
||||
prettier: "npm:^3.1.1"
|
||||
peerDependencies:
|
||||
storybook: ^8.6.2
|
||||
checksum: 10/8cf43eb6ce2df997272c71f3d9f8e85f3fecb6ca1d176c87fd349ea17e1d821dadb43496af5d10a2c6163fd6d920940a253a075a7774fc9bb5f724c132cfcaf0
|
||||
storybook: ^8.6.15
|
||||
checksum: 10/37e6749a8bd633aa7b84546eb8f3bf4f259614846589fe5cfe9b461926b57bbda013948b95ccb12108324f1580d7330fcab7828ab90936b0567dba27e0a626a1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -8604,29 +8605,29 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/test@npm:8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/test@npm:8.6.2"
|
||||
"@storybook/test@npm:8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/test@npm:8.6.15"
|
||||
dependencies:
|
||||
"@storybook/global": "npm:^5.0.0"
|
||||
"@storybook/instrumenter": "npm:8.6.2"
|
||||
"@storybook/instrumenter": "npm:8.6.15"
|
||||
"@testing-library/dom": "npm:10.4.0"
|
||||
"@testing-library/jest-dom": "npm:6.5.0"
|
||||
"@testing-library/user-event": "npm:14.5.2"
|
||||
"@vitest/expect": "npm:2.0.5"
|
||||
"@vitest/spy": "npm:2.0.5"
|
||||
peerDependencies:
|
||||
storybook: ^8.6.2
|
||||
checksum: 10/4cb89e254143374716fcd72c3a9a4c603a9f664162c5755ead7257ce130bc33d3c48c9cbaa35274023f4b961d66c4e84733e35ca663019d770fcfa6ef5b21b84
|
||||
storybook: ^8.6.15
|
||||
checksum: 10/5f54b9ef1910011813059708f6d2c32f4db5f4e719de9de2a7f2280efe7535c45c36668cf3e346b4788f4bbe6b71a3ab169ccdc17f0d8e634443d92849b46bf1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@storybook/theming@npm:8.6.2, @storybook/theming@npm:^8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "@storybook/theming@npm:8.6.2"
|
||||
"@storybook/theming@npm:8.6.15, @storybook/theming@npm:^8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "@storybook/theming@npm:8.6.15"
|
||||
peerDependencies:
|
||||
storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0
|
||||
checksum: 10/81ff1f740edaa000d6abaab5a47b038b46cfc54ddad308335b8d26d7a6f1ee100f617f52d51cd6596f424a534dbda0d9801e3928b4b6f758d9a3e8da6f9d40f5
|
||||
checksum: 10/f02760831a13d7af9dbfeb6feea949f4c13c897861cbc75253a6776d133891567889c8d99c7e91a99124d0772c3bde1f978984c83b19117d1d7c908ed7eb8409
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -31431,11 +31432,11 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"storybook@npm:^8.6.2":
|
||||
version: 8.6.2
|
||||
resolution: "storybook@npm:8.6.2"
|
||||
"storybook@npm:^8.6.15":
|
||||
version: 8.6.15
|
||||
resolution: "storybook@npm:8.6.15"
|
||||
dependencies:
|
||||
"@storybook/core": "npm:8.6.2"
|
||||
"@storybook/core": "npm:8.6.15"
|
||||
peerDependencies:
|
||||
prettier: ^2 || ^3
|
||||
peerDependenciesMeta:
|
||||
@@ -31445,7 +31446,7 @@ __metadata:
|
||||
getstorybook: ./bin/index.cjs
|
||||
sb: ./bin/index.cjs
|
||||
storybook: ./bin/index.cjs
|
||||
checksum: 10/81884ce80d36bfe3170c46ce08168f24a2a27b2698a9ec66ebb1a9727502ab9db3e6e221121a10606ef06db466966b47a12b48b99e519755c508bc183a2de815
|
||||
checksum: 10/15762c79ec8444a46bc14cddfadbdd54dfd379828acd38555887a246c01e7c9ebb61e4eafafe04efb3ddf6278fb47035216e7f7d9f94fc205da148870173abdf
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user