mirror of
https://github.com/grafana/grafana.git
synced 2025-12-22 04:34:27 +08:00
Compare commits
3 Commits
docs/add-t
...
folders/te
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d83ad6e2a | ||
|
|
726ac07372 | ||
|
|
688d6746c9 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -262,3 +262,5 @@ public/mockServiceWorker.js
|
||||
|
||||
# Ignore grafana/hippocampus local cache folder
|
||||
.hippo
|
||||
|
||||
.cache
|
||||
|
||||
@@ -72,6 +72,8 @@ type DashboardHit struct {
|
||||
Folder string `json:"folder,omitempty"`
|
||||
// The resource is managed
|
||||
ManagedBy ManagedBy `json:"managedBy,omitzero,omitempty"`
|
||||
// Owner references set on the resource metadata
|
||||
OwnerReferences []metav1.OwnerReference `json:"ownerReferences,omitempty"`
|
||||
// Stick untyped extra fields in this object (including the sort value)
|
||||
Field *common.Unstructured `json:"field,omitzero,omitempty"`
|
||||
// When using "real" search, this is the score
|
||||
|
||||
@@ -249,6 +249,7 @@ const injectedRtkApi = api
|
||||
sort: queryArg.sort,
|
||||
limit: queryArg.limit,
|
||||
explain: queryArg.explain,
|
||||
owner: queryArg.owner,
|
||||
},
|
||||
}),
|
||||
providesTags: ['Search'],
|
||||
@@ -606,6 +607,8 @@ export type GetSearchApiArg = {
|
||||
type?: 'folder' | 'dashboard';
|
||||
/** search/list within a folder (not recursive) */
|
||||
folder?: string;
|
||||
/** filter by owner reference name or UID */
|
||||
owner?: string;
|
||||
/** count distinct terms for selected fields */
|
||||
facet?: string[];
|
||||
/** tag query filter */
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
unstructuredv1 "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/kube-openapi/pkg/common"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
@@ -38,19 +39,21 @@ import (
|
||||
|
||||
// The DTO returns everything the UI needs in a single request
|
||||
type SearchHandler struct {
|
||||
log log.Logger
|
||||
client resourcepb.ResourceIndexClient
|
||||
tracer trace.Tracer
|
||||
features featuremgmt.FeatureToggles
|
||||
log log.Logger
|
||||
client resourcepb.ResourceIndexClient
|
||||
resourceClient resource.ResourceClient
|
||||
tracer trace.Tracer
|
||||
features featuremgmt.FeatureToggles
|
||||
}
|
||||
|
||||
func NewSearchHandler(tracer trace.Tracer, dual dualwrite.Service, legacyDashboardSearcher resourcepb.ResourceIndexClient, resourceClient resource.ResourceClient, features featuremgmt.FeatureToggles) *SearchHandler {
|
||||
searchClient := resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), dashboardv0alpha1.DashboardResourceInfo.GroupResource(), resourceClient, legacyDashboardSearcher, features)
|
||||
return &SearchHandler{
|
||||
client: searchClient,
|
||||
log: log.New("grafana-apiserver.dashboards.search"),
|
||||
tracer: tracer,
|
||||
features: features,
|
||||
client: searchClient,
|
||||
resourceClient: resourceClient,
|
||||
log: log.New("grafana-apiserver.dashboards.search"),
|
||||
tracer: tracer,
|
||||
features: features,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,6 +332,9 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
s.addOwnerReferences(ctx, result, &parsedResults)
|
||||
s.filterByOwner(queryParams.Get("owner"), &parsedResults)
|
||||
|
||||
s.write(w, parsedResults)
|
||||
}
|
||||
|
||||
@@ -489,6 +495,66 @@ func (s *SearchHandler) write(w http.ResponseWriter, obj any) {
|
||||
_ = json.NewEncoder(w).Encode(obj)
|
||||
}
|
||||
|
||||
func (s *SearchHandler) filterByOwner(owner string, parsedResults *dashboardv0alpha1.SearchResults) {
|
||||
if owner == "" || parsedResults == nil {
|
||||
return
|
||||
}
|
||||
|
||||
filtered := make([]dashboardv0alpha1.DashboardHit, 0, len(parsedResults.Hits))
|
||||
for _, hit := range parsedResults.Hits {
|
||||
for _, ref := range hit.OwnerReferences {
|
||||
if ref.Name == owner || string(ref.UID) == owner {
|
||||
filtered = append(filtered, hit)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parsedResults.Hits = filtered
|
||||
parsedResults.TotalHits = int64(len(filtered))
|
||||
}
|
||||
|
||||
func (s *SearchHandler) addOwnerReferences(ctx context.Context, searchResult *resourcepb.ResourceSearchResponse, parsedResults *dashboardv0alpha1.SearchResults) {
|
||||
if s.resourceClient == nil || searchResult == nil || parsedResults == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if searchResult.Results == nil || len(searchResult.Results.Rows) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if len(searchResult.Results.Rows) != len(parsedResults.Hits) {
|
||||
return
|
||||
}
|
||||
|
||||
for i, row := range searchResult.Results.Rows {
|
||||
key := row.Key
|
||||
if key == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
readResp, err := s.resourceClient.Read(ctx, &resourcepb.ReadRequest{Key: key})
|
||||
if err != nil {
|
||||
s.log.Debug("failed to read resource while adding owner references", "resource", key.Resource, "name", key.Name, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if readResp == nil || readResp.Error != nil || len(readResp.Value) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var obj unstructuredv1.Unstructured
|
||||
if err := obj.UnmarshalJSON(readResp.Value); err != nil {
|
||||
s.log.Debug("failed to unmarshal resource while adding owner references", "resource", key.Resource, "name", key.Name, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if refs := obj.GetOwnerReferences(); len(refs) > 0 {
|
||||
parsedResults.Hits[i].OwnerReferences = refs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Given a namespace and type convert it to a search key
|
||||
func asResourceKey(ns string, k string) (*resourcepb.ResourceKey, error) {
|
||||
key, err := resource.AsResourceKey(ns, k)
|
||||
|
||||
@@ -11,8 +11,9 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
||||
dashboardv0alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
@@ -338,13 +339,184 @@ func TestSearchHandler(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
p := &v0alpha1.SearchResults{}
|
||||
p := &dashboardv0alpha1.SearchResults{}
|
||||
err := json.NewDecoder(resp.Body).Decode(p)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, len(mockResults), len(p.Hits))
|
||||
assert.Equal(t, mockResults[2].Value, p.Hits[0].Title)
|
||||
assert.Equal(t, mockResults[1].Value, p.Hits[3].Title)
|
||||
})
|
||||
t.Run("Adds owner references from resource store", func(t *testing.T) {
|
||||
mockResponse := &resourcepb.ResourceSearchResponse{
|
||||
Results: &resourcepb.ResourceTable{
|
||||
Columns: []*resourcepb.ResourceTableColumnDefinition{
|
||||
{Name: resource.SEARCH_FIELD_TITLE},
|
||||
},
|
||||
Rows: []*resourcepb.ResourceTableRow{
|
||||
{
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Namespace: "test",
|
||||
Group: dashboardv0alpha1.GROUP,
|
||||
Resource: dashboardv0alpha1.DASHBOARD_RESOURCE,
|
||||
Name: "d1",
|
||||
},
|
||||
Cells: [][]byte{[]byte("Dashboard 1")},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ownerRefs := []metav1.OwnerReference{
|
||||
{
|
||||
APIVersion: "team.grafana.app/v1beta1",
|
||||
Kind: "Team",
|
||||
Name: "team-1",
|
||||
UID: "uid-1",
|
||||
},
|
||||
}
|
||||
value, err := json.Marshal(map[string]any{
|
||||
"apiVersion": dashboardv0alpha1.APIVERSION,
|
||||
"kind": "Dashboard",
|
||||
"metadata": map[string]any{
|
||||
"name": "d1",
|
||||
"namespace": "test",
|
||||
"ownerReferences": ownerRefs,
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"title": "Dashboard 1",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
mockClient := &MockClient{
|
||||
MockResponses: []*resourcepb.ResourceSearchResponse{mockResponse},
|
||||
ReadResponses: map[string]*resourcepb.ReadResponse{
|
||||
"d1": {
|
||||
Value: value,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
features := featuremgmt.WithFeatures()
|
||||
searchHandler := SearchHandler{
|
||||
log: log.New("test", "ownerRefs"),
|
||||
client: mockClient,
|
||||
resourceClient: mockClient,
|
||||
tracer: tracing.NewNoopTracerService(),
|
||||
features: features,
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/search", nil)
|
||||
req.Header.Add("content-type", "application/json")
|
||||
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
|
||||
|
||||
searchHandler.DoSearch(rr, req)
|
||||
|
||||
var result dashboardv0alpha1.SearchResults
|
||||
require.NoError(t, json.NewDecoder(rr.Body).Decode(&result))
|
||||
|
||||
require.Len(t, result.Hits, 1)
|
||||
require.Len(t, result.Hits[0].OwnerReferences, 1)
|
||||
assert.Equal(t, ownerRefs[0].Name, result.Hits[0].OwnerReferences[0].Name)
|
||||
assert.Equal(t, ownerRefs[0].Kind, result.Hits[0].OwnerReferences[0].Kind)
|
||||
})
|
||||
t.Run("Filters by owner param", func(t *testing.T) {
|
||||
mockResponse := &resourcepb.ResourceSearchResponse{
|
||||
Results: &resourcepb.ResourceTable{
|
||||
Columns: []*resourcepb.ResourceTableColumnDefinition{
|
||||
{Name: resource.SEARCH_FIELD_TITLE},
|
||||
},
|
||||
Rows: []*resourcepb.ResourceTableRow{
|
||||
{
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Namespace: "test",
|
||||
Group: dashboardv0alpha1.GROUP,
|
||||
Resource: dashboardv0alpha1.DASHBOARD_RESOURCE,
|
||||
Name: "d1",
|
||||
},
|
||||
Cells: [][]byte{[]byte("Dashboard 1")},
|
||||
},
|
||||
{
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Namespace: "test",
|
||||
Group: dashboardv0alpha1.GROUP,
|
||||
Resource: dashboardv0alpha1.DASHBOARD_RESOURCE,
|
||||
Name: "d2",
|
||||
},
|
||||
Cells: [][]byte{[]byte("Dashboard 2")},
|
||||
},
|
||||
},
|
||||
},
|
||||
TotalHits: 2,
|
||||
}
|
||||
|
||||
ownerRefs := []metav1.OwnerReference{
|
||||
{
|
||||
APIVersion: "team.grafana.app/v1beta1",
|
||||
Kind: "Team",
|
||||
Name: "team-1",
|
||||
UID: "uid-1",
|
||||
},
|
||||
}
|
||||
d1Value, err := json.Marshal(map[string]any{
|
||||
"apiVersion": dashboardv0alpha1.APIVERSION,
|
||||
"kind": "Dashboard",
|
||||
"metadata": map[string]any{
|
||||
"name": "d1",
|
||||
"namespace": "test",
|
||||
"ownerReferences": ownerRefs,
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"title": "Dashboard 1",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
d2Value, err := json.Marshal(map[string]any{
|
||||
"apiVersion": dashboardv0alpha1.APIVERSION,
|
||||
"kind": "Dashboard",
|
||||
"metadata": map[string]any{
|
||||
"name": "d2",
|
||||
"namespace": "test",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"title": "Dashboard 2",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
mockClient := &MockClient{
|
||||
MockResponses: []*resourcepb.ResourceSearchResponse{mockResponse},
|
||||
ReadResponses: map[string]*resourcepb.ReadResponse{
|
||||
"d1": {Value: d1Value},
|
||||
"d2": {Value: d2Value},
|
||||
},
|
||||
}
|
||||
|
||||
features := featuremgmt.WithFeatures()
|
||||
searchHandler := SearchHandler{
|
||||
log: log.New("test", "ownerRefs-filter"),
|
||||
client: mockClient,
|
||||
resourceClient: mockClient,
|
||||
tracer: tracing.NewNoopTracerService(),
|
||||
features: features,
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/search?owner=team-1", nil)
|
||||
req.Header.Add("content-type", "application/json")
|
||||
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
|
||||
|
||||
searchHandler.DoSearch(rr, req)
|
||||
|
||||
var result dashboardv0alpha1.SearchResults
|
||||
require.NoError(t, json.NewDecoder(rr.Body).Decode(&result))
|
||||
|
||||
require.Len(t, result.Hits, 1)
|
||||
assert.Equal(t, int64(1), result.TotalHits)
|
||||
assert.Equal(t, "d1", result.Hits[0].Name)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSearchHandlerSharedDashboards(t *testing.T) {
|
||||
@@ -376,7 +548,7 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
p := &v0alpha1.SearchResults{}
|
||||
p := &dashboardv0alpha1.SearchResults{}
|
||||
err := json.NewDecoder(resp.Body).Decode(p)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, len(p.Hits))
|
||||
@@ -466,7 +638,7 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
p := &v0alpha1.SearchResults{}
|
||||
p := &dashboardv0alpha1.SearchResults{}
|
||||
err := json.NewDecoder(resp.Body).Decode(p)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, len(p.Hits))
|
||||
@@ -597,7 +769,7 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
p := &v0alpha1.SearchResults{}
|
||||
p := &dashboardv0alpha1.SearchResults{}
|
||||
err := json.NewDecoder(resp.Body).Decode(p)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, len(mockResponse3.Results.Rows), len(p.Hits))
|
||||
@@ -1017,6 +1189,12 @@ type MockClient struct {
|
||||
MockResponses []*resourcepb.ResourceSearchResponse
|
||||
MockCalls []*resourcepb.ResourceSearchRequest
|
||||
CallCount int
|
||||
|
||||
ReadResponses map[string]*resourcepb.ReadResponse
|
||||
DefaultReadResponse *resourcepb.ReadResponse
|
||||
LastReadRequests []*resourcepb.ReadRequest
|
||||
ReadError error
|
||||
LastReadErrorRequest *resourcepb.ReadRequest
|
||||
}
|
||||
|
||||
type MockResult struct {
|
||||
@@ -1080,6 +1258,22 @@ func (m *MockClient) Update(ctx context.Context, in *resourcepb.UpdateRequest, o
|
||||
return nil, nil
|
||||
}
|
||||
func (m *MockClient) Read(ctx context.Context, in *resourcepb.ReadRequest, opts ...grpc.CallOption) (*resourcepb.ReadResponse, error) {
|
||||
m.LastReadRequests = append(m.LastReadRequests, in)
|
||||
if m.ReadError != nil {
|
||||
m.LastReadErrorRequest = in
|
||||
return nil, m.ReadError
|
||||
}
|
||||
|
||||
if in != nil && in.Key != nil && m.ReadResponses != nil {
|
||||
if resp, ok := m.ReadResponses[in.Key.Name]; ok {
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
if m.DefaultReadResponse != nil {
|
||||
return m.DefaultReadResponse, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
func (m *MockClient) GetBlob(ctx context.Context, in *resourcepb.GetBlobRequest, opts ...grpc.CallOption) (*resourcepb.GetBlobResponse, error) {
|
||||
|
||||
@@ -4,7 +4,13 @@ import { useEffect, useMemo } from 'react';
|
||||
import { AppEvents } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { config, getAppEvents } from '@grafana/runtime';
|
||||
import { DisplayList, iamAPIv0alpha1, useLazyGetDisplayMappingQuery } from 'app/api/clients/iam/v0alpha1';
|
||||
import {
|
||||
API_GROUP as IAM_API_GROUP,
|
||||
API_VERSION as IAM_API_VERSION,
|
||||
DisplayList,
|
||||
iamAPIv0alpha1,
|
||||
useLazyGetDisplayMappingQuery,
|
||||
} from 'app/api/clients/iam/v0alpha1';
|
||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||
import {
|
||||
useDeleteFolderMutation as useDeleteFolderMutationLegacy,
|
||||
@@ -56,6 +62,8 @@ import {
|
||||
ReplaceFolderApiArg,
|
||||
useGetAffectedItemsQuery,
|
||||
FolderInfo,
|
||||
ObjectMeta,
|
||||
OwnerReference,
|
||||
} from './index';
|
||||
|
||||
function getFolderUrl(uid: string, title: string): string {
|
||||
@@ -66,6 +74,10 @@ function getFolderUrl(uid: string, title: string): string {
|
||||
return `${config.appSubUrl}/dashboards/f/${uid}/${slug}`;
|
||||
}
|
||||
|
||||
type CombinedFolder = FolderDTO & {
|
||||
ownerReferences?: OwnerReference[];
|
||||
};
|
||||
|
||||
const combineFolderResponses = (
|
||||
folder: Folder,
|
||||
legacyFolder: FolderDTO,
|
||||
@@ -75,7 +87,7 @@ const combineFolderResponses = (
|
||||
const updatedBy = folder.metadata.annotations?.[AnnoKeyUpdatedBy];
|
||||
const createdBy = folder.metadata.annotations?.[AnnoKeyCreatedBy];
|
||||
|
||||
const newData: FolderDTO = {
|
||||
const newData: CombinedFolder = {
|
||||
canAdmin: legacyFolder.canAdmin,
|
||||
canDelete: legacyFolder.canDelete,
|
||||
canEdit: legacyFolder.canEdit,
|
||||
@@ -84,6 +96,7 @@ const combineFolderResponses = (
|
||||
createdBy: (createdBy && userDisplay?.display[userDisplay?.keys.indexOf(createdBy)]?.displayName) || 'Anonymous',
|
||||
updatedBy: (updatedBy && userDisplay?.display[userDisplay?.keys.indexOf(updatedBy)]?.displayName) || 'Anonymous',
|
||||
...appPlatformFolderToLegacyFolder(folder),
|
||||
ownerReferences: folder.metadata.ownerReferences || [],
|
||||
};
|
||||
|
||||
if (parents.length) {
|
||||
@@ -101,7 +114,7 @@ const combineFolderResponses = (
|
||||
return newData;
|
||||
};
|
||||
|
||||
export async function getFolderByUidFacade(uid: string): Promise<FolderDTO> {
|
||||
export async function getFolderByUidFacade(uid: string) {
|
||||
const isVirtualFolder = uid && [GENERAL_FOLDER_UID, config.sharedWithMeFolderUID].includes(uid);
|
||||
const shouldUseAppPlatformAPI = Boolean(config.featureToggles.foldersAppPlatformAPI);
|
||||
|
||||
@@ -216,7 +229,7 @@ export function useGetFolderQueryFacade(uid?: string) {
|
||||
|
||||
// Stitch together the responses to create a single FolderDTO object so on the outside this behaves as the legacy
|
||||
// api client.
|
||||
let newData: FolderDTO | undefined = undefined;
|
||||
let newData: CombinedFolder | undefined = undefined;
|
||||
if (
|
||||
resultFolder.data &&
|
||||
resultParents.data &&
|
||||
@@ -359,14 +372,36 @@ export function useCreateFolder() {
|
||||
return legacyHook;
|
||||
}
|
||||
|
||||
const createFolderAppPlatform = async (folder: NewFolder) => {
|
||||
const payload: CreateFolderApiArg = {
|
||||
const createFolderAppPlatform = async (payload: NewFolder & { createAsTeamFolder?: boolean; teamUid?: string }) => {
|
||||
const { createAsTeamFolder, teamUid, ...folder } = payload;
|
||||
const slugifiedTitle = kbn.slugifyForUrl(folder.title);
|
||||
|
||||
const metadataName = `team-${slugifiedTitle}`;
|
||||
const partialMetadata: ObjectMeta =
|
||||
createAsTeamFolder && teamUid
|
||||
? {
|
||||
name: metadataName,
|
||||
ownerReferences: [
|
||||
{
|
||||
apiVersion: `${IAM_API_GROUP}/${IAM_API_VERSION}`,
|
||||
kind: 'Team',
|
||||
name: folder.title,
|
||||
uid: teamUid,
|
||||
controller: true,
|
||||
blockOwnerDeletion: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
: { generateName: 'f' };
|
||||
|
||||
const apiPayload: CreateFolderApiArg = {
|
||||
folder: {
|
||||
spec: {
|
||||
title: folder.title,
|
||||
description: 'Testing a description',
|
||||
},
|
||||
metadata: {
|
||||
generateName: 'f',
|
||||
...partialMetadata,
|
||||
annotations: {
|
||||
...(folder.parentUid && { [AnnoKeyFolder]: folder.parentUid }),
|
||||
},
|
||||
@@ -375,7 +410,7 @@ export function useCreateFolder() {
|
||||
},
|
||||
};
|
||||
|
||||
const result = await createFolder(payload);
|
||||
const result = await createFolder(apiPayload);
|
||||
refresh({ childrenOf: folder.parentUid });
|
||||
deletedDashboardsCache.clear();
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { FolderRepo } from './FolderRepo';
|
||||
import { getDOMId, NestedFolderList } from './NestedFolderList';
|
||||
import Trigger from './Trigger';
|
||||
import { useFoldersQuery } from './useFoldersQuery';
|
||||
import { useTeamOwnedFolder } from './useTeamOwnedFolder';
|
||||
import { useTreeInteractions } from './useTreeInteractions';
|
||||
import { getRootFolderItem } from './utils';
|
||||
|
||||
@@ -82,7 +83,10 @@ export function NestedFolderPicker({
|
||||
id,
|
||||
}: NestedFolderPickerProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const selectedFolder = useGetFolderQueryFacade(value);
|
||||
const { folder: teamFolder } = useTeamOwnedFolder();
|
||||
const effectiveValue = value || teamFolder?.name;
|
||||
const selectedFolder = useGetFolderQueryFacade(effectiveValue);
|
||||
|
||||
// user might not have access to the folder, but they have access to the dashboard
|
||||
// in this case we disable the folder picker - this is an edge case when user has edit access to a dashboard
|
||||
// but doesn't have access to the folder
|
||||
@@ -112,6 +116,12 @@ export function NestedFolderPicker({
|
||||
rootFolderItem,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (value === undefined && teamFolder && onChange) {
|
||||
onChange(teamFolder.name, teamFolder.title);
|
||||
}
|
||||
}, [onChange, teamFolder, value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!search) {
|
||||
setSearchResults(null);
|
||||
@@ -209,6 +219,23 @@ export function NestedFolderPicker({
|
||||
[search, fetchFolderPage]
|
||||
);
|
||||
|
||||
const teamFolderTreeItem = useMemo(() => {
|
||||
if (!teamFolder) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen: false,
|
||||
level: 0,
|
||||
item: {
|
||||
kind: 'folder' as const,
|
||||
title: teamFolder.title,
|
||||
uid: teamFolder.name,
|
||||
parentUID: teamFolder.folder,
|
||||
},
|
||||
};
|
||||
}, [teamFolder]);
|
||||
|
||||
const flatTree = useMemo(() => {
|
||||
let flatTree: Array<DashboardsTreeItem<DashboardViewItemWithUIItems>> = [];
|
||||
|
||||
@@ -229,6 +256,10 @@ export function NestedFolderPicker({
|
||||
})) ?? [];
|
||||
}
|
||||
|
||||
if (teamFolderTreeItem) {
|
||||
flatTree = [teamFolderTreeItem, ...flatTree];
|
||||
}
|
||||
|
||||
// It's not super optimal to filter these in an additional iteration, but
|
||||
// these options are used infrequently that its not a big deal
|
||||
if (!showRootFolder || excludeUIDs?.length) {
|
||||
@@ -246,7 +277,7 @@ export function NestedFolderPicker({
|
||||
}
|
||||
|
||||
return flatTree;
|
||||
}, [browseFlatTree, excludeUIDs, isBrowsing, searchResults?.items, showRootFolder]);
|
||||
}, [browseFlatTree, excludeUIDs, isBrowsing, searchResults?.items, showRootFolder, teamFolderTreeItem]);
|
||||
|
||||
const isItemLoaded = useCallback(
|
||||
(itemIndex: number) => {
|
||||
@@ -276,7 +307,7 @@ export function NestedFolderPicker({
|
||||
});
|
||||
|
||||
let label = selectedFolder.data?.title;
|
||||
if (value === '') {
|
||||
if (!label) {
|
||||
label = t('browse-dashboards.folder-picker.root-title', 'Dashboards');
|
||||
}
|
||||
|
||||
@@ -362,7 +393,7 @@ export function NestedFolderPicker({
|
||||
|
||||
<NestedFolderList
|
||||
items={flatTree}
|
||||
selectedFolder={value}
|
||||
selectedFolder={effectiveValue}
|
||||
focusedItemIndex={focusedItemIndex}
|
||||
onFolderExpand={handleFolderExpand}
|
||||
onFolderSelect={handleFolderSelect}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { skipToken } from '@reduxjs/toolkit/query';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { TeamDto } from '@grafana/api-clients/rtkq/legacy';
|
||||
import { useGetSearchQuery } from 'app/api/clients/dashboard/v0alpha1';
|
||||
import { api as profileApi } from 'app/features/profile/api';
|
||||
|
||||
/**
|
||||
* Returns the first folder owned by any team the current user belongs to.
|
||||
* Uses the dashboard v0alpha1 search API with the `owner` filter.
|
||||
*/
|
||||
export function useTeamOwnedFolder() {
|
||||
const [teams, setTeams] = useState<TeamDto[] | null>(null);
|
||||
const [teamError, setTeamError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
profileApi
|
||||
.loadTeams()
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setTeams(result);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setTeamError(err);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const owner = useMemo(() => {
|
||||
if (!teams || teams.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const firstTeam = teams[0];
|
||||
// Prefer UID if available, otherwise fallback to name
|
||||
return firstTeam.uid ?? firstTeam.name;
|
||||
}, [teams]);
|
||||
|
||||
const searchArgs =
|
||||
owner !== undefined
|
||||
? {
|
||||
owner,
|
||||
type: 'folder' as const,
|
||||
// Now with the dummy backend this wouldn't work as we filter for owner after we get results
|
||||
// from the DB and this would be only applied to the DB call.
|
||||
// limit: 1,
|
||||
}
|
||||
: skipToken;
|
||||
|
||||
const { data, isFetching, error: searchError } = useGetSearchQuery(searchArgs);
|
||||
|
||||
const folder = data?.hits?.[0];
|
||||
|
||||
return {
|
||||
folder,
|
||||
isLoading: (teams === null && !teamError) || isFetching,
|
||||
error: teamError ?? searchError,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/* eslint-disable @grafana/i18n/no-untranslated-strings */
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Box, Button, Combobox, ComboboxOption, Divider, Stack, Text } from '@grafana/ui';
|
||||
import { OwnerReference } from 'app/api/clients/folder/v1beta1';
|
||||
import { useListTeamQuery, API_GROUP, API_VERSION } from 'app/api/clients/iam/v0alpha1';
|
||||
import { useDispatch } from 'app/types/store';
|
||||
|
||||
import { TeamOwnerReference } from './OwnerReference';
|
||||
import { SupportedResource, useAddOwnerReference, useGetOwnerReferences } from './hooks';
|
||||
|
||||
const TeamSelector = ({ onChange }: { onChange: (ownerRef: OwnerReference) => void }) => {
|
||||
const { data: teams } = useListTeamQuery({});
|
||||
const teamsOptions = teams?.items.map((team) => ({
|
||||
label: team.spec.title,
|
||||
value: team.metadata.name!,
|
||||
}));
|
||||
return (
|
||||
<Combobox
|
||||
options={teamsOptions}
|
||||
onChange={(team: ComboboxOption<string>) => {
|
||||
onChange({
|
||||
apiVersion: `${API_GROUP}/${API_VERSION}`,
|
||||
kind: 'Team',
|
||||
name: team.label,
|
||||
uid: team.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ManageOwnerReferences = ({
|
||||
resource,
|
||||
resourceId,
|
||||
}: {
|
||||
resource: SupportedResource;
|
||||
resourceId: string;
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const [addingNewReference, setAddingNewReference] = useState(false);
|
||||
const [pendingReference, setPendingReference] = useState<OwnerReference | null>(null);
|
||||
const ownerReferences = useGetOwnerReferences({ resource, resourceId });
|
||||
const [trigger, result] = useAddOwnerReference({ resource, resourceId });
|
||||
|
||||
const addOwnerReference = (ownerReference: OwnerReference) => {
|
||||
trigger(ownerReference);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack direction="column">
|
||||
<Text variant="h3">Owned by:</Text>
|
||||
<Box>
|
||||
{ownerReferences
|
||||
.filter((ownerReference) => ownerReference.kind === 'Team')
|
||||
.map((ownerReference) => (
|
||||
<>
|
||||
<TeamOwnerReference key={ownerReference.uid} ownerReference={ownerReference} />
|
||||
<Divider />
|
||||
</>
|
||||
))}
|
||||
</Box>
|
||||
<Box>
|
||||
{addingNewReference && (
|
||||
<Box paddingBottom={2}>
|
||||
<Text variant="h3">Add new owner reference:</Text>
|
||||
<TeamSelector
|
||||
onChange={(ownerReference) => {
|
||||
setPendingReference(ownerReference);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
addOwnerReference(pendingReference);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Divider />
|
||||
</Box>
|
||||
)}
|
||||
<Button onClick={() => setAddingNewReference(true)}>Add new owner reference</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { OwnerReference } from '@grafana/api-clients/rtkq/folder/v1beta1';
|
||||
import { useGetTeamMembersQuery } from '@grafana/api-clients/rtkq/iam/v0alpha1';
|
||||
import { Stack, Text, Avatar, Link, Tooltip } from '@grafana/ui';
|
||||
|
||||
export const getGravatarUrl = (text: string) => {
|
||||
// todo
|
||||
return `avatar/bd38b9ecaf6169ca02b848f60a44cb95`;
|
||||
};
|
||||
|
||||
export const TeamOwnerReference = ({ ownerReference }: { ownerReference: OwnerReference }) => {
|
||||
const { data: teamMembers } = useGetTeamMembersQuery({ name: ownerReference.uid });
|
||||
|
||||
const avatarURL = getGravatarUrl(ownerReference.name);
|
||||
|
||||
const membersTooltip = (
|
||||
<>
|
||||
<Stack gap={1} direction="column">
|
||||
<Text>Team members:</Text>
|
||||
{teamMembers?.items?.map((member) => (
|
||||
<div key={member.identity.name}>
|
||||
<Avatar src={member.avatarURL} /> {member.displayName}
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Link href={`/org/teams/edit/${ownerReference.uid}/members`} key={ownerReference.uid}>
|
||||
<Tooltip content={membersTooltip}>
|
||||
<Stack gap={1} alignItems="center">
|
||||
<Avatar src={avatarURL} alt={ownerReference.name} /> {ownerReference.name}
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
55
public/app/core/components/OwnerReferences/hooks.ts
Normal file
55
public/app/core/components/OwnerReferences/hooks.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import { useReplaceFolderMutation } from '@grafana/api-clients/rtkq/folder/v1beta1';
|
||||
import { folderAPIv1beta1, OwnerReference } from 'app/api/clients/folder/v1beta1';
|
||||
import { useDispatch } from 'app/types/store';
|
||||
|
||||
const getReferencesEndpointMap = {
|
||||
Folder: (resourceId: string) => folderAPIv1beta1.endpoints.getFolder.initiate({ name: resourceId }),
|
||||
} as const;
|
||||
|
||||
export type SupportedResource = keyof typeof getReferencesEndpointMap;
|
||||
|
||||
export const useGetOwnerReferences = ({
|
||||
resource,
|
||||
resourceId,
|
||||
}: {
|
||||
resource: SupportedResource;
|
||||
resourceId: string;
|
||||
}) => {
|
||||
const [ownerReferences, setOwnerReferences] = useState<OwnerReference[]>([]);
|
||||
const dispatch = useDispatch();
|
||||
const endpointAction = getReferencesEndpointMap[resource];
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(endpointAction(resourceId)).then(({ data }) => {
|
||||
if (data?.metadata?.ownerReferences) {
|
||||
setOwnerReferences(data.metadata.ownerReferences);
|
||||
}
|
||||
});
|
||||
}, [dispatch, endpointAction, resourceId]);
|
||||
|
||||
return ownerReferences;
|
||||
};
|
||||
|
||||
export const useAddOwnerReference = ({ resource, resourceId }: { resource: SupportedResource; resourceId: string }) => {
|
||||
const [replaceFolder, result] = useReplaceFolderMutation();
|
||||
return [
|
||||
(ownerReference: OwnerReference) =>
|
||||
replaceFolder({
|
||||
name: resourceId,
|
||||
|
||||
folder: {
|
||||
status: {},
|
||||
metadata: {
|
||||
name: resourceId,
|
||||
ownerReferences: [ownerReference],
|
||||
},
|
||||
spec: {
|
||||
title: resourceId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
result,
|
||||
] as const;
|
||||
};
|
||||
@@ -6,8 +6,9 @@ import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { LinkButton, FilterInput, useStyles2, Text, Stack } from '@grafana/ui';
|
||||
import { LinkButton, FilterInput, useStyles2, Text, Stack, Box, Divider } from '@grafana/ui';
|
||||
import { useGetFolderQueryFacade, useUpdateFolder } from 'app/api/clients/folder/v1beta1/hooks';
|
||||
import { TeamOwnerReference } from 'app/core/components/OwnerReferences/OwnerReference';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { getConfig } from 'app/core/config';
|
||||
import { useDispatch } from 'app/types/store';
|
||||
@@ -146,6 +147,19 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
|
||||
);
|
||||
};
|
||||
|
||||
const ownerReferences = folderDTO && 'ownerReferences' in folderDTO && (
|
||||
<Box>
|
||||
{folderDTO.ownerReferences
|
||||
?.filter((ref) => ref.kind === 'Team')
|
||||
.map((ref) => (
|
||||
<Stack key={ref.uid} direction="row">
|
||||
<Text>Owned by team:</Text>
|
||||
<TeamOwnerReference ownerReference={ref} />
|
||||
</Stack>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Page
|
||||
navId="dashboards/browse"
|
||||
@@ -153,7 +167,8 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
|
||||
onEditTitle={showEditTitle ? onEditTitle : undefined}
|
||||
renderTitle={renderTitle}
|
||||
actions={
|
||||
<>
|
||||
<Stack alignItems="center">
|
||||
{ownerReferences}
|
||||
{config.featureToggles.restoreDashboards && hasAdminRights && (
|
||||
<LinkButton
|
||||
variant="secondary"
|
||||
@@ -173,7 +188,7 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
|
||||
isReadOnlyRepo={isReadOnlyRepo}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<Page.Contents className={styles.pageContents}>
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function listFolders(
|
||||
});
|
||||
}
|
||||
|
||||
return folders.map((item) => ({
|
||||
const result = folders.map((item) => ({
|
||||
kind: 'folder',
|
||||
uid: item.uid,
|
||||
title: item.title,
|
||||
@@ -40,6 +40,20 @@ export async function listFolders(
|
||||
// URLs from the backend come with subUrlPrefix already included, so match that behaviour here
|
||||
url: isSharedWithMe(item.uid) ? undefined : getFolderURL(item.uid),
|
||||
}));
|
||||
|
||||
if (!parentUID) {
|
||||
// result.unshift({
|
||||
// kind: 'folder',
|
||||
// uid: 'teamfolders',
|
||||
// title: 'Team folders',
|
||||
// parentTitle,
|
||||
// parentUID,
|
||||
// managedBy: undefined,
|
||||
// url: undefined,
|
||||
// });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function listDashboards(parentUID?: string, page = 1, pageSize = PAGE_SIZE): Promise<DashboardViewItem[]> {
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function CheckboxCell({
|
||||
}
|
||||
}
|
||||
|
||||
if (isSharedWithMe(item.uid)) {
|
||||
if (isSharedWithMe(item.uid) || item.uid === 'teamfolders') {
|
||||
return <CheckboxSpacer />;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ import InfiniteLoader from 'react-window-infinite-loader';
|
||||
import { GrafanaTheme2, isTruthy } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { Avatar, useStyles2 } from '@grafana/ui';
|
||||
import { TeamOwnerReference } from 'app/core/components/OwnerReferences/OwnerReference';
|
||||
import { DashboardViewItem } from 'app/features/search/types';
|
||||
|
||||
import {
|
||||
@@ -102,8 +103,27 @@ export function DashboardsTree({
|
||||
Header: t('browse-dashboards.dashboards-tree.tags-column', 'Tags'),
|
||||
Cell: (props: DashboardsTreeCellProps) => <TagsCell {...props} onTagClick={onTagClick} />,
|
||||
};
|
||||
const ownerReferencesColumn: DashboardsTreeColumn = {
|
||||
id: 'ownerReferences',
|
||||
width: 2,
|
||||
Header: 'Owner',
|
||||
Cell: ({ row: { original: data } }) => {
|
||||
const ownerReferences = data.item.ownerReferences;
|
||||
if (!ownerReferences) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{ownerReferences.map((ownerReference) => {
|
||||
return <TeamOwnerReference ownerReference={ownerReference} key={ownerReference.uid} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
const canSelect = canSelectItems(permissions);
|
||||
const columns = [canSelect && checkboxColumn, nameColumn, tagsColumns].filter(isTruthy);
|
||||
const columns = [canSelect && checkboxColumn, nameColumn, ownerReferencesColumn, tagsColumns].filter(isTruthy);
|
||||
|
||||
return columns;
|
||||
}, [onFolderClick, onTagClick, permissions]);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { locationService, reportInteraction } from '@grafana/runtime';
|
||||
import { Button, Drawer, Dropdown, Icon, Menu, MenuItem, Text } from '@grafana/ui';
|
||||
import { appEvents } from 'app/core/app_events';
|
||||
import { Permissions } from 'app/core/components/AccessControl/Permissions';
|
||||
import { ManageOwnerReferences } from 'app/core/components/OwnerReferences/ManageOwnerReferences';
|
||||
import { RepoType } from 'app/features/provisioning/Wizard/types';
|
||||
import { BulkMoveProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkMoveProvisionedResource';
|
||||
import { DeleteProvisionedFolderForm } from 'app/features/provisioning/components/Folders/DeleteProvisionedFolderForm';
|
||||
@@ -30,6 +31,7 @@ interface Props {
|
||||
export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showPermissionsDrawer, setShowPermissionsDrawer] = useState(false);
|
||||
const [showManageOwnersDrawer, setShowManageOwnersDrawer] = useState(false);
|
||||
const [showDeleteProvisionedFolderDrawer, setShowDeleteProvisionedFolderDrawer] = useState(false);
|
||||
const [showMoveProvisionedFolderDrawer, setShowMoveProvisionedFolderDrawer] = useState(false);
|
||||
const [moveFolder] = useMoveFolderMutationFacade();
|
||||
@@ -126,14 +128,18 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props)
|
||||
};
|
||||
|
||||
const managePermissionsLabel = t('browse-dashboards.folder-actions-button.manage-permissions', 'Manage permissions');
|
||||
const manageOwnersLabel = t('browse-dashboards.folder-actions-button.manage-folder-owners', 'Manage folder owners');
|
||||
const moveLabel = t('browse-dashboards.folder-actions-button.move', 'Move this folder');
|
||||
const deleteLabel = t('browse-dashboards.folder-actions-button.delete', 'Delete this folder');
|
||||
|
||||
const showManageOwners = canViewPermissions && !isProvisionedFolder;
|
||||
|
||||
const menu = (
|
||||
<Menu>
|
||||
{canViewPermissions && !isProvisionedFolder && (
|
||||
<MenuItem onClick={() => setShowPermissionsDrawer(true)} label={managePermissionsLabel} />
|
||||
)}
|
||||
{showManageOwners && <MenuItem onClick={() => setShowManageOwnersDrawer(true)} label={manageOwnersLabel} />}
|
||||
{canMoveFolder && !isReadOnlyRepo && (
|
||||
<MenuItem
|
||||
onClick={isProvisionedFolder ? handleShowMoveProvisionedFolderDrawer : showMoveModal}
|
||||
@@ -180,6 +186,16 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props)
|
||||
<Permissions resource="folders" resourceId={folder.uid} canSetPermissions={canSetPermissions} />
|
||||
</Drawer>
|
||||
)}
|
||||
{showManageOwnersDrawer && (
|
||||
<Drawer
|
||||
title={t('browse-dashboards.action.manage-permissions-button', 'Manage owners')}
|
||||
subtitle={folder.title}
|
||||
onClose={() => setShowManageOwnersDrawer(false)}
|
||||
size="md"
|
||||
>
|
||||
<ManageOwnerReferences resource="Folder" resourceId={folder.uid} />
|
||||
</Drawer>
|
||||
)}
|
||||
{showDeleteProvisionedFolderDrawer && (
|
||||
<Drawer
|
||||
title={
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { GENERAL_FOLDER_UID } from 'app/features/search/constants';
|
||||
import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types';
|
||||
import { createAsyncThunk } from 'app/types/store';
|
||||
@@ -53,6 +54,10 @@ export const refreshParents = createAsyncThunk(
|
||||
}
|
||||
);
|
||||
|
||||
const hackGetOwnerRefs = async () => {
|
||||
return getBackendSrv().get('/apis/folder.grafana.app/v1beta1/namespaces/default/folders');
|
||||
};
|
||||
|
||||
export const refetchChildren = createAsyncThunk(
|
||||
'browseDashboards/refetchChildren',
|
||||
async ({ parentUID, pageSize }: RefetchChildrenArgs): Promise<RefetchChildrenResult> => {
|
||||
@@ -66,6 +71,7 @@ export const refetchChildren = createAsyncThunk(
|
||||
let fetchKind: DashboardViewItemKind | undefined = 'folder';
|
||||
|
||||
let children = await listFolders(uid, undefined, page, pageSize);
|
||||
|
||||
let lastPageOfKind = children.length < pageSize;
|
||||
|
||||
// If we've loaded all folders, load the first page of dashboards.
|
||||
@@ -136,6 +142,16 @@ export const fetchNextChildrenPage = createAsyncThunk(
|
||||
? await listFolders(uid, undefined, page, pageSize)
|
||||
: await listDashboards(uid, page, pageSize);
|
||||
|
||||
const foldersWithOwnerRefs = await hackGetOwnerRefs();
|
||||
|
||||
children.forEach((child) => {
|
||||
const ownerRefs = foldersWithOwnerRefs.items.find((folder) => folder.metadata.name === child.uid)?.metadata
|
||||
.ownerReferences;
|
||||
if (ownerRefs) {
|
||||
child.ownerReferences = ownerRefs;
|
||||
}
|
||||
});
|
||||
|
||||
let lastPageOfKind = children.length < pageSize;
|
||||
|
||||
// If we've loaded all folders, load the first page of dashboards.
|
||||
|
||||
@@ -183,7 +183,7 @@ export function createFlatTree(
|
||||
|
||||
const items = [thisItem, ...mappedChildren];
|
||||
|
||||
if (isSharedWithMe(thisItem.item.uid)) {
|
||||
if (isSharedWithMe(thisItem.item.uid) || thisItem.item.uid === 'teamfolders') {
|
||||
items.push({
|
||||
item: {
|
||||
kind: 'ui',
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { FormEvent } from 'react';
|
||||
import { FormEvent, useMemo } from 'react';
|
||||
|
||||
import { useListTeamQuery } from '@grafana/api-clients/rtkq/iam/v0alpha1';
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Button, Checkbox, Stack, RadioButtonGroup, useStyles2 } from '@grafana/ui';
|
||||
import { Button, Checkbox, Stack, RadioButtonGroup, useStyles2, Combobox } from '@grafana/ui';
|
||||
import { SortPicker } from 'app/core/components/Select/SortPicker';
|
||||
import { TagFilter, TermCount } from 'app/core/components/TagFilter/TagFilter';
|
||||
|
||||
@@ -76,10 +77,25 @@ export const ActionRow = ({
|
||||
? [SearchLayout.Folders]
|
||||
: [];
|
||||
|
||||
const teams = useListTeamQuery({});
|
||||
|
||||
const teamOptions = useMemo(() => {
|
||||
return teams.data?.items.map((team) => ({
|
||||
label: team.spec.title,
|
||||
value: team.metadata.name || '',
|
||||
}));
|
||||
}, [teams.data?.items]);
|
||||
return (
|
||||
<Stack justifyContent="space-between" alignItems="center">
|
||||
<Stack gap={2} alignItems="center">
|
||||
<TagFilter isClearable={false} tags={state.tag} tagOptions={getTagOptions} onChange={onTagFilterChange} />
|
||||
<Combobox
|
||||
prefixIcon="user-arrows"
|
||||
onChange={() => {}}
|
||||
placeholder="Filter by owner"
|
||||
options={teamOptions || []}
|
||||
isClearable={false}
|
||||
/>
|
||||
{config.featureToggles.panelTitleSearch && (
|
||||
<Checkbox
|
||||
data-testid="include-panels"
|
||||
@@ -99,6 +115,13 @@ export const ActionRow = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* <div className={styles.checkboxWrapper}>
|
||||
<Checkbox
|
||||
label={t('search.actions.owned-by-me', 'My team folders')}
|
||||
onChange={onStarredFilterChange}
|
||||
value={state.teamFolders}
|
||||
/>
|
||||
</div> */}
|
||||
{state.datasource && (
|
||||
<Button icon="times" variant="secondary" onClick={() => onDatasourceChange(undefined)}>
|
||||
<Trans i18nKey="search.actions.remove-datasource-filter">
|
||||
|
||||
@@ -416,7 +416,7 @@ export function toDashboardResults(rsp: SearchAPIResponse, sort: string): DataFr
|
||||
|
||||
async function loadLocationInfo(): Promise<Record<string, LocationInfo>> {
|
||||
// TODO: use proper pagination for search.
|
||||
const uri = `${searchURI}?type=folders&limit=100000`;
|
||||
const uri = `${searchURI}?type=folder&limit=100000`;
|
||||
const rsp = getBackendSrv()
|
||||
.get<SearchAPIResponse>(uri)
|
||||
.then((rsp) => {
|
||||
|
||||
@@ -60,8 +60,11 @@ export function getIconForKind(kind: string, isOpen?: boolean): IconName {
|
||||
}
|
||||
|
||||
export function getIconForItem(item: DashboardViewItemWithUIItems, isOpen?: boolean): IconName {
|
||||
if (item && isSharedWithMe(item.uid)) {
|
||||
if (item.uid === 'teamfolders') {
|
||||
return 'users-alt';
|
||||
}
|
||||
if (item && isSharedWithMe(item.uid)) {
|
||||
return 'share-alt';
|
||||
} else {
|
||||
return getIconForKind(item.kind, isOpen);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Action } from 'redux';
|
||||
|
||||
import { OwnerReference } from '@grafana/api-clients/rtkq/folder/v1beta1';
|
||||
import { WithAccessControlMetadata } from '@grafana/data';
|
||||
|
||||
import { ManagerKind } from '../apiserver/types';
|
||||
@@ -83,6 +84,7 @@ export interface DashboardViewItem {
|
||||
sortMeta?: number | string; // value sorted by
|
||||
sortMetaName?: string; // name of the value being sorted e.g. 'Views'
|
||||
managedBy?: ManagerKind;
|
||||
ownerReferences?: OwnerReference[];
|
||||
}
|
||||
|
||||
export interface SearchAction extends Action {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { UserEvent } from '@testing-library/user-event';
|
||||
import { Route, Routes } from 'react-router-dom-v5-compat';
|
||||
import { render, screen, waitFor } from 'test/test-utils';
|
||||
|
||||
import { setBackendSrv } from '@grafana/runtime';
|
||||
import { config, setBackendSrv } from '@grafana/runtime';
|
||||
import { setupMockServer } from '@grafana/test-utils/server';
|
||||
import { MOCK_TEAMS } from '@grafana/test-utils/unstable';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
@@ -27,9 +27,15 @@ const setup = async () => {
|
||||
return view;
|
||||
};
|
||||
|
||||
const attemptCreateTeam = async (user: UserEvent, teamName?: string, teamEmail?: string) => {
|
||||
const attemptCreateTeam = async (
|
||||
user: UserEvent,
|
||||
teamName?: string,
|
||||
teamEmail?: string,
|
||||
createTeamFolder?: boolean
|
||||
) => {
|
||||
teamName && (await user.type(screen.getByRole('textbox', { name: /name/i }), teamName));
|
||||
teamEmail && (await user.type(screen.getByLabelText(/email/i), teamEmail));
|
||||
createTeamFolder && (await user.click(screen.getByLabelText(/auto-create a team folder/i)));
|
||||
await user.click(screen.getByRole('button', { name: /create/i }));
|
||||
};
|
||||
|
||||
@@ -72,4 +78,22 @@ describe('Create team', () => {
|
||||
|
||||
expect(screen.queryByText(/edit team page/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('team folders enabled', () => {
|
||||
const originalFeatureToggles = config.featureToggles;
|
||||
beforeEach(() => {
|
||||
config.featureToggles = { ...originalFeatureToggles, teamFolders: true };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
config.featureToggles = originalFeatureToggles;
|
||||
});
|
||||
|
||||
it('renders team folder checkbox', async () => {
|
||||
const { user } = await setup();
|
||||
await attemptCreateTeam(user, MOCK_TEAMS[0].spec.title, undefined, true);
|
||||
|
||||
expect(screen.queryByText(/edit team page/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,8 @@ import { useForm } from 'react-hook-form';
|
||||
|
||||
import { NavModelItem } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { Button, Field, Input, FieldSet, Stack } from '@grafana/ui';
|
||||
import { config, locationService } from '@grafana/runtime';
|
||||
import { Button, Field, Input, FieldSet, Stack, Checkbox, Alert } from '@grafana/ui';
|
||||
import { extractErrorMessage } from 'app/api/utils';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { TeamRolePicker } from 'app/core/components/RolePicker/TeamRolePicker';
|
||||
@@ -16,34 +16,42 @@ import { TeamDTO } from 'app/types/teams';
|
||||
|
||||
import { useCreateTeam } from './hooks';
|
||||
|
||||
const pageNav: NavModelItem = {
|
||||
icon: 'users-alt',
|
||||
id: 'team-new',
|
||||
text: 'New team',
|
||||
subTitle: 'Create a new team. Teams let you grant permissions to a group of users.',
|
||||
};
|
||||
type NewTeamForm = TeamDTO & { createTeamFolder?: boolean };
|
||||
|
||||
const CreateTeam = (): JSX.Element => {
|
||||
export const CreateTeam = (): JSX.Element => {
|
||||
const pageNav: NavModelItem = {
|
||||
icon: 'users-alt',
|
||||
id: 'team-new',
|
||||
text: t('teams.create-team.page-title', 'New team'),
|
||||
subTitle: t(
|
||||
'teams.create-team.page-subtitle',
|
||||
'Create a new team. Teams let you grant permissions to a group of users.'
|
||||
),
|
||||
};
|
||||
|
||||
const teamFoldersEnabled = config.featureToggles.teamFolders;
|
||||
const showRolesPicker = contextSrv.licensedAccessControlEnabled();
|
||||
const currentOrgId = contextSrv.user.orgId;
|
||||
|
||||
const notifyApp = useAppNotification();
|
||||
const [createTeamTrigger] = useCreateTeam();
|
||||
const [createTeamTrigger, createResponse] = useCreateTeam();
|
||||
const [pendingRoles, setPendingRoles] = useState<Role[]>([]);
|
||||
const [{ roleOptions }] = useRoleOptions(currentOrgId);
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useForm<TeamDTO>();
|
||||
} = useForm<NewTeamForm>();
|
||||
|
||||
const createTeam = async (formModel: TeamDTO) => {
|
||||
const createTeam = async (formModel: NewTeamForm) => {
|
||||
try {
|
||||
const { data, error } = await createTeamTrigger(
|
||||
{
|
||||
email: formModel.email || '',
|
||||
name: formModel.name,
|
||||
},
|
||||
pendingRoles
|
||||
pendingRoles,
|
||||
formModel.createTeamFolder
|
||||
);
|
||||
|
||||
const errorMessage = error ? extractErrorMessage(error) : undefined;
|
||||
@@ -73,11 +81,11 @@ const CreateTeam = (): JSX.Element => {
|
||||
label={t('teams.create-team.label-name', 'Name')}
|
||||
required
|
||||
invalid={!!errors.name}
|
||||
error="Team name is required"
|
||||
error={t('teams.create-team.error-name-required', 'Team name is required')}
|
||||
>
|
||||
<Input {...register('name', { required: true })} id="team-name" />
|
||||
</Field>
|
||||
{contextSrv.licensedAccessControlEnabled() && (
|
||||
{showRolesPicker && (
|
||||
<Field noMargin label={t('teams.create-team.label-role', 'Role')}>
|
||||
<TeamRolePicker
|
||||
teamId={0}
|
||||
@@ -106,8 +114,37 @@ const CreateTeam = (): JSX.Element => {
|
||||
placeholder="email@test.com"
|
||||
/>
|
||||
</Field>
|
||||
{teamFoldersEnabled && (
|
||||
<Field
|
||||
noMargin
|
||||
label={t('teams.create-team.team-folder', 'Team folder')}
|
||||
description={t(
|
||||
'teams.create-team.description-team-folder',
|
||||
'This creates a folder associated with the team, where users can add resources like dashboards and schedules with the right permissions.'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
{...register('createTeamFolder')}
|
||||
id="team-folder"
|
||||
label={t(
|
||||
'teams.create-team.team-folder-label-autocreate-a-team-folder',
|
||||
'Auto-create a team folder'
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</Stack>
|
||||
</FieldSet>
|
||||
{Boolean(createResponse.error) && (
|
||||
<Alert title={t('teams.create-team.error-title', 'Error creating team')} severity="error">
|
||||
<Trans i18nKey="teams.create-team.error-message">
|
||||
We were unable to create your new team. Please try again later or contact support.
|
||||
</Trans>
|
||||
<br />
|
||||
<br />
|
||||
<div>{extractErrorMessage(createResponse.error)}</div>
|
||||
</Alert>
|
||||
)}
|
||||
<Button type="submit" variant="primary">
|
||||
<Trans i18nKey="teams.create-team.create">Create</Trans>
|
||||
</Button>
|
||||
|
||||
23
public/app/features/teams/OwnedResources.tsx
Normal file
23
public/app/features/teams/OwnedResources.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useListFolderQuery } from '@grafana/api-clients/rtkq/folder/v1beta1';
|
||||
import { Stack, Text, Link, Icon } from '@grafana/ui';
|
||||
import { Team } from 'app/types/teams';
|
||||
|
||||
export const OwnedResources = ({ team }: { team: Team }) => {
|
||||
const { data } = useListFolderQuery({});
|
||||
const ownedFolders = data?.items.filter((folder) =>
|
||||
folder.metadata.ownerReferences?.some((ref) => ref.uid === team.uid)
|
||||
);
|
||||
return (
|
||||
<Stack gap={1} direction="column">
|
||||
<Text variant="h3">Owned folders:</Text>
|
||||
{ownedFolders &&
|
||||
ownedFolders.map((folder) => (
|
||||
<div key={folder.metadata.uid}>
|
||||
<Link href={`/dashboards/f/${folder.metadata.name}`}>
|
||||
<Icon name="folder" /> <Text>{folder.spec.title}</Text>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { AccessControlAction } from 'app/types/accessControl';
|
||||
import { StoreState, useSelector } from 'app/types/store';
|
||||
|
||||
import { OwnedResources } from './OwnedResources';
|
||||
import TeamGroupSync, { TeamSyncUpgradeContent } from './TeamGroupSync';
|
||||
import TeamPermissions from './TeamPermissions';
|
||||
import TeamSettings from './TeamSettings';
|
||||
@@ -26,9 +27,10 @@ enum PageTypes {
|
||||
Members = 'members',
|
||||
Settings = 'settings',
|
||||
GroupSync = 'groupsync',
|
||||
Resources = 'resources',
|
||||
}
|
||||
|
||||
const PAGES = ['members', 'settings', 'groupsync'];
|
||||
const PAGES = ['members', 'settings', 'groupsync', 'resources'];
|
||||
|
||||
const pageNavSelector = createSelector(
|
||||
[
|
||||
@@ -59,24 +61,30 @@ const TeamPages = memo(() => {
|
||||
const renderPage = () => {
|
||||
const currentPage = PAGES.includes(pageName) ? pageName : PAGES[0];
|
||||
|
||||
const canReadTeam = contextSrv.hasPermissionInMetadata(AccessControlAction.ActionTeamsRead, team!);
|
||||
if (!team) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canReadTeam = contextSrv.hasPermissionInMetadata(AccessControlAction.ActionTeamsRead, team);
|
||||
const canReadTeamPermissions = contextSrv.hasPermissionInMetadata(
|
||||
AccessControlAction.ActionTeamsPermissionsRead,
|
||||
team!
|
||||
team
|
||||
);
|
||||
const canWriteTeamPermissions = contextSrv.hasPermissionInMetadata(
|
||||
AccessControlAction.ActionTeamsPermissionsWrite,
|
||||
team!
|
||||
team
|
||||
);
|
||||
|
||||
switch (currentPage) {
|
||||
case PageTypes.Members:
|
||||
if (canReadTeamPermissions) {
|
||||
return <TeamPermissions team={team!} />;
|
||||
return <TeamPermissions team={team} />;
|
||||
}
|
||||
return null;
|
||||
case PageTypes.Settings:
|
||||
return canReadTeam && <TeamSettings team={team!} />;
|
||||
return canReadTeam && <TeamSettings team={team} />;
|
||||
case PageTypes.Resources:
|
||||
return canReadTeam && <OwnedResources team={team} />;
|
||||
case PageTypes.GroupSync:
|
||||
if (isSyncEnabled.current) {
|
||||
if (canReadTeamPermissions) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { Button, Field, FieldSet, Input, Stack } from '@grafana/ui';
|
||||
import { Button, Divider, Field, FieldSet, Input, Stack } from '@grafana/ui';
|
||||
import { TeamRolePicker } from 'app/core/components/RolePicker/TeamRolePicker';
|
||||
import { useRoleOptions } from 'app/core/components/RolePicker/hooks';
|
||||
import { SharedPreferences } from 'app/core/components/SharedPreferences/SharedPreferences';
|
||||
@@ -97,6 +97,7 @@ const TeamSettings = ({ team }: Props) => {
|
||||
<Trans i18nKey="teams.team-settings.save">Save team details</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
<Divider />
|
||||
<SharedPreferences resourceUri={`teams/${team.id}`} disabled={!canWriteTeamSettings} preferenceType="team" />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { skipToken } from '@reduxjs/toolkit/query';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { useCreateFolder } from 'app/api/clients/folder/v1beta1/hooks';
|
||||
import {
|
||||
useSearchTeamsQuery as useLegacySearchTeamsQuery,
|
||||
useCreateTeamMutation,
|
||||
@@ -127,14 +128,16 @@ export const useDeleteTeam = () => {
|
||||
export const useCreateTeam = () => {
|
||||
const [createTeam, response] = useCreateTeamMutation();
|
||||
const [setTeamRoles] = useSetTeamRolesMutation();
|
||||
const [createFolder] = useCreateFolder();
|
||||
|
||||
const trigger = async (team: CreateTeamCommand, pendingRoles?: Role[]) => {
|
||||
const trigger = async (team: CreateTeamCommand, pendingRoles?: Role[], createTeamFolder?: boolean) => {
|
||||
const mutationResult = await createTeam({
|
||||
createTeamCommand: team,
|
||||
});
|
||||
|
||||
const { data } = mutationResult;
|
||||
|
||||
// Add any pending roles to the team
|
||||
if (data && data.teamId && pendingRoles && pendingRoles.length) {
|
||||
await contextSrv.fetchUserPermissions();
|
||||
if (contextSrv.licensedAccessControlEnabled() && canUpdateRoles()) {
|
||||
@@ -147,6 +150,14 @@ export const useCreateTeam = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (data && data.teamId && createTeamFolder) {
|
||||
await createFolder({
|
||||
title: team.name,
|
||||
createAsTeamFolder: true,
|
||||
teamUid: data.uid,
|
||||
});
|
||||
}
|
||||
|
||||
return mutationResult;
|
||||
};
|
||||
|
||||
|
||||
@@ -59,6 +59,13 @@ export function buildNavModel(team: Team): NavModelItem {
|
||||
url: `org/teams/edit/${team.uid}/members`,
|
||||
});
|
||||
}
|
||||
navModel.children!.push({
|
||||
active: false,
|
||||
icon: 'folder',
|
||||
id: `team-resources-${team.uid}`,
|
||||
text: 'Resources',
|
||||
url: `org/teams/edit/${team.uid}/resources`,
|
||||
});
|
||||
|
||||
const teamGroupSync: NavModelItem = {
|
||||
active: false,
|
||||
|
||||
Reference in New Issue
Block a user